import { spawn } from "node:child_process"; import { constants } from "node:fs"; import { access, rm, stat } from "node:fs/promises"; import { setPriority } from "node:os"; import { delimiter, join } from "node:path"; /** * ffmpeg and ffprobe from PATH. Every call passes an argument array (never a shell), opens * inputs through the `file` protocol only, and forces the demuxer from the artifact's MIME type * so a crafted upload cannot make ffmpeg probe it as a playlist that reads other files or URLs. */ export interface FfmpegInfo { ffmpegPath: string; ffprobePath: string; } export interface MediaProbe { durationMs: number; /** Width and height as displayed: swapped when the stream is rotated by 90 or 270 degrees. */ video?: { codec: string; width: number; height: number; rotation: 0 | 90 | 180 | 270; /** Frames per second; 0 when unknown or implausible (variable frame rate files). */ fps: number; /** Pixel aspect ratio of the displayed frame; 1 for square pixels. */ sampleAspectRatio: number; }; audio?: { codec: string; sampleRate: number; channels: number }; hasAudio: boolean; } export interface FfmpegProgress { outTimeUs?: number; totalSize?: number; done: boolean; } export interface RunFfmpegOptions { signal?: AbortSignal; /** Expected output duration; progress is reported as out_time / durationMs. */ durationMs?: number; onProgress?: (fraction: number) => void; cwd?: string; /** Kills ffmpeg once the output grows past this size. */ maxOutputBytes?: number; /** Files removed when the run fails or is aborted. */ outputs?: readonly string[]; } export class FfmpegError extends Error { constructor( message: string, readonly exitCode: number | null, readonly stderrTail: string, ) { super(message); this.name = "FfmpegError"; } } /** ffmpeg reports progress twice a second while it works; silence this long means a hang. */ const STALL_TIMEOUT_MS = 2 * 60_000; const PROBE_TIMEOUT_MS = 60_000; const STDERR_TAIL_BYTES = 8 * 1024; const KILL_GRACE_MS = 5_000; const MAX_PLAUSIBLE_FPS = 240; const DEMUXERS: Record = { "video/mp4": "mov", "video/quicktime": "mov", "audio/mp4": "mov", "video/webm": "matroska", "audio/webm": "matroska", "audio/mpeg": "mp3", "audio/wav": "wav", "audio/ogg": "ogg", }; let detection: Promise | undefined; /** Finds ffmpeg and ffprobe on PATH once per process; null when either is missing or broken. */ export function detectFfmpeg(): Promise { detection ??= locateFfmpeg(); return detection; } async function locateFfmpeg(): Promise { const [ffmpegPath, ffprobePath] = await Promise.all([ findExecutable("ffmpeg"), findExecutable("ffprobe"), ]); if (!ffmpegPath || !ffprobePath) return null; try { const [ffmpeg, ffprobe] = await Promise.all([ capture(ffmpegPath, ["-hide_banner", "-version"], { timeoutMs: 10_000 }), capture(ffprobePath, ["-hide_banner", "-version"], { timeoutMs: 10_000 }), ]); return ffmpeg.code === 0 && ffprobe.code === 0 ? { ffmpegPath, ffprobePath } : null; } catch { return null; } } async function findExecutable(name: string): Promise { const extensions = process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE").split(";").filter(Boolean) : [""]; for (const directory of (process.env.PATH ?? "").split(delimiter)) { if (!directory) continue; for (const extension of extensions) { const candidate = join(directory, `${name}${extension}`); try { await access(candidate, constants.X_OK); if ((await stat(candidate)).isFile()) return candidate; } catch { // Not in this directory. } } } return null; } async function requireFfmpeg(): Promise { const info = await detectFfmpeg(); if (!info) throw new Error("ffmpeg and ffprobe are not installed on this server."); return info; } /** ffmpeg time argument with millisecond precision and no exponent notation. */ export function ffmpegSeconds(seconds: number): string { return String(Number(Math.max(0, seconds).toFixed(3))); } /** Input arguments for a local file: optional seek, file protocol only, forced demuxer. */ export function ffmpegInputArgs( path: string, options: { mimeType?: string; startSeconds?: number; durationSeconds?: number } = {}, ): string[] { const demuxer = options.mimeType ? DEMUXERS[options.mimeType] : undefined; return [ ...(options.startSeconds ? ["-ss", ffmpegSeconds(options.startSeconds)] : []), ...(options.durationSeconds !== undefined ? ["-t", ffmpegSeconds(options.durationSeconds)] : []), "-protocol_whitelist", "file", ...(demuxer ? ["-f", demuxer] : []), "-i", `file:${path}`, ]; } export async function probeMedia( path: string, options: { signal?: AbortSignal; mimeType?: string } = {}, ): Promise { const { ffprobePath } = await requireFfmpeg(); const input = ffmpegInputArgs(path, { mimeType: options.mimeType }); const result = await capture( ffprobePath, ["-v", "error", ...input, "-print_format", "json", "-show_format", "-show_streams"], { signal: options.signal, timeoutMs: PROBE_TIMEOUT_MS }, ); if (result.code !== 0) throw ffmpegFailure("ffprobe", result.code, result.stderr); const probe = parseProbeOutput(result.stdout); if (probe.durationMs > 0 || (!probe.video && !probe.audio)) return probe; // Recordings streamed by browsers often carry no duration header; read it from the packets. const packets = await capture( ffprobePath, [ "-v", "error", ...input, "-select_streams", probe.video ? "v:0" : "a:0", "-show_entries", "packet=pts_time,duration_time", "-of", "csv=p=0", ], { signal: options.signal, timeoutMs: PROBE_TIMEOUT_MS * 5 }, ); if (packets.code !== 0) throw ffmpegFailure("ffprobe", packets.code, packets.stderr); return { ...probe, durationMs: packetDurationMs(packets.stdout) }; } /** End of the last packet, from ffprobe `packet=pts_time,duration_time` CSV lines. */ export function packetDurationMs(csv: string): number { let end = 0; for (const line of csv.split("\n")) { const [pts, duration] = line.split(",").map(Number); if (pts === undefined || !Number.isFinite(pts)) continue; end = Math.max(end, pts + (duration !== undefined && Number.isFinite(duration) ? duration : 0)); } return Math.round(end * 1000); } interface ProbeStream { codec_type?: string; codec_name?: string; width?: number; height?: number; avg_frame_rate?: string; r_frame_rate?: string; sample_aspect_ratio?: string; sample_rate?: string; channels?: number; duration?: string; disposition?: { attached_pic?: number }; tags?: { rotate?: string }; side_data_list?: { rotation?: number }[]; } /** Parses `ffprobe -print_format json -show_format -show_streams` output. */ export function parseProbeOutput(json: string): MediaProbe { const data = JSON.parse(json) as { format?: { duration?: string }; streams?: ProbeStream[]; }; const streams = data.streams ?? []; // Cover art in audio files is a single attached picture, not a video track. const video = streams.find( (stream) => stream.codec_type === "video" && stream.disposition?.attached_pic !== 1, ); const audio = streams.find((stream) => stream.codec_type === "audio"); const seconds = positive(Number(data.format?.duration)) ?? Math.max(0, ...streams.map((stream) => positive(Number(stream.duration)) ?? 0)); return { durationMs: Math.round(seconds * 1000), ...(video?.width && video.height ? { video: probeVideo(video, video.width, video.height) } : {}), ...(audio ? { audio: { codec: audio.codec_name ?? "", sampleRate: Number(audio.sample_rate) || 0, channels: audio.channels ?? 0, }, } : {}), hasAudio: Boolean(audio), }; } function probeVideo(stream: ProbeStream, width: number, height: number) { const rotation = normalizeRotation( stream.side_data_list?.find((entry) => typeof entry.rotation === "number")?.rotation ?? Number(stream.tags?.rotate ?? 0), ); const sideways = rotation === 90 || rotation === 270; const sar = ratio(stream.sample_aspect_ratio, ":") || 1; const fps = ratio(stream.avg_frame_rate, "/") || ratio(stream.r_frame_rate, "/"); return { codec: stream.codec_name ?? "", width: sideways ? height : width, height: sideways ? width : height, rotation, fps: fps > 0 && fps <= MAX_PLAUSIBLE_FPS ? fps : 0, sampleAspectRatio: sideways ? 1 / sar : sar, }; } function normalizeRotation(degrees: number): 0 | 90 | 180 | 270 { if (!Number.isFinite(degrees)) return 0; const quarter = (((Math.round(degrees / 90) % 4) + 4) % 4) as 0 | 1 | 2 | 3; return ([0, 90, 180, 270] as const)[quarter]; } function ratio(value: string | undefined, separator: string): number { if (!value) return 0; const [numerator, denominator] = value.split(separator).map(Number); if (!numerator || !denominator || !Number.isFinite(numerator / denominator)) return 0; return numerator / denominator; } function positive(value: number): number | undefined { return Number.isFinite(value) && value > 0 ? value : undefined; } /** Feeds ffmpeg `-progress` output (key=value lines) and reports each completed block. */ export function createFfmpegProgressParser( onUpdate: (progress: FfmpegProgress) => void, ): (chunk: string) => void { let buffer = ""; let current: Omit = {}; return (chunk) => { buffer += chunk; for (let newline = buffer.indexOf("\n"); newline >= 0; newline = buffer.indexOf("\n")) { const line = buffer.slice(0, newline).trim(); buffer = buffer.slice(newline + 1); const separator = line.indexOf("="); if (separator < 0) continue; const key = line.slice(0, separator); const value = line.slice(separator + 1).trim(); const number = Number(value); // out_time_ms is microseconds too (a long-standing ffmpeg naming bug). if ((key === "out_time_us" || key === "out_time_ms") && value && number >= 0) { current.outTimeUs = number; } else if (key === "total_size" && value && number >= 0) { current.totalSize = number; } else if (key === "progress") { onUpdate({ ...current, done: value === "end" }); current = {}; } } }; } /** Runs ffmpeg with progress on stdout; rejects with the stderr tail when it fails. */ export async function runFfmpeg( args: readonly string[], options: RunFfmpegOptions = {}, ): Promise { options.signal?.throwIfAborted(); const { ffmpegPath } = await requireFfmpeg(); try { await spawnFfmpeg(ffmpegPath, args, options); } catch (error) { await Promise.all((options.outputs ?? []).map((path) => rm(path, { force: true }))); throw error; } } function spawnFfmpeg( ffmpegPath: string, args: readonly string[], options: RunFfmpegOptions, ): Promise { const { signal, durationMs, onProgress, maxOutputBytes } = options; return new Promise((resolve, reject) => { const child = spawn( ffmpegPath, [ "-hide_banner", "-nostdin", "-nostats", "-loglevel", "error", "-progress", "pipe:1", "-y", ...args, ], { cwd: options.cwd, stdio: ["ignore", "pipe", "pipe"], windowsHide: true }, ); if (child.pid) { try { // Long encodes must not starve the API process that shares this machine. setPriority(child.pid, 10); } catch { // Lowering priority is best effort. } } let stderr = ""; let settled = false; let reported = 0; let failure: unknown; let stallTimer: NodeJS.Timeout | undefined; const finish = (error?: unknown) => { if (settled) return; settled = true; clearTimeout(stallTimer); signal?.removeEventListener("abort", abort); if (error === undefined) resolve(); else reject(error); }; // Settles once the process is gone, so callers can delete its files right away. const kill = (error: unknown) => { if (failure !== undefined || settled) return; failure = error; clearTimeout(stallTimer); child.kill("SIGKILL"); setTimeout(() => finish(error), KILL_GRACE_MS).unref?.(); }; const abort = () => kill(signal?.reason ?? new Error("aborted")); const armStallTimer = () => { clearTimeout(stallTimer); stallTimer = setTimeout( () => kill(new FfmpegError("ffmpeg stopped making progress.", null, tail(stderr))), STALL_TIMEOUT_MS, ); stallTimer.unref?.(); }; const parse = createFfmpegProgressParser((progress) => { if (failure !== undefined) return; if (maxOutputBytes !== undefined && (progress.totalSize ?? 0) > maxOutputBytes) { kill(new Error(outputTooLargeMessage(maxOutputBytes))); return; } if (!onProgress) return; const fraction = progress.done ? 1 : durationMs && progress.outTimeUs !== undefined ? Math.min(1, progress.outTimeUs / (durationMs * 1000)) : 0; if (fraction > reported) { reported = fraction; onProgress(fraction); } }); signal?.addEventListener("abort", abort, { once: true }); armStallTimer(); child.stdout.setEncoding("utf8"); child.stdout.on("data", (chunk: string) => { if (failure === undefined) armStallTimer(); parse(chunk); }); child.stderr.setEncoding("utf8"); child.stderr.on("data", (chunk: string) => { stderr = tail(stderr + chunk); }); child.on("error", (error) => finish(failure ?? error)); child.on("close", (code) => { if (failure !== undefined) finish(failure); else if (code === 0) finish(); else finish(ffmpegFailure("ffmpeg", code, stderr)); }); if (signal?.aborted) abort(); }); } export function outputTooLargeMessage(maxBytes: number): string { return `The result would exceed ${Math.round(maxBytes / (1024 * 1024))} MB.`; } function tail(text: string): string { return text.length > STDERR_TAIL_BYTES ? text.slice(-STDERR_TAIL_BYTES) : text; } function ffmpegFailure(tool: string, code: number | null, stderr: string): FfmpegError { // The cause is usually the line before ffmpeg's generic summary ("Conversion failed!"). const lines = stderr .split("\n") .map((line) => line.trim()) .filter(Boolean) .slice(-2); // Name files by their base name only; the job's temp paths mean nothing to the reader. const detail = lines.join(" ").replaceAll(/(?:file:)?(?:[A-Za-z]:)?[\\/][^\s'"]*[\\/]/g, ""); return new FfmpegError( detail ? `${tool} failed: ${detail}` : `${tool} failed with exit code ${code}.`, code, tail(stderr), ); } /** Extracts the first audio track as 16 kHz mono 16-bit WAV, the format speech models expect. */ export async function extractAudio( input: string, output: string, options: { signal?: AbortSignal; mimeType?: string; startMs?: number; endMs?: number; onProgress?: (fraction: number) => void; } = {}, ): Promise { const probe = await probeMedia(input, { signal: options.signal, mimeType: options.mimeType }); if (!probe.hasAudio) throw new Error("The file has no audio track."); const startMs = Math.max(0, options.startMs ?? 0); const endMs = Math.min(probe.durationMs, options.endMs ?? probe.durationMs); if (endMs <= startMs) throw new Error("The audio range is empty."); await runFfmpeg( [ ...ffmpegInputArgs(input, { mimeType: options.mimeType, startSeconds: startMs / 1000, durationSeconds: (endMs - startMs) / 1000, }), "-map", "0:a:0", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", "-map_metadata", "-1", "-f", "wav", `file:${output}`, ], { signal: options.signal, durationMs: endMs - startMs, onProgress: options.onProgress, outputs: [output], }, ); } /** Writes one JPEG frame at atMs (default 0), falling back to the first frame past the end. */ export async function renderPoster( input: string, output: string, options: { signal?: AbortSignal; mimeType?: string; atMs?: number } = {}, ): Promise { const render = (atMs: number) => runFfmpeg( [ ...ffmpegInputArgs(input, { mimeType: options.mimeType, startSeconds: atMs / 1000 }), "-map", "0:v:0", "-frames:v", "1", "-c:v", "mjpeg", "-q:v", "3", "-map_metadata", "-1", "-f", "image2", "-update", "1", `file:${output}`, ], { signal: options.signal, outputs: [output] }, ); const atMs = Math.max(0, options.atMs ?? 0); await render(atMs); // Seeking into the last frame's gap writes nothing; the first frame is better than no poster. if (atMs > 0 && !(await nonEmptyFile(output))) await render(0); if (!(await nonEmptyFile(output))) throw new Error("Could not render a poster frame."); } async function nonEmptyFile(path: string): Promise { try { return (await stat(path)).size > 0; } catch { return false; } } interface CaptureResult { code: number | null; stdout: string; stderr: string; } function capture( command: string, args: readonly string[], options: { signal?: AbortSignal; timeoutMs: number }, ): Promise { options.signal?.throwIfAborted(); return new Promise((resolve, reject) => { const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true }); const stdout: Buffer[] = []; let stderr = ""; let settled = false; const finish = (error: unknown, result?: CaptureResult) => { if (settled) return; settled = true; clearTimeout(timer); options.signal?.removeEventListener("abort", abort); if (result) resolve(result); else reject(error); }; const kill = (error: unknown) => { child.kill("SIGKILL"); finish(error); }; const abort = () => kill(options.signal?.reason ?? new Error("aborted")); const timer = setTimeout( () => kill(new Error(`${command} timed out after ${options.timeoutMs} ms`)), options.timeoutMs, ); timer.unref?.(); options.signal?.addEventListener("abort", abort, { once: true }); child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk)); child.stderr.setEncoding("utf8"); child.stderr.on("data", (chunk: string) => { stderr = tail(stderr + chunk); }); child.on("error", (error) => finish(error)); child.on("close", (code) => finish(undefined, { code, stdout: Buffer.concat(stdout).toString("utf8"), stderr }), ); }); }