import { AmcpClient } from './amcp.js'; import { spawn } from 'node:child_process'; import { mkdirSync, readdirSync, unlinkSync } 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// // to this directory (shared media volume) so the UI's existing HLS player // (capture's /live/ 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, // SCTE-35: the currently-active ad break, if any. Set by triggerScte and // cleared by a timer when the break window elapses. Surfaced in getStatus // so the UI can render an "in break" state + countdown. scteActive: null, // { eventId, type, durationS, firedAt(iso), endsAt(iso) } }; this._advanceTimer = null; this._scteTimer = 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/ 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