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/.
203 lines
14 KiB
TypeScript
203 lines
14 KiB
TypeScript
/**
|
||
* 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 });
|
||
}
|
||
});
|