import { spawn } from "node:child_process"; import { markToolInputError } from "@snapotter/shared"; import { resolveFfmpeg } from "./binaries.js"; import { type FfmpegProgress, parseProgressBlock } from "./progress.js"; export interface RunFfmpegOptions { signal?: AbortSignal; timeoutMs?: number; onProgress?: (p: FfmpegProgress) => void; } const STDERR_RING_MAX = 16 * 1024; // stderr shapes that mean "the input media is unusable", not an ffmpeg bug. // Marked errors are classified expected upstream and never reach Sentry. const INPUT_ERROR_PATTERNS = [ /received no packets/i, /invalid data found when processing input/i, /could not find codec parameters/i, /moov atom not found/i, ]; /** * Applies the isToolInputError marker (see @snapotter/shared tool-errors.ts) * when the captured stderr matches a known input-failure shape. */ export function markIfInputError(err: E, stderr: string): E { return INPUT_ERROR_PATTERNS.some((re) => re.test(stderr)) ? markToolInputError(err) : err; } /** * Runs ffmpeg with `-progress pipe:1` appended, parsing progress blocks from * stdout. Rejects with the tail of stderr on non-zero exit, timeout or abort. * Resolves with the captured stderr tail (filters like silencedetect report * there). Output must go to a FILE path in args (no stdout piping of media * data). */ export async function runFfmpeg(args: string[], opts: RunFfmpegOptions = {}): Promise { const bin = resolveFfmpeg(); if (!bin) throw new Error("ffmpeg binary not found (set FFMPEG_PATH or install ffmpeg)"); return new Promise((resolvePromise, reject) => { const child = spawn(bin, ["-hide_banner", "-nostdin", "-y", ...args, "-progress", "pipe:1"], { stdio: ["ignore", "pipe", "pipe"], }); let stderrTail = ""; let settled = false; let buffer = ""; const timeoutMs = opts.timeoutMs; const timer = timeoutMs ? setTimeout(() => { fail(new Error(`ffmpeg timed out after ${Math.round(timeoutMs / 1000)}s`)); }, timeoutMs) : undefined; const onAbort = () => fail(new Error("Canceled")); if (opts.signal) { if (opts.signal.aborted) onAbort(); else opts.signal.addEventListener("abort", onAbort, { once: true }); } function cleanup() { clearTimeout(timer); opts.signal?.removeEventListener("abort", onAbort); } function fail(err: Error) { if (settled) return; settled = true; cleanup(); child.kill("SIGKILL"); reject(err); } child.stdout.on("data", (chunk: Buffer) => { buffer += chunk.toString("utf8"); // Blocks end at the line that starts with "progress=" let idx = buffer.indexOf("progress="); while (idx !== -1) { const lineEnd = buffer.indexOf("\n", idx); if (lineEnd === -1) break; const block = buffer.slice(0, lineEnd); buffer = buffer.slice(lineEnd + 1); try { opts.onProgress?.(parseProgressBlock(block)); } catch (cbErr) { fail(cbErr instanceof Error ? cbErr : new Error(String(cbErr))); return; } idx = buffer.indexOf("progress="); } }); child.stderr.on("data", (chunk: Buffer) => { stderrTail = (stderrTail + chunk.toString("utf8")).slice(-STDERR_RING_MAX); }); child.on("error", (err) => fail(err)); child.on("close", (code, signal) => { if (settled) return; settled = true; cleanup(); if (code === 0) resolvePromise(stderrTail); else { const err = new Error(`ffmpeg exited ${code ?? signal}: ${stderrTail.slice(-2000)}`); reject(markIfInputError(err, stderrTail)); } }); }); }