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/.
This commit is contained in:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
159
apps/api/src/video/ffprobe.ts
Normal file
159
apps/api/src/video/ffprobe.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
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>;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue