feat(playout): CasparCG sidecar image + Node AMCP shim

One container per channel. Built like capture/build-with-decklink: NDI +
DeckLink SDKs fetched at build, runs --privileged with Xvfb for the GL
context where no real display is present.

Components:
- entrypoint.sh: Xvfb + CasparCG launch, creates /media/live/<CHANNEL_ID>
- src/amcp.js: TCP AMCP client
- src/playout-manager.js: channel lifecycle, playlist walk via LOADBG AUTO
  for gapless transitions; primary consumer (decklink/ndi/srt/rtmp) plus a
  second FFMPEG HLS consumer (~600 kbps, 2s segments) for the UI preview
- src/index.js: HTTP shim — /channel/start, /playlist/load, transport
- frame-rate helper picks fps from video_format (59.94 → 60000/1001) so
  SEEK / LENGTH frame math is correct
This commit is contained in:
Zac 2026-05-30 13:17:49 +00:00
parent 209f9fda52
commit d62af34e98
7 changed files with 706 additions and 0 deletions

View file

@ -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"]

View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<paths>
<media-path>/media/</media-path>
<log-path>/opt/casparcg/log/</log-path>
<data-path>/opt/casparcg/data/</data-path>
<template-path>/media/templates/</template-path>
</paths>
<!-- Single logical channel. The output consumer (DeckLink / NDI / SRT / RTMP)
is added at runtime over AMCP by the Node shim (playout-manager.js), so no
static consumer is declared here. A screen consumer is intentionally
omitted — this is a headless server. -->
<channels>
<channel>
<video-mode>1080i5994</video-mode>
<consumers>
</consumers>
</channel>
</channels>
<controllers>
<!-- AMCP over TCP — the Node shim connects here. -->
<tcp>
<port>5250</port>
<protocol>AMCP</protocol>
</tcp>
</controllers>
</configuration>

View file

@ -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/<channel_id>/* 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

View file

@ -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"
}
}

View file

@ -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;
}
}

View file

@ -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'));

View file

@ -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/<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}` : '';
// 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/<id> 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/<channel_id>/* 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();