/** * `vclaw video match-highlights` — the PURE cutting half. * * Window planning plus the ffmpeg argv shapes that turn a merged event list into * a reel. Ported from the proven `cut.py` of the live 2026-09-16 cricket run; * the numbers and flag order here are the ones that produced usable reels, not a * fresh guess: * * - `-ss` goes BEFORE `-i` and the window is RE-ENCODED. Input seeking plus a * re-encode is what makes each cut frame-accurate; a stream copy would snap * every window back to the previous keyframe and lose the run-up. * - Uniform encode params across windows (`libx264 veryfast crf 20 yuv420p` + * `aac 128k` stereo) are what lets the final concat be a plain `-c copy`. * - A window shorter than `minSeconds` after padding is dropped: a sub-3 s * fragment reads as a glitch, not a highlight. * * Everything here is a pure function — no ffmpeg is spawned, no file is touched. */ /** Seconds added before each event start (the run-up needs a beat of lead-in). */ export const DEFAULT_PRE_SECONDS = 1.0; /** Seconds added after each event end (the ball has to settle). */ export const DEFAULT_POST_SECONDS = 1.5; /** Windows shorter than this after padding are dropped. */ export const DEFAULT_MIN_WINDOW_SECONDS = 3.0; /** Filmstrip geometry: one frame every 8 s, 160 px wide, 15 columns. */ export const FILMSTRIP_INTERVAL_SECONDS = 8; export const FILMSTRIP_WIDTH = 160; export const FILMSTRIP_COLUMNS = 15; export interface MatchWindowInput { s: number; e: number; t?: string; n?: string; } export interface MatchWindow { start: number; end: number; /** Type of the FIRST event in the window (merging keeps the opener's label). */ type: string; /** Notes of every merged event, joined with " | ". */ note: string; /** How many source events this window covers (>1 after an overlap merge). */ events: number; /** * Indices into the input array that this window covers, in order. * * This is what lets a judge verdict be mapped back to the event it judged. * Reconstructing the event by un-padding (`start + pre`) is NOT safe: a window * clamped at 0 or at the duration no longer carries the padding, and a merged * window would hand ONE verdict to TWO events. */ sources: number[]; } export interface PlanWindowsOptions { durationSeconds: number; pre?: number; post?: number; minSeconds?: number; /** Keep only these event types. Empty/undefined keeps every event. */ types?: readonly string[]; /** * Absorb a window that starts at or before the running window's end (default * true). Judge reels pass `false`: the model is told "window i is candidate i", * so a merge there would silently renumber everything after it. */ mergeOverlaps?: boolean; } /** * Pad → clamp → drop-short → sort → merge-overlaps. * * Merging is what stops a rapid sequence of events becoming a stutter of nearly * identical clips: any window starting at or before the running window's end is * absorbed into it, and the absorbed notes are appended so the EDL still says * what happened. Empty notes are skipped when joining (the Python original * appended them and produced " | | " runs). */ export function planWindows( events: readonly MatchWindowInput[], options: PlanWindowsOptions, ): MatchWindow[] { const pre = options.pre ?? DEFAULT_PRE_SECONDS; const post = options.post ?? DEFAULT_POST_SECONDS; const minSeconds = options.minSeconds ?? DEFAULT_MIN_WINDOW_SECONDS; const keep = new Set((options.types ?? []).filter((t) => t !== '')); const duration = options.durationSeconds; const padded: MatchWindow[] = []; for (const [index, event] of events.entries()) { if (!Number.isFinite(event.s) || !Number.isFinite(event.e)) continue; if (keep.size > 0 && !(event.t !== undefined && keep.has(event.t))) continue; const start = Math.max(0, event.s - pre); const end = Math.min(duration, event.e + post); if (end - start < minSeconds) continue; padded.push({ start, end, type: event.t ?? '?', note: event.n ?? '', events: 1, sources: [index] }); } padded.sort((a, b) => (a.start === b.start ? a.end - b.end : a.start - b.start)); if (options.mergeOverlaps === false) return padded; const merged: MatchWindow[] = []; for (const window of padded) { const last = merged[merged.length - 1]; if (last && window.start <= last.end) { last.end = Math.max(last.end, window.end); last.events += 1; last.sources = [...last.sources, ...window.sources]; if (window.note !== '') last.note = last.note === '' ? window.note : `${last.note} | ${window.note}`; continue; } merged.push({ ...window, sources: [...window.sources] }); } return merged; } /** Total reel length in seconds. */ export function windowSeconds(windows: readonly MatchWindow[]): number { return windows.reduce((total, window) => total + (window.end - window.start), 0); } /** The EDL rows written beside a reel as `.edl.json`. */ export function buildEdl(windows: readonly MatchWindow[]): Array<{ start: number; end: number; type: string; note: string }> { return windows.map((window) => ({ start: Number(window.start.toFixed(3)), end: Number(window.end.toFixed(3)), type: window.type, note: window.note, })); } /** * ffmpeg argv (everything AFTER `ffmpeg -y`) for ONE window. * * `-ss` precedes `-i` (fast input seek) and the window is re-encoded, which is * what makes the cut frame-accurate instead of snapping to a keyframe. */ export function buildWindowCutArgs(input: { source: string; start: number; end: number; output: string; }): string[] { return [ '-v', 'error', '-ss', input.start.toFixed(3), '-i', input.source, '-t', (input.end - input.start).toFixed(3), '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '128k', '-ac', '2', '-movflags', '+faststart', input.output, ]; } /** The `-f concat` list file body for the rendered windows. */ export function buildConcatList(paths: readonly string[]): string { return paths.map((path) => `file '${path.replace(/'/g, `'\\''`)}'\n`).join(''); } /** ffmpeg argv (after `ffmpeg -y`) that concatenates the uniformly-encoded windows without re-encoding. */ export function buildConcatArgs(input: { listPath: string; output: string }): string[] { return [ '-v', 'error', '-f', 'concat', '-safe', '0', '-i', input.listPath, '-c', 'copy', '-movflags', '+faststart', input.output, ]; } /** * Filmstrip tile geometry. * * `tile=15x-1` is INVALID — the tile filter needs explicit columns AND rows, so * the rows are computed from how many frames the reel will actually yield. * The live run's `-1` silently produced no strip at all. */ export function filmstripGrid(input: { seconds: number; intervalSeconds?: number; columns?: number; }): { frames: number; columns: number; rows: number } { const interval = input.intervalSeconds ?? FILMSTRIP_INTERVAL_SECONDS; const columns = Math.max(1, input.columns ?? FILMSTRIP_COLUMNS); const frames = Math.max(1, Math.ceil(input.seconds / interval)); return { frames, columns, rows: Math.max(1, Math.ceil(frames / columns)) }; } /** * ffmpeg argv (after `ffmpeg -y`) for the QC filmstrip: one frame every * `intervalSeconds`, scaled to `width`, tiled into a single image. `tile` pads a * partial last row with black at EOF, so one output frame is always correct. */ export function buildFilmstripArgs(input: { source: string; output: string; seconds: number; intervalSeconds?: number; columns?: number; width?: number; }): string[] { const interval = input.intervalSeconds ?? FILMSTRIP_INTERVAL_SECONDS; const width = input.width ?? FILMSTRIP_WIDTH; const { columns, rows } = filmstripGrid({ seconds: input.seconds, intervalSeconds: interval, ...(input.columns !== undefined ? { columns: input.columns } : {}), }); return [ '-v', 'error', '-i', input.source, '-vf', `fps=1/${interval},scale=${width}:-1,tile=${columns}x${rows}`, '-frames:v', '1', '-update', '1', input.output, ]; }