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(); 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; }