/** * FFmpeg ingest for motion-overlay. * * Thin side-effecting helpers over ffmpeg/ffprobe that the plan/execute layers * call to break an input video into the pieces the composer needs: * - `probeVideo` — duration (s) + width/height + derived aspect label. * - `extractAudio` — pull the audio track to a standalone file. * - `cutTake` — frame-accurate `-ss/-to` cut, re-encoded CRF18. * - `extractFrames` — sample reference frames for `readFrameObservations`. * - `readFrameObservations` — heuristic + notes stub from the sampled frames. * * Every ffmpeg/ffprobe call routes through an injectable `IngestDeps` so tests * run fully offline (no ffmpeg required) by supplying fakes. The defaults wrap * the proven `assemble/ffmpeg.ts` spawn primitives + a small ffprobe shell-out. */ import { spawn } from 'node:child_process'; import { ffprobeDuration, resolveFfprobeBin, runFfmpeg } from '../assemble/ffmpeg.js'; import type { FrameObservations } from './types.js'; export interface VideoProbe { durationSeconds: number; width: number; height: number; /** "16:9" | "9:16" | "1:1" | ":" reduced label. */ aspect: string; } /** Run an ffmpeg arg array. Mirrors `runFfmpeg`'s shape (returns a command). */ export type RunFfmpegFn = (args: string[]) => Promise<{ command: string }>; /** Probe a video → duration/width/height. */ export type ProbeFn = (path: string) => Promise<{ durationSeconds: number; width: number; height: number }>; export interface IngestDeps { /** Real ffmpeg by default; injected fake in tests. */ runFfmpeg?: RunFfmpegFn; /** Real ffprobe by default; injected fake in tests. */ probe?: ProbeFn; } /** Reduce a width:height pair to a tidy aspect label. */ export function aspectLabel(width: number, height: number): string { if (width <= 0 || height <= 0) return `${width}:${height}`; const g = gcd(width, height); const w = width / g; const h = height / g; // Snap the common 16:9 / 9:16 / 1:1 families even when the source is slightly // off (e.g. 1080x1920 reduces cleanly, but 1080x1916 should still read 9:16). const ratio = width / height; if (Math.abs(ratio - 16 / 9) < 0.02) return '16:9'; if (Math.abs(ratio - 9 / 16) < 0.02) return '9:16'; if (Math.abs(ratio - 1) < 0.02) return '1:1'; return `${w}:${h}`; } function gcd(a: number, b: number): number { let x = Math.abs(a); let y = Math.abs(b); while (y) { [x, y] = [y, x % y]; } return x || 1; } /** Default ffprobe-backed probe: duration (s) + the first video stream's w/h. */ const defaultProbe: ProbeFn = async (path: string) => { const durationMs = await ffprobeDuration(path); const { width, height } = await probeDimensions(path); return { durationSeconds: durationMs / 1000, width, height }; }; /** Default ffmpeg runner: the real spawn path from assemble/ffmpeg.ts. */ const defaultRunFfmpeg: RunFfmpegFn = async (args: string[]) => { const { command } = await runFfmpeg(args); return { command }; }; /** ffprobe the first video stream's pixel dimensions. */ function probeDimensions(path: string): Promise<{ width: number; height: number }> { const bin = resolveFfprobeBin(); const args = [ '-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'csv=s=x:p=0', path, ]; return new Promise((resolve, reject) => { const child = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; child.stdout?.on('data', (c: Buffer) => { stdout += c.toString(); }); child.stderr?.on('data', (c: Buffer) => { stderr += c.toString(); }); child.on('error', (err) => reject(new Error(`Failed to spawn ffprobe: ${err.message}`))); child.on('close', (code) => { if (code !== 0) { reject(new Error(`ffprobe exited ${code}: ${stderr.trim().slice(0, 300)}`)); return; } const match = stdout.trim().split('\n')[0]?.match(/^(\d+)x(\d+)$/); if (!match) { reject(new Error(`ffprobe returned unparseable dimensions: ${JSON.stringify(stdout.trim())}`)); return; } resolve({ width: Number(match[1]), height: Number(match[2]) }); }); }); } /** Probe a video for duration + dimensions + aspect label. */ export async function probeVideo(path: string, deps: IngestDeps = {}): Promise { const probe = deps.probe ?? defaultProbe; const { durationSeconds, width, height } = await probe(path); return { durationSeconds, width, height, aspect: aspectLabel(width, height) }; } /** Extract the input's audio track to `outPath` (copy codec, no re-encode). */ export async function extractAudio(inputPath: string, outPath: string, deps: IngestDeps = {}): Promise<{ command: string }> { const run = deps.runFfmpeg ?? defaultRunFfmpeg; // -vn drops video; copy the audio stream as-is for fidelity (CRITICAL RULE 1). return run(['-i', inputPath, '-vn', '-c:a', 'copy', outPath]); } /** * Cut a take frame-accurately from `inputPath` between `start` and `end` * (absolute seconds), re-encoding so the cut is exact (not keyframe-snapped). * Matches the design spec: `-ss/-to -c:v libx264 -crf 18 -preset fast -c:a aac`. */ export async function cutTake( inputPath: string, start: number, end: number, outPath: string, deps: IngestDeps = {}, ): Promise<{ command: string }> { const run = deps.runFfmpeg ?? defaultRunFfmpeg; return run([ '-i', inputPath, '-ss', String(start), '-to', String(end), '-c:v', 'libx264', '-crf', '18', '-preset', 'fast', '-c:a', 'aac', outPath, ]); } /** * Sample `count` reference frames evenly across `[0, durationSeconds)` to * `/frame_%02d.jpg`. Returns the relative-ish filenames produced (the * caller owns the dir). One ffmpeg call using the `fps` filter. */ export async function extractFrames( inputPath: string, outDir: string, durationSeconds: number, count: number, deps: IngestDeps = {}, ): Promise { const run = deps.runFfmpeg ?? defaultRunFfmpeg; const n = Math.max(1, Math.floor(count)); // Sample at count/duration fps so we land ~n frames across the clip. const fps = durationSeconds > 0 ? n / durationSeconds : 1; const pattern = `${outDir}/frame_%02d.jpg`; await run(['-i', inputPath, '-vf', `fps=${fps.toFixed(6)}`, '-frames:v', String(n), pattern]); const names: string[] = []; for (let i = 1; i <= n; i++) { names.push(`frame_${String(i).padStart(2, '0')}.jpg`); } return names; } /** * Derive `FrameObservations` from the sampled frames + the probe aspect. * * v1 is a deterministic heuristic stub: a portrait (9:16) talking-head is * assumed centred; a landscape source is assumed to have the speaker on one * side. It records a note about the untouchable region so the composer's GLOBAL * RULES carry it through. Pure given its inputs — no image decoding (that is a * later enhancement) — so it stays offline-testable. */ export function readFrameObservations(probe: { aspect: string }, frameFiles: string[]): FrameObservations { const notes: string[] = []; notes.push(`Reference frames sampled: ${frameFiles.length}.`); let speakerSide: FrameObservations['speakerSide']; if (probe.aspect === '9:16' || probe.aspect === '1:1') { speakerSide = 'center'; notes.push('Vertical/square source: assume the speaker is centred; keep the central region untouched.'); } else { speakerSide = 'left'; notes.push('Landscape source: assume the speaker occupies one side; keep that region untouched.'); } return { speakerSide, notes }; }