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/.
159 lines
5.4 KiB
TypeScript
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>;
|
|
}
|