import { spawn } from "node:child_process"; const FFPROBE_TIMEOUT_MS = 30_000; const MAX_OUTPUT_BYTES = 10 * 1024 * 1024; export interface VideoProbeResult { width?: number; height?: number; durationMs?: number; frameRate?: number; codec?: string; audioCodec?: string; } /** * Keep the input as a discrete argv value. `-i` ensures even a path beginning * with a dash is treated as input rather than another ffprobe option. */ export function buildFfprobeArgs(inputPath: string): string[] { return [ "-v", "error", "-print_format", "json", "-show_format", "-show_streams", "-i", inputPath, ]; } export async function probeVideoFile( inputPath: string, executable = process.env.FFPROBE_PATH ?? "ffprobe", ): Promise { if (inputPath.length === 0) throw new Error("ffprobe input path must not be empty"); const output = await new Promise((resolve, reject) => { const child = spawn(executable, buildFfprobeArgs(inputPath), { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], }); const stdout: Buffer[] = []; const stderr: Buffer[] = []; let outputBytes = 0; let settled = false; const finish = (error: Error | undefined, value?: string): void => { if (settled) return; settled = true; clearTimeout(timeout); if (error === undefined) resolve(value ?? ""); else reject(error); }; const timeout = setTimeout(() => { child.kill("SIGKILL"); finish(new Error(`ffprobe timed out after ${FFPROBE_TIMEOUT_MS}ms`)); }, FFPROBE_TIMEOUT_MS); child.stdout.on("data", (chunk: Buffer) => { outputBytes += chunk.length; if (outputBytes > MAX_OUTPUT_BYTES) { child.kill("SIGKILL"); finish(new Error("ffprobe output exceeded the 10 MiB limit")); return; } stdout.push(chunk); }); child.stderr.on("data", (chunk: Buffer) => { if (Buffer.concat(stderr).length < 16_384) stderr.push(chunk); }); child.once("error", (error) => finish(error)); child.once("close", (code, signal) => { if (code !== 0) { const detail = Buffer.concat(stderr).toString("utf8").trim(); finish( new Error( `ffprobe failed (${signal === null ? `exit ${String(code)}` : `signal ${signal}`})${ detail === "" ? "" : `: ${detail}` }`, ), ); return; } finish(undefined, Buffer.concat(stdout).toString("utf8")); }); }); return parseFfprobeOutput(output); } export function parseFfprobeOutput(output: string): VideoProbeResult { let parsed: unknown; try { parsed = JSON.parse(output) as unknown; } catch { throw new Error("ffprobe returned invalid JSON"); } const root = asRecord(parsed); if (root === undefined) throw new Error("ffprobe returned an invalid result"); const streams = Array.isArray(root.streams) ? root.streams.map(asRecord).filter((stream): stream is Record => stream !== undefined) : []; const format = asRecord(root.format); const video = streams.find((stream) => stream.codec_type === "video"); if (video === undefined) throw new Error("ffprobe found no video stream"); const audio = streams.find((stream) => stream.codec_type === "audio"); const durationSeconds = finiteNumber(format?.duration) ?? finiteNumber(video?.duration); const frameRate = rationalNumber(video?.avg_frame_rate) ?? rationalNumber(video?.r_frame_rate); return { ...optionalNumber("width", finiteInteger(video?.width)), ...optionalNumber("height", finiteInteger(video?.height)), ...optionalNumber( "durationMs", durationSeconds === undefined ? undefined : Math.max(0, Math.round(durationSeconds * 1000)), ), ...optionalNumber("frameRate", frameRate), ...optionalString("codec", video?.codec_name), ...optionalString("audioCodec", audio?.codec_name), }; } function asRecord(value: unknown): Record | undefined { if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; return value as Record; } function finiteNumber(value: unknown): number | undefined { const number = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN; return Number.isFinite(number) ? number : undefined; } function finiteInteger(value: unknown): number | undefined { const number = finiteNumber(value); return number !== undefined && Number.isInteger(number) ? number : undefined; } function rationalNumber(value: unknown): number | undefined { if (typeof value !== "string") return finiteNumber(value); const [numeratorText, denominatorText, extra] = value.split("/"); if (extra !== undefined || numeratorText === undefined || denominatorText === undefined) { return finiteNumber(value); } const numerator = Number(numeratorText); const denominator = Number(denominatorText); if (!Number.isFinite(numerator) || !Number.isFinite(denominator) || denominator === 0) return undefined; return numerator / denominator; } function optionalNumber(key: K, value: number | undefined): Partial> { return value === undefined ? {} : { [key]: value } as Record; } function optionalString(key: K, value: unknown): Partial> { return typeof value !== "string" || value === "" ? {} : { [key]: value } as Record; }