Cupido sin flechas ya suena y tiene portadas

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
dev 4 weeks ago
parent ba99701b17
commit af40c25e1b

@ -37,6 +37,7 @@
"db:animos": "node --env-file=.env scripts/db/sembrar-animos.mjs", "db:animos": "node --env-file=.env scripts/db/sembrar-animos.mjs",
"album:leer": "node scripts/album/leer.mjs", "album:leer": "node scripts/album/leer.mjs",
"album:importar": "node --env-file=.env scripts/album/importar.mjs", "album:importar": "node --env-file=.env scripts/album/importar.mjs",
"album:medios": "node --env-file=.env scripts/album/medios.mjs",
"db:completar": "node --env-file=.env scripts/db/completar-catalogo.mjs", "db:completar": "node --env-file=.env scripts/db/completar-catalogo.mjs",
"db:comparar": "node --experimental-strip-types --import ./scripts/db/resolver-lib.mjs --env-file=.env scripts/db/comparar-catalogo.mjs" "db:comparar": "node --experimental-strip-types --import ./scripts/db/resolver-lib.mjs --env-file=.env scripts/db/comparar-catalogo.mjs"
}, },

@ -141,6 +141,27 @@ const primeroQueHaya = (archivos, patrones) => {
return null; return null;
}; };
/**
* Una subcarpeta por su nombre, sin mirar mayúsculas.
*
* Hay `cover`, `Cover` y `video` junto a `Video` en la misma carpeta de
* álbum. En Windows da igual y por eso no se notaba; en el servidor, que es
* Linux, `Video` y `video` son dos sitios distintos y la mitad de las portadas
* se habrían perdido.
*/
function subcarpeta(carpeta, patron) {
const nombre = fs
.readdirSync(carpeta, { withFileTypes: true })
.find((d) => d.isDirectory() && patron.test(d.name))?.name;
return nombre ? path.join(carpeta, nombre) : null;
}
/** Las imágenes de una carpeta, o nada si la carpeta no existe. */
function imagenesDe(carpeta) {
if (!carpeta || !fs.existsSync(carpeta)) return [];
return fs.readdirSync(carpeta).filter((n) => /\.(jpe?g|png|webp)$/i.test(n));
}
/** /**
* De «Ámame esta noche, nada más» a `amame-esta-noche-nada-mas`. * De «Ámame esta noche, nada más» a `amame-esta-noche-nada-mas`.
* *
@ -162,10 +183,23 @@ export function leerCancion(carpeta) {
.filter((d) => d.isFile()) .filter((d) => d.isFile())
.map((d) => d.name); .map((d) => d.name);
const enVideo = fs.existsSync(path.join(carpeta, 'Video')) const carpetaVideo = subcarpeta(carpeta, /^videos?$/i);
? fs.readdirSync(path.join(carpeta, 'Video')).filter((n) => !n.endsWith('.psd')) const enVideo = carpetaVideo
? fs.readdirSync(carpetaVideo).filter((n) => !n.endsWith('.psd'))
: []; : [];
/*
* La portada de verdad, cuando la hay, vive en su propia carpeta: `cover`,
* `Cover` o `covers`. Ocho de los diez temas la tienen ahí y ninguno la
* tiene suelta en la raíz, así que buscarla solo en la raíz y en el vídeo
* dejaba a casi todo el disco con la miniatura de YouTube por portada.
*
* `cover-low` es la versión pequeña, y `cover-inicio` un fotograma: la
* buena es `cover.jpg` a secas.
*/
const carpetaCover = subcarpeta(carpeta, /^covers?$/i);
const enCover = imagenesDe(carpetaCover);
/* /*
* El título sale del nombre del máster y no de la carpeta: las carpetas * El título sale del nombre del máster y no de la carpeta: las carpetas
* están sin acentuar o mal acentuadas —«Quèdate», «Pague», «para que * están sin acentuar o mal acentuadas —«Quèdate», «Pague», «para que
@ -200,16 +234,31 @@ export function leerCancion(carpeta) {
/** Las demás tomas: coberturas, seleccionadas, versiones de trabajo. */ /** Las demás tomas: coberturas, seleccionadas, versiones de trabajo. */
otras: archivos.filter((a) => /\.(mp3|wav)$/i.test(a) && !MASTER.test(a)) otras: archivos.filter((a) => /\.(mp3|wav)$/i.test(a) && !MASTER.test(a))
}, },
portada: primeroQueHaya(archivos, [ /**
/^cover\.jpe?g$/i, * La portada, buscada donde de verdad está, por orden de preferencia:
/^cover-album\./i, * la carpeta `cover`, luego la raíz del tema, y solo al final la miniatura
/^cover-mp3\./i, * de YouTube, que es 16:9 y hay que recortar.
/^cover/i */
]), portada: (() => {
portadaEnVideo: primeroQueHaya(enVideo, [/^cover\.jpe?g$/i, /^portada-video-youtube\.jpe?g$/i]), const enLaCarpeta = primeroQueHaya(enCover, [/^cover\.jpe?g$/i, /^cover[-.]/i, /\.jpe?g$/i]);
if (enLaCarpeta) return { ruta: path.join(carpetaCover, enLaCarpeta), recortar: false };
const suelta = primeroQueHaya(archivos, [/^cover\.jpe?g$/i, /^cover-album\./i, /^cover/i]);
if (suelta) return { ruta: path.join(carpeta, suelta), recortar: false };
const miniatura = primeroQueHaya(enVideo, [
/^cover\.jpe?g$/i,
/^portada-video-youtube\.jpe?g$/i
]);
return miniatura ? { ruta: path.join(carpetaVideo, miniatura), recortar: true } : null;
})(),
video: video:
primeroQueHaya(enVideo, [/^Senza Paura.*\.mp4$/i, /\.mp4$/i]) ?? (carpetaVideo &&
primeroQueHaya(archivos, [/\.mp4$/i]), primeroQueHaya(enVideo, [/^Senza Paura.*\.mp4$/i, /\.mp4$/i]) &&
path.join(carpetaVideo, primeroQueHaya(enVideo, [/^Senza Paura.*\.mp4$/i, /\.mp4$/i]))) ||
(primeroQueHaya(archivos, [/\.mp4$/i]) &&
path.join(carpeta, primeroQueHaya(archivos, [/\.mp4$/i]))) ||
null,
miniatura: primeroQueHaya(enVideo, [ miniatura: primeroQueHaya(enVideo, [
/^portada-video-youtube\.jpe?g$/i, /^portada-video-youtube\.jpe?g$/i,
/^portada-video-youtube-fin(al)?\.jpe?g$/i /^portada-video-youtube-fin(al)?\.jpe?g$/i

@ -59,10 +59,10 @@ for (const c of album.canciones) {
(c.audio.otras.length ? ` (+${c.audio.otras.length} tomas)` : '') (c.audio.otras.length ? ` (+${c.audio.otras.length} tomas)` : '')
); );
console.log( console.log(
` imagen: portada ${c.portada ?? c.portadaEnVideo ?? 'FALTA'}` + ` portada: ${c.portada ? path.basename(c.portada.ruta) : 'FALTA'}` +
` · miniatura ${c.miniatura ?? 'FALTA'}` `${c.portada?.recortar ? ' (miniatura 16:9, hay que recortarla)' : ''}`
); );
console.log(` vídeo: ${c.video ?? 'FALTA'}`); console.log(` vídeo: ${c.video ? path.basename(c.video) : 'FALTA'}`);
console.log(''); console.log('');
} }
@ -72,7 +72,8 @@ const faltan = [
['sin prompt de estilo', sin((c) => c.letra && !c.letra.estilo)], ['sin prompt de estilo', sin((c) => c.letra && !c.letra.estilo)],
['sin letra limpia', sin((c) => c.letra && !c.letra.limpia)], ['sin letra limpia', sin((c) => c.letra && !c.letra.limpia)],
['sin mp3 máster', sin((c) => !c.audio.mp3)], ['sin mp3 máster', sin((c) => !c.audio.mp3)],
['sin portada', sin((c) => !c.portada && !c.portadaEnVideo)], ['sin portada', sin((c) => !c.portada)],
['con la miniatura por portada', sin((c) => c.portada?.recortar)],
['sin vídeo', sin((c) => !c.video)] ['sin vídeo', sin((c) => !c.video)]
].filter(([, cuales]) => cuales.length > 0); ].filter(([, cuales]) => cuales.length > 0);

@ -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.');

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

Loading…
Cancel
Save

Powered by TurnKey Linux.