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

81 lines
3.8 KiB
TypeScript

import type { ProductionOverlay, ProductionScene, ProductionSpec } from "../production";
import { layoutRects, rectFromBasisPoints, type Canvas, type CompositorGraph, type MixInput, type OverlaySpec, type TileSpec } from "./command-builder";
/** Desired-state v2 scene → typed compositor graph over a list of source ids (input order). */
export interface ScenePlan {
sourceIds: string[];
/** Same order as sourceIds: whether the compositor needs that source's audio. */
audio: boolean[];
assetIds: string[];
graph: CompositorGraph;
/** Things in the snapshot this slice renders as black/skips, for the observed reason. */
skipped: string[];
}
export function programScene(spec: ProductionSpec): ProductionScene {
const scene = spec.scenes.find((s) => s.id === spec.buses.program);
if (!scene) throw new Error("program bus names no scene");
return scene;
}
/**
* `available(sourceId)` says whether the worker has an ingest for that source:
* S2a ingests HLS sources only; playlist sources (S2b feeder) render black.
* Overlay order: `layer` ascending, snapshot order within a layer.
*/
export function planScene(spec: ProductionSpec, scene: ProductionScene, canvas: Canvas, available: (sourceId: string) => boolean): ScenePlan {
const skipped: string[] = [];
const sourceIds: string[] = [];
const indexOf = (id: string): number => {
let i = sourceIds.indexOf(id);
if (i < 0) { sourceIds.push(id); i = sourceIds.length - 1; }
return i;
};
const grid = layoutRects(scene.layout, canvas);
const slots = [...scene.slots].sort((a, b) => a.index - b.index);
const tiles: TileSpec[] = [];
const gains = new Map<number, number>();
for (const slot of slots) {
const rect = slot.rect ? rectFromBasisPoints(slot.rect, canvas) : grid[slot.index];
if (!rect) continue;
let source: number | null = null;
if (slot.sourceId !== null) {
if (available(slot.sourceId)) source = indexOf(slot.sourceId);
else skipped.push(`slot ${slot.index}: source ${slot.sourceId} has no ingest in this worker build`);
}
tiles.push({ source, rect, fit: slot.fit });
if (source !== null && slot.audioMode !== "off") gains.set(source, Math.max(gains.get(source) ?? -Infinity, slot.gainDb));
}
const audio = sourceIds.map((_, i) => gains.has(i));
const mix: MixInput[] = [...gains.entries()].sort((a, b) => a[0] - b[0]).map(([source, gainDb]) => ({ source, gainDb }));
const assetIds: string[] = [];
const overlays: OverlaySpec[] = [];
const ordered = spec.overlays
.map((o, i) => ({ o, i }))
.filter(({ o }) => o.sceneId === scene.id && o.enabled)
.sort((a, b) => a.o.layer - b.o.layer || a.i - b.i)
.map(({ o }) => o);
for (const o of ordered) {
const spec = overlaySpec(o, canvas, (assetId) => {
let i = assetIds.indexOf(assetId);
if (i < 0) { assetIds.push(assetId); i = assetIds.length - 1; }
return i;
});
if (spec) overlays.push(spec);
else skipped.push(`overlay ${o.id}: ${o.kind} is not composited in S2a`);
}
return {
sourceIds, audio, assetIds, skipped,
graph: { canvas, layout: scene.layout, tiles, overlays, audio: { inputs: mix, masterGainDb: spec.audio.masterGainDb } },
};
}
function overlaySpec(o: ProductionOverlay, canvas: Canvas, asset: (id: string) => number): OverlaySpec | null {
if (o.assetId === null) return null; // clock: server-generated drawtext is a later slice
const x = Math.min(canvas.width, Math.round((o.position.x * canvas.width) / 10_000));
const y = Math.min(canvas.height, Math.round((o.position.y * canvas.height) / 10_000));
if (o.kind === "chyron" && o.chyron) return { kind: "chyron", asset: asset(o.assetId), y, speedPxPerSec: o.chyron.speedPxPerSec };
if (o.kind === "lower_third" || o.kind === "logo") return { kind: "image", asset: asset(o.assetId), x, y };
return null;
}