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/.
110 lines
7.1 KiB
TypeScript
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);
|
|
}
|
|
}
|