dragonflight/services/playout/src/playout-manager.js

438 lines
19 KiB
JavaScript
Raw Normal View History

import { AmcpClient } from './amcp.js';
import { spawn } from 'node:child_process';
import { mkdirSync } from 'node:fs';
// Playout manager — owns one CasparCG channel's lifecycle inside this sidecar.
//
// One sidecar container == one CasparCG Server == one logical channel (channel
// index 1 in CasparCG terms). We add the output consumer (DeckLink / NDI / SRT
// / RTMP) at start, then walk a playlist by cueing the next clip on a background
// layer (LOADBG ... AUTO) so CasparCG performs a gapless transition at end of
// the current clip.
//
// Media is referenced by a path relative to CasparCG's configured media folder
// (/media inside the container). The mam-api stages assets from S3 to that
// shared volume and passes the resolved relative path on each item.
const CHANNEL = 1; // single CasparCG channel per sidecar
const FG_LAYER = 10; // foreground (on-air) layer
const MEDIA_ROOT = process.env.CASPAR_MEDIA_ROOT || '/media';
// Channel-id-derived HLS preview path. The mam-api proxies /live/<channel_id>/
// to this directory (shared media volume) so the UI's existing HLS player
// (capture's /live/<id> plumbing) works for playout monitors with zero new
// transport.
const CHANNEL_ID = process.env.CHANNEL_ID || '';
const HLS_DIR = CHANNEL_ID ? `${MEDIA_ROOT}/live/${CHANNEL_ID}` : '';
// Loopback UDP port CasparCG's preview STREAM consumer publishes mpegts to, and
// the standalone ffmpeg re-muxer reads from. One CasparCG per sidecar, so a
// fixed port is fine; allow override for parallel local testing.
const PREVIEW_UDP_PORT = parseInt(process.env.PREVIEW_UDP_PORT || '9710', 10);
const PREVIEW_UDP_URL = `udp://127.0.0.1:${PREVIEW_UDP_PORT}`;
// CasparCG SEEK / LENGTH are in frames, not seconds. Capture standard is 59.94;
// SD/film modes need their own values. Default 60000/1001 matches both
// '1080p5994' and '1080i5994'.
function fpsFor(videoFormat) {
const f = String(videoFormat || '').toLowerCase();
if (f.endsWith('5994')) return 60000 / 1001;
if (f.endsWith('p60') || f.endsWith('i60')) return 60;
if (f.endsWith('p50') || f.endsWith('i50')) return 50;
if (f.endsWith('2997')) return 30000 / 1001;
if (f.endsWith('p30')) return 30;
if (f.endsWith('p25')) return 25;
if (f.endsWith('p24') || f.endsWith('2398')) return 24000 / 1001;
return 60000 / 1001; // safe default for the house standard
}
// CasparCG transition syntax fragments keyed by our item.transition value.
function transitionArgs(transition, ms, fps) {
if (!transition || transition === 'cut' || !ms) return '';
const frames = Math.max(1, Math.round((ms / 1000) * fps));
if (transition === 'mix') return ` MIX ${frames}`;
if (transition === 'wipe') return ` WIPE ${frames}`;
return '';
}
// Turn an absolute /media path (or a relative one) into the token CasparCG
// expects: a path relative to MEDIA_ROOT, without extension, forward-slashed.
// CasparCG resolves "subdir/clip" against its media folder + probes extensions.
function toCasparToken(mediaPath) {
let p = String(mediaPath || '');
if (p.startsWith(MEDIA_ROOT)) p = p.slice(MEDIA_ROOT.length);
p = p.replace(/^\/+/, '');
p = p.replace(/\.[^/.]+$/, ''); // strip extension
return p;
}
export class PlayoutManager {
constructor() {
this.amcp = new AmcpClient({
host: process.env.CASPAR_HOST || '127.0.0.1',
port: parseInt(process.env.CASPAR_PORT || '5250', 10),
});
this.state = {
running: false,
outputType: null,
outputConfig: null,
videoFormat: null,
playlist: [], // resolved items in play order
currentIndex: -1,
loop: false,
currentClip: null,
startedAt: null,
lastError: null,
};
this._advanceTimer = null;
this._hlsProc = null; // standalone ffmpeg re-mux child process
this._hlsRestartTimer = null;
}
async _consumerCommand(outputType, cfg) {
// Returns the AMCP ADD argument string for the requested output target.
if (outputType === 'decklink') {
const dev = cfg.device_index || 1;
return `DECKLINK DEVICE ${dev} EMBEDDED_AUDIO`;
}
if (outputType === 'ndi') {
const name = cfg.ndi_name || 'DRAGONFLIGHT';
return `NDI NAME "${name}"`;
}
if (outputType === 'srt' || outputType === 'rtmp') {
// CasparCG 2.3 streams via the FFMPEG consumer, invoked with the STREAM
// keyword (FILE/STREAM are interchangeable aliases for it; the bare word
// "FFMPEG" is the PRODUCER and is NOT a valid consumer keyword). Args must
// use ffmpeg's -param:stream form (-codec:v, not -vcodec) or CasparCG
// rejects them. The channel feeds the consumer as RGBA, so a
// format=yuv420p filter is required before libx264.
const url = cfg.url || '';
if (outputType === 'srt') {
const latency = cfg.latency || 200;
const full = url.includes('latency=') ? url : `${url}${url.includes('?') ? '&' : '?'}latency=${latency}`;
return `STREAM "${full}" -format mpegts -codec:v libx264 -preset:v veryfast -tune:v zerolatency -b:v 6M -codec:a aac -b:a 192k -filter:v format=yuv420p`;
}
const target = cfg.key ? `${url}/${cfg.key}` : url;
return `STREAM "${target}" -format flv -codec:v libx264 -preset:v veryfast -tune:v zerolatency -b:v 6M -codec:a aac -b:a 192k -filter:v format=yuv420p`;
}
throw new Error(`Unknown output_type: ${outputType}`);
}
// Start the channel: bring up CasparCG's primary output consumer for the
// target, plus a second FFMPEG consumer writing HLS for the UI preview
// monitor (~4-6s lag, reuses capture's /live/<id> plumbing).
//
// The primary consumer failure is NON-FATAL. CasparCG can decode and route
// media through its pipeline even without an output consumer. This means:
// - NDI channels work (load/play/transport) even if libndi.so is absent.
// - SRT/RTMP channels work even if the destination URL is unreachable.
// - The HLS preview consumer is always attempted independently.
//
// state.consumerError is set when the primary consumer fails so the mam-api
// can surface a warning in the channel status without blocking operation.
async startChannel({ outputType, outputConfig = {}, videoFormat = '1080p5994' }) {
await this.amcp.waitReady(30000);
// Set the channel video mode first.
try { await this.amcp.send(`SET ${CHANNEL} MODE ${videoFormat}`); }
catch (err) { console.warn(`[playout] SET MODE failed (continuing): ${err.message}`); }
// Primary output consumer — non-fatal.
let consumerError = null;
try {
const consumer = await this._consumerCommand(outputType, outputConfig);
await this.amcp.send(`ADD ${CHANNEL} ${consumer}`);
} catch (err) {
consumerError = err.message;
console.warn(`[playout] primary consumer ADD failed (continuing without output): ${err.message}`);
}
// HLS preview consumer — always attempt, independently non-fatal.
if (HLS_DIR) {
try {
await this._addHlsConsumer();
console.log(`[playout] HLS preview at ${HLS_DIR}/index.m3u8`);
} catch (err) {
console.warn(`[playout] HLS preview consumer failed: ${err.message}`);
}
}
this.state.running = true;
this.state.outputType = outputType;
this.state.outputConfig = outputConfig;
this.state.videoFormat = videoFormat;
this.state.fps = fpsFor(videoFormat);
this.state.startedAt = new Date().toISOString();
this.state.lastError = consumerError;
console.log(`[playout] channel started output=${outputType} mode=${videoFormat} fps=${this.state.fps.toFixed(3)}${consumerError ? ' ⚠ consumer: ' + consumerError : ''}`);
return this.getStatus();
}
// HLS preview for the web UI confidence monitor.
//
// ── Why not CasparCG's own HLS (FILE/STREAM ".../index.m3u8") ──────────────
// CasparCG's bundled FFMPEG consumer muxes a BROKEN audio track into the HLS:
// ffprobe reports `aac, sample_rate=0` and ffmpeg decoding the playlist fails
// with "Invalid data ... abuffer: Value inf for parameter 'time_base' ...
// time_base 1/0". That corrupt audio prevents BOTH ffmpeg and hls.js from
// decoding, so the browser <video> sits at readyState 0 and the preview stays
// black. The video track itself is perfectly clean h264. Critically, the
// consumer IGNORES every arg that would fix it — `-an`, `-codec:a`, `-g`,
// `-r`, `-force_key_frames` are all silently dropped ("Unused option"), so we
// CANNOT remove the audio from inside CasparCG.
//
// ── The fix: STREAM mpegts to UDP loopback, re-mux with a STANDALONE ffmpeg ─
// CasparCG outputs a plain mpegts elementary stream to a local UDP port (its
// STREAM/mpegts path is fine — the breakage is specific to its HLS muxer). A
// Node-spawned standalone ffmpeg (where `-an` actually works) reads that UDP
// stream, drops audio, copies the clean h264 video, and writes proper HLS.
// `-c:v copy` avoids re-encoding. The program audio is untouched — it rides
// the PRIMARY SRT/RTMP/SDI/NDI consumer, which we never modify.
async _addHlsConsumer() {
// 1) CasparCG → mpegts over UDP loopback. The channel feeds RGBA, so a
// format=yuv420p filter is required before libx264.
const streamArgs = [
`STREAM "${PREVIEW_UDP_URL}?pkt_size=1316"`,
'-format mpegts',
'-codec:v libx264 -preset:v veryfast -tune:v zerolatency',
'-b:v 2M -maxrate 2M -bufsize 4M',
'-codec:a aac -b:a 96k',
'-filter:v format=yuv420p',
].join(' ');
await this.amcp.send(`ADD ${CHANNEL} ${streamArgs}`);
// 2) Standalone ffmpeg re-mux: UDP mpegts → clean video-only HLS.
this._startHlsRemux();
}
// Spawn (or respawn) the standalone ffmpeg that re-muxes the loopback mpegts
// into video-only HLS. Restarts automatically if it dies while the channel is
// still running (e.g. brief UDP gap before CasparCG's consumer is up).
_startHlsRemux() {
if (!HLS_DIR) return;
try { mkdirSync(HLS_DIR, { recursive: true }); } catch (_) {}
this._stopHlsRemux();
const out = `${HLS_DIR}/index.m3u8`;
const args = [
'-hide_banner', '-loglevel', 'warning',
// Read the live mpegts loopback. genpts rebuilds timestamps; the analyze/
// probe sizes are kept small so playback starts promptly.
'-fflags', '+genpts',
'-analyzeduration', '2000000', '-probesize', '2000000',
'-i', `${PREVIEW_UDP_URL}?fifo_size=1000000&overrun_nonfatal=1`,
// Drop the (broken) audio entirely.
'-an',
// Re-encode (NOT -c:v copy) to uniform, keyframe-aligned 2s segments with
// regenerated CFR timestamps. -c:v copy passed CasparCG's erratic
// real-time keyframes straight through, producing segments of 0.62.8s
// and irregular PTS; hls.js can't build a live timeline from that — it
// logs "sliding 0.00 / MISSED", never loads a fragment, and the monitor
// stays black even though the stream decodes cleanly server-side. A
// standalone ffmpeg honours -force_key_frames, so every GOP (and thus
// every HLS segment) is exactly 2.0s. fps=30 forces CFR; scale to 720p
// keeps the confidence monitor cheap.
'-vf', 'fps=30,scale=-2:720,format=yuv420p',
'-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency',
'-b:v', '1500k', '-maxrate', '2M', '-bufsize', '4M',
'-g', '60', '-keyint_min', '60', '-sc_threshold', '0',
'-force_key_frames', 'expr:gte(t,n_forced*2)',
'-f', 'hls',
'-hls_time', '2',
'-hls_list_size', '8',
'-hls_flags', 'delete_segments+append_list+independent_segments',
'-hls_segment_filename', `${HLS_DIR}/index%d.ts`,
out,
];
const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] });
this._hlsProc = proc;
proc.stderr.on('data', (d) => {
const line = d.toString().trim();
if (line) console.warn(`[playout][hls-ffmpeg] ${line}`);
});
proc.on('exit', (code, signal) => {
console.warn(`[playout] HLS re-mux ffmpeg exited code=${code} signal=${signal}`);
if (this._hlsProc === proc) this._hlsProc = null;
// Auto-respawn while the channel is running (and we didn't kill it).
if (this.state.running && signal !== 'SIGTERM' && signal !== 'SIGKILL') {
this._hlsRestartTimer = setTimeout(() => {
this._hlsRestartTimer = null;
if (this.state.running) {
console.log('[playout] respawning HLS re-mux ffmpeg');
this._startHlsRemux();
}
}, 1000);
}
});
proc.on('error', (err) => {
console.warn(`[playout] HLS re-mux ffmpeg spawn error: ${err.message}`);
});
console.log(`[playout] HLS re-mux ffmpeg started: ${PREVIEW_UDP_URL} -> ${out}`);
}
_stopHlsRemux() {
if (this._hlsRestartTimer) {
clearTimeout(this._hlsRestartTimer);
this._hlsRestartTimer = null;
}
if (this._hlsProc) {
const proc = this._hlsProc;
this._hlsProc = null;
try { proc.kill('SIGTERM'); } catch (_) {}
}
}
async stopChannel() {
this._clearAdvance();
this.state.running = false; // set first so the ffmpeg exit handler won't respawn
this._stopHlsRemux();
try { await this.amcp.send(`STOP ${CHANNEL}-${FG_LAYER}`); } catch (_) {}
try { await this.amcp.send(`CLEAR ${CHANNEL}`); } catch (_) {}
this.state.running = false;
this.state.playlist = [];
this.state.currentIndex = -1;
this.state.currentClip = null;
console.log('[playout] channel stopped');
return { stopped: true };
}
// Load a playlist (array of { id, asset_id, media_path, in_point, out_point,
// transition, transition_ms, clip_name }) and start playing from index 0.
async loadPlaylist({ items = [], loop = false }) {
if (!this.state.running) {
throw new Error('Channel not started — call /channel/start first');
}
this.state.playlist = items;
this.state.loop = !!loop;
this.state.currentIndex = -1;
if (items.length === 0) return this.getStatus();
await this._playIndex(0);
return this.getStatus();
}
async _playIndex(index) {
const item = this.state.playlist[index];
if (!item) return;
const fps = this.state.fps || fpsFor(this.state.videoFormat);
const token = toCasparToken(item.media_path);
const seek = item.in_point ? ` SEEK ${Math.round(item.in_point * fps)}` : '';
const length = (item.out_point && item.out_point > (item.in_point || 0))
? ` LENGTH ${Math.round((item.out_point - (item.in_point || 0)) * fps)}`
: '';
const trans = transitionArgs(item.transition, item.transition_ms, fps);
// PLAY puts the clip on the foreground layer immediately (first clip), with
// the configured transition. Subsequent clips are cued via LOADBG ... AUTO
// for a gapless hand-off; see _scheduleAdvance.
await this.amcp.send(`PLAY ${CHANNEL}-${FG_LAYER} "${token}"${seek}${length}${trans}`);
this.state.currentIndex = index;
this.state.currentClip = item.clip_name || token;
console.log(`[playout] PLAY [${index}] ${token}`);
this._reportAsRunStart(item);
this._scheduleAdvance(item);
}
// Effective on-air duration of an item in milliseconds. Prefers an explicit
// in/out trim, else the asset's full duration. Returns null when unknown (no
// duration metadata + no out_point) so the caller can skip the timer.
_itemDurationMs(item) {
const inS = item.in_point || 0;
if (item.out_point && item.out_point > inS) return (item.out_point - inS) * 1000;
if (item.asset_duration_ms != null) return Math.max(0, item.asset_duration_ms - inS * 1000);
return null;
}
// CasparCG's LOADBG ... AUTO swaps the cued background clip to foreground when
// the current clip ends, giving a gapless visual take. But CasparCG won't cue
// clip N+2 on its own and won't move OUR pointer / as-run bookkeeping. So we
// also arm a duration-based timer: when the current clip is due to end we
// advance currentIndex and cue the following clip. This keeps an arbitrary-
// length playlist walking, not just the first two items.
_scheduleAdvance(item) {
this._clearAdvance();
const next = this._nextIndex();
if (next === null) return; // end of a non-looping playlist
const nextItem = this.state.playlist[next];
const nextToken = toCasparToken(nextItem.media_path);
const fps = this.state.fps || fpsFor(this.state.videoFormat);
const trans = transitionArgs(nextItem.transition, nextItem.transition_ms, fps);
// Cue next on background with AUTO so CasparCG performs the gapless take.
this.amcp.send(`LOADBG ${CHANNEL}-${FG_LAYER} "${nextToken}" AUTO${trans}`)
.catch((err) => console.warn(`[playout] LOADBG failed: ${err.message}`));
// Arm the pointer-advance timer. Without duration metadata we can't time the
// hand-off; leave AUTO to take clip N+1 visually but log a warning since the
// pointer (and thus clip N+2 cueing) will stall.
const durMs = this._itemDurationMs(item);
if (durMs == null) {
console.warn(`[playout] no duration for clip [${this.state.currentIndex}] — pointer advance stalled after this clip`);
return;
}
this._advanceTimer = setTimeout(() => {
this._advanceTimer = null;
// The AUTO take already happened in CasparCG; just move our pointer and
// cue the clip after next. _playIndex would re-PLAY and double-take, so we
// advance state directly and re-arm.
this.state.currentIndex = next;
this.state.currentClip = nextItem.clip_name || nextToken;
console.log(`[playout] advance -> [${next}] ${nextToken}`);
this._reportAsRunStart(nextItem);
this._scheduleAdvance(nextItem);
}, Math.max(250, durMs));
}
_nextIndex() {
const n = this.state.currentIndex + 1;
if (n < this.state.playlist.length) return n;
if (this.state.loop && this.state.playlist.length > 0) return 0;
return null;
}
_clearAdvance() {
if (this._advanceTimer) { clearTimeout(this._advanceTimer); this._advanceTimer = null; }
}
async skip() {
const next = this._nextIndex();
if (next === null) { await this.stopChannel(); return this.getStatus(); }
await this._playIndex(next);
return this.getStatus();
}
async pause() {
try { await this.amcp.send(`PAUSE ${CHANNEL}-${FG_LAYER}`); } catch (_) {}
return this.getStatus();
}
async resume() {
try { await this.amcp.send(`RESUME ${CHANNEL}-${FG_LAYER}`); } catch (_) {}
return this.getStatus();
}
_reportAsRunStart(item) {
// The mam-api owns the as-run table; the sidecar just logs locally. The API
// polls /status and writes as-run rows on clip change. Keeping the DB write
// in the API avoids giving the sidecar a DB connection.
this.state.currentItemId = item.id || null;
this.state.currentItemStartedAt = new Date().toISOString();
}
getStatus() {
return {
running: this.state.running,
outputType: this.state.outputType,
videoFormat: this.state.videoFormat,
currentIndex: this.state.currentIndex,
currentClip: this.state.currentClip,
currentItemId: this.state.currentItemId || null,
currentItemStartedAt: this.state.currentItemStartedAt || null,
playlistLength: this.state.playlist.length,
loop: this.state.loop,
startedAt: this.state.startedAt,
lastError: this.state.lastError,
};
}
}
export default new PlayoutManager();