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 { 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 { for (const sub of this.subscriptions) sub.unsubscribe(); await this.proc?.terminate(); this.proc = null; } kill(signal: NodeJS.Signals): void { this.proc?.kill(signal); } }