T-HUB/apps/media-worker/src/gate/protocol.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

111 lines
6.6 KiB
TypeScript

/**
* Frame-gate pipe contract (docs/design/streaming-architecture.md §1.4).
*
* Two implementations speak it: the Rust helper (src/gate/rust, the production
* choice, see runs/wp4/s2a-report.md) and the Node reference gate
* (src/gate/node-gate.ts, kept for spike/contract comparison only; ProgramChain refuses it).
*
* argv <width> <height> <fps> <ringFrames> <warmFrames> <encoderVideoFifo> <encoderAudioFifo> [copy]
* fd 0 control lines: ATTACH <id> <videoFifo> <audioFifo> [origin] | DETACH <id> | STATS | RESET (peak counters) | QUIT
* fd 1 event lines: READY <videoBytes> <audioBytes> <ring> <warm> | ATTACHED <id>
* | ACTIVE <id> <tick> [<depthA> <behind> <have> <age>] (Rust: A's buffered depth, B's backward step, B's depth, ticks B warmed)
* | RETIRED <id> | DROPPED <id> | DETACHED <id> | EOF <id> | STALL <id> <tick>
* | STATS k=v... | ERROR <id> <what>
* | LAG <id> <behind> (Rust: pending slot still behind A's on-air source position after 3 s;
* the gate never splices backwards, the worker replaces the compositor)
* | GAP <v|a> <id> <tick> <ticksSinceActive> <lateUs> (diagnostic: a repeat/silence fill, first 200)
* encoder FIFOs (opened for writing by path; the open is the rendezvous with the encoder):
* video = rawvideo yuv420p, fixed frame size; audio = s16le 48 kHz stereo, fixed bytes per frame
* compositor FIFOs (ATTACH): same formats, read non-blocking into frame rings. Video frame i and audio
* frame i of one compositor are one source pair: the Rust gate drops and skips both streams in lockstep.
*
* Exactly one video frame and one audio frame are written per tick.
*/
export const GATE_SAMPLE_RATE = 48_000;
export const GATE_CHANNELS = 2;
const BYTES_PER_SAMPLE = 2 * GATE_CHANNELS;
export interface FrameGeometry { width: number; height: number; fps: number }
export function videoFrameBytes(g: FrameGeometry): number { return (g.width * g.height * 3) / 2; }
export function audioFrameBytes(g: FrameGeometry): number { return (GATE_SAMPLE_RATE / g.fps) * BYTES_PER_SAMPLE; }
export function assertGeometry(g: FrameGeometry): void {
if (!Number.isSafeInteger(g.width) || !Number.isSafeInteger(g.height) || g.width < 2 || g.height < 2 || g.width % 2 !== 0 || g.height % 2 !== 0 || g.width > 7680 || g.height > 4320) {
throw new Error("frame geometry must be even and at most 7680x4320");
}
if (!Number.isSafeInteger(g.fps) || g.fps < 1 || g.fps > 120 || GATE_SAMPLE_RATE % g.fps !== 0) throw new Error("fps must divide 48000");
}
export interface GateStats {
ticks: number; idle: number; vrep: number; asil: number; drops: number; swaps: number;
lateMaxUs: number; lateOverFrame: number; writeMaxUs: number; cpuUs: number; wallUs: number;
active: string; pending: string;
/** Node gate only: longest GC pause, pool allocations after warm-up, peak encoder write backlog. */
gcMaxUs: number; allocs: number; backlogMax: number;
/** Frames dropped because the encoder stopped reading (backlog > 2 s). */
outDrops: number;
lateOver2ms: number; lateOver5ms: number;
/** Pending compositors reported `LAG` (still behind A's on-air source position after 3 s), and the largest gap seen. */
lagged: number; behindMax: number;
}
export type GateEvent =
| { type: "READY"; videoBytes: number; audioBytes: number }
| { type: "RESET" }
| { type: "GAP"; stream: "v" | "a"; id: number; tick: number; sinceActive: number; lateUs: number }
| { type: "ATTACHED" | "RETIRED" | "DROPPED" | "DETACHED" | "EOF"; id: number }
| { type: "ACTIVE" | "STALL"; id: number; tick: number }
| { type: "LAG"; id: number; behind: number }
| { type: "STATS"; stats: GateStats }
| { type: "ERROR"; id: number; what: string };
const STAT_KEYS: Record<string, keyof GateStats> = {
ticks: "ticks", idle: "idle", vrep: "vrep", asil: "asil", drops: "drops", swaps: "swaps",
late_max_us: "lateMaxUs", late_over_frame: "lateOverFrame", write_max_us: "writeMaxUs",
cpu_us: "cpuUs", wall_us: "wallUs", active: "active", pending: "pending",
gc_max_us: "gcMaxUs", allocs: "allocs", backlog_max: "backlogMax", out_drops: "outDrops", late_over_2ms: "lateOver2ms", late_over_5ms: "lateOver5ms", lagged: "lagged", behind_max: "behindMax",
};
export function parseGateEvent(line: string): GateEvent | null {
const [type, ...rest] = line.trim().split(/\s+/);
const n = (i: number): number => Number(rest[i]);
switch (type) {
case "READY": return { type, videoBytes: n(0), audioBytes: n(1) };
case "RESET": return { type };
case "GAP": return { type, stream: rest[0] === "a" ? "a" : "v", id: n(1), tick: n(2), sinceActive: n(3), lateUs: n(4) };
case "ATTACHED": case "RETIRED": case "DROPPED": case "DETACHED": case "EOF": return { type, id: n(0) };
case "ACTIVE": case "STALL": return { type, id: n(0), tick: n(1) };
case "LAG": return { type, id: n(0), behind: n(1) };
case "ERROR": return { type, id: n(0), what: rest[1] ?? "" };
case "STATS": {
const stats: GateStats = { ticks: 0, idle: 0, vrep: 0, asil: 0, drops: 0, swaps: 0, lateMaxUs: 0, lateOverFrame: 0, writeMaxUs: 0, cpuUs: 0, wallUs: 0, active: "-", pending: "-", gcMaxUs: 0, allocs: 0, backlogMax: 0, outDrops: 0, lateOver2ms: 0, lateOver5ms: 0, lagged: 0, behindMax: 0 };
for (const pair of rest) {
const [k, v] = pair.split("=");
const key = k === undefined ? undefined : STAT_KEYS[k];
if (key === undefined || v === undefined) continue;
if (key === "active" || key === "pending") stats[key] = v;
else stats[key] = Number(v);
}
return { type, stats };
}
default: return null;
}
}
/** FIFO paths are generated by the worker (scratch dir + numeric id); nothing else may reach a control line. */
const SAFE_PATH = /^\/[A-Za-z0-9._/-]+$/;
/**
* `origin`: source frame index of the compositor's first output frame, in a
* numbering shared by consecutive compositors of one chain. The Rust gate uses
* it to splice B in at A's next source frame (no backwards step or forward skip at a swap).
*/
export function attachLine(id: number, videoFifo: string, audioFifo: string, origin?: number): string {
if (!Number.isSafeInteger(id) || id < 0 || id > 0xffffffff) throw new Error("gate slot id out of range");
for (const path of [videoFifo, audioFifo]) if (!SAFE_PATH.test(path) || path.includes("..")) throw new Error("unsafe gate fifo path");
if (origin !== undefined && !Number.isSafeInteger(origin)) throw new Error("gate origin must be an integer");
return `ATTACH ${id} ${videoFifo} ${audioFifo}${origin === undefined ? "" : ` ${origin}`}\n`;
}