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/.
111 lines
6.6 KiB
TypeScript
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`;
|
|
}
|