From 12115a053a0dd063bcfc839cba48aed8db4a1ff6 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Sun, 31 May 2026 11:45:25 -0400 Subject: [PATCH 01/25] feat(playout): fix 409 drag bug, add HLS preview, advanced playlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix event bubbling: e.stopPropagation() in onItemDrop prevents duplicate POST when dropping on an existing playlist item - Wrap all drop handlers in try/catch with inline error display - ProgramMonitor: replace text placeholder with hls.js video player loading /media/live//index.m3u8; falls back to native HLS on Safari; destroys Hls instance on channel stop/unmount - Playlist: per-item duration (MM:SS), staging progress bar with animated stripe while staging, now-playing highlight + ▶ indicator driven by engine.currentIndex from 4s status poll - Playlist footer: clip count + total duration sum - Transport: Play button disabled + shows '⏳ N staging' until all items are media_status=ready, eliminating the staging-not-ready 409 Co-Authored-By: Claude Sonnet 4.6 --- services/web-ui/public/screens-playout.jsx | 200 +++++++++++++++------ services/web-ui/public/styles-playout.css | 76 +++++++- 2 files changed, 217 insertions(+), 59 deletions(-) diff --git a/services/web-ui/public/screens-playout.jsx b/services/web-ui/public/screens-playout.jsx index 49f9d23..965d32c 100644 --- a/services/web-ui/public/screens-playout.jsx +++ b/services/web-ui/public/screens-playout.jsx @@ -24,6 +24,26 @@ async function poFetch(path, opts) { return window.ZAMPP_API.fetch('/playout' + path, opts); } +// ── Helpers ────────────────────────────────────────────────────────────────── + +function fmtDuration(secs) { + if (!secs || secs < 0) return '—'; + const s = Math.floor(secs); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const ss = s % 60; + const mm = String(m).padStart(2, '0'); + const ssStr = String(ss).padStart(2, '0'); + return h > 0 ? `${h}:${mm}:${ssStr}` : `${m}:${ssStr}`; +} + +function itemEffectiveDuration(it) { + const total = (it.asset_duration_ms || 0) / 1000; + const inPt = it.in_point != null ? Number(it.in_point) : 0; + const outPt = it.out_point != null ? Number(it.out_point) : total; + return Math.max(0, outPt - inPt); +} + // ── Output-config sub-form (varies by output type) ─────────────────────────── function OutputConfigFields({ type, config, onChange }) { const set = (k, v) => onChange({ ...config, [k]: v }); @@ -175,29 +195,37 @@ function MediaBin({ projectId }) { ); } -const MEDIA_STATUS_BADGE = { - ready: 'success', staging: 'warn', pending: 'neutral', error: 'error', -}; +// ── Staging progress bar ────────────────────────────────────────────────────── +function StagingBar({ status }) { + return ( + ); } -// Modal listing all Premiere panel downloads (ZXP + Windows installer for -// each released version). Sourced from window.PREMIERE_RELEASES, written by -// the Settings → SDKs section in screens-admin.jsx. -function PremiereDownloadModal({ onClose }) { +// Combined downloads modal: Premiere Pro panel + Dragon-ISO. +function DownloadsModal({ onClose }) { const releases = (window.PREMIERE_RELEASES || []).slice().sort((a, b) => { - // Newest first; fall back to lexicographic compare on version string. const av = String(a.version || ''), bv = String(b.version || ''); return bv.localeCompare(av, undefined, { numeric: true }); }); const latest = window.PREMIERE_LATEST || releases[0] || null; + const DRAGON_ISO_RELEASES_URL = 'https://forge.wilddragon.net/WildDragonLLC/dragon-iso/releases'; + return (
-
e.stopPropagation()} style={{ maxWidth: 560 }}> +
e.stopPropagation()} style={{ maxWidth: 580 }}>
-
Premiere panel
+
Downloads
- Adobe Premiere Pro (UXP) integration. Install the .ccx per workstation via the Adobe UXP Developer Tool, or double-click it with Creative Cloud installed. + Premiere Pro panel and Dragon-ISO NDI tools.
+ {/* ── Premiere panel ── */} +
+ + Premiere Pro panel (UXP) +
+
+ Install the .ccx per workstation via the Adobe UXP Developer Tool, or double-click it with Creative Cloud installed. +
{releases.length === 0 && ( -
- No releases registered yet. Upload one from Settings → Capture SDKs. +
+ No releases registered. Upload one from Settings → Capture SDKs.
)} {releases.map((rel, i) => ( @@ -293,23 +308,39 @@ function PremiereDownloadModal({ onClose }) {
))} + + {/* ── Dragon-ISO ── */} +
+ + Dragon-ISO +
+
+ NDI ISO recorder for Microsoft Teams. Windows, .NET 8 WPF. +
+
+
+ Releases +
+ +
-
- Need help installing? Use the Adobe Extension Manager or UPIA. -
diff --git a/services/web-ui/public/screens-playout.jsx b/services/web-ui/public/screens-playout.jsx index 965d32c..f214dcb 100644 --- a/services/web-ui/public/screens-playout.jsx +++ b/services/web-ui/public/screens-playout.jsx @@ -456,6 +456,13 @@ function ChannelDetail({ channel, onChannelChange }) { const updated = await poFetch('/channels/' + channel.id + '/stop', { method: 'POST' }); setCh(updated); onChannelChange(updated); }; + const deleteChannel = async () => { + if (!window.confirm('Delete channel "' + ch.name + '"? This cannot be undone.')) return; + try { + await poFetch('/channels/' + ch.id, { method: 'DELETE' }); + onChannelChange({ ...ch, _deleted: true }); + } catch (e) { alert(e.message); } + }; // engine.currentIndex maps directly to the sorted item position. const activeIndex = (engine && engine.currentIndex >= 0) ? engine.currentIndex : -1; @@ -471,6 +478,9 @@ function ChannelDetail({ channel, onChannelChange }) { {ch.status === 'running' ? : } + {ch.status !== 'running' && ( + + )}
{ch.error_message &&
{ch.error_message}
} @@ -514,6 +524,14 @@ function Playout() { const selected = (channels || []).find(c => c.id === selectedId) || null; const onChannelChange = (updated) => { + if (updated._deleted) { + setChannels(cs => { + const next = (cs || []).filter(c => c.id !== updated.id); + setSelectedId(next.length ? next[0].id : null); + return next; + }); + return; + } setChannels(cs => (cs || []).map(c => c.id === updated.id ? updated : c)); }; diff --git a/services/web-ui/public/styles-playout.css b/services/web-ui/public/styles-playout.css index 3882822..1699146 100644 --- a/services/web-ui/public/styles-playout.css +++ b/services/web-ui/public/styles-playout.css @@ -156,6 +156,15 @@ } .po-pl-total { color: var(--text-2); } +/* Downloads modal section header */ +.downloads-section-head { + display: flex; align-items: center; gap: 6px; + font-size: 11px; font-weight: 600; text-transform: uppercase; + letter-spacing: 0.06em; color: var(--text-3); + padding-bottom: 8px; border-bottom: 1px solid var(--border); + margin-bottom: 10px; +} + /* Small button variants reused */ .btn.xs { padding: 2px 8px; font-size: 11px; } .btn.sm { padding: 5px 10px; font-size: 12px; } diff --git a/services/worker/src/workers/playout-stage.js b/services/worker/src/workers/playout-stage.js index b65066a..3a3ccb6 100644 --- a/services/worker/src/workers/playout-stage.js +++ b/services/worker/src/workers/playout-stage.js @@ -57,10 +57,30 @@ async function measureLoudness(inputPath) { return JSON.parse(match[0]); } +function isFiniteLoudness(val) { + const n = parseFloat(val); + return isFinite(n); +} + async function applyLoudnorm(inputPath, outputPath, m) { // Pass 2: linear normalization using pass 1's measurements. -c:v copy keeps // the video stream intact so we only re-encode audio (target AAC stereo, the // common-denominator CasparCG ffmpeg producer accepts). + // + // Silent / no-audio clips measure I=-inf which ffmpeg rejects in pass 2. + // When any loudnorm measurement is non-finite, fall back to a plain audio + // transcode (AAC 192k) with no loudness adjustment — the clip has no + // meaningful audio to normalize. + const silentOrNoAudio = !isFiniteLoudness(m.input_i) || !isFiniteLoudness(m.input_tp); + if (silentOrNoAudio) { + console.log(`[playout-stage] loudnorm skip — silent/no audio (I=${m.input_i}), transcoding audio only`); + await runFfmpeg([ + '-hide_banner', '-nostats', '-y', '-i', inputPath, + '-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', '-ar', '48000', + outputPath, + ]); + return; + } await runFfmpeg([ '-hide_banner', '-nostats', '-y', '-i', inputPath, '-af', `loudnorm=I=-23:TP=-1:LRA=11:measured_I=${m.input_i}:measured_TP=${m.input_tp}:measured_LRA=${m.input_lra}:measured_thresh=${m.input_thresh}:offset=${m.target_offset}:linear=true:print_format=summary`, From e51cf1aa9cd6dc4489f3e72d78e376e48c2510cf Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Sun, 31 May 2026 12:06:08 -0400 Subject: [PATCH 03/25] feat(jobs): surface playout-stage queue in Jobs screen - jobs.js: add playout-stage BullMQ queue to QUEUES; asset_id from job data is already resolved to a name by attachAssetNames - screens-jobs.jsx: map type 'playout-stage' -> kind 'Stage' with monitor icon Co-Authored-By: Claude Sonnet 4.6 --- services/mam-api/src/routes/jobs.js | 26 +++++++++++++------------ services/web-ui/public/screens-jobs.jsx | 4 ++-- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/services/mam-api/src/routes/jobs.js b/services/mam-api/src/routes/jobs.js index 9efa751..5608cb1 100644 --- a/services/mam-api/src/routes/jobs.js +++ b/services/mam-api/src/routes/jobs.js @@ -22,20 +22,22 @@ const parseRedisUrl = (url) => { const redisConn = parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'); -const proxyQueue = new Queue('proxy', { connection: redisConn }); -const thumbnailQueue = new Queue('thumbnail', { connection: redisConn }); -const filmstripQueue = new Queue('filmstrip', { connection: redisConn }); -const conformQueue = new Queue('conform', { connection: redisConn }); -const importQueue = new Queue('import', { connection: redisConn }); -const trimQueue = new Queue('trim', { connection: redisConn }); +const proxyQueue = new Queue('proxy', { connection: redisConn }); +const thumbnailQueue = new Queue('thumbnail', { connection: redisConn }); +const filmstripQueue = new Queue('filmstrip', { connection: redisConn }); +const conformQueue = new Queue('conform', { connection: redisConn }); +const importQueue = new Queue('import', { connection: redisConn }); +const trimQueue = new Queue('trim', { connection: redisConn }); +const playoutStageQueue = new Queue('playout-stage', { connection: redisConn }); const QUEUES = [ - { queue: proxyQueue, type: 'proxy' }, - { queue: thumbnailQueue, type: 'thumbnail' }, - { queue: filmstripQueue, type: 'filmstrip' }, - { queue: conformQueue, type: 'conform' }, - { queue: importQueue, type: 'import' }, - { queue: trimQueue, type: 'trim' }, + { queue: proxyQueue, type: 'proxy' }, + { queue: thumbnailQueue, type: 'thumbnail' }, + { queue: filmstripQueue, type: 'filmstrip' }, + { queue: conformQueue, type: 'conform' }, + { queue: importQueue, type: 'import' }, + { queue: trimQueue, type: 'trim' }, + { queue: playoutStageQueue, type: 'playout-stage' }, ]; // BullMQ state → API status mapping diff --git a/services/web-ui/public/screens-jobs.jsx b/services/web-ui/public/screens-jobs.jsx index e023d10..cbdd363 100644 --- a/services/web-ui/public/screens-jobs.jsx +++ b/services/web-ui/public/screens-jobs.jsx @@ -45,7 +45,7 @@ function Jobs({ navigate }) { const normalizeJob = (j) => { const statusMap = { waiting: 'queued', active: 'running', completed: 'done', failed: 'failed' }; - const kindMap = { proxy: 'Proxy', thumbnail: 'Thumbnail', conform: 'Conform', transcode: 'Transcode', import: 'YouTube' }; + const kindMap = { proxy: 'Proxy', thumbnail: 'Thumbnail', conform: 'Conform', transcode: 'Transcode', import: 'YouTube', 'playout-stage': 'Stage' }; const meta = j.metadata || {}; return { ...j, @@ -207,7 +207,7 @@ function Jobs({ navigate }) { } function JobRow({ job, onRetry, onDelete }) { - const iconMap = { Proxy: 'proxy', Transcode: 'film', Thumbnail: 'image', Conform: 'layers' }; + const iconMap = { Proxy: 'proxy', Transcode: 'film', Thumbnail: 'image', Conform: 'layers', Stage: 'monitor' }; return (
From e8f91cf4b46523fd8df5261c652a3051240040a6 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Sun, 31 May 2026 12:34:41 -0400 Subject: [PATCH 04/25] fix(playout): immediate failover on new channels + play 502 vs 409 - spawnChannelSidecar: set last_heartbeat_at = NOW() when flipping channel to 'running'. Without this, last_heartbeat_at is NULL so the first scheduler tick sees ageMs = (now - epoch) >> TIMEOUT_MS and triggers failover before the sidecar has had a single chance to respond. - scheduler playoutHealthTick: when last_heartbeat_at is NULL fall back to updated_at as the baseline (belt-and-suspenders with the spawnChannelSidecar fix). Also include updated_at in the query. - POST /channels/:id/play: catch callSidecar errors explicitly and return 502 Bad Gateway instead of delegating to next(err) which the error middleware maps to 409 Conflict. Co-Authored-By: Claude Sonnet 4.6 --- services/mam-api/src/routes/playout.js | 16 ++++++++++++++-- services/mam-api/src/scheduler.js | 9 +++++++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/services/mam-api/src/routes/playout.js b/services/mam-api/src/routes/playout.js index 4b68e0c..491dd87 100644 --- a/services/mam-api/src/routes/playout.js +++ b/services/mam-api/src/routes/playout.js @@ -338,9 +338,14 @@ async function spawnChannelSidecar(channel) { } } + // Set last_heartbeat_at = NOW() so the scheduler health tick treats this + // channel as freshly alive. Without this, last_heartbeat_at starts as NULL + // (epoch=0), and the very first tick sees ageMs >> TIMEOUT_MS and triggers + // failover immediately — before the sidecar has had a chance to respond. const { rows } = await pool.query( `UPDATE playout_channels - SET status = 'running', container_id = $1, container_meta = $2, updated_at = NOW() + SET status = 'running', container_id = $1, container_meta = $2, + last_heartbeat_at = NOW(), updated_at = NOW() WHERE id = $3 RETURNING *`, [containerId, JSON.stringify(containerMeta), channel.id] ); @@ -448,7 +453,14 @@ router.post('/channels/:id/play', requireChannelEdit, async (req, res, next) => asset_duration_ms: i.asset_duration_ms != null ? Number(i.asset_duration_ms) : null, })), }; - const out = await callSidecar(req.channel, '/playlist/load', 'POST', payload); + // callSidecar throws on network/timeout errors. Return 502 (not 409) so + // the UI and operators know it's a gateway problem, not a state conflict. + let out; + try { + out = await callSidecar(req.channel, '/playlist/load', 'POST', payload); + } catch (err) { + return res.status(502).json({ error: 'Sidecar unreachable: ' + err.message }); + } res.json(out); } catch (err) { next(err); } }); diff --git a/services/mam-api/src/scheduler.js b/services/mam-api/src/scheduler.js index 027fa5e..51436b4 100644 --- a/services/mam-api/src/scheduler.js +++ b/services/mam-api/src/scheduler.js @@ -222,7 +222,7 @@ async function playoutHealthTick(client) { let channels; try { ({ rows: channels } = await client.query( - `SELECT id, output_type, container_meta, node_id, last_heartbeat_at, restart_count + `SELECT id, output_type, container_meta, node_id, last_heartbeat_at, updated_at, restart_count FROM playout_channels WHERE status = 'running'` )); } catch (err) { @@ -244,7 +244,12 @@ async function playoutHealthTick(client) { 'UPDATE playout_channels SET last_heartbeat_at = NOW() WHERE id = $1', [ch.id] ); } catch (err) { - const lastSeen = ch.last_heartbeat_at ? new Date(ch.last_heartbeat_at).getTime() : 0; + // When last_heartbeat_at is NULL (channel just spawned), fall back to + // updated_at (set to NOW() by spawnChannelSidecar). This prevents a + // brand-new channel from being failed over on the very first tick because + // epoch-0 age always exceeds TIMEOUT_MS. + const baseline = ch.last_heartbeat_at || ch.updated_at; + const lastSeen = baseline ? new Date(baseline).getTime() : Date.now(); const ageMs = Date.now() - lastSeen; if (ageMs < TIMEOUT_MS) continue; // not yet 3 misses From 00b04aa4a8c6fe5b0f390abc40dd5377bca37c9b Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Sun, 31 May 2026 13:01:21 -0400 Subject: [PATCH 05/25] fix(playout): non-fatal consumer + loadPlaylist guard - startChannel: make primary consumer ADD non-fatal. CasparCG decodes and routes media without an output consumer, so NDI channels (no SDK) and misconfigured SRT/RTMP channels still load/play clips and expose the HLS preview. state.lastError carries the consumer error for UI visibility without blocking operation. - loadPlaylist: throw early if state.running=false (channel/start was never called or failed hard) with a clear error instead of a cryptic CasparCG AMCP error propagating to the operator. Co-Authored-By: Claude Sonnet 4.6 --- services/playout/src/playout-manager.js | 31 ++++++++++++++++++++----- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/services/playout/src/playout-manager.js b/services/playout/src/playout-manager.js index 95b856c..5609e09 100644 --- a/services/playout/src/playout-manager.js +++ b/services/playout/src/playout-manager.js @@ -111,22 +111,38 @@ export class PlayoutManager { // 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). + // + // The primary consumer failure is NON-FATAL. CasparCG can decode and route + // media through its pipeline even without an output consumer. This means: + // - NDI channels work (load/play/transport) even if libndi.so is absent. + // - SRT/RTMP channels work even if the destination URL is unreachable. + // - The HLS preview consumer is always attempted independently. + // + // state.consumerError is set when the primary consumer fails so the mam-api + // can surface a warning in the channel status without blocking operation. async startChannel({ outputType, outputConfig = {}, videoFormat = '1080p5994' }) { await this.amcp.waitReady(30000); - // Set the channel video mode, then attach the output consumer. + // Set the channel video mode first. 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}`); + // Primary output consumer — non-fatal. + let consumerError = null; + try { + const consumer = await this._consumerCommand(outputType, outputConfig); + await this.amcp.send(`ADD ${CHANNEL} ${consumer}`); + } catch (err) { + consumerError = err.message; + console.warn(`[playout] primary consumer ADD failed (continuing without output): ${err.message}`); + } + // HLS preview consumer — always attempt, independently non-fatal. 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}`); } } @@ -137,8 +153,8 @@ export class PlayoutManager { 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)}`); + this.state.lastError = consumerError; + console.log(`[playout] channel started output=${outputType} mode=${videoFormat} fps=${this.state.fps.toFixed(3)}${consumerError ? ' ⚠ consumer: ' + consumerError : ''}`); return this.getStatus(); } @@ -181,6 +197,9 @@ export class PlayoutManager { // 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 }) { + if (!this.state.running) { + throw new Error('Channel not started — call /channel/start first'); + } this.state.playlist = items; this.state.loop = !!loop; this.state.currentIndex = -1; From d778aa4cdb66e87da633b1c074d8784e767fe7ad Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Sun, 31 May 2026 13:16:57 -0400 Subject: [PATCH 06/25] fix(playout): HLS preview path + live elapsed counter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - nginx.conf: add /media/live/ location serving from the media volume mount. CasparCG sidecar writes HLS preview to /media/live// but nginx only had /live/ (capture volume). Without this, preview requests returned the SPA shell instead of the .m3u8 playlist. - ProgramMonitor: add live elapsed counter (MM:SS, ticks every 500ms) driven by engine.currentItemStartedAt. Shows alongside clip index. Adds a ⚠ pip when lastError is set (e.g. NDI SDK missing) without blocking operation. Co-Authored-By: Claude Sonnet 4.6 --- services/web-ui/nginx.conf | 11 ++++- services/web-ui/public/screens-playout.jsx | 47 ++++++++++++++++++---- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/services/web-ui/nginx.conf b/services/web-ui/nginx.conf index 621112e..2590563 100644 --- a/services/web-ui/nginx.conf +++ b/services/web-ui/nginx.conf @@ -61,7 +61,7 @@ server { add_header Cache-Control "no-cache, no-store, must-revalidate"; } - # Live HLS — served from /live (bind-mounted shared volume), low cache so playlist refreshes + # Live HLS — served from /live (bind-mounted capture live volume) location /live/ { alias /live/; types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; } @@ -69,6 +69,15 @@ server { add_header Access-Control-Allow-Origin *; } + # Playout HLS preview — CasparCG sidecar writes to the media volume under + # /media/live//. This is a separate volume from /live/ (capture). + location /media/live/ { + alias /media/live/; + types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; } + add_header Cache-Control "no-cache"; + add_header Access-Control-Allow-Origin *; + } + # API proxy - forward to mam-api service location /api/ { set $api_upstream http://mam-api:3000; diff --git a/services/web-ui/public/screens-playout.jsx b/services/web-ui/public/screens-playout.jsx index f214dcb..0e906fc 100644 --- a/services/web-ui/public/screens-playout.jsx +++ b/services/web-ui/public/screens-playout.jsx @@ -345,12 +345,35 @@ function Transport({ channel, playlistId, items, onStatus }) { ); } +// ── Elapsed timer ───────────────────────────────────────────────────────────── +function useElapsed(startedAt) { + const [elapsed, setElapsed] = React.useState(0); + React.useEffect(() => { + if (!startedAt) { setElapsed(0); return; } + const base = new Date(startedAt).getTime(); + const tick = () => setElapsed(Math.max(0, Math.floor((Date.now() - base) / 1000))); + tick(); + const id = setInterval(tick, 500); + return () => clearInterval(id); + }, [startedAt]); + return elapsed; +} + +function fmtElapsed(secs) { + const h = Math.floor(secs / 3600); + const m = Math.floor((secs % 3600) / 60); + const s = secs % 60; + return (h > 0 ? String(h).padStart(2,'0') + ':' : '') + + String(m).padStart(2,'0') + ':' + String(s).padStart(2,'0'); +} + // ── Program monitor ────────────────────────────────────────────────────────── function ProgramMonitor({ channel, engine }) { const videoRef = React.useRef(null); const hlsRef = React.useRef(null); const onAir = channel.status === 'running'; const previewUrl = `/media/live/${channel.id}/index.m3u8`; + const elapsed = useElapsed(engine && engine.currentItemStartedAt); React.useEffect(() => { const vid = videoRef.current; @@ -390,13 +413,23 @@ function ProgramMonitor({ channel, engine }) {
Channel stopped
)}
- {engine && ( -
- {engine.currentClip && {engine.currentClip}} - clip {engine.currentIndex >= 0 ? engine.currentIndex + 1 : '–'} / {engine.playlistLength || 0} - {engine.loop && · loop} -
- )} +
+ {engine && engine.currentClip + ? {engine.currentClip} + : {onAir ? 'Idle' : 'Stopped'}} + {engine && engine.currentIndex >= 0 && ( + + + {fmtElapsed(elapsed)} + + clip {engine.currentIndex + 1}/{engine.playlistLength || 0} + {engine.loop && } + + )} + {engine && engine.lastError && ( + + )} +
); } From f28799317d9f0103534be058f949a59c28a26589 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Sun, 31 May 2026 13:35:45 -0400 Subject: [PATCH 07/25] fix(playout): clean CFR HLS preview so hls.js can sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the black preview: CasparCG's real-time channel feeds the HLS consumer frames with irregular timestamps (the "packet with pts X has duration 0" warnings). With frame-count GOPs (-g 60) the muxer split points drift, producing erratic segment durations (0.4s-4.2s) that exceed the declared TARGETDURATION. hls.js parses the resulting live playlist but can never establish a fragment timeline — it reloads forever ("sliding 0.00 / prev-sn na / MISSED") and never appends a fragment, so the video element stays at readyState 0 (black). Verified live via the browser: manifest + segments serve 200, segment is valid h264/aac with a keyframe start, yet hls.js logs zero FRAG_LOADED. Fix: force a constant output frame rate (-r 30000/1001, regenerates uniform PTS) and time-based keyframes every 2s (-force_key_frames expr:gte(t,n_forced*2)), so every segment is a clean keyframe-aligned 2.0s chunk. Yields a spec-compliant playlist (TARGETDURATION 2, stable 8-segment/16s window) identical in shape to the capture/VOD HLS the rest of the app already plays successfully through the same hls.js. Co-Authored-By: Claude Opus 4.8 --- services/playout/src/playout-manager.js | 31 ++++++++++++++++--------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/services/playout/src/playout-manager.js b/services/playout/src/playout-manager.js index 5609e09..e1562c4 100644 --- a/services/playout/src/playout-manager.js +++ b/services/playout/src/playout-manager.js @@ -159,24 +159,33 @@ export class PlayoutManager { } // Low-bitrate HLS for the web UI preview. Segments land in the shared media - // volume; the mam-api serves /live//* from there. + // volume; the mam-api serves /media/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. - // FILE keyword (alias of the FFMPEG consumer) writing a segmented HLS - // playlist. Same arg rules as the STREAM consumer: -param:stream form and a - // format=yuv420p filter ahead of libx264 (channel output is RGBA). + // The CasparCG channel feeds this consumer in real time, and its frame + // timestamps are irregular ("packet with pts X has duration 0" warnings). + // With frame-count GOPs (-g 60) the HLS muxer split points drift, producing + // erratic segment durations (0.4s–4.2s) and TARGETDURATION violations. The + // result is a live playlist hls.js parses but can never sync to — it + // reloads forever ("sliding 0.00 / prev-sn na / MISSED") and never appends + // a fragment, so the preview stays black. + // + // Fix: force a constant output frame rate (-r, regenerates uniform PTS) and + // TIME-based keyframes every 2s (-force_key_frames) so every segment is a + // clean, keyframe-aligned 2.0s chunk. This yields a spec-compliant playlist + // (TARGETDURATION 2, stable 8-segment / 16s window) identical in shape to + // the capture/VOD HLS the rest of the app already plays. const out = `${HLS_DIR}/index.m3u8`; const args = [ `FILE "${out}"`, '-format hls', '-hls_time 2', - '-hls_list_size 6', - '-hls_flags delete_segments+append_list', + '-hls_list_size 8', + '-hls_flags delete_segments+append_list+independent_segments', '-codec:v libx264 -preset:v veryfast -tune:v zerolatency -b:v 800k -maxrate 1M -bufsize 2M', - '-g 60 -keyint_min 60 -sc_threshold 0', - '-codec:a aac -b:a 96k', + // 29.97 CFR confidence feed: -r forces constant frame rate (fixes the + // duration-0 PTS), -force_key_frames pins keyframes to 2s media boundaries. + '-r 30000/1001 -force_key_frames expr:gte(t,n_forced*2) -sc_threshold 0', + '-codec:a aac -b:a 96k -ar 48000', '-filter:v format=yuv420p', ].join(' '); await this.amcp.send(`ADD ${CHANNEL} ${args}`); From 87d988810fc5d5fa29b8659f258f25b518b8bb67 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Sun, 31 May 2026 13:38:21 -0400 Subject: [PATCH 08/25] fix(playout): use CFR rate + frame GOP for uniform HLS segments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CasparCG's FFMPEG consumer ignores -force_key_frames ("Unused option") because it routes args to the muxer, not the encoder. Revert to the frame-based GOP (-g 60 -keyint_min 60) but keep the forced CFR rate (-r 30000/1001): at 29.97fps a 60-frame GOP is exactly 2.0s, so keyframes and HLS splits land on clean 2s boundaries. CFR is what was missing originally — with the channel's irregular feed rate, "60 frames" drifted. Co-Authored-By: Claude Opus 4.8 --- services/playout/src/playout-manager.js | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/services/playout/src/playout-manager.js b/services/playout/src/playout-manager.js index e1562c4..35f1f2f 100644 --- a/services/playout/src/playout-manager.js +++ b/services/playout/src/playout-manager.js @@ -169,11 +169,16 @@ export class PlayoutManager { // reloads forever ("sliding 0.00 / prev-sn na / MISSED") and never appends // a fragment, so the preview stays black. // - // Fix: force a constant output frame rate (-r, regenerates uniform PTS) and - // TIME-based keyframes every 2s (-force_key_frames) so every segment is a - // clean, keyframe-aligned 2.0s chunk. This yields a spec-compliant playlist - // (TARGETDURATION 2, stable 8-segment / 16s window) identical in shape to - // the capture/VOD HLS the rest of the app already plays. + // Fix: force a constant output frame rate (-r 30000/1001 = 29.97 CFR). This + // regenerates uniform PTS and — critically — makes the frame-based GOP land + // on regular time boundaries: at 29.97 fps a 60-frame GOP (-g 60) is exactly + // 2.0s, so every keyframe (and therefore every HLS split at -hls_time 2) is + // a clean 2.0s boundary. The original used -g 60 WITHOUT -r, so "60 frames" + // varied with the channel's irregular feed rate and segments drifted. + // + // NOTE: -force_key_frames does NOT work here — CasparCG's FFMPEG consumer + // routes args to the muxer, not the encoder, and logs it as "Unused option". + // The CFR rate + frame GOP is the combination that actually takes effect. const out = `${HLS_DIR}/index.m3u8`; const args = [ `FILE "${out}"`, @@ -182,9 +187,7 @@ export class PlayoutManager { '-hls_list_size 8', '-hls_flags delete_segments+append_list+independent_segments', '-codec:v libx264 -preset:v veryfast -tune:v zerolatency -b:v 800k -maxrate 1M -bufsize 2M', - // 29.97 CFR confidence feed: -r forces constant frame rate (fixes the - // duration-0 PTS), -force_key_frames pins keyframes to 2s media boundaries. - '-r 30000/1001 -force_key_frames expr:gte(t,n_forced*2) -sc_threshold 0', + '-r 30000/1001 -g 60 -keyint_min 60 -sc_threshold 0', '-codec:a aac -b:a 96k -ar 48000', '-filter:v format=yuv420p', ].join(' '); From 426273129de7ea44f50492e79746b7daa45ece97 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Sun, 31 May 2026 13:44:45 -0400 Subject: [PATCH 09/25] fix(playout): video-only HLS preview (broken audio time_base was the black-screen cause) Definitive root cause of the black preview, found via server-side ffmpeg decode of the live playlist: Error while decoding stream #0:1: Invalid data found (x57) [abuffer] Value inf for parameter 'time_base' ... time_base to value 1/0 Stream #0:1 is the AAC audio. CasparCG's real-time channel feeds the HLS consumer an audio stream whose muxed time_base is 1/0 (infinity). ffmpeg itself cannot decode the playlist, and hls.js silently fails to append the fragment after demux, so the