/** * 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> = { 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 { 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; } 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(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).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): 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): 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): 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).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 }, }; }