/** * Shared FFmpeg / ffprobe helper for the assemble stage (sub-slice 3e). * * Source of truth: `skills/video-replicator/scripts/ffmpeg_wrapper.py`. * * This ports the proven Python `FFmpegWrapper` spawn primitives to Node: * - `runFfmpeg` mirrors `FFmpegWrapper.run` — prepends `-y`, captures stderr, * raises on a non-zero exit (here: `VclawError('ffmpeg_failed', ...)`). * - `ffprobeDuration` mirrors `FFmpegWrapper.probe`/`get_duration` — runs * ffprobe with `format=duration` and returns the value in **milliseconds**. * - `STANDARD_VIDEO_ARGS` / `STANDARD_AUDIO_ARGS` encode the exact codec / * rate / preset / crf knobs the Python encoders use, so segments produced by * `animate-slides.ts` (and later 3h stitch) share one uniform encoding. * * IMPORTANT — testing boundary: the *arg shapes* built here are the unit-tested * surface. Actually spawning ffmpeg/ffprobe against real media (and eyeballing * the result) is a HUMAN integration checkpoint, explicitly out of scope for * the unit tests. Tests exercise the dry-run path + the env/bin override only; * they never require ffmpeg to be installed. * * ffmpeg/ffprobe are system binaries (no npm dep); they are spawned directly. */ import { spawn } from 'node:child_process'; import { VclawError } from '../errors.js'; /** * Standard H.264 video encoding params — matches the Python encoders * (`-r 24 -c:v libx264 -preset fast -crf 20`). Keeping these uniform across * every segment is what lets the 3h stitch step concat segments cleanly. */ export const STANDARD_VIDEO_ARGS: readonly string[] = [ '-r', '24', '-c:v', 'libx264', '-preset', 'fast', '-crf', '20', ]; /** * Standard AAC audio encoding params — matches the Python encoders * (`-c:a aac -ar 44100 -ac 2`). */ export const STANDARD_AUDIO_ARGS: readonly string[] = [ '-c:a', 'aac', '-ar', '44100', '-ac', '2', ]; /** Resolve the ffmpeg binary: explicit opt > VCLAW_FFMPEG_BIN env > `ffmpeg`. */ export function resolveFfmpegBin(explicit?: string): string { return explicit ?? process.env.VCLAW_FFMPEG_BIN ?? 'ffmpeg'; } /** Resolve the ffprobe binary: explicit opt > VCLAW_FFPROBE_BIN env > `ffprobe`. */ export function resolveFfprobeBin(explicit?: string): string { return explicit ?? process.env.VCLAW_FFPROBE_BIN ?? 'ffprobe'; } /** * Capability-aware ffmpeg resolution. * * Homebrew ships a SLIM `ffmpeg` (the one on PATH) and a keg-only `ffmpeg-full` * that is never symlinked. Three filters the repo actually uses — `subtitles`, * `ass`, `drawtext` — exist ONLY in the full build. Without this, a caption burn * silently degrades to a soft-muxed subtitle track: the file plays, exit code is * 0, and the words are not in the pixels. * * `skills/rhyme-factory/scripts/lib/common.sh` already solves this in shell * (`FFMPEG_FULL` + `require_ffmpeg_full`); this is that pattern in TypeScript so * the Node lanes get it too. * * This is NOT a silent fallback across routes (which the ADRs forbid) — it picks * between binaries for the SAME operation, and `resolveCapableFfmpeg` reports * which one it chose so the selection is visible to the caller. */ const CAPABLE_FFMPEG_CANDIDATES: readonly string[] = [ '/opt/homebrew/opt/ffmpeg-full/bin/ffmpeg', '/usr/local/opt/ffmpeg-full/bin/ffmpeg', ]; /** Cache of `` -> advertised filter names. Probing spawns a process; do it once. */ const filterCache = new Map>(); /** Probe one binary's `-filters` output. Returns an empty set if it cannot run. */ export async function probeFfmpegFilters(bin: string): Promise> { const cached = filterCache.get(bin); if (cached) return cached; const names = new Set(); try { const out = await new Promise((resolve, reject) => { const child = spawn(bin, ['-hide_banner', '-filters'], { stdio: ['ignore', 'pipe', 'pipe'] }); let buf = ''; child.stdout.on('data', (c) => { buf += String(c); }); child.stderr.on('data', (c) => { buf += String(c); }); child.on('error', reject); child.on('close', () => resolve(buf)); }); // Rows look like: ` .. subtitles V->V Render text subtitles ...` for (const line of out.split('\n')) { const m = /^\s*[A-Z.]{0,3}\s+([A-Za-z0-9_]+)\s+\S+->\S+/.exec(line); if (m) names.add(m[1]); } } catch { // Unreadable binary -> empty capability set; the caller decides what that means. } filterCache.set(bin, names); return names; } /** True when `bin` advertises every filter in `filters`. */ export async function ffmpegBinSupportsFilters(bin: string, filters: readonly string[]): Promise { if (filters.length === 0) return true; const have = await probeFfmpegFilters(bin); return filters.every((f) => have.has(f)); } export interface CapableFfmpeg { /** The binary to spawn. */ bin: string; /** Where it came from — for logging, so the choice is never invisible. */ source: 'explicit' | 'env' | 'path' | 'fallback'; } /** * Resolve an ffmpeg that can actually perform `requiredFilters`. * * Order: explicit opt > `VCLAW_FFMPEG_BIN` > PATH `ffmpeg` > known ffmpeg-full * locations. An explicit/env choice is NEVER silently overridden — if the * operator named a binary that cannot do the job, that is an error, not a cue to * substitute a different one. When nothing was configured, a capable binary is * auto-attached. * * Throws `VclawError('ffmpeg_failed')` naming the missing filters and the fix. */ export async function resolveCapableFfmpeg( requiredFilters: readonly string[], explicit?: string, ): Promise { const configured = explicit ?? process.env.VCLAW_FFMPEG_BIN; if (configured) { const source: CapableFfmpeg['source'] = explicit ? 'explicit' : 'env'; if (await ffmpegBinSupportsFilters(configured, requiredFilters)) return { bin: configured, source }; throw new VclawError( 'ffmpeg_failed', `The configured ffmpeg (${configured}) does not provide: ${requiredFilters.join(', ')}. ` + 'Point VCLAW_FFMPEG_BIN at a build that does (on macOS: `brew install ffmpeg-full`, ' + 'then /opt/homebrew/opt/ffmpeg-full/bin/ffmpeg).', ); } if (await ffmpegBinSupportsFilters('ffmpeg', requiredFilters)) return { bin: 'ffmpeg', source: 'path' }; for (const candidate of CAPABLE_FFMPEG_CANDIDATES) { if (await ffmpegBinSupportsFilters(candidate, requiredFilters)) return { bin: candidate, source: 'fallback' }; } throw new VclawError( 'ffmpeg_failed', `No available ffmpeg provides: ${requiredFilters.join(', ')}. ` + 'The Homebrew `ffmpeg` formula is a slim build without libass/libfreetype. ' + 'Install the full build (`brew install ffmpeg-full`) or set VCLAW_FFMPEG_BIN to a capable binary.', ); } /** Test seam — clears the per-binary filter cache. */ export function resetFfmpegCapabilityCache(): void { filterCache.clear(); } /** * Per-clip cut-at-N tail trim (WS9). Returns the ffmpeg `-t ` flag pair * when `maxSeconds` is a positive number, else `[]` (no trim). PURE — splice the * result into a per-clip ffmpeg arg array just before the output path. */ export function trimTailArgs(maxSeconds?: number): string[] { return maxSeconds && maxSeconds > 0 ? ['-t', String(maxSeconds)] : []; } /** * Letterbox normalization filter (WS9). Scales the source to the canvas * `width` (preserving aspect via `-2`) and pads it onto a `width`x`height` * black canvas, producing cinematic bars. Mirrors the DHUAAN TITLEFIT pattern * (`scale=W:-2,pad=W:H:0:(oh-ih)/2:black`). Returns '' when no `ratio` is given * (an empty/undefined ratio disables the filter). PURE — no ffmpeg spawned. * * `ratio` is the target aspect ratio label (e.g. '2.39:1'); the actual letterbox * geometry comes from the `width`/`height` canvas the caller already targets. */ export function letterboxFilter( ratio: string | undefined, width: number, height: number, ): string { if (!ratio) return ''; return `scale=${width}:-2,pad=${width}:${height}:0:(oh-ih)/2:black`; } export interface RunFfmpegOptions { /** Build + return the command string without spawning ffmpeg. */ dryRun?: boolean; /** Override the ffmpeg binary (falls back to VCLAW_FFMPEG_BIN, then `ffmpeg`). */ ffmpegBin?: string; } export interface RunFfmpegResult { exitCode: number; stderr: string; /** The fully-resolved command line that was (or would be) run. */ command: string; } /** * Render a binary + args into a printable command string. Tokens containing * whitespace or shell-significant chars are single-quoted so the printed * command is copy-pasteable. This is for *logging/inspection only* — the actual * spawn passes the args array verbatim (no shell), so quoting never affects * execution. */ function formatCommand(bin: string, args: string[]): string { const quote = (tok: string): string => /[^A-Za-z0-9_\-./:=,]/.test(tok) ? `'${tok.replace(/'/g, `'\\''`)}'` : tok; return [bin, ...args].map(quote).join(' '); } /** * Run ffmpeg with the given args. The `-y` flag (overwrite output without * prompting) is prepended automatically, mirroring the Python wrapper, so * callers never block on an interactive prompt. * * On `dryRun`, returns the command string without spawning (exitCode 0, * empty stderr). On a non-zero exit, throws `VclawError('ffmpeg_failed', ...)`. * * NOTE: This is the real-spawn path. Unit tests must use `dryRun: true` — we do * NOT run ffmpeg in tests. */ export async function runFfmpeg( args: string[], opts: RunFfmpegOptions = {}, ): Promise { const bin = resolveFfmpegBin(opts.ffmpegBin); // Prepend -y exactly like FFmpegWrapper.run. const fullArgs = ['-y', ...args]; const command = formatCommand(bin, fullArgs); if (opts.dryRun) { return { exitCode: 0, stderr: '', command }; } return await new Promise((resolve, reject) => { const child = spawn(bin, fullArgs, { stdio: ['ignore', 'ignore', 'pipe'] }); let stderr = ''; child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString(); }); child.on('error', (err) => { reject( new VclawError( 'ffmpeg_failed', `Failed to spawn ffmpeg ("${bin}"): ${err.message}`, { bin, command }, ), ); }); child.on('close', (code) => { const exitCode = code ?? 1; if (exitCode !== 0) { reject( new VclawError( 'ffmpeg_failed', `ffmpeg exited with code ${exitCode}.`, { exitCode, command, stderr: stderr.trim().slice(0, 500) }, ), ); return; } resolve({ exitCode, stderr, command }); }); }); } export interface FfprobeDurationOptions { /** Return 0 without spawning (dry-run friendly, mirrors callers). */ dryRun?: boolean; /** Override the ffprobe binary (falls back to VCLAW_FFPROBE_BIN, then `ffprobe`). */ ffprobeBin?: string; } /** * Probe a media file's duration, returned in **milliseconds**. * * Mirrors `FFmpegWrapper.probe(entries="format=duration")` + `get_duration`: * ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 * * On `dryRun`, returns 0 without spawning. Throws `VclawError('ffmpeg_failed', ...)` * if ffprobe fails or emits an unparseable duration. */ export async function ffprobeDuration( path: string, opts: FfprobeDurationOptions = {}, ): Promise { if (opts.dryRun) return 0; const bin = resolveFfprobeBin(opts.ffprobeBin); const args = [ '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', path, ]; const command = formatCommand(bin, args); return await new Promise((resolve, reject) => { const child = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString(); }); child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString(); }); child.on('error', (err) => { reject( new VclawError('ffmpeg_failed', `Failed to spawn ffprobe ("${bin}"): ${err.message}`, { bin, command, }), ); }); child.on('close', (code) => { const exitCode = code ?? 1; if (exitCode !== 0) { reject( new VclawError('ffmpeg_failed', `ffprobe exited with code ${exitCode} for "${path}".`, { exitCode, command, stderr: stderr.trim().slice(0, 500), }), ); return; } const seconds = Number.parseFloat(stdout.trim()); if (!Number.isFinite(seconds)) { reject( new VclawError( 'ffmpeg_failed', `ffprobe returned an unparseable duration for "${path}": ${JSON.stringify(stdout.trim())}`, { command }, ), ); return; } resolve(Math.round(seconds * 1000)); }); }); } export interface IsValidMp4Options { /** Override the ffprobe binary (falls back to VCLAW_FFPROBE_BIN, then `ffprobe`). */ ffprobeBin?: string; /** Probe timeout in ms; on expiry the probe is killed and false is returned. Default 15000. */ timeoutMs?: number; } /** * True iff ffprobe can read a positive duration from `path`. Catches * truncated/no-moov MP4s that pass existence+size checks but fail at * stitch. Ported from parallel_video_gen._ffprobe_is_valid_mp4. Never * throws — returns false on any probe failure / missing file / timeout. * * Mirrors the Python guard's args exactly: * ffprobe -v error -show_entries format=duration -of csv=p=0 * returning true only when ffprobe exits 0 AND stdout trims to a finite * number > 0. */ export async function isValidMp4(path: string, opts: IsValidMp4Options = {}): Promise { const bin = resolveFfprobeBin(opts.ffprobeBin); const timeoutMs = opts.timeoutMs ?? 15000; const args = [ '-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', path, ]; return await new Promise((resolve) => { let settled = false; const done = (value: boolean): void => { if (settled) return; settled = true; clearTimeout(timer); resolve(value); }; let child: ReturnType; try { child = spawn(bin, args, { stdio: ['ignore', 'pipe', 'ignore'] }); } catch { // Spawn threw synchronously (e.g. binary not found on some platforms). done(false); return; } const timer = setTimeout(() => { child.kill('SIGKILL'); done(false); }, timeoutMs); let stdout = ''; child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString(); }); // Missing binary / spawn failure → false (never throws). child.on('error', () => done(false)); child.on('close', (code) => { if (code !== 0) { done(false); return; } const seconds = Number.parseFloat(stdout.trim()); done(Number.isFinite(seconds) && seconds > 0); }); }); }