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

250 lines
11 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.

/**
* Playlist feeder (streaming-architecture.md §5.1): a `kind: "playlist"` source
* becomes a continuous input like any other. The feeder walks the ordered asset
* list, opens each asset (a presigned GET through the ingest egress relay),
* remuxes it to MPEG-TS (pre-normalized at upload: copy only, never a
* transcode) and pipes the remuxes one after another into a FIFO that one
* `playlistPipe` ingest reads at native rate. FFmpeg reads only pipes (WP9);
* no concat protocol.
*
* - Timestamps: each remux is shifted to where the previous asset ended (its
* `-progress` end time), so the concatenated stream is monotonic. FFmpeg
* 5.1's own discontinuity correction stalls `-re` for 1–2 s per boundary.
* - Boundaries: two overlapped stages. Asset i+1's remux starts as soon as
* asset i's remux has exited (its end time is then known), while asset i is
* still being fed from its capture buffer (~4 MiB ahead, several seconds), so
* the next asset's bytes are waiting when the current one ends: no gap.
* - Failure: an asset that cannot be opened or remuxed is skipped. When a whole
* pass of the list fails (or the list is empty), the slate floor plays for a
* few seconds and the list is retried.
* - End: `loop: false` plays the list once, then the slate for good.
* - Desired-state v2: `update()` adopts a new asset list without a restart. The
* asset on air finishes; playback continues after it in the new list (or at
* the new list's start when it is no longer there). An already prepared next
* asset that is no longer next is discarded.
*/
import { closeSync, constants, openSync, rmSync } from "node:fs";
import { Socket } from "node:net";
import { PassThrough, type Readable } from "node:stream";
import { makeFifo, openBlockingReader } from "../gate/fifo";
import { spawnManaged, type ManagedProcess, type StderrRedactor } from "../relay/ffmpeg";
import { buildPlaylistRemuxArgs, buildSlateArgs, type Canvas } from "./command-builder";
/** Opens one playlist asset's bytes (WP7: presigned GET through the pinned ingest egress relay). */
export interface PlaylistAssetSource { open(assetId: string): Promise<Readable> }
export interface PlaylistSpec { assetIds: string[]; loop: boolean }
export type FeederEvent =
| { type: "asset"; assetId: string; index: number }
| { type: "asset-failed"; assetId: string; reason: string }
| { type: "slate"; reason: "unavailable" | "ended" }
| { type: "adopted"; assetIds: number };
export interface PlaylistFeederDeps {
assets: PlaylistAssetSource;
canvas: Canvas;
scratchDir: string;
redactor: StderrRedactor;
onStderr(message: string): void;
onEvent?(event: FeederEvent): void;
/** Rendered slate PNG (S4, overlay renderer); null = black. */
slateImage?: string | null;
slateSeconds?: number;
}
/** Bytes of produced MPEG-TS held ahead of the FIFO per producer (~5 s at 6 Mb/s). */
const CAPTURE_BYTES = 4 << 20;
/** One remux (or slate) run: its captured output and, once it exits, its end time. */
interface Job {
proc: ManagedProcess;
output: Readable;
/** Resolves when the producer exits: success, and the end time it reported (µs, excluding the offset). */
done: Promise<{ ok: boolean; endUs: number; reason: string }>;
input: Readable | null;
cancelled: boolean;
}
let nextFeeder = 1;
export class PlaylistFeeder {
private spec: PlaylistSpec;
private out: Socket | null = null;
private reader: { fd: number; releaseHolder(): void } | null = null;
private stopping = false;
private running: Promise<void> = Promise.resolve();
private current: { assetId: string; index: number } | null = null;
private nextIndex = 0;
/** Bumped by `update()`: a job prepared under an older list is discarded. */
private generation = 0;
private readonly jobs = new Set<Job>();
private abortFeed: (() => void) | null = null;
/** Assets started, assets failed, slate chunks fed. */
readonly stats = { assets: 0, failures: 0, slates: 0 };
ended = false;
constructor(readonly sourceId: string, spec: PlaylistSpec, private readonly deps: PlaylistFeederDeps) {
this.spec = { assetIds: [...spec.assetIds], loop: spec.loop };
}
get nowPlaying(): string | null { return this.current?.assetId ?? null; }
/**
* Creates the FIFO and starts feeding. Returns the blocking read end for the ingest's stdin;
* call `releaseReader()` once the ingest has it.
*/
start(): number {
const path = makeFifo(this.deps.scratchDir, `playlist-${nextFeeder++}.fifo`);
const reader = openBlockingReader(path);
this.reader = reader;
const out = new Socket({ fd: openSync(path, constants.O_WRONLY | constants.O_NONBLOCK), readable: false, writable: true });
out.on("error", () => { if (!this.stopping) void this.stop(); }); // the ingest is gone; IngestProcess reports its exit
this.out = out;
reader.releaseHolder();
rmSync(path, { force: true });
this.running = this.run().catch((error: unknown) => this.deps.onStderr(`playlist feeder ${this.sourceId}: ${String(error)}`));
return reader.fd;
}
releaseReader(): void {
if (this.reader) { closeSync(this.reader.fd); this.reader = null; }
}
/** Desired-state v2: adopt a new list at the next asset boundary (no restart, no mid-asset cut). */
update(spec: PlaylistSpec): void {
this.spec = { assetIds: [...spec.assetIds], loop: spec.loop };
const at = this.current ? this.spec.assetIds.indexOf(this.current.assetId) : -1;
this.nextIndex = at >= 0 ? at + 1 : 0;
this.ended = false;
this.generation += 1;
this.deps.onEvent?.({ type: "adopted", assetIds: this.spec.assetIds.length });
}
private peekNext(): { assetId: string; index: number } | null {
const ids = this.spec.assetIds;
if (ids.length === 0) return null;
let index = this.nextIndex;
if (index >= ids.length) {
if (!this.spec.loop) return null;
index = 0;
}
return { assetId: ids[index]!, index };
}
/** Spawns a remux of `asset` (or the slate) at `offsetUs`, capturing its output from the start. */
private produce(asset: { assetId: string; index: number } | null, offsetUs: number): Job {
const args = asset ? buildPlaylistRemuxArgs(offsetUs)
: buildSlateArgs({ canvas: this.deps.canvas, seconds: this.deps.slateSeconds ?? 5, image: this.deps.slateImage ?? null, offsetUs });
const proc = spawnManaged("ffmpeg", args, [asset ? "pipe" : "ignore", "pipe", "pipe", "pipe"], this.deps.redactor, this.deps.onStderr);
// A producer may exit before its turn (a broken asset at once, a small one that fits in the
// buffers), and Node then resumes its unconsumed stdout itself, dropping data and 'end'. The
// capture keeps both until fed; backpressure holds a large asset's remux CAPTURE_BYTES ahead.
const output = new PassThrough({ highWaterMark: CAPTURE_BYTES });
const stdout = proc.child.stdout as Readable | null;
if (stdout) stdout.pipe(output);
else output.end();
let progress = "";
(proc.child.stdio[3] as Readable | null)?.on("data", (chunk: Buffer) => { progress = (progress + chunk.toString("latin1")).slice(-4096); });
let failed: string | null = null;
const done = proc.exited.then(({ code }) => {
const endUs = Number([...progress.matchAll(/out_time_us=(\d+)/g)].at(-1)?.[1] ?? 0);
return { ok: failed === null && code === 0 && endUs > 0, endUs, reason: failed ?? `producer exited code=${code} end=${endUs}us` };
});
const job: Job = { proc, output, input: null, done, cancelled: false };
if (asset) {
proc.child.stdin?.on("error", () => undefined); // EPIPE when the remux rejects its input
void this.deps.assets.open(asset.assetId).then((input) => {
if (job.cancelled || this.stopping || proc.child.exitCode !== null || proc.child.signalCode !== null) { input.destroy(); return; }
job.input = input;
input.on("error", (error: Error) => { failed = `asset read failed: ${error.name}`; proc.kill("SIGKILL"); });
if (proc.child.stdin) input.pipe(proc.child.stdin);
}, (error: unknown) => {
failed = `asset open failed: ${error instanceof Error ? error.name : "error"}`;
proc.kill("SIGKILL");
});
}
this.jobs.add(job);
void done.then(() => this.jobs.delete(job));
return job;
}
private discard(job: Job): void {
job.cancelled = true;
job.input?.destroy();
job.output.destroy();
job.proc.kill("SIGKILL");
}
/** Pipes a job's captured output into the FIFO; resolves once all of it has been handed over. */
private async feed(job: Job): Promise<void> {
const out = this.out;
if (!out) return;
job.output.pipe(out, { end: false });
// Until the producer's last byte ('end'). On stop the FIFO socket is destroyed, which unpipes
// and pauses the capture, so 'end' would never come: stop() aborts the wait instead.
await new Promise<void>((resolve) => {
this.abortFeed = resolve;
job.output.once("end", resolve);
job.output.once("error", () => resolve());
});
this.abortFeed = null;
job.output.unpipe(out);
}
private async run(): Promise<void> {
let offsetUs = 0;
let failedInARow = 0;
let slateNext = false;
let feeding: Promise<void> = Promise.resolve();
while (!this.stopping) {
const generation = this.generation;
const next = slateNext ? null : this.peekNext();
slateNext = false;
// Prepared while the previous job is still being fed (its producer has exited: offsetUs is final).
const job = this.produce(next, offsetUs);
await feeding;
if (this.stopping) { this.discard(job); break; }
if (generation !== this.generation) { this.discard(job); continue; } // re-plan under the new list
if (next) {
this.nextIndex = next.index + 1;
this.current = next;
this.stats.assets += 1;
this.deps.onEvent?.({ type: "asset", assetId: next.assetId, index: next.index });
} else {
this.current = null;
this.ended = this.spec.assetIds.length > 0 && !this.spec.loop && this.nextIndex >= this.spec.assetIds.length;
this.stats.slates += 1;
this.deps.onEvent?.({ type: "slate", reason: this.ended ? "ended" : "unavailable" });
}
feeding = this.feed(job);
const result = await job.done;
if (result.ok) {
offsetUs += result.endUs;
failedInARow = 0;
continue;
}
if (!next) continue; // a failed slate is retried (offset unchanged)
this.stats.failures += 1;
failedInARow += 1;
this.deps.onEvent?.({ type: "asset-failed", assetId: next.assetId, reason: result.reason });
if (failedInARow >= Math.max(1, this.spec.assetIds.length)) {
failedInARow = 0;
slateNext = true;
}
}
}
async stop(): Promise<void> {
if (this.stopping) { await this.running; return; }
this.stopping = true;
const jobs = [...this.jobs];
for (const job of jobs) this.discard(job);
this.abortFeed?.();
this.out?.destroy();
this.out = null;
this.releaseReader();
await this.running;
await Promise.all(jobs.map((job) => job.done));
}
}