/** * 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 [copy] * fd 0 control lines: ATTACH [origin] | DETACH | STATS | RESET (peak counters) | QUIT * fd 1 event lines: READY | ATTACHED * | ACTIVE [ ] (Rust: A's buffered depth, B's backward step, B's depth, ticks B warmed) * | RETIRED | DROPPED | DETACHED | EOF | STALL * | STATS k=v... | ERROR * | LAG (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 (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 = { 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`; }