T-HUB/apps/api/src/video/ffprobe.ts
glass beadian aa5e262f3a Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.

MIT licensed. See README.md and docs/.
2026-10-10 11:56:42 +00:00

159 lines
5.4 KiB
TypeScript

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<VideoProbeResult> {
if (inputPath.length === 0) throw new Error("ffprobe input path must not be empty");
const output = await new Promise<string>((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<string, unknown> => 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<string, unknown> | undefined {
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
return value as Record<string, unknown>;
}
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<K extends string>(key: K, value: number | undefined): Partial<Record<K, number>> {
return value === undefined ? {} : { [key]: value } as Record<K, number>;
}
function optionalString<K extends string>(key: K, value: unknown): Partial<Record<K, string>> {
return typeof value !== "string" || value === "" ? {} : { [key]: value } as Record<K, string>;
}