T-HUB/apps/api/src/media-control/production.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

358 lines
16 KiB
TypeScript

/**
* Desired-state v2: the production part of a channel snapshot
* (docs/design/streaming-architecture.md §2, §4, §6 S1).
*
* This file is mirrored byte-for-byte at apps/media-worker/src/production.ts
* (a test asserts it). It has no imports so both builds compile it as-is.
*
* Everything that can reach a filtergraph or argv is typed: ids are UUIDs,
* assets are sha256 content ids, geometry is integer basis points, gains are
* 3 dB steps, enums are closed. Labels are display text for operators and logs
* only; they are still held to a strict allowlist so a filtergraph payload
* (`;`, `[`, `,`, `'`, `movie=`) can never ride along in a snapshot.
*/
export type SceneLayout = "single" | "pip" | "2x1" | "2x2" | "3x3" | "4x4" | "custom";
export type SlotFit = "contain" | "cover" | "crop";
export type AudioMode = "off" | "on" | "primary";
export type OverlayKind = "lower_third" | "chyron" | "logo" | "clock";
export type ClockFormat = "HH:MM" | "HH:MM:SS";
export type FadeMs = 300 | 500 | 1000 | 2000;
export const SCENE_LAYOUTS: readonly SceneLayout[] = ["single", "pip", "2x1", "2x2", "3x3", "4x4", "custom"];
export const SLOT_FITS: readonly SlotFit[] = ["contain", "cover", "crop"];
export const AUDIO_MODES: readonly AudioMode[] = ["off", "on", "primary"];
export const OVERLAY_KINDS: readonly OverlayKind[] = ["lower_third", "chyron", "logo", "clock"];
export const CLOCK_FORMATS: readonly ClockFormat[] = ["HH:MM", "HH:MM:SS"];
export const FADE_MS: readonly FadeMs[] = [300, 500, 1000, 2000];
/** Slot capacity per layout. `custom` places up to 16 explicit rectangles. */
export const LAYOUT_CAPACITY: Readonly<Record<SceneLayout, number>> = {
single: 1, pip: 2, "2x1": 2, "2x2": 4, "3x3": 9, "4x4": 16, custom: 16,
};
/** Geometry is integer basis points of the output frame (0..10000), resolution independent. */
export const GEOMETRY_SCALE = 10_000;
export const GAIN_DB_MIN = -24;
export const GAIN_DB_MAX = 0;
export const GAIN_DB_STEP = 3;
export const CHYRON_SPEED_MIN = 40;
export const CHYRON_SPEED_MAX = 400;
export const CHYRON_STRIP_MAX_PX = 16_384;
export const MAX_SOURCES = 32;
export const MAX_SCENES = 32;
export const MAX_OVERLAYS = 64;
export const MAX_PLAYLIST_ITEMS = 500;
export const MAX_OVERLAY_LAYER = 7;
export interface Rect { x: number; y: number; w: number; h: number }
export interface Point { x: number; y: number }
export interface HlsProductionSource { id: string; label: string; kind: "hls"; url: string }
/** Ordered, pre-normalized video asset ids (video_assets.id), fed by the worker's asset feeder. */
export interface PlaylistProductionSource { id: string; label: string; kind: "playlist"; assetIds: string[]; loop: boolean }
export type ProductionSource = HlsProductionSource | PlaylistProductionSource;
export interface SceneSlot {
index: number;
/** null = empty slot (renders the fallback). */
sourceId: string | null;
/** Required for `custom` and `pip` layouts; null means the layout's own grid cell. */
rect: Rect | null;
fit: SlotFit;
audioMode: AudioMode;
gainDb: number;
}
export interface ProductionScene { id: string; label: string; layout: SceneLayout; slots: SceneSlot[] }
export interface ProductionBuses { preview: string | null; program: string | null }
export interface ProductionOverlay {
/** overlay_slots.id */
id: string;
sceneId: string;
kind: OverlayKind;
enabled: boolean;
layer: number;
position: Point;
/** overlay_revisions.id; null only for the server-generated clock. */
revisionId: string | null;
/** sha256 content id of the rendered PNG; null only for the clock. */
assetId: string | null;
chyron: { speedPxPerSec: number; stripWidthPx: number } | null;
clock: { format: ClockFormat } | null;
}
export type Transition = { type: "cut" } | { type: "fade"; ms: FadeMs };
export interface ProgramAudio { masterGainDb: number }
export interface ProductionSpec {
scenes: ProductionScene[];
buses: ProductionBuses;
sources: ProductionSource[];
overlays: ProductionOverlay[];
transition: Transition;
audio: ProgramAudio;
}
export class ProductionSpecError extends Error {
constructor(message: string) {
super(message);
this.name = "ProductionSpecError";
}
}
const SECRETISH = /key|secret|token|password|passwd|credential(?!HandleId)|auth/i;
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const SHA256 = /^[0-9a-f]{64}$/;
/** Letters, digits, space and `_ . -` only: no filtergraph or shell metacharacters, no `=`. */
const LABEL = /^[\p{L}\p{N}][\p{L}\p{N} _.-]{0,63}$/u;
function fail(message: string): never { throw new ProductionSpecError(message); }
function record(value: unknown, field: string, allowed: readonly string[]): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) fail(`${field} must be an object`);
for (const key of Object.keys(value)) {
if (allowed.includes(key)) continue;
if (SECRETISH.test(key)) fail(`${field}.${key}: secrets are never accepted; reference a vault credentialHandleId`);
fail(`${field}.${key} is not allowed`);
}
for (const key of allowed) if (!(key in value)) fail(`${field}.${key} is required`);
return value as Record<string, unknown>;
}
function list(value: unknown, field: string, max: number): unknown[] {
if (!Array.isArray(value)) fail(`${field} must be an array`);
if (value.length > max) fail(`${field} allows at most ${max} entries`);
return value;
}
function uuid(value: unknown, field: string): string {
if (typeof value !== "string" || !UUID.test(value)) fail(`${field} must be a lowercase UUID`);
return value;
}
function nullableUuid(value: unknown, field: string): string | null {
return value === null ? null : uuid(value, field);
}
function label(value: unknown, field: string): string {
if (typeof value !== "string" || !LABEL.test(value)) {
fail(`${field} must be 1-64 letters, digits, spaces, '_', '.', '-'`);
}
return value;
}
function int(value: unknown, field: string, min: number, max: number): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min || value > max) {
fail(`${field} must be an integer in ${min}..${max}`);
}
return value;
}
function bool(value: unknown, field: string): boolean {
if (typeof value !== "boolean") fail(`${field} must be a boolean`);
return value;
}
function oneOf<T extends string | number>(value: unknown, field: string, options: readonly T[]): T {
const found = options.find((option) => option === value);
if (found === undefined) fail(`${field} must be one of ${options.join(", ")}`);
return found;
}
export function gainDb(value: unknown, field: string): number {
const gain = int(value, field, GAIN_DB_MIN, GAIN_DB_MAX);
if (gain % GAIN_DB_STEP !== 0) fail(`${field} must be a multiple of ${GAIN_DB_STEP} dB`);
return gain === 0 ? 0 : gain;
}
function rect(value: unknown, field: string): Rect {
const r = record(value, field, ["x", "y", "w", "h"]);
const x = int(r.x, `${field}.x`, 0, GEOMETRY_SCALE - 1);
const y = int(r.y, `${field}.y`, 0, GEOMETRY_SCALE - 1);
const w = int(r.w, `${field}.w`, 1, GEOMETRY_SCALE);
const h = int(r.h, `${field}.h`, 1, GEOMETRY_SCALE);
if (x + w > GEOMETRY_SCALE || y + h > GEOMETRY_SCALE) fail(`${field} must lie inside the frame`);
return { x, y, w, h };
}
function point(value: unknown, field: string): Point {
const p = record(value, field, ["x", "y"]);
return { x: int(p.x, `${field}.x`, 0, GEOMETRY_SCALE), y: int(p.y, `${field}.y`, 0, GEOMETRY_SCALE) };
}
function unique(ids: string[], field: string): void {
if (new Set(ids).size !== ids.length) fail(`${field} ids must be unique`);
}
/**
* HLS URLs are shape-checked by the caller with its own endpoint validator
* (API and worker differ on loopback strictness); here only bounds are checked.
*/
function source(value: unknown, field: string): ProductionSource {
if (typeof value !== "object" || value === null || Array.isArray(value)) fail(`${field} must be an object`);
const kind = (value as Record<string, unknown>).kind;
if (kind === "hls") {
const s = record(value, field, ["id", "label", "kind", "url"]);
if (typeof s.url !== "string" || s.url.trim() === "" || s.url.length > 2048) fail(`${field}.url must be a non-empty string`);
return { id: uuid(s.id, `${field}.id`), label: label(s.label, `${field}.label`), kind: "hls", url: s.url };
}
if (kind === "playlist") {
const s = record(value, field, ["id", "label", "kind", "assetIds", "loop"]);
const assetIds = list(s.assetIds, `${field}.assetIds`, MAX_PLAYLIST_ITEMS).map((item, i) => uuid(item, `${field}.assetIds[${i}]`));
if (assetIds.length === 0) fail(`${field}.assetIds must not be empty`);
return { id: uuid(s.id, `${field}.id`), label: label(s.label, `${field}.label`), kind: "playlist", assetIds, loop: bool(s.loop, `${field}.loop`) };
}
return fail(`${field}.kind must be hls or playlist`);
}
function slot(value: unknown, field: string, layout: SceneLayout, sources: ReadonlySet<string>): SceneSlot {
const s = record(value, field, ["index", "sourceId", "rect", "fit", "audioMode", "gainDb"]);
const index = int(s.index, `${field}.index`, 0, LAYOUT_CAPACITY[layout] - 1);
const sourceId = nullableUuid(s.sourceId, `${field}.sourceId`);
if (sourceId !== null && !sources.has(sourceId)) fail(`${field}.sourceId must name a source of this channel`);
const needsRect = layout === "custom" || (layout === "pip" && index > 0);
const slotRect = s.rect === null ? null : rect(s.rect, `${field}.rect`);
if (needsRect && slotRect === null) fail(`${field}.rect is required for the ${layout} layout`);
if (!needsRect && slotRect !== null) fail(`${field}.rect is only allowed for custom layouts and PIP insets`);
return {
index, sourceId, rect: slotRect,
fit: oneOf(s.fit, `${field}.fit`, SLOT_FITS),
audioMode: oneOf(s.audioMode, `${field}.audioMode`, AUDIO_MODES),
gainDb: gainDb(s.gainDb, `${field}.gainDb`),
};
}
function scene(value: unknown, field: string, sources: ReadonlySet<string>): ProductionScene {
const s = record(value, field, ["id", "label", "layout", "slots"]);
const layout = oneOf(s.layout, `${field}.layout`, SCENE_LAYOUTS);
const slots = list(s.slots, `${field}.slots`, LAYOUT_CAPACITY[layout]).map((item, i) => slot(item, `${field}.slots[${i}]`, layout, sources));
if (new Set(slots.map((x) => x.index)).size !== slots.length) fail(`${field}.slots indexes must be unique`);
if (slots.filter((x) => x.audioMode === "primary").length > 1) fail(`${field}.slots: at most one primary audio slot per scene`);
return { id: uuid(s.id, `${field}.id`), label: label(s.label, `${field}.label`), layout, slots };
}
function overlay(value: unknown, field: string, scenes: ReadonlySet<string>): ProductionOverlay {
const o = record(value, field, ["id", "sceneId", "kind", "enabled", "layer", "position", "revisionId", "assetId", "chyron", "clock"]);
const sceneId = uuid(o.sceneId, `${field}.sceneId`);
if (!scenes.has(sceneId)) fail(`${field}.sceneId must name a scene of this channel`);
const kind = oneOf(o.kind, `${field}.kind`, OVERLAY_KINDS);
const rendered = kind !== "clock";
let revisionId: string | null = null;
let assetId: string | null = null;
if (rendered) {
revisionId = uuid(o.revisionId, `${field}.revisionId`);
if (typeof o.assetId !== "string" || !SHA256.test(o.assetId)) fail(`${field}.assetId must be a sha256 content id`);
assetId = o.assetId;
} else if (o.revisionId !== null || o.assetId !== null) fail(`${field}: a clock overlay has no rendered asset`);
let chyron: ProductionOverlay["chyron"] = null;
if (kind === "chyron") {
const c = record(o.chyron, `${field}.chyron`, ["speedPxPerSec", "stripWidthPx"]);
chyron = {
speedPxPerSec: int(c.speedPxPerSec, `${field}.chyron.speedPxPerSec`, CHYRON_SPEED_MIN, CHYRON_SPEED_MAX),
stripWidthPx: int(c.stripWidthPx, `${field}.chyron.stripWidthPx`, 1, CHYRON_STRIP_MAX_PX),
};
} else if (o.chyron !== null) fail(`${field}.chyron is only allowed for chyron overlays`);
let clock: ProductionOverlay["clock"] = null;
if (kind === "clock") {
const c = record(o.clock, `${field}.clock`, ["format"]);
clock = { format: oneOf(c.format, `${field}.clock.format`, CLOCK_FORMATS) };
} else if (o.clock !== null) fail(`${field}.clock is only allowed for clock overlays`);
return {
id: uuid(o.id, `${field}.id`), sceneId, kind,
enabled: bool(o.enabled, `${field}.enabled`),
layer: int(o.layer, `${field}.layer`, 0, MAX_OVERLAY_LAYER),
position: point(o.position, `${field}.position`),
revisionId, assetId, chyron, clock,
};
}
function transition(value: unknown, field: string): Transition {
if (typeof value !== "object" || value === null || Array.isArray(value)) fail(`${field} must be an object`);
const type = (value as Record<string, unknown>).type;
if (type === "cut") {
record(value, field, ["type"]);
return { type: "cut" };
}
if (type === "fade") {
const t = record(value, field, ["type", "ms"]);
return { type: "fade", ms: oneOf(t.ms, `${field}.ms`, FADE_MS) };
}
return fail(`${field}.type must be cut or fade`);
}
/** Parse and normalize; throws ProductionSpecError with a field path on the first problem. */
export function parseProductionSpec(value: unknown, field = "production"): ProductionSpec {
const p = record(value, field, ["scenes", "buses", "sources", "overlays", "transition", "audio"]);
const sources = list(p.sources, `${field}.sources`, MAX_SOURCES).map((item, i) => source(item, `${field}.sources[${i}]`));
unique(sources.map((s) => s.id), `${field}.sources`);
const sourceIds = new Set(sources.map((s) => s.id));
const scenes = list(p.scenes, `${field}.scenes`, MAX_SCENES).map((item, i) => scene(item, `${field}.scenes[${i}]`, sourceIds));
unique(scenes.map((s) => s.id), `${field}.scenes`);
const sceneIds = new Set(scenes.map((s) => s.id));
const overlays = list(p.overlays, `${field}.overlays`, MAX_OVERLAYS).map((item, i) => overlay(item, `${field}.overlays[${i}]`, sceneIds));
unique(overlays.map((o) => o.id), `${field}.overlays`);
const b = record(p.buses, `${field}.buses`, ["preview", "program"]);
const buses: ProductionBuses = {
preview: nullableUuid(b.preview, `${field}.buses.preview`),
program: nullableUuid(b.program, `${field}.buses.program`),
};
for (const bus of ["preview", "program"] as const) {
const id = buses[bus];
if (id !== null && !sceneIds.has(id)) fail(`${field}.buses.${bus} must name a scene of this channel`);
}
const a = record(p.audio, `${field}.audio`, ["masterGainDb"]);
return {
scenes, buses, sources, overlays,
transition: transition(p.transition, `${field}.transition`),
audio: { masterGainDb: gainDb(a.masterGainDb, `${field}.audio.masterGainDb`) },
};
}
export function isProductionSpec(value: unknown): value is ProductionSpec {
try {
parseProductionSpec(value);
return true;
} catch {
return false;
}
}
/** Every remote URL a production snapshot can make the worker fetch. */
export function productionHlsUrls(spec: ProductionSpec): string[] {
return spec.sources.flatMap((s) => (s.kind === "hls" ? [s.url] : []));
}
/** Declared key order for signing: insertion order of the input can never drift the signed bytes. */
export function canonicalProduction(spec: ProductionSpec): ProductionSpec {
return {
scenes: spec.scenes.map((s) => ({
id: s.id, label: s.label, layout: s.layout,
slots: s.slots.map((x) => ({
index: x.index, sourceId: x.sourceId,
rect: x.rect === null ? null : { x: x.rect.x, y: x.rect.y, w: x.rect.w, h: x.rect.h },
fit: x.fit, audioMode: x.audioMode, gainDb: x.gainDb,
})),
})),
buses: { preview: spec.buses.preview, program: spec.buses.program },
sources: spec.sources.map((s): ProductionSource => (s.kind === "hls"
? { id: s.id, label: s.label, kind: "hls", url: s.url }
: { id: s.id, label: s.label, kind: "playlist", assetIds: [...s.assetIds], loop: s.loop })),
overlays: spec.overlays.map((o) => ({
id: o.id, sceneId: o.sceneId, kind: o.kind, enabled: o.enabled, layer: o.layer,
position: { x: o.position.x, y: o.position.y },
revisionId: o.revisionId, assetId: o.assetId,
chyron: o.chyron === null ? null : { speedPxPerSec: o.chyron.speedPxPerSec, stripWidthPx: o.chyron.stripWidthPx },
clock: o.clock === null ? null : { format: o.clock.format },
})),
transition: spec.transition.type === "cut" ? { type: "cut" } : { type: "fade", ms: spec.transition.ms },
audio: { masterGainDb: spec.audio.masterGainDb },
};
}