import { JOB_TIMEOUT_MS } from "../config"; import { unprocessable } from "../errors"; /** * ffprobe, with its own field names passed through untouched. * * `codec_name`, `r_frame_rate`, `nb_frames` and the rest reach the caller exactly as ffprobe emits * them. Renaming them into some house style would mean maintaining a translation table and would * make ffprobe's own documentation stop applying to this API, which is the opposite of the point. */ export type FfprobeStream = { index: number; codec_type?: string; codec_name?: string; width?: number; height?: number; [key: string]: unknown; }; export type FfprobeResult = { format?: { format_name?: string; duration?: string; bit_rate?: string; [key: string]: unknown }; streams?: FfprobeStream[]; }; export async function ffprobe(path: string): Promise { // Spawned as an argv ARRAY, never a command string — there is no shell here to quote for, so a // filename containing shell metacharacters is inert data. See docs/SECURITY.md, Layer 1. // // `-protocol_whitelist file` is the second lock: `path` is always one this process constructed, // but if that ever stops being true, ffprobe still cannot be talked into speaking http. const proc = Bun.spawn( [ "ffprobe", "-v", "error", "-protocol_whitelist", "file", "-print_format", "json", "-show_format", "-show_streams", path, ], { stdout: "pipe", stderr: "pipe", stdin: "ignore" }, ); const timer = setTimeout(() => proc.kill(), JOB_TIMEOUT_MS); try { const [stdout, stderr, code] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited, ]); if (code !== 0) { throw unprocessable(`Could not read that file: ${stderr.trim().split("\n").at(-1) ?? "ffprobe failed"}`); } return JSON.parse(stdout) as FfprobeResult; } finally { clearTimeout(timer); } }