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();