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/.
58 lines
3.9 KiB
TypeScript
58 lines
3.9 KiB
TypeScript
import { MediaCommandBuilder, type Demuxer } from "./command";
|
|
import { spawnSandbox, type SandboxChild, type SandboxOptions } from "./runtime";
|
|
export interface ProbeResult {
|
|
format: { format_name?: string; duration?: string; size?: string };
|
|
streams: Array<{ index: number; codec_type: "video" | "audio" | "subtitle" | "data" | "attachment"; codec_name?: string; width?: number; height?: number; sample_rate?: string; channels?: number }>;
|
|
}
|
|
const fail = (): never => { throw new Error("invalid probe output"); };
|
|
function record(value: unknown): Record<string, unknown> {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) fail();
|
|
return value as Record<string, unknown>;
|
|
}
|
|
function keys(value: Record<string, unknown>, allowed: string[]): void {
|
|
if (Object.keys(value).some(key => !allowed.includes(key))) fail();
|
|
}
|
|
export function validateProbe(value: unknown): ProbeResult {
|
|
const root = record(value); keys(root, ["format", "streams"]);
|
|
const format = record(root.format); keys(format, ["format_name", "duration", "size"]);
|
|
for (const [k, v] of Object.entries(format)) {
|
|
if (typeof v !== "string" || v.length > 128 || !(k === "format_name" ? /^[a-z0-9_,]+$/ : /^\d+(?:\.\d+)?$/).test(v)) fail();
|
|
if (k !== "format_name" && !Number.isFinite(Number(v))) fail();
|
|
}
|
|
if (!Array.isArray(root.streams) || root.streams.length > 64) fail();
|
|
const indices = new Set<number>();
|
|
for (const raw of root.streams as unknown[]) {
|
|
const stream = record(raw); keys(stream, ["index", "codec_type", "codec_name", "width", "height", "sample_rate", "channels"]);
|
|
if (!Number.isSafeInteger(stream.index) || Number(stream.index) < 0 || indices.has(Number(stream.index))) fail();
|
|
indices.add(Number(stream.index));
|
|
if (!["video", "audio", "subtitle", "data", "attachment"].includes(String(stream.codec_type))) fail();
|
|
for (const k of ["width", "height", "channels"]) {
|
|
if (stream[k] !== undefined && (!Number.isSafeInteger(stream[k]) || Number(stream[k]) <= 0 || Number(stream[k]) > (k === "channels" ? 64 : 16384))) fail();
|
|
}
|
|
if (stream.codec_name !== undefined && (typeof stream.codec_name !== "string" || !/^[a-z0-9_]{1,128}$/.test(stream.codec_name))) fail();
|
|
if (stream.sample_rate !== undefined && (typeof stream.sample_rate !== "string" || !/^\d{1,7}$/.test(stream.sample_rate) || Number(stream.sample_rate) > 768000)) fail();
|
|
}
|
|
return value as ProbeResult;
|
|
}
|
|
/** Exported collector allows testing hostile/oversized output using a real child. */
|
|
export async function collectProbe(job: SandboxChild, maxBytes = 256 * 1024, onLog: (line: string) => void = () => undefined): Promise<ProbeResult> {
|
|
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > 1024 * 1024) { job.kill(); throw new Error("invalid probe output cap"); }
|
|
let size = 0; let oversized = false; const chunks: Buffer[] = [];
|
|
job.child.stdout?.on("data", (chunk: Buffer) => {
|
|
size += chunk.length;
|
|
if (size > maxBytes) { oversized = true; job.kill(); return; }
|
|
chunks.push(chunk);
|
|
});
|
|
job.child.stderr?.on("data", () => onLog("media probe stderr suppressed"));
|
|
let spawnFailed = false; job.child.once("error", () => { spawnFailed = true; });
|
|
const result = await job.exited;
|
|
if (oversized) throw new Error("probe output limit exceeded");
|
|
if (spawnFailed || result.code !== 0) throw new Error("media probe failed");
|
|
let parsed: unknown;
|
|
try { parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { return fail(); }
|
|
return validateProbe(parsed);
|
|
}
|
|
export function probe(assetId: string, format: Demuxer, options: SandboxOptions, onLog?: (line: string) => void): Promise<ProbeResult> {
|
|
if (!options.asset || options.asset.id !== assetId) throw new Error("probe asset mapping required");
|
|
return collectProbe(spawnSandbox(new MediaCommandBuilder().probe(assetId, format), options, { stdio: ["ignore", "pipe", "pipe"] }), undefined, onLog);
|
|
}
|