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/.
95 lines
3.8 KiB
TypeScript
95 lines
3.8 KiB
TypeScript
import type { StdioOptions } from "node:child_process";
|
|
import type { FrameGate } from "../gate/frame-gate";
|
|
import { spawnManaged, type ManagedProcess, type StderrRedactor } from "../relay/ffmpeg";
|
|
import { buildCompositorArgs, type CompositorGraph, type CompositorSource } from "./command-builder";
|
|
import type { BusSubscription, SourceBus } from "./source-bus";
|
|
|
|
/** A scene ready to compose: buses in input order plus the typed graph (source indexes refer to `inputs`). */
|
|
export interface ComposeRequest {
|
|
inputs: Array<{ bus: SourceBus; audio: boolean }>;
|
|
assets: string[];
|
|
graph: CompositorGraph;
|
|
}
|
|
|
|
const FIRST_INPUT_FD = 4;
|
|
|
|
/**
|
|
* One compositor process (A or B of a swap). Reads its sources from bus
|
|
* subscription FIFOs inherited on fds 4.., writes the gate's slot FIFOs on fds 1 and 3.
|
|
* Its lifetime is bounded by the gate: when the gate retires the slot the FIFOs
|
|
* close and the process gets EPIPE; `stop()` also escalates to SIGKILL.
|
|
*/
|
|
export class CompositorInstance {
|
|
private proc: ManagedProcess | null = null;
|
|
/** One per input, in request order (valid after `start()`). */
|
|
subscriptions: BusSubscription[] = [];
|
|
/** Gate origin sent at attach (see protocol.attachLine). */
|
|
origin: number | undefined;
|
|
slotId = 0;
|
|
|
|
constructor(
|
|
private readonly gate: FrameGate,
|
|
private readonly request: ComposeRequest,
|
|
private readonly redactor: StderrRedactor,
|
|
private readonly onStderr: (message: string) => void,
|
|
) {}
|
|
|
|
get pid(): number | undefined { return this.proc?.child.pid; }
|
|
|
|
/**
|
|
* Subscribes, spawns and attaches to the gate as the pending slot. `originFor`
|
|
* maps the fresh subscriptions to a gate origin. Resolve ≠ on air: use `gate.waitActive(slotId)`.
|
|
*/
|
|
async start(originFor?: (subscriptions: BusSubscription[]) => number | undefined): Promise<void> {
|
|
const slot = this.gate.allocateSlot();
|
|
this.slotId = slot.id;
|
|
try {
|
|
for (const input of this.request.inputs) this.subscriptions.push(await input.bus.subscribe({ video: true, audio: input.audio }));
|
|
} catch (error) {
|
|
slot.fifos.releaseWriters();
|
|
slot.fifos.releaseHolders();
|
|
slot.fifos.unlink();
|
|
await this.stop();
|
|
throw error;
|
|
}
|
|
let next = FIRST_INPUT_FD;
|
|
const inputFds: number[] = [];
|
|
const sources: CompositorSource[] = this.subscriptions.map((sub) => {
|
|
if (sub.videoFd === null) throw new Error("compositor input without video");
|
|
inputFds.push(sub.videoFd);
|
|
const video = { kind: "rawPipe" as const, fd: next++ };
|
|
if (sub.audioFd === null) return { video, audio: null };
|
|
inputFds.push(sub.audioFd);
|
|
return { video, audio: { kind: "rawPipe" as const, fd: next++ } };
|
|
});
|
|
const args = buildCompositorArgs({ sources, assets: this.request.assets, graph: this.request.graph, output: { kind: "gate", videoFd: 1, audioFd: 3 } });
|
|
const stdio: StdioOptions = ["ignore", slot.fifos.videoFd, "pipe", slot.fifos.audioFd, ...inputFds];
|
|
let proc: ManagedProcess;
|
|
try {
|
|
proc = spawnManaged("ffmpeg", args, stdio, this.redactor, this.onStderr);
|
|
} finally {
|
|
slot.fifos.releaseWriters();
|
|
// The compositor holds the read ends now: when it exits, the bus sees EPIPE and drops it.
|
|
for (const sub of this.subscriptions) sub.releaseReaders();
|
|
}
|
|
this.proc = proc;
|
|
try {
|
|
await proc.started;
|
|
this.origin = originFor?.(this.subscriptions);
|
|
await this.gate.attach(slot, this.origin);
|
|
} catch (error) {
|
|
slot.fifos.releaseHolders();
|
|
slot.fifos.unlink();
|
|
await this.stop();
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async stop(): Promise<void> {
|
|
for (const sub of this.subscriptions) sub.unsubscribe();
|
|
await this.proc?.terminate();
|
|
this.proc = null;
|
|
}
|
|
|
|
kill(signal: NodeJS.Signals): void { this.proc?.kill(signal); }
|
|
}
|