/** * Narration combined-track concat (resolves the `concatenate_audio_files` * 3b deferral in `tts.ts`). * * Ported from `skills/video-replicator/scripts/generate_tts.py` * (`generate_silence` + `concatenate_audio_files`): concatenate per-scene * narration audio into a single track, optionally padding each scene's audio * with trailing silence to match its scene's on-screen duration (so the * combined narration stays aligned to the slide timeline). * * Repo pattern (sub-slice 3h): the PLAN — every ffmpeg arg array and concat * list — is built by the pure {@link planNarrationConcat} and is the tested * surface; {@link runNarrationConcat} is the thin real-spawn executor and is * never run in tests (use `dryRun`). * * Encoding mirrors the Python: silence is anullsrc 44.1kHz stereo, per-scene * pad-concat is stream-copy, and the final combined track is always * libmp3lame 128k (an .mp3, regardless of the per-scene input format). */ import { mkdir, writeFile } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; import { runFfmpeg, type RunFfmpegOptions } from './ffmpeg.js'; import { VclawError } from '../errors.js'; /** Escape a path for an ffmpeg concat-demuxer list line: file ''. */ export function concatListLine(path: string): string { return `file '${resolve(path).replace(/'/g, `'\\''`)}'`; } export interface NarrationConcatInput { /** Per-scene audio paths, in scene order. */ files: string[]; /** Combined output path (.mp3 — the track is always re-encoded libmp3lame). */ output: string; /** Working directory for silence/padded intermediates + concat lists. */ tempDir: string; /** * Pad each scene's audio with trailing silence up to its target duration. * Requires both duration arrays; scenes beyond `sceneDurationsSec`, scenes * with unknown (<=0) actual duration, and scenes already at/over target are * used as-is (the Python's rules). */ padToDuration?: boolean; /** Measured per-scene audio durations, seconds (parallel to `files`). */ actualDurationsSec?: number[]; /** Target per-scene on-screen durations, seconds (parallel to `files`). */ sceneDurationsSec?: number[]; } export interface NarrationPadStep { sceneIndex: number; gapSeconds: number; /** ffmpeg args generating the silence gap file. */ silencePath: string; silenceArgs: string[]; /** Concat-demuxer list (content + path) joining original + silence. */ listPath: string; listContent: string; /** ffmpeg args stream-copy-concatenating original + silence. */ paddedPath: string; concatArgs: string[]; } export interface NarrationConcatPlan { /** Per-scene padding work (empty when padding is off / not needed). */ padSteps: NarrationPadStep[]; /** The file list actually concatenated (originals with padded substitutions). */ effectiveFiles: string[]; finalListPath: string; finalListContent: string; finalArgs: string[]; output: string; } /** * Build the full concat plan — pure (no I/O): all ffmpeg arg arrays and * concat-list contents, with padded intermediates substituted into the final * list. Throws only on invalid input (no files). */ export function planNarrationConcat(input: NarrationConcatInput): NarrationConcatPlan { if (!Array.isArray(input.files) || input.files.length === 0) { throw new VclawError( 'unexpected_internal_error', 'planNarrationConcat: input.files must be a non-empty array', ); } const padSteps: NarrationPadStep[] = []; const effectiveFiles = [...input.files]; if (input.padToDuration && input.sceneDurationsSec && input.actualDurationsSec) { for (let i = 0; i < input.files.length; i += 1) { if (i >= input.sceneDurationsSec.length) continue; const target = input.sceneDurationsSec[i]; const actual = input.actualDurationsSec[i] ?? 0; if (!(actual > 0) || !(target > actual)) continue; const gapSeconds = target - actual; const silencePath = join(input.tempDir, `silence_${i}.mp3`); const paddedPath = join(input.tempDir, `padded_${i}.mp3`); const listPath = join(input.tempDir, `concat_${i}.txt`); padSteps.push({ sceneIndex: i, gapSeconds, silencePath, silenceArgs: [ '-f', 'lavfi', '-i', 'anullsrc=r=44100:cl=stereo', '-t', gapSeconds.toFixed(3), '-c:a', 'libmp3lame', '-b:a', '128k', silencePath, ], listPath, listContent: `${concatListLine(input.files[i])}\n${concatListLine(silencePath)}\n`, paddedPath, concatArgs: ['-f', 'concat', '-safe', '0', '-i', listPath, '-c', 'copy', paddedPath], }); effectiveFiles[i] = paddedPath; } } const finalListPath = `${input.output}.concat.txt`; return { padSteps, effectiveFiles, finalListPath, finalListContent: effectiveFiles.map((f) => `${concatListLine(f)}\n`).join(''), finalArgs: [ '-f', 'concat', '-safe', '0', '-i', finalListPath, '-c:a', 'libmp3lame', '-b:a', '128k', input.output, ], output: input.output, }; } /** * Execute a {@link NarrationConcatPlan}: write the concat lists, run each pad * step, then the final concat. Real-spawn path — unit tests must pass * `dryRun: true` (lists are still written so the command is inspectable). */ export async function runNarrationConcat( plan: NarrationConcatPlan, opts: RunFfmpegOptions = {}, ): Promise<{ output: string; commands: string[] }> { const commands: string[] = []; await mkdir(dirname(plan.finalListPath), { recursive: true }); for (const step of plan.padSteps) { await mkdir(dirname(step.listPath), { recursive: true }); const silence = await runFfmpeg(step.silenceArgs, opts); commands.push(silence.command); await writeFile(step.listPath, step.listContent, 'utf8'); const padded = await runFfmpeg(step.concatArgs, opts); commands.push(padded.command); } await writeFile(plan.finalListPath, plan.finalListContent, 'utf8'); const final = await runFfmpeg(plan.finalArgs, opts); commands.push(final.command); return { output: plan.output, commands }; }