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:
glass beadian 2026-10-10 11:56:42 +00:00
commit aa5e262f3a
880 changed files with 111042 additions and 0 deletions

View file

@ -0,0 +1,203 @@
/**
* MediaCommandBuilder: argv shape, typed-param rejection, and golden-frame
* hashes per layout (deterministic synthetic inputs: seeded noise over test
* patterns; framemd5 of the composited output, video + program audio).
*
* Hashes are pinned to the system FFmpeg (5.1.x here). UPDATE_GOLDEN=1 rewrites
* src/compositor/golden-frames.json after an intentional graph change or an
* FFmpeg upgrade.
*/
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import {
buildCompositorArgs, buildEncoderArgs, buildFilterGraph, buildIngestArgs, CommandBuildError, layoutRects, rectFromBasisPoints,
SYNTHETIC_PATTERNS, type Canvas, type CompositorPlan, type PxRect,
} from "./command-builder";
import type { SceneLayout } from "../production";
import { makeAssets } from "./testing";
const canvas: Canvas = { width: 320, height: 180, fps: 30 };
const GOLDEN = join(__dirname, "golden-frames.json");
function synthetic(i: number) {
return { video: { kind: "synthetic" as const, pattern: SYNTHETIC_PATTERNS[i % SYNTHETIC_PATTERNS.length]!, seed: 1000 + i }, audio: { kind: "synthetic" as const, toneHz: 220 + 110 * i } };
}
const CUSTOM: PxRect[] = [{ x: 0, y: 0, w: 160, h: 90 }, { x: 160, y: 0, w: 160, h: 90 }, { x: 80, y: 90, w: 160, h: 90 }];
const CUSTOM_OVERLAP: PxRect[] = [{ x: 0, y: 0, w: 200, h: 112 }, { x: 120, y: 68, w: 200, h: 112 }];
interface Case { name: string; layout: SceneLayout; rects: PxRect[]; empty?: number[]; overlays: "none" | "lower" | "chyron" | "both"; mix: Array<[number, number]>; master: number }
const CASES: Case[] = [
{ name: "single", layout: "single", rects: layoutRects("single", canvas), overlays: "none", mix: [[0, 0]], master: 0 },
{ name: "pip", layout: "pip", rects: [{ x: 0, y: 0, w: 320, h: 180 }, rectFromBasisPoints({ x: 6000, y: 6000, w: 3500, h: 3500 }, canvas)], overlays: "lower", mix: [[0, 0], [1, -12]], master: -3 },
{ name: "2x1", layout: "2x1", rects: layoutRects("2x1", canvas), overlays: "none", mix: [[0, -3], [1, -3]], master: 0 },
{ name: "2x2", layout: "2x2", rects: layoutRects("2x2", canvas), empty: [3], overlays: "chyron", mix: [[0, 0]], master: -6 },
{ name: "3x3", layout: "3x3", rects: layoutRects("3x3", canvas), empty: [4, 8], overlays: "both", mix: [[0, 0], [1, -6], [2, -24]], master: 0 },
{ name: "4x4", layout: "4x4", rects: layoutRects("4x4", canvas), empty: [5, 10, 15], overlays: "none", mix: [], master: 0 },
{ name: "custom", layout: "custom", rects: CUSTOM, overlays: "lower", mix: [[2, 0]], master: 0 },
{ name: "custom-overlap", layout: "custom", rects: CUSTOM_OVERLAP, overlays: "both", mix: [[0, 0], [1, 0]], master: -9 },
];
function plan(c: Case, assets: string[]): CompositorPlan {
const tiles = c.rects.map((rect, i) => ({ source: c.empty?.includes(i) ? null : i % 4, rect, fit: (["contain", "cover", "crop"] as const)[i % 3]! }));
const overlays = [
...(c.overlays === "lower" || c.overlays === "both" ? [{ kind: "image" as const, asset: 0, x: 12, y: 140 }] : []),
...(c.overlays === "chyron" || c.overlays === "both" ? [{ kind: "chyron" as const, asset: 1, y: 164, speedPxPerSec: 120 }] : []),
];
return {
sources: [0, 1, 2, 3].map(synthetic), assets,
graph: { canvas, layout: c.layout, tiles, overlays, audio: { inputs: c.mix.map(([source, gainDb]) => ({ source, gainDb })), masterGainDb: c.master } },
output: { kind: "framemd5", frames: 4 },
};
}
function hashes(p: CompositorPlan): string[] {
const r = spawnSync("ffmpeg", buildCompositorArgs(p), { encoding: "utf8", timeout: 60_000 });
assert.equal(r.status, 0, r.stderr);
// framemd5 lines: stream, dts, pts, duration, size, hash — keep stream/pts/size/hash.
return r.stdout.split("\n").filter((l) => l && !l.startsWith("#")).map((l) => l.split(",").map((x) => x.trim())).map((f) => `${f[0]}:${f[2]}:${f[4]}:${f[5]}`);
}
test("golden-frame hashes per layout (deterministic synthetic inputs)", { timeout: 300_000 }, () => {
const dir = mkdtempSync(join(tmpdir(), "golden-"));
try {
const assets = makeAssets(dir);
const got: Record<string, string[]> = {};
for (const c of CASES) {
const p = plan(c, assets);
const h = hashes(p);
assert.ok(h.some((l) => l.startsWith("0:")) && h.some((l) => l.startsWith("1:")), `${c.name}: video and audio frames`);
assert.deepEqual(hashes(p), h, `${c.name}: two runs hash identically`);
got[c.name] = h;
}
if (process.env.UPDATE_GOLDEN || !existsSync(GOLDEN)) {
writeFileSync(GOLDEN, `${JSON.stringify({ ffmpeg: ffmpegVersion(), canvas, frames: 4, layouts: got }, null, 2)}\n`);
}
const golden = JSON.parse(readFileSync(GOLDEN, "utf8")) as { ffmpeg: string; layouts: Record<string, string[]> };
for (const c of CASES) assert.deepEqual(got[c.name], golden.layouts[c.name], `${c.name} matches golden (pinned to ${golden.ffmpeg}; UPDATE_GOLDEN=1 after an intentional change)`);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
function ffmpegVersion(): string {
return spawnSync("ffmpeg", ["-version"], { encoding: "utf8" }).stdout.split("\n")[0] ?? "unknown";
}
test("layout cells are even, cover the canvas, and xstack/overlay is chosen by geometry", () => {
for (const layout of ["2x1", "2x2", "3x3", "4x4"] as const) {
for (const c of [canvas, { width: 1920, height: 1080, fps: 30 }, { width: 1280, height: 720, fps: 30 }]) {
const rects = layoutRects(layout, c);
assert.equal(rects.reduce((a, r) => a + r.w * r.h, 0), c.width * c.height, `${layout} ${c.width}x${c.height} tiles cover the canvas`);
for (const r of rects) for (const v of [r.x, r.y, r.w, r.h]) assert.equal(v % 2, 0);
}
}
const idx = { video: [0, 1, 2, 3], audio: [null, null, null, null], assets: [] };
const base = { sources: [0, 1, 2, 3].map(synthetic), assets: [], output: { kind: "framemd5" as const, frames: 1 } };
const audio = { inputs: [], masterGainDb: 0 };
const grid = buildFilterGraph({ ...base, graph: { canvas, layout: "2x2", tiles: layoutRects("2x2", canvas).map((rect, i) => ({ source: i, rect, fit: "contain" as const })), overlays: [], audio } }, idx);
assert.match(grid, /xstack=inputs=4:layout=0_0\|160_0\|0_90\|160_90:fill=black/);
assert.match(grid, /anullsrc=r=48000:cl=stereo/);
const pip = buildFilterGraph({ ...base, graph: { canvas, layout: "pip", tiles: [{ source: 0, rect: { x: 0, y: 0, w: 320, h: 180 }, fit: "cover" }, { source: 1, rect: { x: 200, y: 100, w: 100, h: 56 }, fit: "contain" }], overlays: [], audio } }, idx);
assert.match(pip, /overlay=x=200:y=100/);
assert.doesNotMatch(pip, /xstack/);
// Overlapping custom rects: the base is the first tile padded (paced by a live input), not a lavfi color source.
const overlap = buildFilterGraph({ ...base, graph: { canvas, layout: "custom", tiles: CUSTOM_OVERLAP.map((rect, i) => ({ source: i, rect, fit: "cover" as const })), overlays: [], audio } }, idx);
assert.match(overlap, /\[t0\]pad=320:180:0:0:color=black\[bg\];\[bg\]\[t1\]overlay=x=120:y=68/);
assert.doesNotMatch(overlap, /color=c=black/);
const shared = buildFilterGraph({ ...base, graph: { canvas, layout: "2x1", tiles: layoutRects("2x1", canvas).map((rect) => ({ source: 0, rect, fit: "crop" as const })), overlays: [], audio } }, idx);
assert.match(shared, /\[0:v\]split=2\[s0_0\]\[s0_1\]/);
});
test("program mix follows §2.3: volume per input → amix → master gain, 3 dB steps only", () => {
const idx = { video: [0, 2, 4], audio: [1, 3, 5], assets: [] };
const p = (inputs: Array<{ source: number; gainDb: number }>, masterGainDb = 0): CompositorPlan => ({
sources: [0, 1, 2].map(synthetic), assets: [], output: { kind: "framemd5", frames: 1 },
graph: { canvas, layout: "single", tiles: [{ source: 0, rect: { x: 0, y: 0, w: 320, h: 180 }, fit: "contain" }], overlays: [], audio: { inputs, masterGainDb } },
});
const g = buildFilterGraph(p([{ source: 0, gainDb: -6 }, { source: 2, gainDb: 0 }], -3), idx);
assert.match(g, /\[1:a\]aformat=[^;]*,volume=-6dB\[m0\]/);
assert.match(g, /\[5:a\]aformat=[^;]*,volume=0dB\[m1\]/);
assert.match(g, /\[m0\]\[m1\]amix=inputs=2:duration=longest:dropout_transition=2:normalize=1,volume=-3dB/);
assert.doesNotMatch(buildFilterGraph(p([{ source: 0, gainDb: 0 }]), idx), /amix/);
for (const bad of [-1, -25, 3, 1.5]) assert.throws(() => buildCompositorArgs(p([{ source: 0, gainDb: bad }])), /gainDb/);
assert.throws(() => buildCompositorArgs(p([{ source: 0, gainDb: 0 }, { source: 0, gainDb: -3 }])), /repeats a source/);
assert.throws(() => buildCompositorArgs(p([{ source: 0, gainDb: 0 }], -2)), /masterGainDb/);
});
test("typed params only: hostile values never reach argv", () => {
const good: CompositorPlan = {
sources: [{ video: { kind: "rawPipe", fd: 4 }, audio: { kind: "rawPipe", fd: 5 } }],
assets: [`/scratch/assets/${"a".repeat(64)}.png`],
graph: { canvas, layout: "single", tiles: [{ source: 0, rect: { x: 0, y: 0, w: 320, h: 180 }, fit: "contain" }], overlays: [{ kind: "chyron", asset: 0, y: 160, speedPxPerSec: 100 }], audio: { inputs: [{ source: 0, gainDb: 0 }], masterGainDb: 0 } },
output: { kind: "gate", videoFd: 1, audioFd: 3 },
};
const args = buildCompositorArgs(good);
assert.ok(args.includes("pipe:4") && args.includes("pipe:5") && args.includes(`file:/scratch/assets/${"a".repeat(64)}.png`));
assert.deepEqual(args.slice(-1), ["pipe:3"]);
const hostileAssets = ["/scratch/../etc/passwd.png", "/scratch/x;rm.png", "relative/" + "a".repeat(64) + ".png", `/scratch/${"a".repeat(64)}.png'`, `/s/movie=${"a".repeat(58)}.png`, "/scratch/assets/notahash.png"];
for (const asset of hostileAssets) assert.throws(() => buildCompositorArgs({ ...good, assets: [asset] }), CommandBuildError, asset);
const g = good.graph;
assert.throws(() => buildCompositorArgs({ ...good, graph: { ...g, tiles: [{ ...g.tiles[0]!, rect: { x: 1, y: 0, w: 320, h: 180 } }] } }), /even/);
assert.throws(() => buildCompositorArgs({ ...good, graph: { ...g, tiles: [{ ...g.tiles[0]!, rect: { x: 0, y: 0, w: 322, h: 180 } }] } }), CommandBuildError);
assert.throws(() => buildCompositorArgs({ ...good, graph: { ...g, overlays: [{ kind: "chyron", asset: 0, y: 160, speedPxPerSec: 39 }] } }), /speedPxPerSec/);
assert.throws(() => buildCompositorArgs({ ...good, graph: { ...g, overlays: [{ kind: "image", asset: 1, x: 0, y: 0 }] } }), /asset/);
assert.throws(() => buildCompositorArgs({ ...good, sources: [{ video: { kind: "rawPipe", fd: 1.5 }, audio: null }] }), /fd/);
assert.throws(() => buildCompositorArgs({ ...good, graph: { ...g, canvas: { width: 321, height: 180, fps: 30 } } }), /even/);
assert.throws(() => buildCompositorArgs({ ...good, graph: { ...g, canvas: { width: 320, height: 180, fps: 7 } } }), /fps/);
// Nothing in argv is a shell: no metacharacters outside the filtergraph and the expression quotes.
for (const a of args) if (!a.includes("[")) assert.doesNotMatch(a, /[;&|`$<>]/, a);
});
test("ingest normalize and persistent encoder argv", () => {
const ingest = buildIngestArgs({ input: { kind: "mpegtsPipe" }, canvas: { width: 1920, height: 1080, fps: 30 }, audio: "source" });
assert.deepEqual(ingest.slice(ingest.indexOf("-f"), ingest.indexOf("-f") + 4), ["-f", "mpegts", "-i", "pipe:0"]);
assert.ok(ingest.includes("pipe:1") && ingest.includes("pipe:3"));
assert.ok(ingest.some((a) => a.startsWith("scale=1920:1080:force_original_aspect_ratio=decrease") && a.includes("fps=30")));
const silent = buildIngestArgs({ input: { kind: "mpegtsPipe" }, canvas, audio: "none" });
assert.ok(silent.includes("anullsrc=r=48000:cl=stereo") && silent.includes("1:a:0"));
const enc = buildEncoderArgs({ canvas: { width: 1920, height: 1080, fps: 30 }, videoBitrateKbps: 6000, audioBitrateKbps: 128, output: { kind: "flvPipe", fd: 4 } });
assert.deepEqual(enc.slice(-3), ["-f", "flv", "pipe:4"]);
assert.ok(enc.includes("pipe:0") && enc.includes("pipe:3") && enc.includes("6000k") && enc.includes("60"));
assert.throws(() => buildEncoderArgs({ canvas, videoBitrateKbps: 1e9, audioBitrateKbps: 128, output: { kind: "null" } }));
});
test("ingest ×N normalize stage end to end: MPEG-TS pipe (4:3, no audio) and synthetic sources fill their buses", { timeout: 60_000 }, async () => {
const { IngestProcess } = await import("./ingest");
const { ensureGateBinary, passthroughRedactor } = await import("./testing");
assert.ok(ensureGateBinary(), "native helpers (source bus) build");
// 4:3 MPEG-TS without audio on the ingest's stdin, as the egress relay would deliver it.
const ts = spawnSync("ffmpeg", ["-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc2=s=640x480:r=25:d=2", "-c:v", "mpeg2video", "-f", "mpegts", "-"], { maxBuffer: 64 << 20 });
assert.equal(ts.status, 0);
const dir = mkdtempSync(join(tmpdir(), "ingest-"));
const tsPath = join(dir, "in.ts");
writeFileSync(tsPath, ts.stdout);
const { openSync, closeSync } = await import("node:fs");
const fd = openSync(tsPath, "r");
const exits: string[] = [];
const pipeIngest = new IngestProcess("a", { input: { kind: "mpegtsPipe" }, canvas, audio: "none" }, fd, join(dir, "scratch"), passthroughRedactor, () => undefined, (_id, r) => exits.push(r));
const synth = new IngestProcess("b", { input: { kind: "synthetic", pattern: "smptehdbars", seed: 7, toneHz: 440 }, canvas, audio: "source" }, null, join(dir, "scratch"), passthroughRedactor, () => undefined, (_id, r) => exits.push(r));
try {
await pipeIngest.start();
closeSync(fd);
await synth.start();
await new Promise((r) => setTimeout(r, 1_500));
// 2 s of 25 fps input → 30 fps CFR on the canvas (decoded faster than real time).
const piped = await pipeIngest.bus.stats();
assert.ok(piped.pairs >= 50, `mpegts pairs ${piped.pairs}`);
assert.equal(piped.silenceFilled, 0, "anullsrc supplies audio for a silent source");
// -re synthetic: real-time pace.
const synthetic = await synth.bus.stats();
assert.ok(synthetic.pairs >= 30 && synthetic.pairs <= 60, `synthetic pairs ${synthetic.pairs}`);
assert.deepEqual(exits, []);
} finally {
await pipeIngest.stop();
await synth.stop();
rmSync(dir, { recursive: true, force: true });
}
});