/** * Measure what an audio backend actually delivered. * * Every backend returns a `durationMs` that is the REQUEST, not the result: * * native-lyria3.ts durationSec * 1000 (what we asked for) * native-elevenlabs-sfx durationSec * 1000 (what we asked for) * native-elevenlabs-tts estimateDurationMs(text) (~14 chars/sec guess) * * Consumers read the field as delivered length, and it is routinely wrong: * * soundtrack.json said 64000ms the mp3 was 30.77s (four films running) * narration.json said 54357ms the mp3 was 60.14s * narration.json said 65643ms the mp3 was 69.01s * * The soundtrack case is the expensive one. A 30.7s bed under a 64.1s film gets * looped by the mixer, and the loop seam lands wherever it lands — on one * delivered film, 6.7s into the climax shot. Nothing reported that, because the * artifact said the bed was 64 seconds long. * * `native-lyria3.ts` records the reasoning that produced this: "MP3 duration * parsing is out of scope — Lyria 3 returns audio/mpeg without a trivial header * read." True, and beside the point: ffprobe is already a hard dependency of * this repo, and `ffprobeDuration` already exists. No header parsing needed. * * A failed probe keeps the estimate rather than inventing a number — the file * is still on disk and still playable, and a wrong-but-close duration beats * refusing to write the artifact at all. */ import { existsSync } from 'node:fs'; import { ffprobeDuration } from '../assemble/ffmpeg.js'; export interface MeasureDurationOptions { /** Dry runs never wrote a file; keep the planned figure. */ dryRun?: boolean; ffprobeBin?: string; } /** * The delivered duration of `path` in ms, falling back to `plannedMs` when the * file cannot be probed (missing, dry run, or ffprobe unavailable). */ export async function measureDeliveredDurationMs( path: string, plannedMs: number, options: MeasureDurationOptions = {}, ): Promise { if (options.dryRun) return plannedMs; if (!path || !existsSync(path)) return plannedMs; try { // ffprobeDuration returns MILLISECONDS despite the bare name — its own doc // says so, and stitch.ts divides by 1000 to get seconds. Converting again // here reported a 30-second bed as 30,694 seconds, which is exactly the // sort of thing a "measured" figure is supposed to stop. const ms = await ffprobeDuration(path, { ...(options.ffprobeBin ? { ffprobeBin: options.ffprobeBin } : {}), }); if (!Number.isFinite(ms) || ms <= 0) return plannedMs; return Math.round(ms); } catch { return plannedMs; } }