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,211 @@
/**
* Test/spike fixtures for the compositor chain (not used in production paths).
*/
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { closeSync, copyFileSync, openSync, readFileSync } from "node:fs";
import { Socket } from "node:net";
import { join } from "node:path";
import { SCENE_LAYOUTS, type SceneLayout } from "../production";
import { ensureHelperBinaries } from "../gate/binary";
import { layoutRects, type Canvas, type CompositorGraph, type OverlaySpec, type PxRect } from "./command-builder";
import type { FrameGate } from "../gate/frame-gate";
import type { ComposeRequest } from "./compositor";
import { audioFrameBytes, videoFrameBytes, type FrameGeometry } from "../gate/protocol";
import type { SinkReport } from "../gate/sink";
import { spawnManaged, type ManagedProcess } from "../relay/ffmpeg";
import { SourceBus } from "./source-bus";
export const passthroughRedactor = { redact: (s: string): string => s };
/**
* Luma of source frame n: a frame counter that survives scaling. Solid frames
* scale to (almost) the same value; swscale can round ±1 at some ratios
* (4x4 tiles), so the counter moves in steps of 4 and the sink rounds.
*/
export function counterLuma(n: number): number { return 20 + 4 * (n % 50); }
/**
* A live source paced by the wall clock that writes into a bus's ingest FIFOs
* (the bytes an ingest process would write): solid frames whose luma is the
* frame counter, and a 1500 Hz sine that holds a whole number of periods per
* frame at 24/25/30/60 fps — any splice that is not on a frame boundary, and
* any inserted silence, shows up at the sink.
*/
export class CounterSource {
readonly bus: SourceBus;
private timer: ReturnType<typeof setTimeout> | null = null;
private n = 0;
private readonly frames: Buffer[];
private readonly pcm: Buffer;
private out: { video: Socket; audio: Socket } | null = null;
constructor(private readonly g: FrameGeometry, scratchDir: string, amplitude = 4000) {
this.bus = new SourceBus({ geometry: g, scratchDir, redactor: passthroughRedactor, onStderr: (m) => process.stderr.write(m) });
const luma = g.width * g.height;
this.frames = Array.from({ length: 50 }, (_, i) => {
const f = Buffer.alloc(videoFrameBytes(g), 128);
f.fill(counterLuma(i), 0, luma);
return f;
});
const samples = audioFrameBytes(g) / 4;
this.pcm = Buffer.alloc(audioFrameBytes(g));
for (let s = 0; s < samples; s += 1) {
const v = Math.round(amplitude * Math.sin((2 * Math.PI * 1500 * s) / 48_000));
this.pcm.writeInt16LE(v, s * 4);
this.pcm.writeInt16LE(v, s * 4 + 2);
}
}
get framesSent(): number { return this.n; }
async start(): Promise<void> {
const fds = await this.bus.start();
this.bus.adoptIngestWriters();
const open = (fd: number): Socket => new Socket({ fd, readable: false, writable: true }).on("error", () => undefined);
const out = { video: open(fds.videoFd), audio: open(fds.audioFd) };
this.out = out;
const t0 = process.hrtime.bigint();
const period = 1_000_000_000n / BigInt(this.g.fps);
const step = (): void => {
const now = process.hrtime.bigint();
while (t0 + BigInt(this.n) * period <= now) {
out.video.write(this.frames[this.n % 50]!);
out.audio.write(this.pcm);
this.n += 1;
}
const wait = Number((t0 + BigInt(this.n) * period - process.hrtime.bigint()) / 1_000_000n);
this.timer = setTimeout(step, Math.max(0, wait));
};
step();
}
async stop(): Promise<void> {
if (this.timer) clearTimeout(this.timer);
this.timer = null;
this.out?.video.destroy();
this.out?.audio.destroy();
this.out = null;
await this.bus.stop();
}
}
/** The verifying null encoder (src/gate/sink-cli.ts) as its own process, reading the gate's encoder FIFOs. */
export class SinkProcess {
private proc: ManagedProcess | null = null;
private out = "";
async start(gate: FrameGate, g: FrameGeometry): Promise<void> {
const cwd = join(__dirname, "../..");
const proc = spawnManaged(process.execPath, ["--import", "tsx", join(__dirname, "../gate/sink-cli.ts"),
gate.encoderFifos.videoPath, gate.encoderFifos.audioPath, String(g.width), String(g.height), String(g.fps)],
["pipe", "pipe", "pipe"], passthroughRedactor, (m) => process.stderr.write(m), cwd);
this.proc = proc;
proc.child.stdout?.setEncoding("utf8");
proc.child.stdout?.on("data", (c: string) => { this.out += c; });
const t0 = Date.now();
while (!this.out.includes("OPEN\n")) {
if (Date.now() - t0 > 15_000) throw new Error("sink did not open");
await new Promise((r) => setTimeout(r, 10));
}
}
async report(firstActiveTick: number): Promise<SinkReport & { silentAfterFirstActive: number }> {
const proc = this.proc;
if (!proc) throw new Error("sink not started");
proc.child.stdin?.write(`REPORT ${firstActiveTick}\n`);
await proc.exited;
const line = this.out.split("\n").find((l) => l.startsWith("{"));
if (!line) throw new Error("sink produced no report");
return JSON.parse(line) as SinkReport & { silentAfterFirstActive: number };
}
/** Also close the sink when a gate assertion fails before REPORT is requested. */
async stop(): Promise<void> {
await this.proc?.terminate(500);
this.proc = null;
}
}
/** H.264 + AAC MPEG-TS clip at the canvas size (encoded once, before measuring): the stand-in for a live HLS source. */
export function encodeSourceClip(path: string, canvas: Canvas, seconds = 12): void {
const r = spawnSync("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", `testsrc2=s=${canvas.width}x${canvas.height}:r=${canvas.fps}:d=${seconds}`,
"-f", "lavfi", "-i", `sine=frequency=500:sample_rate=48000:d=${seconds}`,
"-c:v", "libx264", "-preset", "veryfast", "-b:v", "6000k", "-g", String(2 * canvas.fps), "-c:a", "aac", "-b:a", "128k", "-f", "mpegts", path]);
if (r.status !== 0) throw new Error("source clip encode failed");
}
/**
* Stand-in for the pinned egress relay (WP7): a paced, looping remux of `clip` into a FIFO.
* Returns the read end for an `mpegtsPipe` ingest's stdin (close it after spawning the ingest).
*/
export async function startPacer(clip: string, dir: string, name: string): Promise<{ readFd: number; proc: ManagedProcess }> {
const fifo = join(dir, `${name}.fifo`);
spawnSync("mkfifo", [fifo]);
const readFd = openSync(fifo, "r+");
const writeFd = openSync(fifo, "w");
const proc = spawnManaged("ffmpeg", ["-hide_banner", "-loglevel", "error", "-re", "-stream_loop", "-1", "-i", clip, "-c", "copy", "-f", "mpegts", "pipe:1"],
["ignore", writeFd, "pipe"], passthroughRedactor, () => undefined);
await proc.started;
closeSync(writeFd);
return { readFd, proc };
}
export const ZERO_GAP_CANVAS: Canvas = { width: 320, height: 180, fps: 30 };
const canvas = ZERO_GAP_CANVAS;
/** Both native helpers (frame gate + source bus), built offline if missing or stale (src/gate/binary.ts). */
export function ensureGateBinary(): boolean { return ensureHelperBinaries(); }
export function makeAssets(dir: string): string[] {
const out: string[] = [];
for (const src of ["color=c=red@0.6:s=160x24", "testsrc2=s=600x16"]) {
const tmp = join(dir, `tmp-${out.length}.png`);
const r = spawnSync("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", `${src},format=rgba`, "-frames:v", "1", "-fflags", "+bitexact", "-flags", "+bitexact", tmp]);
assert.equal(r.status, 0, "asset render");
const path = join(dir, `${createHash("sha256").update(readFileSync(tmp)).digest("hex")}.png`);
copyFileSync(tmp, path);
out.push(path);
}
return out;
}
const CUSTOM: PxRect[] = [{ x: 0, y: 0, w: 160, h: 90 }, { x: 160, y: 90, w: 160, h: 90 }, { x: 100, y: 40, w: 120, h: 68 }];
/** Swap i: cycles layouts × overlay sets × gains. Slot 0 always shows source 0 at the top-left (the counter pixel). */
export function scriptedGraph(i: number): CompositorGraph {
const layouts = SCENE_LAYOUTS;
const layout: SceneLayout = layouts[i % layouts.length]!;
const rects = layout === "pip" ? [{ x: 0, y: 0, w: 320, h: 180 }, { x: 200, y: 100, w: 100, h: 56 }]
: layout === "custom" ? CUSTOM : layoutRects(layout, canvas);
const tiles = rects.map((rect, t) => ({ source: t === 0 ? 0 : (t % 5 === 4 ? null : t % 4), rect, fit: (["contain", "cover", "crop"] as const)[(i + t) % 3]! }));
if (tiles[0]) tiles[0].fit = "cover"; // cover/crop keep the counter pixel (0,0) on picture, contain letterboxes it
const overlays: OverlaySpec[] = [];
if (i % 3 !== 0) overlays.push({ kind: "image", asset: 0, x: 10, y: 130 });
if (i % 2 === 0) overlays.push({ kind: "chyron", asset: 1, y: 160, speedPxPerSec: 40 + ((i * 20) % 360) });
const gain = -3 * (i % 9);
const audible = [...new Set(tiles.map((t) => t.source).filter((s): s is number => s !== null))].slice(0, 1 + (i % 3));
return { canvas, layout, tiles, overlays, audio: { inputs: audible.map((source, k) => ({ source, gainDb: k === 0 ? gain : -6 })), masterGainDb: -3 * (i % 3) } };
}
/**
* Compose request over only the sources the graph uses (renumbered), as
* `planScene` does in production: ffmpeg never reads an input that is not wired
* into the filtergraph, so an unused bus subscription would just stall.
*/
export function composeOver(buses: SourceBus[], assets: string[], graph: CompositorGraph): ComposeRequest {
const used = [...new Set(graph.tiles.map((t) => t.source).concat(graph.audio.inputs.map((m) => m.source)).filter((s): s is number => s !== null))].sort((a, b) => a - b);
const index = (s: number): number => used.indexOf(s);
const audible = new Set(graph.audio.inputs.map((m) => m.source));
return {
inputs: used.map((s) => ({ bus: buses[s]!, audio: audible.has(s) })),
assets,
graph: {
...graph,
tiles: graph.tiles.map((t) => ({ ...t, source: t.source === null ? null : index(t.source) })),
audio: { ...graph.audio, inputs: graph.audio.inputs.map((m) => ({ ...m, source: index(m.source) })) },
},
};
}