The video timeline for Solsticio is driven by audio. Each phrase of the voiceover is a separate MP3 file, and the composition step needs to know exactly how long each clip is to place subtitles and on-screen elements. The Fish Audio API does not return timestamps, so the script is chopped into phrases and each clip is measured individually.
The obvious approach is to shell out to ffprobe. That works, but it means shipping ffmpeg in the CI image. For a narrow case where the encoder is known, parsing the container header directly is a short, dependency-free function.
MP3: walking the frames
The MP3 parser lives in scripts/lib/tts/mp3-duration.mjs. The file header explains why the naive approach fails:
/**
* Duración exacta de un MP3, recorriendo sus cabeceras de frame.
*
* Hace falta porque la línea de tiempo del vídeo la marca el audio: cada frase
* de la locución es un mp3 y la composición necesita saber cuánto dura para
* colocar el subtítulo y lo que pasa en pantalla. La API de Fish Audio **no
* devuelve marcas de tiempo**, así que trocear el guion en frases y medir cada
* clip es lo que sustituye a la alineación palabra por palabra.
*
* No sirve `bytes * 8 / bitrate`: eso solo vale con bitrate constante y da un
* error acumulado con VBR. Recorrer los frames es exacto en ambos casos, y son
* treinta líneas sin dependencias — meter ffprobe o un paquete de npm para esto
* sería peor.
*/
// Tabla de bitrates de MPEG Layer III, en kbps, por índice de cabecera.
The bytes * 8 / bitrate formula only works with constant bitrate. With VBR, the error accumulates across the file. Walking the frames is exact in both cases.
The function starts by skipping the ID3v2 tag if present, then scans for frame sync markers. Each valid frame header contains the MPEG version, layer, bitrate index, sample rate index, and padding bit. From those fields, the frame length is computed and the duration is accumulated.
export function mp3Duration(buf) {
let pos = id3Size(buf);
let seconds = 0;
while (pos + 4 <= buf.length) {
// Sincronía: once bits a uno.
if (buf[pos] !== 0xff || (buf[pos + 1] & 0xe0) !== 0xe0) {
pos++;
continue;
}
const versionBits = (buf[pos + 1] >> 3) & 0x03; // 3=MPEG1, 2=MPEG2, 0=MPEG2.5
const layerBits = (buf[pos + 1] >> 1) & 0x03; // 1 = Layer III
const bitrateIdx = (buf[pos + 2] >> 4) & 0x0f;
const rateIdx = (buf[pos + 2] >> 2) & 0x03;
const padding = (buf[pos + 2] >> 1) & 0x01;
const rates = SAMPLE_RATE[versionBits];
if (layerBits !== 1 || !rates || rateIdx === 3 || bitrateIdx === 0 || bitrateIdx === 15) {
pos++; // falsa sincronía
continue;
}
const sampleRate = rates[rateIdx];
const bitrate = (versionBits === 3 ? BITRATE_V1[bitrateIdx] : BITRATE_V2[bitrateIdx]) * 1000;
// MPEG1 Layer III son 1152 muestras por frame; MPEG2 y 2.5, 576.
const samples = versionBits === 3 ? 1152 : 576;
const frameLen = Math.floor((samples / 8) * (bitrate / sampleRate)) + padding;
if (frameLen <= 4) {
pos++;
continue;
}
seconds += samples / sampleRate;
pos += frameLen;
}
return seconds;
}
The loop advances by the computed frame length after each valid frame. If the sync check fails or the frame length is implausibly small, it advances by one byte and keeps scanning. This handles false syncs without needing to know where the next real frame starts.
The bitrate tables cover MPEG 1 and MPEG 2 Layer III. The sample rate lookup handles three versions: MPEG 1, MPEG 2, and MPEG 2.5. MPEG 1 Layer III frames carry 1152 samples; MPEG 2 and 2.5 carry 576. The padding bit adds one byte to the frame length when set.
MP4: reading two windows
The MP4 prober is in scripts/lib/validate.mjs. It reads duration and track types by walking the file's atoms. The comment in the source says it plainly:
/**
* Comprobaciones antes de publicar.
*
* Publicar es irreversible en la práctica: en Bluesky y Mastodon hay que borrar
* a mano, y en Instagram el post ya lo ha visto quien lo haya visto. Con catorce
* tipos de vídeo, la probabilidad de que uno salga roto (render a medias, la
* locución cortada, un fichero de 60 MB que revienta la subida) deja de ser
* teórica. Esto lo detiene antes, no después.
*
* Los límites de plataforma NO estaban codificados en ningún poster: los
* truncados que había eran de texto, y no había una sola comprobación de tamaño
* ni de duración. Aquí están, en un sitio, con su motivo.
*/
import { statSync, openSync, readSync, closeSync, readFileSync } from 'node:fs';
import { specFor, PLATFORMS, idDePlataforma } from './platforms.mjs';
import { captionFor, altText, longitudPublicada } from './captions.mjs';
The atoms that matter, mvhd and hdlr, live at the beginning or the end of the file. The prober reads two windows instead of the whole file, which can be 40 MB.
export function probeMp4(file) {
const tracks = [];
let seconds = 0;
const fd = openSync(file, 'r');
try {
const size = statSync(file).size;
// Los átomos que interesan (mvhd y hdlr) viven al principio o al final;
// se leen dos ventanas en vez del fichero entero, que puede ser de 40 MB.
for (const [start, len] of windows(size)) {
const buf = Buffer.alloc(len);
readSync(fd, buf, 0, len, start);
for (let i = 0; i + 32 < buf.length; i++) {
const tag = buf.toString('latin1', i, i + 4);
if (tag === 'hdlr') {
const kind = buf.toString('latin1', i + 12, i + 16);
if (!tracks.includes(kind)) tracks.push(kind);
} else if (tag === 'mvhd' && seconds === 0) {
const version = buf[i + 4];
const timescale = version === 1 ? Number(buf.readBigUInt64BE(i + 20)) : buf.readUInt32BE(i + 16);
const duration = version === 1 ? Number(buf.readBigUInt64BE(i + 28)) : buf.readUInt32BE(i + 20);
if (timescale > 0) seconds = duration / timescale;
}
}
}
} catch {
/* fichero raro: se devuelve lo que se haya podido leer */
} finally {
closeSync(fd);
}
return { seconds, tracks };
}
The windows function returns one window if the file is small enough, otherwise two: one at the start and one at the end, each up to 512 KB. The scan looks for four-byte tags. When it finds hdlr, it reads the track kind at offset 12. When it finds mvhd and duration has not been set yet, it reads the version byte to decide between 32-bit and 64-bit timescale and duration fields.
The mvhd atom stores duration as a count of timescale units. Dividing duration by timescale gives seconds. The version byte determines whether those fields are 32-bit or 64-bit integers.
What the validators check
The prober feeds into validateVideo, which runs before publishing. The limits are explicit:
maxBytes: 45 * 1024 * 1024
minSeconds: 5
maxSeconds: 180
The 45 MB cap sits below the 50 MB Telegram bot limit. The 180 second cap stays under the Shorts cutoff and the Bluesky limit. The 5 second minimum catches renders that produced almost nothing.
The validator also checks that the MP4 has both a video track (vide) and an audio track (soun). Some platforms reject an MP4 without an audio track even if the content is silent, so the render step enforces an audio track.
A sidecar JSON file, written by the render step, records whether the piece came out with voice. If voiceOk is false, the validator adds a warning. The video is not discarded; it ships with music and subtitles. But whoever reviews it needs to know the voice is missing.
Measuring what gets published
The caption checker in validate.mjs measures the exact text that each platform will publish, not three separate fields. The comment explains the shift:
/**
* Comprueba, RED A RED, que lo que se va a publicar cabe.
*
* Los posters truncan, así que nada revienta — pero un texto truncado se corta a
* mitad de frase y se publica igual. Esto avisa ANTES, mientras es un cambio de
* una línea en captions.mjs.
*
* 🔴 Antes miraba TRES textos sueltos (`short`, `alt`, `title`) contra tres
* límites escritos a mano, y ninguno era el que se publicaba: desde que cada red
* compone el suyo con `captionFor`, lo que hay que medir es exactamente eso. El
* caso peliagudo sigue siendo Bluesky, donde el poster construye los facets
* confiando en que el texto quepa en 300 grafemas.
*
* Se mide en GRAFEMAS y no en `.length`: un emoji son dos unidades de código y
* las redes que cuentan de verdad cuentan grafemas.
*
* @returns string[] avisos (vacío si todo cabe)
*/
Before, the checker looked at short, alt, and title against three hand-written limits. None of those was what actually got published. Since each network composes its own text with captionFor, the checker measures that composed text.
Measurement is in graphemes, not .length. An emoji is two code units but one grapheme. The networks that count for real count graphemes.
The list of networks to measure is maintained by hand. The comment on that list is blunt:
/**
* Los destinos del fan-out cuyo texto compone `captionFor`, y que por tanto hay
* que medir.
*
* 🔴 Esta lista se mantiene a mano y `fanout.mjs` tiene la suya: un destino que
* se añada allí y se olvide aquí **no se mide y nadie se entera**. El control
* positivo no lo caza, porque demuestra que el comprobador ve lo que busca — no
* que esté buscando en todos los sitios. Por eso hay abajo un cotejo contra el
* catálogo de plataformas, y toda ausencia deliberada está escrita con su motivo.
*
* Pasó de verdad: `pinterest` (el directo, el del terreno propio) compone su
* título y su descripción con `captionFor` desde ago-2026 y se quedó fuera de
* esta lista. Once redes medidas de catorce destinos.
*/
A destination added to fanout.mjs and forgotten in this list does not get measured, and nobody notices. The positive control does not catch that, because it proves the checker sees what it is looking for, not that it is looking in all the places.
To close that gap, the checker reads fanout.mjs source code and extracts the destination names from the LOCAL, REMOTE, and OWN maps.
function destinosDelFanout() {
let src;
try {
src = readFileSync(new URL('./fanout.mjs', import.meta.url), 'utf8');
} catch {
return null; // no se pudo leer: quien llama lo dice, no lo da por vacío
}
const nombres = new Set();
for (const mapa of ['LOCAL', 'REMOTE', 'OWN']) {
const i = src.indexOf(`const ${mapa} = {`);
if (i < 0) continue;
// Se recorre contando llaves en vez de con una regex: un `{` dentro del mapa
// (un objeto anidado el día de mañana) cortaría la regex por donde no es.
let prof = 0;
let fin = i;
for (let k = src.indexOf(`{`, i); k < src.length; k++) {
if (src[k] === '{') prof++;
else if (src[k] === '}') { prof--; if (prof === 0) { fin = k; break; } }
}
const cuerpo = src.slice(src.indexOf(`{`, i) + 1, fin);
for (const trozo of cuerpo.split(',')) {
const clave = trozo.split(':')[0].trim().replace(/^['"]|['"]$/g, '');
if (/^[a-z][a-z0-9-]*$/.test(clave)) nombres.add(clave);
}
}
return nombres;
}
The source is read instead of imported because the eleven posters capture their credentials in module-level constants at import time, and social-daily.mjs imports the fan-out after applyLangCreds(). Importing it here would move that import earlier and break per-language credential application.
A positive control seeds the expected set with five destinations that must be present. If the reader finds fewer, it reports that the reader is broken and the absence check is unreliable.
The checker also compares the measured list against the platform catalog. Any platform with a spec but no measurement and no documented exclusion gets flagged. The exclusions are written out one by one, with a reason. An absence without a reason is indistinguishable from an oversight, which is exactly the failure this module exists to catch.
Boundaries
The MP3 parser handles MPEG 1, 2, and 2.5 Layer III with the standard bitrate tables. It does not handle other layers or non-standard encoders. The frame walk assumes the file is a sequence of frames after the ID3 tag, with no other metadata in between.
The MP4 prober reads two windows of up to 512 KB each. If the mvhd or hdlr atoms fall outside those windows, they are not found. The prober returns whatever it managed to read; a file that produces no tracks and no duration triggers a warning in the validator rather than an error.
Both parsers use only Node.js built-in modules. No ffmpeg, no npm packages. The MP3 parser is 76 lines including comments and tables. The MP4 prober is a single function plus a small window helper. For the specific encoders and file structures these pipelines produce, that is enough.
Top comments (0)