From 27a868aa5cfc78b3dd9d65b00e0c126ed16b98e1 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Sun, 31 May 2026 13:55:14 -0400 Subject: [PATCH] fix(playout): clean video-only HLS preview via standalone ffmpeg re-mux CasparCG's bundled FFMPEG/HLS consumer muxes a broken audio track (aac sample_rate=0, time_base 1/0) into the preview, and silently drops every arg that would remove it (-an, -codec:a, -g, -r all "Unused option"). That corrupt audio black-screens the browser preview because neither ffmpeg nor hls.js can decode the playlist. Re-architect the preview path: CasparCG now STREAMs plain mpegts to a UDP loopback port, and a Node-spawned STANDALONE ffmpeg (where -an actually works) re-muxes it to clean, video-only HLS with -c:v copy. The child process is tracked, auto-respawned while running, and killed in stopChannel(). The PRIMARY SRT/RTMP/SDI/NDI output (with program audio) is untouched. Also fix the Dockerfile to match the working image: ubuntu:22.04 base + CasparCG 2.4.0 ubuntu22 zip + NodeSource Node 20, and add a standalone ffmpeg CLI. The old 2.3.3 tarball URL 404s. entrypoint.sh updated for the 2.4.x bin/casparcg layout + bundled lib/ LD_LIBRARY_PATH. Co-Authored-By: Claude Opus 4.8 --- services/playout/Dockerfile | 60 ++++++---- services/playout/entrypoint.sh | 34 ++++-- services/playout/src/playout-manager.js | 145 ++++++++++++++++++------ 3 files changed, 172 insertions(+), 67 deletions(-) diff --git a/services/playout/Dockerfile b/services/playout/Dockerfile index 9b53179..f93bcba 100644 --- a/services/playout/Dockerfile +++ b/services/playout/Dockerfile @@ -6,36 +6,58 @@ # --privileged by mam-api (same as capture) so DeckLink / NDI hardware is # reachable when present. # +# CasparCG 2.4.x no longer ships a self-contained Linux tarball — the GitHub +# release provides either Ubuntu .deb packages or an "ubuntu22" zip that bundles +# the binary + its .so files under bin/ and lib/. We use the zip on an +# ubuntu:22.04 base so the bundled libs match the host glibc/abi, then install +# Node 20 from NodeSource on top. +# # 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 +# from a URL supplied as a build arg (mirror it into your own artifact store); +# the build still succeeds without it (NDI/DeckLink consumers simply won't be # available — SRT/RTMP/test output still work). -FROM node:20-bookworm +FROM ubuntu:22.04 -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 CASPAR_VERSION=2.4.0-stable +ARG CASPAR_URL=https://github.com/CasparCG/server/releases/download/v2.4.0-stable/casparcg-server-v2.4.0-stable-ubuntu22.zip 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). +# CasparCG 2.4 runtime deps + Xvfb for headless GL + CEF (HTML producer) deps + +# Node 20 (NodeSource) + a STANDALONE ffmpeg CLI. The standalone ffmpeg is what +# the Node shim spawns to re-mux the CasparCG mpegts preview stream into clean, +# video-only HLS (CasparCG's own FFMPEG consumer silently drops -an and muxes a +# broken audio track, which black-screens the browser preview). 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 \ + ca-certificates curl unzip tar xz-utils gnupg ffmpeg \ + xvfb libgl1-mesa-dri libglu1-mesa fonts-dejavu-core \ + libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \ + libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \ + libgbm1 libpango-1.0-0 libcairo2 libasound2 libatspi2.0-0 \ + && mkdir -p /etc/apt/keyrings \ + && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ + | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" \ + > /etc/apt/sources.list.d/nodesource.list \ + && apt-get update && apt-get install -y --no-install-recommends nodejs \ && 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) +# ── CasparCG Server (ubuntu22 zip bundle) ──────────────────────────────────── +# The zip extracts to /opt/casparcg_server with the binary at bin/casparcg and +# its bundled .so files under lib/ (added to LD_LIBRARY_PATH by entrypoint.sh). +# Symlink to /opt/casparcg so the config/entrypoint paths stay stable. +WORKDIR /tmp/caspar +RUN set -eux; \ + curl -fsSL "$CASPAR_URL" -o caspar.zip; \ + unzip -q caspar.zip -d /opt; \ + chmod +x /opt/casparcg_server/bin/casparcg /opt/casparcg_server/scanner 2>/dev/null || true; \ + ls /opt/casparcg_server/; \ + test -x /opt/casparcg_server/bin/casparcg; \ + ln -sfn /opt/casparcg_server /opt/casparcg; \ + echo "caspar binary: /opt/casparcg_server/bin/casparcg"; \ + cd /; rm -rf /tmp/caspar # ── NDI runtime (optional) ─────────────────────────────────────────────────── # If an NDI SDK tarball URL is provided, extract its libs to /opt/ndi-lib and diff --git a/services/playout/entrypoint.sh b/services/playout/entrypoint.sh index 7f432d5..7cde7f1 100644 --- a/services/playout/entrypoint.sh +++ b/services/playout/entrypoint.sh @@ -7,29 +7,42 @@ 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). +# Ensure the HLS preview directory exists before the re-mux ffmpeg writes to it +# (mam-api serves /live//* from the shared media 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). +mkdir -p /media/casparcg/log /media/casparcg/data /media/templates + +# CEF (HTML producer) initialises an NSS database at /root/.pki/nssdb and +# Chrome caches under HOME. Pre-create writable dirs so CEF doesn't SIGABRT +# ~30s into the run when it first lazily inits. +mkdir -p /root/.pki/nssdb /root/.cache /tmp/cef-cache +chmod 700 /root/.pki/nssdb +export HOME=/root + +# 2.4.x zip bundles its own .so files under lib/ — add to LD_LIBRARY_PATH. +export LD_LIBRARY_PATH="/opt/casparcg/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + cd /opt/casparcg -CASPAR_BIN="./casparcg" -[ -x "$CASPAR_BIN" ] || CASPAR_BIN="./CasparCG Server" -echo "[entrypoint] launching CasparCG: $CASPAR_BIN" -"$CASPAR_BIN" & +CASPAR_CFG=/opt/casparcg/casparcg.config +# 2.4.x: binary at bin/casparcg. 2.5.x: symlinked to casparcg at root. +if [ -x "./bin/casparcg" ]; then CASPAR_BIN="./bin/casparcg"; +elif [ -x "./casparcg" ]; then CASPAR_BIN="./casparcg"; +elif [ -x "./CasparCG Server" ]; then CASPAR_BIN="./CasparCG Server"; +elif command -v casparcg >/dev/null; then CASPAR_BIN="casparcg"; +else echo "[entrypoint] ERROR: casparcg binary not found"; exit 1; fi +echo "[entrypoint] launching CasparCG: $CASPAR_BIN $CASPAR_CFG" +"$CASPAR_BIN" "$CASPAR_CFG" & 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 @@ -38,7 +51,6 @@ term() { } trap term SIGTERM SIGINT -# Launch the Node control shim (foreground). If it exits, stop the container. cd /app node src/index.js & NODE_PID=$! diff --git a/services/playout/src/playout-manager.js b/services/playout/src/playout-manager.js index 32c4eda..bef98e4 100644 --- a/services/playout/src/playout-manager.js +++ b/services/playout/src/playout-manager.js @@ -1,4 +1,6 @@ import { AmcpClient } from './amcp.js'; +import { spawn } from 'node:child_process'; +import { mkdirSync } from 'node:fs'; // Playout manager — owns one CasparCG channel's lifecycle inside this sidecar. // @@ -23,6 +25,12 @@ const MEDIA_ROOT = process.env.CASPAR_MEDIA_ROOT || '/media'; const CHANNEL_ID = process.env.CHANNEL_ID || ''; const HLS_DIR = CHANNEL_ID ? `${MEDIA_ROOT}/live/${CHANNEL_ID}` : ''; +// Loopback UDP port CasparCG's preview STREAM consumer publishes mpegts to, and +// the standalone ffmpeg re-muxer reads from. One CasparCG per sidecar, so a +// fixed port is fine; allow override for parallel local testing. +const PREVIEW_UDP_PORT = parseInt(process.env.PREVIEW_UDP_PORT || '9710', 10); +const PREVIEW_UDP_URL = `udp://127.0.0.1:${PREVIEW_UDP_PORT}`; + // 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'. @@ -77,6 +85,8 @@ export class PlayoutManager { lastError: null, }; this._advanceTimer = null; + this._hlsProc = null; // standalone ffmpeg re-mux child process + this._hlsRestartTimer = null; } async _consumerCommand(outputType, cfg) { @@ -158,50 +168,111 @@ export class PlayoutManager { 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. + // HLS preview for the web UI confidence monitor. + // + // ── Why not CasparCG's own HLS (FILE/STREAM ".../index.m3u8") ────────────── + // CasparCG's bundled FFMPEG consumer muxes a BROKEN audio track into the HLS: + // ffprobe reports `aac, sample_rate=0` and ffmpeg decoding the playlist fails + // with "Invalid data ... abuffer: Value inf for parameter 'time_base' ... + // time_base 1/0". That corrupt audio prevents BOTH ffmpeg and hls.js from + // decoding, so the browser