/** * 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 -- --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//-.mp3 el máster; privado, fuera de git * static/muestras//-.mp3 el fragmento que suena en la web * static/images//.jpg la portada * * La clave del máster manda: el catálogo le quita el `audio/` y con lo que * queda compone `/muestras/`. 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 -- "" [--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.');