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+ FFMPEG consumer streams the channel out via libavformat. // SRT/RTMP both go through the ffmpeg mpegts/flv muxers. const url = cfg.url || ''; if (outputType === 'srt') { const latency = cfg.latency || 200; const full = url.includes('latency=') ? url : `${url}${url.includes('?') ? '&' : '?'}latency=${latency}`; return `FFMPEG "${full}" -format mpegts -vcodec libx264 -preset veryfast -tune zerolatency -b:v 6M -acodec aac -b:a 192k`; } const target = cfg.key ? `${url}/${cfg.key}` : url; return `FFMPEG "${target}" -format flv -vcodec libx264 -preset veryfast -tune zerolatency -b:v 6M -acodec aac -b:a 192k`; } 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). async startChannel({ outputType, outputConfig = {}, videoFormat = '1080p5994' }) { await this.amcp.waitReady(30000); // Set the channel video mode, then attach the output consumer. try { await this.amcp.send(`SET ${CHANNEL} MODE ${videoFormat}`); } catch (err) { console.warn(`[playout] SET MODE failed (continuing): ${err.message}`); } const consumer = await this._consumerCommand(outputType, outputConfig); await this.amcp.send(`ADD ${CHANNEL} ${consumer}`); if (HLS_DIR) { try { await this._addHlsConsumer(); console.log(`[playout] HLS preview at ${HLS_DIR}/index.m3u8`); } catch (err) { // HLS preview is non-fatal — operators still get the on-air output. 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 = null; console.log(`[playout] channel started output=${outputType} mode=${videoFormat} fps=${this.state.fps.toFixed(3)}`); return this.getStatus(); } // Low-bitrate HLS for the web UI preview. Segments land in the shared media // volume; the mam-api serves /live//* from there. async _addHlsConsumer() { // mkdir is done by the entrypoint; CasparCG's ffmpeg consumer creates the // playlist on first segment. 2s segments / 6-window list keeps lag low // without thrashing disk. const out = `${HLS_DIR}/index.m3u8`; const args = [ `FFMPEG "${out}"`, '-format hls', '-hls_time 2', '-hls_list_size 6', '-hls_flags delete_segments+append_list', '-vcodec libx264 -preset veryfast -tune zerolatency -b:v 800k -maxrate 1M -bufsize 2M', '-g 60 -keyint_min 60 -sc_threshold 0', '-acodec aac -b:a 96k', ].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 }) { 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); } // CasparCG's LOADBG ... AUTO automatically swaps the background layer to // foreground when the current clip ends. To keep our bookkeeping (currentIndex // / as-run) in sync we additionally poll INFO and advance our pointer when the // foreground clip changes. For Phase A we use a simpler model: cue the next // clip with AUTO and use a duration-based timer to move our pointer. _scheduleAdvance(item) { this._clearAdvance(); const next = this._nextIndex(); if (next === null) return; 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}`)); } _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();