/** * cut-segment.ts — FRAME-EXACT extraction of one montage segment from a source * clip. PURE arg-builder (no I/O, no spawn) so it is unit-testable like * {@link buildAnimateArgs}; `runFfmpeg` prepends the `ffmpeg -y` and spawns. * * WHY THIS EXISTS — the load-bearing fix for audio-synced montages: * - Cutting each segment with `-t ` makes ffmpeg quantize to whole * frames and bias SHORT (~0.01–0.02s lost per cut). Over ~88 cuts that * accumulates LINEARLY to ~2s, so a beat-/vocal-synced edit drifts AHEAD of * its muxed audio by the end (lips/hits land early). The cure is to emit an * EXACT integer frame count via `-frames:v round(dur*fps)` (same trick as * {@link buildAnimateArgs}) so cumulative video-time == planned time. * - `-nostdin` so ffmpeg never consumes a caller's stdin (a `while read` loop * fed from a seg list otherwise loses bytes and corrupts the next path). * - Full-frame `scale=…:force_original_aspect_ratio=increase,crop=W:H` (never a * 2.39 crop — that chops heads on close-ups) + `setsar=1` so heterogeneous * source clips (different fps/SAR) concat cleanly. * - `-ss` BEFORE `-i` (fast input seek; ffmpeg's default `accurate_seek` makes * the first output frame land on the exact requested frame). * * The companion {@link assertNoDrift} lets the assembler verify, after building, * that the concatenated video length equals the planner total within one frame. */ import { TARGET_FPS } from './animate-slides.js'; export const CUT_DEFAULT_WIDTH = 1280; export const CUT_DEFAULT_HEIGHT = 720; export interface BuildSegmentCutArgsInput { /** Source clip to cut from. */ clipPath: string; /** Input-seek point in seconds (the lip-sync scrub offset / B-roll in-point). */ inSeconds: number; /** Desired segment duration; quantized to an exact whole number of frames. */ durationSec: number; /** Where the cut segment is written. */ outputPath: string; /** Output frame width (default 1280). */ width?: number; /** Output frame height (default 720). */ height?: number; /** Output frame rate (default 24, matching {@link TARGET_FPS}). */ fps?: number; /** x264 CRF (default 18). */ crf?: number; /** x264 preset (default 'veryfast' — segments are re-graded later in one pass). */ preset?: string; /** Keep source audio? Default false (`-an`); montage audio is muxed at the end. */ keepAudio?: boolean; } /** Exact whole-frame count for a segment duration (>= 1). */ export function segmentFrameCount(durationSec: number, fps: number = TARGET_FPS): number { return Math.max(1, Math.round(durationSec * fps)); } /** * Build the FFmpeg args for one frame-exact segment cut. PURE — the returned * array is everything AFTER `ffmpeg -y`. Outputs exactly * `segmentFrameCount(durationSec, fps)` frames starting at `inSeconds`. */ export function buildSegmentCutArgs(input: BuildSegmentCutArgsInput): string[] { const width = input.width ?? CUT_DEFAULT_WIDTH; const height = input.height ?? CUT_DEFAULT_HEIGHT; const fps = input.fps ?? TARGET_FPS; const crf = input.crf ?? 18; const preset = input.preset ?? 'veryfast'; const frames = segmentFrameCount(input.durationSec, fps); const vf = `scale=${width}:${height}:force_original_aspect_ratio=increase,` + `crop=${width}:${height},fps=${fps},setsar=1`; const args = [ '-nostdin', '-ss', input.inSeconds.toFixed(4), '-i', input.clipPath, '-vf', vf, '-frames:v', String(frames), ]; if (!input.keepAudio) args.push('-an'); args.push( '-c:v', 'libx264', '-crf', String(crf), '-preset', preset, '-pix_fmt', 'yuv420p', input.outputPath, ); return args; } /** * Assert the built (concatenated) video length matches the planned length to * within one frame. Throws with a descriptive message otherwise. Use this as * the post-build gate on any audio-synced assembly so cumulative drift can never * silently regress. */ export function assertNoDrift( plannedSeconds: number, builtSeconds: number, fps: number = TARGET_FPS, ): void { const frameSec = 1 / fps; const driftSec = Math.abs(plannedSeconds - builtSeconds); if (driftSec > frameSec + 1e-6) { const driftFrames = (driftSec * fps).toFixed(2); throw new Error( `assembly drift: built ${builtSeconds.toFixed(3)}s vs planned ` + `${plannedSeconds.toFixed(3)}s (${driftSec.toFixed(3)}s / ${driftFrames} frames > 1 frame). ` + `Every segment must be cut frame-exact (-frames:v), not with -t.`, ); } }