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/.
81 lines
3.8 KiB
TypeScript
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;
|
|
}
|