import { AmcpClient } from './amcp.js'; // 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}` : ''; // 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; } 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(); } // Low-bitrate HLS for the web UI preview. Segments land in the shared media // volume; the mam-api serves /media/live//* from there. async _addHlsConsumer() { // The CasparCG channel feeds this consumer in real time, and its frame // timestamps are irregular ("packet with pts X has duration 0" warnings). // With frame-count GOPs (-g 60) the HLS muxer split points drift, producing // erratic segment durations (0.4s–4.2s) and TARGETDURATION violations. The // result is a live playlist hls.js parses but can never sync to — it // reloads forever ("sliding 0.00 / prev-sn na / MISSED") and never appends // a fragment, so the preview stays black. // // The HLS preview is a VIDEO-ONLY confidence monitor. We deliberately drop // audio (-an). // // Why: CasparCG's real-time channel feeds the FFMPEG consumer an audio // stream whose muxed time_base comes out as 1/0 (infinity). ffmpeg itself // can't decode the resulting playlist ("Invalid data ... abuffer: Value inf // for parameter 'time_base'"), and hls.js silently fails to append the // fragment after demux — the video element sits at readyState 0 and the // preview stays black. Dropping audio removes the broken stream entirely; // the remaining video track is clean h264 and plays in hls.js. A confidence // monitor doesn't need audio — the real program audio rides the primary // SRT/RTMP/SDI/NDI output, which is unaffected. // // NOTE: encoder options like -g / -r / -force_key_frames are NOT honored // here — CasparCG's FFMPEG consumer applies args to the muxer, not the // encoder (it logs "Unused option"). Segment cadence follows the channel's // own keyframes; that's fine for a video-only preview. const out = `${HLS_DIR}/index.m3u8`; const args = [ `FILE "${out}"`, '-format hls', '-hls_time 2', '-hls_list_size 8', '-hls_flags delete_segments+append_list+independent_segments', '-codec:v libx264 -preset:v veryfast -tune:v zerolatency -b:v 800k -maxrate 1M -bufsize 2M', '-an', '-filter:v format=yuv420p', ].join(' '); await this.amcp.send(`ADD ${CHANNEL} ${args}`); } async stopChannel() { this._clearAdvance(); 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();