/** * MediaCommandBuilder: argv shape, typed-param rejection, and golden-frame * hashes per layout (deterministic synthetic inputs: seeded noise over test * patterns; framemd5 of the composited output, video + program audio). * * Hashes are pinned to the system FFmpeg (5.1.x here). UPDATE_GOLDEN=1 rewrites * src/compositor/golden-frames.json after an intentional graph change or an * FFmpeg upgrade. */ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { buildCompositorArgs, buildEncoderArgs, buildFilterGraph, buildIngestArgs, CommandBuildError, layoutRects, rectFromBasisPoints, SYNTHETIC_PATTERNS, type Canvas, type CompositorPlan, type PxRect, } from "./command-builder"; import type { SceneLayout } from "../production"; import { makeAssets } from "./testing"; const canvas: Canvas = { width: 320, height: 180, fps: 30 }; const GOLDEN = join(__dirname, "golden-frames.json"); function synthetic(i: number) { return { video: { kind: "synthetic" as const, pattern: SYNTHETIC_PATTERNS[i % SYNTHETIC_PATTERNS.length]!, seed: 1000 + i }, audio: { kind: "synthetic" as const, toneHz: 220 + 110 * i } }; } const CUSTOM: PxRect[] = [{ x: 0, y: 0, w: 160, h: 90 }, { x: 160, y: 0, w: 160, h: 90 }, { x: 80, y: 90, w: 160, h: 90 }]; const CUSTOM_OVERLAP: PxRect[] = [{ x: 0, y: 0, w: 200, h: 112 }, { x: 120, y: 68, w: 200, h: 112 }]; interface Case { name: string; layout: SceneLayout; rects: PxRect[]; empty?: number[]; overlays: "none" | "lower" | "chyron" | "both"; mix: Array<[number, number]>; master: number } const CASES: Case[] = [ { name: "single", layout: "single", rects: layoutRects("single", canvas), overlays: "none", mix: [[0, 0]], master: 0 }, { name: "pip", layout: "pip", rects: [{ x: 0, y: 0, w: 320, h: 180 }, rectFromBasisPoints({ x: 6000, y: 6000, w: 3500, h: 3500 }, canvas)], overlays: "lower", mix: [[0, 0], [1, -12]], master: -3 }, { name: "2x1", layout: "2x1", rects: layoutRects("2x1", canvas), overlays: "none", mix: [[0, -3], [1, -3]], master: 0 }, { name: "2x2", layout: "2x2", rects: layoutRects("2x2", canvas), empty: [3], overlays: "chyron", mix: [[0, 0]], master: -6 }, { name: "3x3", layout: "3x3", rects: layoutRects("3x3", canvas), empty: [4, 8], overlays: "both", mix: [[0, 0], [1, -6], [2, -24]], master: 0 }, { name: "4x4", layout: "4x4", rects: layoutRects("4x4", canvas), empty: [5, 10, 15], overlays: "none", mix: [], master: 0 }, { name: "custom", layout: "custom", rects: CUSTOM, overlays: "lower", mix: [[2, 0]], master: 0 }, { name: "custom-overlap", layout: "custom", rects: CUSTOM_OVERLAP, overlays: "both", mix: [[0, 0], [1, 0]], master: -9 }, ]; function plan(c: Case, assets: string[]): CompositorPlan { const tiles = c.rects.map((rect, i) => ({ source: c.empty?.includes(i) ? null : i % 4, rect, fit: (["contain", "cover", "crop"] as const)[i % 3]! })); const overlays = [ ...(c.overlays === "lower" || c.overlays === "both" ? [{ kind: "image" as const, asset: 0, x: 12, y: 140 }] : []), ...(c.overlays === "chyron" || c.overlays === "both" ? [{ kind: "chyron" as const, asset: 1, y: 164, speedPxPerSec: 120 }] : []), ]; return { sources: [0, 1, 2, 3].map(synthetic), assets, graph: { canvas, layout: c.layout, tiles, overlays, audio: { inputs: c.mix.map(([source, gainDb]) => ({ source, gainDb })), masterGainDb: c.master } }, output: { kind: "framemd5", frames: 4 }, }; } function hashes(p: CompositorPlan): string[] { const r = spawnSync("ffmpeg", buildCompositorArgs(p), { encoding: "utf8", timeout: 60_000 }); assert.equal(r.status, 0, r.stderr); // framemd5 lines: stream, dts, pts, duration, size, hash — keep stream/pts/size/hash. return r.stdout.split("\n").filter((l) => l && !l.startsWith("#")).map((l) => l.split(",").map((x) => x.trim())).map((f) => `${f[0]}:${f[2]}:${f[4]}:${f[5]}`); } test("golden-frame hashes per layout (deterministic synthetic inputs)", { timeout: 300_000 }, () => { const dir = mkdtempSync(join(tmpdir(), "golden-")); try { const assets = makeAssets(dir); const got: Record = {}; for (const c of CASES) { const p = plan(c, assets); const h = hashes(p); assert.ok(h.some((l) => l.startsWith("0:")) && h.some((l) => l.startsWith("1:")), `${c.name}: video and audio frames`); assert.deepEqual(hashes(p), h, `${c.name}: two runs hash identically`); got[c.name] = h; } if (process.env.UPDATE_GOLDEN || !existsSync(GOLDEN)) { writeFileSync(GOLDEN, `${JSON.stringify({ ffmpeg: ffmpegVersion(), canvas, frames: 4, layouts: got }, null, 2)}\n`); } const golden = JSON.parse(readFileSync(GOLDEN, "utf8")) as { ffmpeg: string; layouts: Record }; for (const c of CASES) assert.deepEqual(got[c.name], golden.layouts[c.name], `${c.name} matches golden (pinned to ${golden.ffmpeg}; UPDATE_GOLDEN=1 after an intentional change)`); } finally { rmSync(dir, { recursive: true, force: true }); } }); function ffmpegVersion(): string { return spawnSync("ffmpeg", ["-version"], { encoding: "utf8" }).stdout.split("\n")[0] ?? "unknown"; } test("layout cells are even, cover the canvas, and xstack/overlay is chosen by geometry", () => { for (const layout of ["2x1", "2x2", "3x3", "4x4"] as const) { for (const c of [canvas, { width: 1920, height: 1080, fps: 30 }, { width: 1280, height: 720, fps: 30 }]) { const rects = layoutRects(layout, c); assert.equal(rects.reduce((a, r) => a + r.w * r.h, 0), c.width * c.height, `${layout} ${c.width}x${c.height} tiles cover the canvas`); for (const r of rects) for (const v of [r.x, r.y, r.w, r.h]) assert.equal(v % 2, 0); } } const idx = { video: [0, 1, 2, 3], audio: [null, null, null, null], assets: [] }; const base = { sources: [0, 1, 2, 3].map(synthetic), assets: [], output: { kind: "framemd5" as const, frames: 1 } }; const audio = { inputs: [], masterGainDb: 0 }; const grid = buildFilterGraph({ ...base, graph: { canvas, layout: "2x2", tiles: layoutRects("2x2", canvas).map((rect, i) => ({ source: i, rect, fit: "contain" as const })), overlays: [], audio } }, idx); assert.match(grid, /xstack=inputs=4:layout=0_0\|160_0\|0_90\|160_90:fill=black/); assert.match(grid, /anullsrc=r=48000:cl=stereo/); const pip = buildFilterGraph({ ...base, graph: { canvas, layout: "pip", tiles: [{ source: 0, rect: { x: 0, y: 0, w: 320, h: 180 }, fit: "cover" }, { source: 1, rect: { x: 200, y: 100, w: 100, h: 56 }, fit: "contain" }], overlays: [], audio } }, idx); assert.match(pip, /overlay=x=200:y=100/); assert.doesNotMatch(pip, /xstack/); // Overlapping custom rects: the base is the first tile padded (paced by a live input), not a lavfi color source. const overlap = buildFilterGraph({ ...base, graph: { canvas, layout: "custom", tiles: CUSTOM_OVERLAP.map((rect, i) => ({ source: i, rect, fit: "cover" as const })), overlays: [], audio } }, idx); assert.match(overlap, /\[t0\]pad=320:180:0:0:color=black\[bg\];\[bg\]\[t1\]overlay=x=120:y=68/); assert.doesNotMatch(overlap, /color=c=black/); const shared = buildFilterGraph({ ...base, graph: { canvas, layout: "2x1", tiles: layoutRects("2x1", canvas).map((rect) => ({ source: 0, rect, fit: "crop" as const })), overlays: [], audio } }, idx); assert.match(shared, /\[0:v\]split=2\[s0_0\]\[s0_1\]/); }); test("program mix follows §2.3: volume per input → amix → master gain, 3 dB steps only", () => { const idx = { video: [0, 2, 4], audio: [1, 3, 5], assets: [] }; const p = (inputs: Array<{ source: number; gainDb: number }>, masterGainDb = 0): CompositorPlan => ({ sources: [0, 1, 2].map(synthetic), assets: [], output: { kind: "framemd5", frames: 1 }, graph: { canvas, layout: "single", tiles: [{ source: 0, rect: { x: 0, y: 0, w: 320, h: 180 }, fit: "contain" }], overlays: [], audio: { inputs, masterGainDb } }, }); const g = buildFilterGraph(p([{ source: 0, gainDb: -6 }, { source: 2, gainDb: 0 }], -3), idx); assert.match(g, /\[1:a\]aformat=[^;]*,volume=-6dB\[m0\]/); assert.match(g, /\[5:a\]aformat=[^;]*,volume=0dB\[m1\]/); assert.match(g, /\[m0\]\[m1\]amix=inputs=2:duration=longest:dropout_transition=2:normalize=1,volume=-3dB/); assert.doesNotMatch(buildFilterGraph(p([{ source: 0, gainDb: 0 }]), idx), /amix/); for (const bad of [-1, -25, 3, 1.5]) assert.throws(() => buildCompositorArgs(p([{ source: 0, gainDb: bad }])), /gainDb/); assert.throws(() => buildCompositorArgs(p([{ source: 0, gainDb: 0 }, { source: 0, gainDb: -3 }])), /repeats a source/); assert.throws(() => buildCompositorArgs(p([{ source: 0, gainDb: 0 }], -2)), /masterGainDb/); }); test("typed params only: hostile values never reach argv", () => { const good: CompositorPlan = { sources: [{ video: { kind: "rawPipe", fd: 4 }, audio: { kind: "rawPipe", fd: 5 } }], assets: [`/scratch/assets/${"a".repeat(64)}.png`], graph: { canvas, layout: "single", tiles: [{ source: 0, rect: { x: 0, y: 0, w: 320, h: 180 }, fit: "contain" }], overlays: [{ kind: "chyron", asset: 0, y: 160, speedPxPerSec: 100 }], audio: { inputs: [{ source: 0, gainDb: 0 }], masterGainDb: 0 } }, output: { kind: "gate", videoFd: 1, audioFd: 3 }, }; const args = buildCompositorArgs(good); assert.ok(args.includes("pipe:4") && args.includes("pipe:5") && args.includes(`file:/scratch/assets/${"a".repeat(64)}.png`)); assert.deepEqual(args.slice(-1), ["pipe:3"]); const hostileAssets = ["/scratch/../etc/passwd.png", "/scratch/x;rm.png", "relative/" + "a".repeat(64) + ".png", `/scratch/${"a".repeat(64)}.png'`, `/s/movie=${"a".repeat(58)}.png`, "/scratch/assets/notahash.png"]; for (const asset of hostileAssets) assert.throws(() => buildCompositorArgs({ ...good, assets: [asset] }), CommandBuildError, asset); const g = good.graph; assert.throws(() => buildCompositorArgs({ ...good, graph: { ...g, tiles: [{ ...g.tiles[0]!, rect: { x: 1, y: 0, w: 320, h: 180 } }] } }), /even/); assert.throws(() => buildCompositorArgs({ ...good, graph: { ...g, tiles: [{ ...g.tiles[0]!, rect: { x: 0, y: 0, w: 322, h: 180 } }] } }), CommandBuildError); assert.throws(() => buildCompositorArgs({ ...good, graph: { ...g, overlays: [{ kind: "chyron", asset: 0, y: 160, speedPxPerSec: 39 }] } }), /speedPxPerSec/); assert.throws(() => buildCompositorArgs({ ...good, graph: { ...g, overlays: [{ kind: "image", asset: 1, x: 0, y: 0 }] } }), /asset/); assert.throws(() => buildCompositorArgs({ ...good, sources: [{ video: { kind: "rawPipe", fd: 1.5 }, audio: null }] }), /fd/); assert.throws(() => buildCompositorArgs({ ...good, graph: { ...g, canvas: { width: 321, height: 180, fps: 30 } } }), /even/); assert.throws(() => buildCompositorArgs({ ...good, graph: { ...g, canvas: { width: 320, height: 180, fps: 7 } } }), /fps/); // Nothing in argv is a shell: no metacharacters outside the filtergraph and the expression quotes. for (const a of args) if (!a.includes("[")) assert.doesNotMatch(a, /[;&|`$<>]/, a); }); test("ingest normalize and persistent encoder argv", () => { const ingest = buildIngestArgs({ input: { kind: "mpegtsPipe" }, canvas: { width: 1920, height: 1080, fps: 30 }, audio: "source" }); assert.deepEqual(ingest.slice(ingest.indexOf("-f"), ingest.indexOf("-f") + 4), ["-f", "mpegts", "-i", "pipe:0"]); assert.ok(ingest.includes("pipe:1") && ingest.includes("pipe:3")); assert.ok(ingest.some((a) => a.startsWith("scale=1920:1080:force_original_aspect_ratio=decrease") && a.includes("fps=30"))); const silent = buildIngestArgs({ input: { kind: "mpegtsPipe" }, canvas, audio: "none" }); assert.ok(silent.includes("anullsrc=r=48000:cl=stereo") && silent.includes("1:a:0")); const enc = buildEncoderArgs({ canvas: { width: 1920, height: 1080, fps: 30 }, videoBitrateKbps: 6000, audioBitrateKbps: 128, output: { kind: "flvPipe", fd: 4 } }); assert.deepEqual(enc.slice(-3), ["-f", "flv", "pipe:4"]); assert.ok(enc.includes("pipe:0") && enc.includes("pipe:3") && enc.includes("6000k") && enc.includes("60")); assert.throws(() => buildEncoderArgs({ canvas, videoBitrateKbps: 1e9, audioBitrateKbps: 128, output: { kind: "null" } })); }); test("ingest ×N normalize stage end to end: MPEG-TS pipe (4:3, no audio) and synthetic sources fill their buses", { timeout: 60_000 }, async () => { const { IngestProcess } = await import("./ingest"); const { ensureGateBinary, passthroughRedactor } = await import("./testing"); assert.ok(ensureGateBinary(), "native helpers (source bus) build"); // 4:3 MPEG-TS without audio on the ingest's stdin, as the egress relay would deliver it. const ts = spawnSync("ffmpeg", ["-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc2=s=640x480:r=25:d=2", "-c:v", "mpeg2video", "-f", "mpegts", "-"], { maxBuffer: 64 << 20 }); assert.equal(ts.status, 0); const dir = mkdtempSync(join(tmpdir(), "ingest-")); const tsPath = join(dir, "in.ts"); writeFileSync(tsPath, ts.stdout); const { openSync, closeSync } = await import("node:fs"); const fd = openSync(tsPath, "r"); const exits: string[] = []; const pipeIngest = new IngestProcess("a", { input: { kind: "mpegtsPipe" }, canvas, audio: "none" }, fd, join(dir, "scratch"), passthroughRedactor, () => undefined, (_id, r) => exits.push(r)); const synth = new IngestProcess("b", { input: { kind: "synthetic", pattern: "smptehdbars", seed: 7, toneHz: 440 }, canvas, audio: "source" }, null, join(dir, "scratch"), passthroughRedactor, () => undefined, (_id, r) => exits.push(r)); try { await pipeIngest.start(); closeSync(fd); await synth.start(); await new Promise((r) => setTimeout(r, 1_500)); // 2 s of 25 fps input → 30 fps CFR on the canvas (decoded faster than real time). const piped = await pipeIngest.bus.stats(); assert.ok(piped.pairs >= 50, `mpegts pairs ${piped.pairs}`); assert.equal(piped.silenceFilled, 0, "anullsrc supplies audio for a silent source"); // -re synthetic: real-time pace. const synthetic = await synth.bus.stats(); assert.ok(synthetic.pairs >= 30 && synthetic.pairs <= 60, `synthetic pairs ${synthetic.pairs}`); assert.deepEqual(exits, []); } finally { await pipeIngest.stop(); await synth.stop(); rmSync(dir, { recursive: true, force: true }); } });