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