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/.
This commit is contained in:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
81
apps/media-worker/src/compositor/scene-plan.ts
Normal file
81
apps/media-worker/src/compositor/scene-plan.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue