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
|
// 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
|
// (the host-bound /growing volume is used instead, or S3 streaming if growing
|
||||||
// is off).
|
// 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_USERNAME = process.env.GROWING_SMB_USERNAME || '';
|
||||||
const GROWING_SMB_PASSWORD = process.env.GROWING_SMB_PASSWORD || '';
|
const GROWING_SMB_PASSWORD = process.env.GROWING_SMB_PASSWORD || '';
|
||||||
const GROWING_SMB_VERS = process.env.GROWING_SMB_VERS || '3.0';
|
const GROWING_SMB_VERS = process.env.GROWING_SMB_VERS || '3.0';
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { AmcpClient } from './amcp.js';
|
import { AmcpClient } from './amcp.js';
|
||||||
import { spawn } from 'node:child_process';
|
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.
|
// Playout manager — owns one CasparCG channel's lifecycle inside this sidecar.
|
||||||
//
|
//
|
||||||
|
|
@ -211,6 +211,20 @@ export class PlayoutManager {
|
||||||
_startHlsRemux() {
|
_startHlsRemux() {
|
||||||
if (!HLS_DIR) return;
|
if (!HLS_DIR) return;
|
||||||
try { mkdirSync(HLS_DIR, { recursive: true }); } catch (_) {}
|
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();
|
this._stopHlsRemux();
|
||||||
|
|
||||||
const out = `${HLS_DIR}/index.m3u8`;
|
const out = `${HLS_DIR}/index.m3u8`;
|
||||||
|
|
|
||||||
|
|
@ -997,6 +997,7 @@ function Capture({ navigate }) {
|
||||||
/* ===== Monitors ===== */
|
/* ===== Monitors ===== */
|
||||||
function Monitors({ navigate }) {
|
function Monitors({ navigate }) {
|
||||||
const [recorders, setRecorders] = React.useState(window.ZAMPP_DATA?.RECORDERS || []);
|
const [recorders, setRecorders] = React.useState(window.ZAMPP_DATA?.RECORDERS || []);
|
||||||
|
const [channels, setChannels] = React.useState([]);
|
||||||
const [grid, setGrid] = React.useState(4);
|
const [grid, setGrid] = React.useState(4);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|
@ -1008,6 +1009,11 @@ function Monitors({ navigate }) {
|
||||||
setRecorders(norm);
|
setRecorders(norm);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.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();
|
refresh();
|
||||||
const id = setInterval(refresh, 5000);
|
const id = setInterval(refresh, 5000);
|
||||||
|
|
@ -1032,18 +1038,87 @@ function Monitors({ navigate }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="page-body">
|
<div className="page-body">
|
||||||
{feeds.length === 0 ? (
|
{feeds.length === 0 && channels.length === 0 ? (
|
||||||
<div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>No active feeds. Start a recorder to see live video here.</div>
|
<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 }}>
|
<React.Fragment>
|
||||||
{feeds.map((f, i) => <MonitorTile key={f.id} feed={f} seed={i + 1} />)}
|
{feeds.length > 0 && (
|
||||||
</div>
|
<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>
|
||||||
</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 }) {
|
function MonitorTile({ feed, seed }) {
|
||||||
const [levels, setLevels] = React.useState([0.65, 0.78]);
|
const [levels, setLevels] = React.useState([0.65, 0.78]);
|
||||||
const isLive = feed.status === 'recording';
|
const isLive = feed.status === 'recording';
|
||||||
|
|
|
||||||
|
|
@ -392,6 +392,15 @@ function ProgramMonitor({ channel, engine }) {
|
||||||
const hls = new window.Hls({
|
const hls = new window.Hls({
|
||||||
liveSyncDurationCount: 3,
|
liveSyncDurationCount: 3,
|
||||||
liveMaxLatencyDurationCount: 6,
|
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
|
// The playlist is served from /api/ (auth-gated); send the session
|
||||||
// cookie so the request authenticates. Segments are static + public.
|
// cookie so the request authenticates. Segments are static + public.
|
||||||
xhrSetup: (xhr) => { xhr.withCredentials = true; },
|
xhrSetup: (xhr) => { xhr.withCredentials = true; },
|
||||||
|
|
@ -400,6 +409,51 @@ function ProgramMonitor({ channel, engine }) {
|
||||||
hls.loadSource(previewUrl);
|
hls.loadSource(previewUrl);
|
||||||
hls.attachMedia(vid);
|
hls.attachMedia(vid);
|
||||||
hls.on(window.Hls.Events.MANIFEST_PARSED, () => vid.play().catch(() => {}));
|
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')) {
|
} else if (vid.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
// Native HLS (Safari).
|
// Native HLS (Safari).
|
||||||
vid.src = previewUrl;
|
vid.src = previewUrl;
|
||||||
|
|
|
||||||
|
|
@ -344,6 +344,15 @@
|
||||||
.capture-stat-value { font-size: 13px; margin-top: 2px; }
|
.capture-stat-value { font-size: 13px; margin-top: 2px; }
|
||||||
|
|
||||||
/* ========== Monitors ========== */
|
/* ========== 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 {
|
.monitors-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue