T-HUB/apps/media-worker/src/compositor/production-job.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

181 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, { input: IngestInput; fd: number | null }>;
output: EncoderOutput;
close(): Promise<void>;
}>;
}
/** Overlay PNGs by content id, fetched through assignment-scoped presigned GETs (§4.3) into scratch. */
export interface AssetStore { localPath(assetId: string): Promise<string> }
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<string, IngestProcess>();
private feeders = new Map<string, PlaylistFeeder>();
private chain: ProgramChain | null = null;
private taps: PreviewTaps | null = null;
private closeEgress: (() => Promise<void>) | 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<void> {
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<void> {
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<void> => {
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<boolean> {
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<ComposeRequest> {
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<void> {
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;
}
}