You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
senzapaura_es/scripts/album/medios.mjs

278 lines
8.5 KiB

/**
* Coloca el audio y las portadas de un álbum, y los ata a sus canciones.
*
* npm run album:medios -- "S:\\senza paura\\album - cupido-sin-flechas"
* npm run album:medios -- <carpeta> --escribir
*
* **Por defecto no toca nada**: dice lo que haría y se para.
*
* El importador mete el texto; esto mete los archivos. Van aparte porque son
* dos cosas distintas: el texto son unos kilobytes que caben en una
* transacción, y esto son cien megas de másteres que se copian, se recortan y
* se convierten. Y porque el texto puede entrar hoy y los archivos la semana
* que viene.
*
* ## Dónde acaba cada cosa
*
* media/audio/<disco>/<NN>-<slug>.mp3 el máster; privado, fuera de git
* static/muestras/<disco>/<NN>-<slug>.mp3 el fragmento que suena en la web
* static/images/<disco>/<slug>.jpg la portada
*
* La clave del máster manda: el catálogo le quita el `audio/` y con lo que
* queda compone `/muestras/<eso>`. Por eso las dos rutas tienen que ser la
* misma debajo, y por eso no se pueden nombrar por separado.
*
* ## La muestra
*
* Cuarenta segundos desde el treinta, con cuatro de fundido. El `-ss` va antes
* del `-i`: detrás, el filtro sigue viendo los tiempos del original, el fundido
* cae seis segundos después de empezar y el resto sale mudo.
*
* ## La portada
*
* Casi todas las que hay son miniaturas de YouTube, 16:9. Se recorta el
* cuadrado del centro, que es lo único que se puede hacer sin inventar: si el
* título del vídeo va escrito a los lados, se corta. Sale un aviso por cada una
* que venga de una miniatura, para poder mirarlas.
*/
import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import pg from 'pg';
import { leerAlbum } from './carpeta.mjs';
const CARPETA = process.argv[2];
const ESCRIBIR = process.argv.includes('--escribir');
if (!CARPETA || !fs.existsSync(CARPETA)) {
console.error('uso: npm run album:medios -- "<carpeta>" [--escribir]');
process.exit(1);
}
const LADO = 1000;
const MUESTRA_DESDE = 30;
const MUESTRA_DURA = 40;
/* -------------------------------------------------------------------------
* Herramientas
* ---------------------------------------------------------------------- */
const ffmpeg = (args) => execFileSync('ffmpeg', ['-v', 'error', '-y', ...args], { stdio: 'pipe' });
function ffprobe(archivo, entradas, flujo) {
return execFileSync(
'ffprobe',
[
'-v',
'error',
...(flujo ? ['-select_streams', flujo] : []),
'-show_entries',
entradas,
'-of',
'default=nw=1:nk=1',
archivo
],
{ encoding: 'utf8' }
)
.trim()
.split(/\r?\n/);
}
const segundosDe = (archivo) => Math.round(Number(ffprobe(archivo, 'format=duration')[0]));
const tamanoDe = (archivo) => ffprobe(archivo, 'stream=width,height', 'v:0').map(Number);
const sha256 = (archivo) => createHash('sha256').update(fs.readFileSync(archivo)).digest('hex');
const asegurar = (destino) => fs.mkdirSync(path.dirname(destino), { recursive: true });
/* -------------------------------------------------------------------------
* Qué se va a hacer
* ---------------------------------------------------------------------- */
const album = leerAlbum(CARPETA);
const disco = album.slug;
/** Dos dígitos, como en la convención de `media/LEEME.md`. */
const dosDigitos = (n) => String(n).padStart(2, '0');
const trabajo = album.canciones.map((c, i) => {
const origen = path.join(CARPETA, c.carpeta);
const numero = dosDigitos(i + 1);
return {
...c,
numero: i + 1,
masterOrigen: c.audio.mp3 ? path.join(origen, c.audio.mp3) : null,
claveAudio: `audio/${disco}/${numero}-${c.slug}.mp3`,
claveMuestra: `muestras/${disco}/${numero}-${c.slug}.mp3`,
claveImagen: `images/${disco}/${c.slug}.jpg`,
imagenOrigen: c.portada?.ruta ?? null,
/* Una miniatura de YouTube es 16:9: al cuadrarla se corta por los lados. */
esMiniatura: c.portada?.recortar ?? false
};
});
console.log(`\n${album.titulo} · ${trabajo.length} temas\n`);
for (const t of trabajo) {
const audio = t.masterOrigen ? `${segundosDe(t.masterOrigen)}s` : 'SIN MÁSTER';
const img = t.imagenOrigen
? `${tamanoDe(t.imagenOrigen).join('x')}${t.esMiniatura ? ' (miniatura 16:9, se recorta)' : ''}`
: 'SIN PORTADA';
console.log(` ${dosDigitos(t.numero)} ${t.titulo.padEnd(30)} ${audio.padStart(6)} ${img}`);
}
const recortadas = trabajo.filter((t) => t.esMiniatura).length;
if (recortadas > 0) {
console.log(
`\n${recortadas} portadas salen de una miniatura de YouTube y se recortan al centro.`
);
console.log('Míralas después: si el título del vídeo iba a los lados, se habrá cortado.');
}
if (!ESCRIBIR) {
console.log('\nEnsayo. No se ha copiado ni escrito nada. Añade --escribir.');
process.exit(0);
}
/* -------------------------------------------------------------------------
* Hacerlo
* ---------------------------------------------------------------------- */
const c = new pg.Client({ connectionString: process.env.DATABASE_URL, query_timeout: 60_000 });
await c.connect();
/** Alta o actualización de un medio por su clave, que es única de hecho. */
async function ponerMedio(clase, clave, archivo, extra = {}) {
const { rows } = await c.query(
`insert into medio (id, clase, clave, publico, mime, bytes, sha256, duracion_s, ancho, alto, alt)
values (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
on conflict (clave) do update set
bytes = excluded.bytes, sha256 = excluded.sha256,
duracion_s = excluded.duracion_s, ancho = excluded.ancho, alto = excluded.alto
returning id`,
[
clase,
clave,
extra.publico ?? false,
extra.mime,
fs.statSync(archivo).size,
sha256(archivo),
extra.duracion ?? null,
extra.ancho ?? null,
extra.alto ?? null,
extra.alt ?? null
]
);
return rows[0].id;
}
let audios = 0;
let imagenes = 0;
for (const t of trabajo) {
if (t.masterOrigen) {
const master = path.join('media', 'audio', disco, path.basename(t.claveAudio));
asegurar(master);
fs.copyFileSync(t.masterOrigen, master);
const muestra = path.join('static', 'muestras', disco, path.basename(t.claveMuestra));
asegurar(muestra);
ffmpeg([
'-ss',
String(MUESTRA_DESDE),
'-t',
String(MUESTRA_DURA),
'-i',
master,
'-af',
`afade=t=out:st=${MUESTRA_DURA - 4}:d=4`,
muestra
]);
const duracion = segundosDe(master);
const idAudio = await ponerMedio('audio', t.claveAudio, master, {
mime: 'audio/mpeg',
duracion
});
const idMuestra = await ponerMedio('muestra', t.claveMuestra, muestra, {
mime: 'audio/mpeg',
publico: true,
duracion: segundosDe(muestra)
});
await c.query(
`update cancion set audio_id = $2, muestra_id = $3, duracion_s = coalesce(duracion_s, $4)
where slug = $1`,
[t.slug, idAudio, idMuestra, duracion]
);
audios++;
}
if (t.imagenOrigen) {
const imagen = path.join('static', 'images', disco, path.basename(t.claveImagen));
asegurar(imagen);
// El cuadrado del centro, y a mil píxeles: es lo que pide la tarjeta.
ffmpeg([
'-i',
t.imagenOrigen,
'-vf',
`crop='min(iw,ih)':'min(iw,ih)',scale=${LADO}:${LADO}`,
'-q:v',
'3',
imagen
]);
const idImagen = await ponerMedio('imagen', t.claveImagen, imagen, {
mime: 'image/jpeg',
publico: true,
ancho: LADO,
alto: LADO,
alt: `Portada de ${t.titulo}`
});
await c.query('update cancion set portada_id = $2 where slug = $1', [t.slug, idImagen]);
imagenes++;
}
}
/*
* La portada del disco: la del primer tema que tenga una cuadrada de verdad, y
* si no la del primero que tenga cualquiera. Un disco sin portada sale con el
* hueco de la inicial, que es correcto pero no es una portada.
*/
const paraElDisco =
trabajo.find((t) => t.imagenOrigen && !t.esMiniatura) ?? trabajo.find((t) => t.imagenOrigen);
if (paraElDisco) {
const clave = `images/${disco}/${disco}.jpg`;
const destino = path.join('static', 'images', disco, `${disco}.jpg`);
asegurar(destino);
ffmpeg([
'-i',
paraElDisco.imagenOrigen,
'-vf',
`crop='min(iw,ih)':'min(iw,ih)',scale=${LADO}:${LADO}`,
'-q:v',
'3',
destino
]);
const id = await ponerMedio('imagen', clave, destino, {
mime: 'image/jpeg',
publico: true,
ancho: LADO,
alto: LADO,
alt: `Portada de ${album.titulo}`
});
await c.query('update album set portada_id = $2 where slug = $1', [disco, id]);
console.log(`\nPortada del disco: la de «${paraElDisco.titulo}».`);
}
await c.end();
console.log(`\nHecho: ${audios} másteres con su muestra, ${imagenes} portadas.`);
console.log('Los másteres van en media/audio, que no está en git. Cópialos al desplegar.');

Powered by TurnKey Linux.