Claude f14d91d24a
refactor(media): trim unused ffmpeg wrapper options
Binary paths, the ffmpeg version, the probe's container name and a
configurable stall timeout had no caller; detection is cached per process,
so every helper looks the binaries up itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:51:44 +00:00

589 lines
19 KiB
TypeScript

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<string, string> = {
"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<FfmpegInfo | null> | undefined;
/** Finds ffmpeg and ffprobe on PATH once per process; null when either is missing or broken. */
export function detectFfmpeg(): Promise<FfmpegInfo | null> {
detection ??= locateFfmpeg();
return detection;
}
async function locateFfmpeg(): Promise<FfmpegInfo | null> {
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<string | null> {
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<FfmpegInfo> {
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<MediaProbe> {
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<FfmpegProgress, "done"> = {};
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<void> {
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<void> {
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<void> {
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<void> {
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<boolean> {
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<CaptureResult> {
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 }),
);
});
}