diff --git a/services/playout/Dockerfile b/services/playout/Dockerfile new file mode 100644 index 0000000..9b53179 --- /dev/null +++ b/services/playout/Dockerfile @@ -0,0 +1,68 @@ +# Wild Dragon Playout sidecar — CasparCG Server + Node AMCP control shim. +# +# CasparCG's mixer needs an OpenGL context. On a node with a real GPU we'd pass +# the device + driver through; for the headless / no-GPU case we run a virtual +# framebuffer (Xvfb) so the GL context initialises. The container is launched +# --privileged by mam-api (same as capture) so DeckLink / NDI hardware is +# reachable when present. +# +# NDI + DeckLink SDKs are NOT redistributable. They are fetched at build time +# from URLs supplied as build args (mirror them into your own artifact store); +# the build still succeeds without them (NDI/DeckLink consumers simply won't be +# available — SRT/RTMP/test output still work). + +FROM node:20-bookworm + +ARG CASPAR_VERSION=2.3.3-stable +ARG CASPAR_URL=https://github.com/CasparCG/server/releases/download/v2.3.3-stable/CasparCG-Server-2.3.3-stable-Linux.tar.gz +ARG NDI_SDK_URL= + +ENV DEBIAN_FRONTEND=noninteractive + +# CasparCG 2.3 Linux runtime deps + Xvfb for headless GL + ffmpeg libs for the +# FFMPEG consumer (SRT/RTMP output). +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl tar xz-utils \ + xvfb libgl1-mesa-glx libgl1-mesa-dri libglu1-mesa \ + libx11-6 libxext6 libxrandr2 libxcursor1 libxinerama1 libxi6 \ + libopenal1 libsndfile1 libavformat59 libavcodec59 libavfilter8 \ + libswscale6 libswresample4 libpostproc56 fonts-dejavu-core \ + && rm -rf /var/lib/apt/lists/* + +# ── CasparCG Server ────────────────────────────────────────────────────────── +WORKDIR /opt +RUN curl -fsSL "$CASPAR_URL" -o caspar.tar.gz \ + && mkdir -p /opt/casparcg \ + && tar xzf caspar.tar.gz -C /opt/casparcg --strip-components=1 \ + && rm caspar.tar.gz \ + && (test -f /opt/casparcg/casparcg || test -f /opt/casparcg/CasparCG\ Server || true) + +# ── NDI runtime (optional) ─────────────────────────────────────────────────── +# If an NDI SDK tarball URL is provided, extract its libs to /opt/ndi-lib and +# point CasparCG at them via NDI_RUNTIME_DIR_V6. Pin the SDK version to what the +# server expects (the common docker failure is a libndi .so version mismatch). +RUN if [ -n "$NDI_SDK_URL" ]; then \ + mkdir -p /opt/ndi-lib && \ + curl -fsSL "$NDI_SDK_URL" -o /tmp/ndi.tar.gz && \ + tar xzf /tmp/ndi.tar.gz -C /tmp && \ + find /tmp -name 'libndi*.so*' -exec cp -a {} /opt/ndi-lib/ \; && \ + rm -f /tmp/ndi.tar.gz && ldconfig /opt/ndi-lib || true; \ + fi +ENV NDI_RUNTIME_DIR_V6=/opt/ndi-lib + +# CasparCG media folder — mam-api stages assets from S3 into this volume. +RUN mkdir -p /media + +# ── Node control shim ──────────────────────────────────────────────────────── +WORKDIR /app +COPY package*.json ./ +RUN npm install --omit=dev +COPY . . + +# CasparCG config + entrypoint +COPY casparcg.config /opt/casparcg/casparcg.config +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 3002 5250 +ENTRYPOINT ["/entrypoint.sh"] diff --git a/services/playout/casparcg.config b/services/playout/casparcg.config new file mode 100644 index 0000000..961bab5 --- /dev/null +++ b/services/playout/casparcg.config @@ -0,0 +1,29 @@ + + + + /media/ + /opt/casparcg/log/ + /opt/casparcg/data/ + /media/templates/ + + + + + + 1080i5994 + + + + + + + + + 5250 + AMCP + + + diff --git a/services/playout/entrypoint.sh b/services/playout/entrypoint.sh new file mode 100644 index 0000000..7f432d5 --- /dev/null +++ b/services/playout/entrypoint.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Headless GL: start a virtual framebuffer unless a real DISPLAY is provided +# (a GPU node may pass through an X socket). CasparCG's mixer needs a GL context. +if [ -z "${DISPLAY:-}" ]; then + echo "[entrypoint] starting Xvfb on :99" + Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp & + export DISPLAY=:99 + # Give Xvfb a moment to create the socket. + for i in $(seq 1 20); do + [ -e /tmp/.X11-unix/X99 ] && break + sleep 0.25 + done +fi + +# Ensure the HLS preview directory exists before CasparCG attaches its second +# FFMPEG consumer (mam-api serves /live//* from the shared volume). +if [ -n "${CHANNEL_ID:-}" ]; then + mkdir -p "/media/live/${CHANNEL_ID}" +fi + +# Launch CasparCG Server from its install dir (it reads ./casparcg.config and +# resolves relative media paths against the configured media folder). +cd /opt/casparcg +CASPAR_BIN="./casparcg" +[ -x "$CASPAR_BIN" ] || CASPAR_BIN="./CasparCG Server" +echo "[entrypoint] launching CasparCG: $CASPAR_BIN" +"$CASPAR_BIN" & +CASPAR_PID=$! + +# Forward termination to CasparCG so the channel closes cleanly. +term() { + echo "[entrypoint] terminating CasparCG ($CASPAR_PID)" + kill -TERM "$CASPAR_PID" 2>/dev/null || true + wait "$CASPAR_PID" 2>/dev/null || true + exit 0 +} +trap term SIGTERM SIGINT + +# Launch the Node control shim (foreground). If it exits, stop the container. +cd /app +node src/index.js & +NODE_PID=$! + +wait -n "$CASPAR_PID" "$NODE_PID" +term diff --git a/services/playout/package.json b/services/playout/package.json new file mode 100644 index 0000000..fff4480 --- /dev/null +++ b/services/playout/package.json @@ -0,0 +1,18 @@ +{ + "name": "wild-dragon-playout", + "version": "1.0.0", + "description": "Wild Dragon MAM playout sidecar — wraps a CasparCG Server instance and drives it over AMCP for master-control playout (SDI / NDI / SRT / RTMP).", + "type": "module", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "express": "^4.18.0", + "cors": "^2.8.0", + "dotenv": "^16.4.0" + } +} diff --git a/services/playout/src/amcp.js b/services/playout/src/amcp.js new file mode 100644 index 0000000..d60754a --- /dev/null +++ b/services/playout/src/amcp.js @@ -0,0 +1,182 @@ +import net from 'node:net'; + +// Minimal AMCP (Advanced Media Control Protocol) client for CasparCG. +// +// AMCP is a line-based TCP protocol: each command is a single CRLF-terminated +// line, and the server replies with a status line ("201 PLAY OK\r\n") optionally +// followed by data lines. We keep one persistent socket per CasparCG instance +// and serialize commands through a FIFO queue — CasparCG processes one command +// at a time per connection, so interleaving replies would otherwise be +// ambiguous. +// +// We only implement the subset the playout sidecar needs (PLAY / LOADBG / STOP / +// CLEAR / INFO / ADD / REMOVE). Responses are returned raw; callers parse the +// status code where they care. + +const CRLF = '\r\n'; + +export class AmcpClient { + constructor({ host = '127.0.0.1', port = 5250 } = {}) { + this.host = host; + this.port = port; + this.socket = null; + this.connected = false; + this._buffer = ''; + this._queue = []; // pending { command, resolve, reject, timer } + this._active = null; // command currently awaiting a reply + this._reconnectTimer = null; + } + + connect() { + if (this.socket) return; + const socket = net.createConnection({ host: this.host, port: this.port }); + socket.setEncoding('utf8'); + socket.setKeepAlive(true, 10000); + + socket.on('connect', () => { + this.connected = true; + console.log(`[amcp] connected to ${this.host}:${this.port}`); + }); + socket.on('data', (chunk) => this._onData(chunk)); + socket.on('error', (err) => { + console.error(`[amcp] socket error: ${err.message}`); + }); + socket.on('close', () => { + this.connected = false; + this.socket = null; + // Fail any in-flight + queued commands so callers don't hang. + const pending = this._active ? [this._active, ...this._queue] : [...this._queue]; + this._active = null; + this._queue = []; + for (const p of pending) { + clearTimeout(p.timer); + p.reject(new Error('AMCP connection closed')); + } + this._scheduleReconnect(); + }); + + this.socket = socket; + } + + _scheduleReconnect() { + if (this._reconnectTimer) return; + this._reconnectTimer = setTimeout(() => { + this._reconnectTimer = null; + console.log('[amcp] reconnecting...'); + this.connect(); + }, 2000); + } + + // Wait until the socket is usable, up to timeoutMs. + async waitReady(timeoutMs = 30000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (this.connected) return true; + if (!this.socket) this.connect(); + await new Promise((r) => setTimeout(r, 250)); + } + throw new Error('AMCP not ready within timeout'); + } + + _onData(chunk) { + this._buffer += chunk; + // A CasparCG reply is a status line, optionally followed by data lines. + // The simplest robust framing: a command's reply is complete when we see a + // status line AND (for 2-line "200" multi-line replies) the terminating + // blank line. For our command subset, single-status-line replies dominate; + // we treat a reply as complete at each newline and let the active command + // decide whether it has enough. To keep this correct for INFO (multi-line), + // we accumulate until the buffer ends with a known terminator. + if (!this._active) { + // Unsolicited data (e.g. connection banner) — discard. + this._buffer = ''; + return; + } + // CasparCG ends multi-line replies with CRLF on an empty line. Single-line + // replies (201/202/4xx/5xx) end with a single CRLF. Resolve when we have at + // least one complete line; for "200 ... OK" (list follows) wait for the + // blank-line terminator. + const firstLineEnd = this._buffer.indexOf(CRLF); + if (firstLineEnd === -1) return; + const statusLine = this._buffer.slice(0, firstLineEnd); + const code = parseInt(statusLine, 10); + + if (code === 200) { + // Multi-line: data lines until an empty line. + const term = this._buffer.indexOf(CRLF + CRLF); + if (term === -1) return; // wait for more + const full = this._buffer.slice(0, term); + this._buffer = this._buffer.slice(term + 4); + this._finishActive(null, full); + return; + } + + if (code === 201 || code === 202) { + // 201: one data line follows the status line. 202: status only. + if (code === 201) { + const secondLineEnd = this._buffer.indexOf(CRLF, firstLineEnd + 2); + if (secondLineEnd === -1) return; + const full = this._buffer.slice(0, secondLineEnd); + this._buffer = this._buffer.slice(secondLineEnd + 2); + this._finishActive(null, full); + } else { + const full = this._buffer.slice(0, firstLineEnd); + this._buffer = this._buffer.slice(firstLineEnd + 2); + this._finishActive(null, full); + } + return; + } + + // 4xx / 5xx error, or any other single-line status. + const full = this._buffer.slice(0, firstLineEnd); + this._buffer = this._buffer.slice(firstLineEnd + 2); + if (code >= 400) this._finishActive(new Error(`AMCP error: ${full}`), full); + else this._finishActive(null, full); + } + + _finishActive(err, data) { + const active = this._active; + this._active = null; + if (active) { + clearTimeout(active.timer); + if (err) active.reject(err); + else active.resolve(data); + } + this._pump(); + } + + _pump() { + if (this._active || this._queue.length === 0) return; + const next = this._queue.shift(); + this._active = next; + try { + this.socket.write(next.command + CRLF); + } catch (err) { + this._active = null; + clearTimeout(next.timer); + next.reject(err); + } + } + + // Send a single AMCP command and resolve with the raw reply string. + send(command, { timeoutMs = 15000 } = {}) { + return new Promise((resolve, reject) => { + const entry = { command, resolve, reject, timer: null }; + entry.timer = setTimeout(() => { + // Drop from queue if still pending; if active, detach so the next + // reply doesn't get misrouted. + if (this._active === entry) this._active = null; + else this._queue = this._queue.filter((e) => e !== entry); + reject(new Error(`AMCP command timed out: ${command}`)); + }, timeoutMs); + this._queue.push(entry); + this._pump(); + }); + } + + close() { + if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; } + if (this.socket) { try { this.socket.destroy(); } catch (_) {} this.socket = null; } + this.connected = false; + } +} diff --git a/services/playout/src/index.js b/services/playout/src/index.js new file mode 100644 index 0000000..accbd2d --- /dev/null +++ b/services/playout/src/index.js @@ -0,0 +1,85 @@ +import express from 'express'; +import cors from 'cors'; +import dotenv from 'dotenv'; +import playoutManager from './playout-manager.js'; + +dotenv.config(); + +const app = express(); +const PORT = process.env.PORT || 3002; + +app.use(cors()); +app.use(express.json()); + +app.get('/health', (req, res) => res.json({ status: 'ok' })); + +// Start the channel's output consumer. Body: { outputType, outputConfig, videoFormat } +app.post('/channel/start', async (req, res) => { + try { + const out = await playoutManager.startChannel(req.body || {}); + res.json(out); + } catch (err) { + console.error('[playout] /channel/start error:', err.message); + res.status(500).json({ error: err.message }); + } +}); + +app.post('/channel/stop', async (req, res) => { + try { res.json(await playoutManager.stopChannel()); } + catch (err) { res.status(500).json({ error: err.message }); } +}); + +// Load + start a playlist. Body: { items: [...], loop } +app.post('/playlist/load', async (req, res) => { + try { + const { items = [], loop = false } = req.body || {}; + res.json(await playoutManager.loadPlaylist({ items, loop })); + } catch (err) { + console.error('[playout] /playlist/load error:', err.message); + res.status(500).json({ error: err.message }); + } +}); + +app.post('/transport/skip', async (req, res) => { try { res.json(await playoutManager.skip()); } catch (e) { res.status(500).json({ error: e.message }); } }); +app.post('/transport/pause', async (req, res) => { try { res.json(await playoutManager.pause()); } catch (e) { res.status(500).json({ error: e.message }); } }); +app.post('/transport/resume', async (req, res) => { try { res.json(await playoutManager.resume()); } catch (e) { res.status(500).json({ error: e.message }); } }); + +app.get('/status', (req, res) => res.json(playoutManager.getStatus())); + +// Auto-start: when the sidecar is spawned by mam-api with channel env, bring up +// the output consumer immediately so the container is "on air idle" (black/slate) +// the moment it boots, mirroring the capture sidecar's bootstrap pattern. +async function bootstrap() { + const outputType = process.env.OUTPUT_TYPE; + if (!outputType) { + console.log('[bootstrap] no OUTPUT_TYPE — on-demand sidecar, waiting for /channel/start'); + return; + } + let outputConfig = {}; + try { outputConfig = JSON.parse(process.env.OUTPUT_CONFIG || '{}'); } + catch (err) { console.error('[bootstrap] bad OUTPUT_CONFIG json:', err.message); } + const videoFormat = process.env.VIDEO_FORMAT || '1080i5994'; + try { + await playoutManager.startChannel({ outputType, outputConfig, videoFormat }); + } catch (err) { + console.error('[bootstrap] channel start failed:', err.message); + } +} + +const server = app.listen(PORT, () => { + console.log(`Wild Dragon Playout Service listening on port ${PORT}`); + // Give CasparCG a moment to come up (started by the container entrypoint). + playoutManager.amcp.connect(); + bootstrap(); +}); + +function shutdown(sig) { + console.log(`[playout] ${sig} — shutting down`); + playoutManager.stopChannel().catch(() => {}).finally(() => { + playoutManager.amcp.close(); + server.close(() => process.exit(0)); + setTimeout(() => process.exit(0), 5000); + }); +} +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); diff --git a/services/playout/src/playout-manager.js b/services/playout/src/playout-manager.js new file mode 100644 index 0000000..9c2ba84 --- /dev/null +++ b/services/playout/src/playout-manager.js @@ -0,0 +1,277 @@ +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();