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/.
358 lines
16 KiB
TypeScript
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 },
|
|
};
|
|
}
|