El importador metió el texto; esto mete los archivos. Diez másteres a media/audio (fuera de git), su muestra de cuarenta segundos a static/muestras y la portada cuadrada de mil píxeles a static/images, con sus filas en `medio` y atadas a cancion.audio_id, muestra_id y portada_id. El disco se queda con la portada del primer tema porque la carpeta no trae una suya. Las portadas estaban en subcarpetas `cover` y `covers`, no en la raíz de cada canción: el lector solo miraba la raíz y `Video`, así que ocho de las diez iban a acabar con la miniatura de YouTube recortada. Ahora busca la subcarpeta sin mirar mayúsculas, que además es un fallo de verdad en Linux, donde `Video` no es `video`. Comprobado a ojo: el botón de escuchar ya no está apagado, la muestra suena (0:40, con su etiqueta) y las diez portadas cargan. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>master
@ -0,0 +1,277 @@
|
|||||||
|
/**
|
||||||
|
* 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.');
|
||||||
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 261 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 239 KiB |
|
After Width: | Height: | Size: 338 KiB |
|
After Width: | Height: | Size: 237 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 267 KiB |