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

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); }
}