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
589 lines
19 KiB
TypeScript
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 }),
|
|
);
|
|
});
|
|
}
|