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/.
181 lines
9.3 KiB
TypeScript
181 lines
9.3 KiB
TypeScript
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;
|
||
}
|
||
}
|