T-HUB/apps/media-worker/src/compositor/testing.ts
glass beadian aa5e262f3a 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/.
2026-10-10 11:56:42 +00:00

211 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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) })) },
},
};
}