import { createHash } from "node:crypto"; import type { GateImpl } from "../gate/frame-gate"; import { snapshotHlsUrls, OUTPUT_PROFILES, type DesiredChannel } from "../types"; import { validateEndpointShape, type EndpointValidator } from "../relay/endpoints"; import type { StderrRedactor, VaultHandleBroker } from "../relay/ffmpeg"; import type { Canvas, EncoderOutput, IngestInput } from "./command-builder"; import type { ComposeRequest } from "./compositor"; import { ManifestPreviewPublisher, type PreviewManifestStore, type PreviewObjectStore } from "../preview/contract"; import type { HlsRendition } from "../preview/argv"; import { PreviewTaps } from "../preview/taps"; import { IngestProcess } from "./ingest"; import { PlaylistFeeder, type PlaylistAssetSource } from "./playlist-feeder"; import { ProgramChain } from "./program"; import type { SourceBus } from "./source-bus"; import { planScene, programScene } from "./scene-plan"; /** WP7 egress for a production snapshot: one pinned MPEG-TS fd per HLS source, plus the push fd. */ export interface ProductionEgress { prepare(config: DesiredChannel, validator: EndpointValidator, vault: VaultHandleBroker): Promise<{ sources: Map; output: EncoderOutput; close(): Promise; }>; } /** Overlay PNGs by content id, fetched through assignment-scoped presigned GETs (§4.3) into scratch. */ export interface AssetStore { localPath(assetId: string): Promise } export interface ProductionJobDeps { validator: EndpointValidator; egress: ProductionEgress; vault: VaultHandleBroker; assets: AssetStore; redactor: StderrRedactor; onStderr(message: string): void; onExit(reason: string): void; scratchDir: string; gateImpl?: GateImpl; /** Playlist sources (§5.1). Without it, playlist sources render black and are reported in `lastSkipped`. */ playlists?: PlaylistAssetSource; /** Preview taps (§2.1) publish through the API's manifest contract. Without it, no taps run. */ preview?: { objects: PreviewObjectStore; manifests: PreviewManifestStore }; } /** * What may change by swap. Anything else (the set of sources, an HLS URL, output, profile, mode) * restarts the channel. A playlist's asset list and loop flag are not part of it: the feeder * adopts them in place (desired-state v2: an edited playlist never interrupts the program). */ function pathKey(c: DesiredChannel): string { const sources = c.production?.sources.map((src) => (src.kind === "hls" ? { id: src.id, kind: src.kind, url: src.url } : { id: src.id, kind: src.kind })) ?? null; return createHash("sha256").update(JSON.stringify({ profile: c.profile, mode: c.mode, output: c.output, sources, source: c.source })).digest("hex"); } /** * Supervisor RelayJob for a desired-state v2 snapshot. `start()` brings up * ingest×N (HLS through the egress relay, playlists through the asset feeder) * → gate → encoder, puts the program scene on air, and starts the preview taps. * `reconfigure()` is the meaning of an accepted RUNNING snapshot while already * RUNNING: a TAKE, layout, overlay or gain change is an A/B compositor swap, * playlist edits are adopted by their feeders, and it resolves only once the * new compositor is on air at a frame boundary. */ export class ProductionJob { private ingests = new Map(); private feeders = new Map(); private chain: ProgramChain | null = null; private taps: PreviewTaps | null = null; private closeEgress: (() => Promise) | null = null; private key = ""; private canvas: Canvas | null = null; lastSkipped: string[] = []; constructor(private readonly deps: ProductionJobDeps) {} get program(): ProgramChain | null { return this.chain; } get previews(): PreviewTaps | null { return this.taps; } feeder(sourceId: string): PlaylistFeeder | undefined { return this.feeders.get(sourceId); } /** The source's bus (its ingest's fan-out), for stats. */ sourceBus(sourceId: string): SourceBus | undefined { return this.ingests.get(sourceId)?.bus; } async start(config: DesiredChannel): Promise { const spec = config.production; if (!spec || !config.output) throw new Error("production job needs a production snapshot with an output"); for (const url of snapshotHlsUrls(config)) validateEndpointShape(url, "hls"); validateEndpointShape(config.output.endpointUrl, "output"); const profile = OUTPUT_PROFILES[config.profile]; const canvas: Canvas = { width: profile.width, height: profile.height, fps: profile.fps }; this.canvas = canvas; const egress = await this.deps.egress.prepare(config, this.deps.validator, this.deps.vault); this.closeEgress = egress.close; const lost = (id: string, reason: string): void => this.deps.onExit(`source ${id}: ${reason}`); try { for (const [sourceId, { input, fd }] of egress.sources) { const ingest = new IngestProcess(sourceId, { input, canvas, audio: "source" }, fd, this.deps.scratchDir, this.deps.redactor, this.deps.onStderr, lost); this.ingests.set(sourceId, ingest); await ingest.start(); } const playlists = this.deps.playlists; for (const src of spec.sources) { if (src.kind !== "playlist" || !playlists) continue; const feeder = new PlaylistFeeder(src.id, { assetIds: src.assetIds, loop: src.loop }, { assets: playlists, canvas, scratchDir: this.deps.scratchDir, redactor: this.deps.redactor, onStderr: this.deps.onStderr, onEvent: (e) => { if (e.type === "asset-failed" || e.type === "slate") this.deps.onStderr(`playlist ${src.id}: ${e.type}${"reason" in e ? ` ${e.reason}` : ""}`); }, }); this.feeders.set(src.id, feeder); const fd = feeder.start(); const ingest = new IngestProcess(src.id, { input: { kind: "playlistPipe" }, canvas, audio: "source" }, fd, this.deps.scratchDir, this.deps.redactor, this.deps.onStderr, lost); this.ingests.set(src.id, ingest); try { await ingest.start(); } finally { feeder.releaseReader(); } } this.chain = new ProgramChain({ canvas, scratchDir: this.deps.scratchDir, videoBitrateKbps: Number.parseInt(profile.videoBitrate, 10), output: egress.output, redactor: this.deps.redactor, onStderr: this.deps.onStderr, onFatal: (reason) => this.deps.onExit(reason), gateImpl: this.deps.gateImpl, programTap: this.deps.preview !== undefined, }); await this.chain.start(await this.compose(config)); this.key = pathKey(config); } catch (error) { await this.stop(); throw error; } await this.startPreviews(config); } /** * Preview taps never gate the program: a tap that cannot start is reported and the channel runs on. * The program tap's renditions are always on (§2.2: the PGM tile is always live); source renditions * start on demand (`previews.demand()`). */ private async startPreviews(config: DesiredChannel): Promise { const preview = this.deps.preview; if (!preview || !this.chain?.programBus) return; const taps = new PreviewTaps({ publisher: new ManifestPreviewPublisher({ workspaceId: config.workspaceId, channelId: config.channelId }, preview.objects, preview.manifests), scratchDir: this.deps.scratchDir, redactor: this.deps.redactor, onStderr: this.deps.onStderr, onError: (tap, reason) => this.deps.onStderr(`preview ${tap}: ${reason}`), }); this.taps = taps; const add = async (tap: string, bus: SourceBus, alwaysOn: HlsRendition[]): Promise => { try { await taps.add(tap, bus, { alwaysOn }); } catch (error) { this.deps.onStderr(`preview ${tap}: ${String(error)}`); } }; await add("program", this.chain.programBus, ["video", "audio"]); for (const [sourceId, ingest] of this.ingests) await add(sourceId, ingest.bus, []); } /** false = this change cannot be a swap (sources/output/profile changed): the supervisor restarts. */ async reconfigure(config: DesiredChannel): Promise { if (!this.chain || !config.production || pathKey(config) !== this.key) return false; for (const src of config.production.sources) { if (src.kind === "playlist") this.feeders.get(src.id)?.update({ assetIds: src.assetIds, loop: src.loop }); } await this.chain.swap(await this.compose(config)); return true; } private async compose(config: DesiredChannel): Promise { if (!config.production || !this.canvas) throw new Error("no production spec"); const plan = planScene(config.production, programScene(config.production), this.canvas, (id) => this.ingests.has(id)); this.lastSkipped = plan.skipped; const inputs = plan.sourceIds.map((id, i) => { const ingest = this.ingests.get(id); if (!ingest) throw new Error("planned source has no ingest"); return { bus: ingest.bus, audio: plan.audio[i] ?? false }; }); const assets = await Promise.all(plan.assetIds.map((id) => this.deps.assets.localPath(id))); return { inputs, assets, graph: plan.graph }; } async stop(): Promise { await this.taps?.stop(); this.taps = null; await this.chain?.stop(); this.chain = null; for (const feeder of this.feeders.values()) await feeder.stop(); this.feeders.clear(); for (const ingest of this.ingests.values()) await ingest.stop(); this.ingests.clear(); await this.closeEgress?.(); this.closeEgress = null; } }