T-HUB/packages/media-sandbox/src/command.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

110 lines
7.1 KiB
TypeScript

export const DEMUXERS = ["mov", "matroska", "mpegts", "wav", "mp3", "ogg", "flac", "image2", "png_pipe", "jpeg_pipe", "rawvideo", "s16le"] as const;
export type Demuxer = typeof DEMUXERS[number];
export interface MediaCommand { readonly binary: "ffmpeg" | "ffprobe"; readonly argv: readonly string[] }
const issued = new WeakSet<object>();
function command(binary: MediaCommand["binary"], argv: string[]): MediaCommand {
const result = Object.freeze({ binary, argv: Object.freeze(argv) }); issued.add(result); return result;
}
export function assertMediaCommand(value: MediaCommand): void {
if (!issued.has(value)) throw new Error("MediaCommandBuilder command required");
}
export function assertAssetId(id: string): void {
if (!/^(?:[0-9a-f]{64}|[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/.test(id)) throw new Error("invalid server asset id");
}
function demuxer(value: string): void {
if (!(DEMUXERS as readonly string[]).includes(value)) throw new Error("demuxer denied");
}
export function assetPath(id: string): string { assertAssetId(id); return `file:/upload/${id}`; }
const FILTERS = new Set(["scale", "pad", "setsar", "crop", "color", "split", "null", "overlay", "xstack", "format", "anullsrc", "aformat", "volume", "amix", "asplit", "noise", "fps", "aresample", "asetpts", "setpts"]);
function validateFilters(text: string): void {
if (text.length > 65536 || /(?:movie|amovie|subtitles|drawtext|zmq|azmq)\s*=|https?:|file:|concat:/i.test(text)) throw new Error("filter IO denied");
let quote = false, escaped = false, start = 0;
const validate = (part: string): void => {
const name = /^(?:\[[a-zA-Z0-9_:]+\])*([a-z][a-z0-9_]*)(?:=|\[|$)/.exec(part)?.[1];
if (!name || !FILTERS.has(name)) throw new Error("filter IO denied");
};
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (escaped) { escaped = false; continue; }
if (c === "\\") { escaped = true; continue; }
if (c === "'") quote = !quote;
if (!quote && (c === "," || c === ";")) { validate(text.slice(start, i)); start = i + 1; }
}
if (quote || escaped) throw new Error("filter IO denied");
validate(text.slice(start));
}
/** No original filename, arbitrary URL, shell string or extra-arguments escape hatch. */
export class MediaCommandBuilder {
probe(assetId: string, format: Demuxer): MediaCommand {
demuxer(format);
return command("ffprobe", ["-hide_banner", "-v", "error", "-protocol_whitelist", "file", "-f", format,
"-show_entries", "format=duration,size,format_name:stream=index,codec_type,codec_name,width,height,sample_rate,channels",
"-of", "json", "-i", assetPath(assetId)]);
}
transcode(assetId: string, format: Demuxer, outputId: string, dimensions: { width: number; height: number }): MediaCommand {
demuxer(format); assertAssetId(outputId);
for (const v of [dimensions.width, dimensions.height]) {
if (!Number.isSafeInteger(v) || v < 2 || v > 4096 || v % 2) throw new Error("invalid dimensions");
}
return command("ffmpeg", ["-hide_banner", "-nostdin", "-loglevel", "error", "-threads", "2", "-filter_threads", "1",
"-protocol_whitelist", "file", "-f", format, "-i", assetPath(assetId), "-vf", `scale=${dimensions.width}:${dimensions.height}`,
"-c:v", "libx264", "-preset", "veryfast", "-c:a", "aac", "-f", "mp4", "-y", `file:/scratch/${outputId}.mp4`]);
}
/** Adapter ONLY for argv from the worker's existing typed graph builders. */
supervisor(binary: MediaCommand["binary"], args: readonly string[], scratch: string): MediaCommand {
if (binary !== "ffmpeg") throw new Error("use the typed probe wrapper");
const flags = new Set(["-hide_banner", "-nostdin", "-re", "-y", "-an", "-vn", "-shortest"]);
const values = new Set(["-loglevel", "-threads", "-threads:v", "-filter_threads", "-filter_complex_threads", "-thread_queue_size", "-probesize", "-analyzeduration",
"-f", "-pix_fmt", "-s", "-framerate", "-ar", "-ac", "-fflags", "-i", "-vf", "-af", "-filter_complex", "-map", "-t", "-c", "-c:v", "-c:a",
"-preset", "-tune", "-profile:v", "-level:v", "-b:v", "-b:a", "-maxrate", "-bufsize", "-g", "-keyint_min", "-sc_threshold",
"-fps_mode", "-flush_packets", "-flags:v", "-flags:a", "-frames:v", "-q:v", "-progress", "-output_ts_offset", "-muxdelay", "-muxpreload",
"-loop", "-mpegts_flags", "-hls_time", "-hls_list_size", "-hls_segment_type", "-hls_fmp4_init_filename", "-hls_flags", "-hls_segment_filename", "-protocol_whitelist"]);
const argv: string[] = []; let format: string | undefined;
const mapPath = (value: string): string => {
const raw = value.startsWith("file:") ? value.slice(5) : value;
if (!raw.startsWith(`${scratch}/`) || raw.split("/").includes("..")) throw new Error("media path denied");
const suffix = raw.slice(scratch.length);
if (!/^\/[A-Za-z0-9_./%\-]+$/.test(suffix)) throw new Error("media path denied");
return `file:/scratch${suffix}`;
};
for (let i = 0; i < args.length; i++) {
const key = args[i]!;
if (flags.has(key)) { argv.push(key); continue; }
if (!key.startsWith("-") || key === "-") {
if (key === "-" && format === "null") argv.push("pipe:1");
else if (format === "tee" && /^\[f=(?:rawvideo|s16le):onfail=ignore\]pipe:\d{1,3}\|\[f=null\]-$/.test(key)) argv.push(key);
else argv.push(/^pipe:\d{1,3}$/.test(key) ? key : mapPath(key));
format = undefined; continue;
}
if (!values.has(key)) throw new Error("media option denied");
let value = args[++i];
if (value === undefined || /[\u0000-\u001f\u007f]/.test(value)) throw new Error("media option denied");
if (key === "-protocol_whitelist") continue; // Replaced per input below.
if (key === "-f") format = value;
if (["-vf", "-af", "-filter_complex"].includes(key)) validateFilters(value);
if (key === "-i") {
if (/^pipe:\d{1,3}$/.test(value)) {
if (!format) { format = "mpegts"; argv.push("-f", format); } demuxer(format);
argv.push("-protocol_whitelist", "pipe");
} else if (format === "lavfi") {
if (!/^(?:testsrc2|smptehdbars|mandelbrot|rgbtestsrc|sine|color|anullsrc)=/.test(value) || /[\/;]|(?:movie|http|file|concat):/i.test(value)) throw new Error("synthetic input denied");
argv.push("-protocol_whitelist", "file");
} else {
const path = mapPath(value);
if (!/\/[0-9a-f]{64}\.png$/.test(path)) throw new Error("asset input denied");
argv.push("-protocol_whitelist", "file", "-f", "png_pipe"); value = path;
}
format = undefined;
} else if (key === "-hls_segment_filename") { argv.push(key, mapPath(value)); continue; }
else if (key === "-progress" && !/^pipe:\d{1,3}$/.test(value)) throw new Error("progress endpoint denied");
else if (key === "-hls_fmp4_init_filename" && value !== "init.mp4") throw new Error("init filename denied");
else if (!["-i", "-vf", "-af", "-filter_complex", "-framerate"].includes(key) && /(?:https?|rtmp|srt|tcp|udp|file|concat):|\//i.test(value)) throw new Error("media protocol denied");
argv.push(key, value);
}
return command(binary, argv);
}
}