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:
parent
b597ffd58e
commit
d908c0c056
5 changed files with 171 additions and 7 deletions
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue