T-HUB/apps/media-worker/src/compositor/scene-plan.test.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

68 lines
4.4 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import { parseProductionSpec } from "../production";
import { buildCompositorArgs } from "./command-builder";
import { planScene, programScene } from "./scene-plan";
const id = (n: number) => `00000000-0000-4000-8000-${String(n).padStart(12, "0")}`;
const sha = (c: string) => c.repeat(64);
const spec = parseProductionSpec({
sources: [
{ id: id(1), label: "Cam A", kind: "hls", url: "https://a.example/live.m3u8" },
{ id: id(2), label: "Cam B", kind: "hls", url: "https://b.example/live.m3u8" },
{ id: id(3), label: "Loop", kind: "playlist", assetIds: [id(9)], loop: true },
],
scenes: [
{ id: id(10), label: "Grid", layout: "2x2", slots: [
{ index: 0, sourceId: id(1), rect: null, fit: "cover", audioMode: "primary", gainDb: 0 },
{ index: 1, sourceId: id(2), rect: null, fit: "contain", audioMode: "on", gainDb: -6 },
{ index: 2, sourceId: id(3), rect: null, fit: "contain", audioMode: "on", gainDb: 0 },
{ index: 3, sourceId: id(1), rect: null, fit: "crop", audioMode: "on", gainDb: -12 },
] },
{ id: id(11), label: "PIP", layout: "pip", slots: [
{ index: 0, sourceId: id(2), rect: null, fit: "cover", audioMode: "primary", gainDb: -3 },
{ index: 1, sourceId: id(1), rect: { x: 6500, y: 6500, w: 3000, h: 3000 }, fit: "contain", audioMode: "off", gainDb: 0 },
] },
],
buses: { preview: id(11), program: id(10) },
overlays: [
{ id: id(20), sceneId: id(10), kind: "chyron", enabled: true, layer: 2, position: { x: 0, y: 9000 }, revisionId: id(30), assetId: sha("c"), chyron: { speedPxPerSec: 120, stripWidthPx: 4000 }, clock: null },
{ id: id(21), sceneId: id(10), kind: "lower_third", enabled: true, layer: 1, position: { x: 500, y: 7500 }, revisionId: id(31), assetId: sha("a"), chyron: null, clock: null },
{ id: id(22), sceneId: id(10), kind: "logo", enabled: false, layer: 3, position: { x: 9000, y: 500 }, revisionId: id(32), assetId: sha("b"), chyron: null, clock: null },
{ id: id(23), sceneId: id(10), kind: "clock", enabled: true, layer: 4, position: { x: 9000, y: 500 }, revisionId: null, assetId: null, chyron: null, clock: { format: "HH:MM" } },
{ id: id(24), sceneId: id(11), kind: "lower_third", enabled: true, layer: 0, position: { x: 0, y: 0 }, revisionId: id(33), assetId: sha("d"), chyron: null, clock: null },
],
transition: { type: "cut" },
audio: { masterGainDb: -3 },
});
const canvas = { width: 1920, height: 1080, fps: 30 };
const hls = (sourceId: string) => spec.sources.find((s) => s.id === sourceId)?.kind === "hls";
test("program scene → typed graph: grid cells, shared source, playlist black, one mix input per source", () => {
const plan = planScene(spec, programScene(spec), canvas, hls);
assert.deepEqual(plan.sourceIds, [id(1), id(2)]);
assert.deepEqual(plan.audio, [true, true]);
assert.deepEqual(plan.graph.tiles.map((t) => t.source), [0, 1, null, 0]);
assert.deepEqual(plan.graph.tiles[3]?.rect, { x: 960, y: 540, w: 960, h: 540 });
// Cam A is primary at 0 dB and "on" at -12 dB in slot 3: one input at the louder gain.
assert.deepEqual(plan.graph.audio, { inputs: [{ source: 0, gainDb: 0 }, { source: 1, gainDb: -6 }], masterGainDb: -3 });
// Layer order (lower third under chyron); disabled logo and S2a-unsupported clock skipped.
assert.deepEqual(plan.assetIds, [sha("a"), sha("c")]);
assert.deepEqual(plan.graph.overlays, [{ kind: "image", asset: 0, x: 96, y: 810 }, { kind: "chyron", asset: 1, y: 972, speedPxPerSec: 120 }]);
assert.equal(plan.skipped.length, 2);
const args = buildCompositorArgs({
sources: plan.sourceIds.map((_, i) => ({ video: { kind: "rawPipe" as const, fd: 4 + 2 * i }, audio: { kind: "rawPipe" as const, fd: 5 + 2 * i } })),
assets: plan.assetIds.map((a) => `/scratch/assets/${a}.png`), graph: plan.graph, output: { kind: "gate", videoFd: 1, audioFd: 3 },
});
assert.ok(!args.join(" ").includes("Cam A"), "labels never reach argv");
assert.ok(!args.join(" ").includes("example"), "source URLs never reach the compositor");
});
test("PIP scene: inset rect from basis points, audio off slot not mixed", () => {
const plan = planScene(spec, spec.scenes[1]!, canvas, hls);
assert.deepEqual(plan.sourceIds, [id(2), id(1)]);
assert.deepEqual(plan.audio, [true, false]);
assert.deepEqual(plan.graph.tiles[1]?.rect, { x: 1248, y: 702, w: 576, h: 324 });
assert.deepEqual(plan.graph.audio.inputs, [{ source: 0, gainDb: -3 }]);
});