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/.
211 lines
10 KiB
TypeScript
211 lines
10 KiB
TypeScript
/**
|
||
* 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) })) },
|
||
},
|
||
};
|
||
}
|