fix(playout+capture+monitors): preview recovery, SMB UNC, playout monitors

Playout preview: add hls.js ERROR handler (recover media/network errors,
resume on stall) + live-edge tuning — first transient error no longer halts
<video> to black. Purge stale HLS segments before re-mux (re)start so a
prior/duplicate sidecar session can't corrupt the live playlist.

Growing files: normalize growing_smb_mount (smb://, \host\share, host/share)
to CIFS UNC //host/share in capture-manager — mount no longer fails and
falls back to S3.

Monitors: surface playout channels as monitor tiles (live HLS on-air,
idle placeholder otherwise) in a labeled Playout group.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Zac Gaetano 2026-05-31 17:56:45 -04:00
parent b597ffd58e
commit d908c0c056
5 changed files with 171 additions and 7 deletions

View file

@ -19,7 +19,19 @@ const GROWING_PATH = process.env.GROWING_PATH || '/growing';
// by the API from global Settings. Empty GROWING_SMB_MOUNT → no system mount
// (the host-bound /growing volume is used instead, or S3 streaming if growing
// is off).
const GROWING_SMB_MOUNT = process.env.GROWING_SMB_MOUNT || '';
// mount.cifs needs a UNC source (//host/share). Operators (and Settings) often
// store the share as an `smb://host/share` URL or a Windows `\\host\share`
// path; the kernel rejects those outright ("Mounting cifs URL not implemented
// yet"), which silently drops us back to S3. Normalize any of these forms to
// the `//host/share` UNC the mount helper accepts.
function toUncShare(raw) {
if (!raw) return '';
let s = String(raw).trim().replace(/\\/g, '/'); // \\host\share -> //host/share
s = s.replace(/^smb:\/\//i, '//'); // smb://host/share -> //host/share
if (!s.startsWith('//')) s = '//' + s.replace(/^\/+/, ''); // host/share -> //host/share
return s;
}
const GROWING_SMB_MOUNT = toUncShare(process.env.GROWING_SMB_MOUNT || '');
const GROWING_SMB_USERNAME = process.env.GROWING_SMB_USERNAME || '';
const GROWING_SMB_PASSWORD = process.env.GROWING_SMB_PASSWORD || '';
const GROWING_SMB_VERS = process.env.GROWING_SMB_VERS || '3.0';

View file

@ -1,6 +1,6 @@
import { AmcpClient } from './amcp.js';
import { spawn } from 'node:child_process';
import { mkdirSync } from 'node:fs';
import { mkdirSync, readdirSync, unlinkSync } from 'node:fs';
// Playout manager — owns one CasparCG channel's lifecycle inside this sidecar.
//
@ -211,6 +211,20 @@ export class PlayoutManager {
_startHlsRemux() {
if (!HLS_DIR) return;
try { mkdirSync(HLS_DIR, { recursive: true }); } catch (_) {}
// Purge stale HLS artifacts from any prior session before starting. The
// /media volume is a shared host bind, so a previous (or duplicate/failover)
// sidecar can leave orphaned index*.ts + an old index.m3u8 behind. ffmpeg's
// index%d.ts counter restarts at 0, so those leftovers collide with the new
// segment numbering and can briefly corrupt the live playlist hls.js reads
// (it sees a frozen / non-monotonic edge → monitor goes black). A clean dir
// per session guarantees a coherent live timeline.
try {
for (const f of readdirSync(HLS_DIR)) {
if (/\.ts$/.test(f) || /\.m3u8$/.test(f)) {
try { unlinkSync(`${HLS_DIR}/${f}`); } catch (_) {}
}
}
} catch (_) {}
this._stopHlsRemux();
const out = `${HLS_DIR}/index.m3u8`;

View file

@ -997,6 +997,7 @@ function Capture({ navigate }) {
/* ===== Monitors ===== */
function Monitors({ navigate }) {
const [recorders, setRecorders] = React.useState(window.ZAMPP_DATA?.RECORDERS || []);
const [channels, setChannels] = React.useState([]);
const [grid, setGrid] = React.useState(4);
React.useEffect(() => {
@ -1008,6 +1009,11 @@ function Monitors({ navigate }) {
setRecorders(norm);
})
.catch(() => {});
// Playout channels surface here too so an operator can watch on-air
// output alongside ingest. Degrade silently if the endpoint is absent.
window.ZAMPP_API.fetch('/playout/channels')
.then(raw => setChannels(Array.isArray(raw) ? raw : []))
.catch(() => setChannels([]));
};
refresh();
const id = setInterval(refresh, 5000);
@ -1032,18 +1038,87 @@ function Monitors({ navigate }) {
</div>
</div>
<div className="page-body">
{feeds.length === 0 ? (
<div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>No active feeds. Start a recorder to see live video here.</div>
{feeds.length === 0 && channels.length === 0 ? (
<div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>No active feeds. Start a recorder or playout channel to see live video here.</div>
) : (
<div className="monitors-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(' + grid + ', 1fr)', gap: 8 }}>
{feeds.map((f, i) => <MonitorTile key={f.id} feed={f} seed={i + 1} />)}
</div>
<React.Fragment>
{feeds.length > 0 && (
<React.Fragment>
<div className="monitor-section-head">Ingest</div>
<div className="monitors-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(' + grid + ', 1fr)', gap: 8 }}>
{feeds.map((f, i) => <MonitorTile key={f.id} feed={f} seed={i + 1} />)}
</div>
</React.Fragment>
)}
{channels.length > 0 && (
<React.Fragment>
<div className="monitor-section-head">Playout</div>
<div className="monitors-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(' + grid + ', 1fr)', gap: 8 }}>
{channels.slice(0, grid * grid).map(c => <PlayoutMonitorTile key={c.id} channel={c} />)}
</div>
</React.Fragment>
)}
</React.Fragment>
)}
</div>
</div>
);
}
function PlayoutMonitorTile({ channel }) {
const videoRef = React.useRef(null);
const hlsRef = React.useRef(null);
const onAir = channel.status === 'running';
const previewUrl = '/api/v1/playout/channels/' + channel.id + '/hls/index.m3u8';
React.useEffect(() => {
const vid = videoRef.current;
if (!vid) return;
if (hlsRef.current) { try { hlsRef.current.destroy(); } catch (_) {} hlsRef.current = null; }
if (!onAir) { vid.src = ''; return; }
if (window.Hls && window.Hls.isSupported()) {
const hls = new window.Hls({
liveSyncDurationCount: 3,
liveMaxLatencyDurationCount: 6,
xhrSetup: (xhr) => { xhr.withCredentials = true; },
});
hlsRef.current = hls;
hls.loadSource(previewUrl);
hls.attachMedia(vid);
hls.on(window.Hls.Events.MANIFEST_PARSED, () => vid.play().catch(() => {}));
} else if (vid.canPlayType('application/vnd.apple.mpegurl')) {
vid.src = previewUrl;
vid.play().catch(() => {});
}
return () => {
if (hlsRef.current) { try { hlsRef.current.destroy(); } catch (_) {} hlsRef.current = null; }
};
}, [onAir, channel.id]);
return (
<div className="monitor-tile">
{onAir ? (
<video ref={videoRef} muted playsInline autoPlay
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block', background: '#000' }} />
) : (
<FauxFrame />
)}
{onAir && <div style={{ position: 'absolute', inset: 0, border: '2px solid var(--live)', pointerEvents: 'none', borderRadius: 'inherit' }} />}
{!onAir && (
<div style={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center', color: 'var(--text-3)', fontSize: 11 }}>channel idle</div>
)}
<div style={{ position: 'absolute', top: 8, left: 8, display: 'flex', gap: 6 }}>
{onAir ? <span className="badge live">ON AIR</span> : <span className="badge neutral">IDLE</span>}
</div>
<div className="monitor-tile-label">
<span className="name">{channel.name}</span>
{channel.output_type && <span className="time mono">{String(channel.output_type).toUpperCase()}</span>}
</div>
</div>
);
}
function MonitorTile({ feed, seed }) {
const [levels, setLevels] = React.useState([0.65, 0.78]);
const isLive = feed.status === 'recording';

View file

@ -392,6 +392,15 @@ function ProgramMonitor({ channel, engine }) {
const hls = new window.Hls({
liveSyncDurationCount: 3,
liveMaxLatencyDurationCount: 6,
// Keep hls.js pinned to the live edge. The preview is a CPU-encoded
// confidence monitor whose live-edge segment may still be mid-write
// when first fetched; a small back-buffer + tolerant stall handling
// lets the player skip transient gaps instead of freezing.
backBufferLength: 8,
maxBufferLength: 10,
liveDurationInfinity: true,
highBufferWatchdogPeriod: 1,
nudgeMaxRetry: 10,
// The playlist is served from /api/ (auth-gated); send the session
// cookie so the request authenticates. Segments are static + public.
xhrSetup: (xhr) => { xhr.withCredentials = true; },
@ -400,6 +409,51 @@ function ProgramMonitor({ channel, engine }) {
hls.loadSource(previewUrl);
hls.attachMedia(vid);
hls.on(window.Hls.Events.MANIFEST_PARSED, () => vid.play().catch(() => {}));
// Resilient recovery. Without this, the FIRST fatal hls.js error (a
// buffer stall on the live edge, a media/decode error, or a transient
// fragment/playlist load error against the rewinding live playlist)
// permanently halts playback and the monitor goes black which is
// exactly the "flashes a frame then stays black" symptom: hls.js renders
// a fragment or two, hits an unrecovered error, and never resumes. We
// distinguish error types and recover in place rather than tearing down.
let recoverCount = 0;
hls.on(window.Hls.Events.ERROR, (_evt, data) => {
if (!data.fatal) {
// Non-fatal buffer stalls: nudge hls.js back to the live edge.
if (data.details === window.Hls.ErrorDetails.BUFFER_STALLED_ERROR) {
try { hls.startLoad(); } catch (_) {}
}
return;
}
switch (data.type) {
case window.Hls.ErrorTypes.NETWORK_ERROR:
// Playlist/fragment load errors against the live edge are usually
// transient (segment rotated or mid-write). Re-arm the loader.
try { hls.startLoad(); } catch (_) {}
break;
case window.Hls.ErrorTypes.MEDIA_ERROR:
// Decode/buffer-append failures: flush + rebuild the buffer.
recoverCount += 1;
if (recoverCount <= 3) {
try { hls.recoverMediaError(); } catch (_) {}
} else {
// Repeated media errors: full reload of the source from scratch.
recoverCount = 0;
try { hls.destroy(); } catch (_) {}
if (hlsRef.current === hls) hlsRef.current = null;
}
break;
default:
// Unrecoverable: drop the instance so a re-render can re-init.
try { hls.destroy(); } catch (_) {}
if (hlsRef.current === hls) hlsRef.current = null;
}
});
// A stalled <video> (readyState frozen) gets a gentle kick back to live.
hls.on(window.Hls.Events.FRAG_BUFFERED, () => {
if (vid.paused) vid.play().catch(() => {});
});
} else if (vid.canPlayType('application/vnd.apple.mpegurl')) {
// Native HLS (Safari).
vid.src = previewUrl;

View file

@ -344,6 +344,15 @@
.capture-stat-value { font-size: 13px; margin-top: 2px; }
/* ========== Monitors ========== */
.monitor-section-head {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-3);
margin: 4px 2px 6px;
}
.monitor-section-head + .monitors-grid { margin-bottom: 14px; }
.monitors-grid {
display: grid;
gap: 10px;