From 9ec2997f53abab9e8cba045996f4f5a2aaa04f67 Mon Sep 17 00:00:00 2001 From: Zac Date: Tue, 2 Jun 2026 01:34:34 +0000 Subject: [PATCH 1/2] fix(recorder-card): show live framerate and ticking elapsed from capture signal - _normRecorder: add framerate field (recording_framerate || 'native'); remove stale static elapsed calc (was computing at poll time, never ticked) - RecorderRow: replace frozen useMemo elapsed with a live 1s setInterval that anchors to liveStatus.duration (authoritative from capture container) and falls back to wall-clock diff from recorder.started_at so the counter starts immediately on record and never freezes between 3s status polls - displayFramerate: shows currentFps (2dp + 'fps') when recorder is live and currentFps > 0; falls back to configured recording_framerate or 'native' - Framerate stat block: always visible (was conditional on currentFps != null); replaced the separate FPS-only block with a unified Framerate stat - Also fixes latent padStart(2, '00') typo on minutes field in old elapsed calc Co-Authored-By: Claude Sonnet 4.6 --- services/web-ui/public/screens-ingest.jsx | 62 +++++++++++++++-------- 1 file changed, 41 insertions(+), 21 deletions(-) diff --git a/services/web-ui/public/screens-ingest.jsx b/services/web-ui/public/screens-ingest.jsx index 48e92f0..08354fb 100644 --- a/services/web-ui/public/screens-ingest.jsx +++ b/services/web-ui/public/screens-ingest.jsx @@ -491,13 +491,6 @@ function HlsPreview({ assetId, recorderId, muted = true, controls = false, class /* ===== Recorders ===== */ function _normRecorder(r) { - let elapsed = '·'; - if (r.status === 'recording' && r.started_at) { - const s = Math.floor((Date.now() - new Date(r.started_at)) / 1000); - elapsed = String(Math.floor(s / 3600)).padStart(2, '0') + ':' + - String(Math.floor((s % 3600) / 60)).padStart(2, '00') + ':' + - String(s % 60).padStart(2, '0'); - } const cfg = r.source_config || {}; return { ...r, @@ -505,8 +498,9 @@ function _normRecorder(r) { url: cfg.url || cfg.address || cfg.srt_url || cfg.rtmp_url || r.source_type || '·', codec: r.recording_codec || '·', res: r.recording_resolution || '·', + framerate: r.recording_framerate || 'native', node: r.node_id ? r.node_id.slice(0, 8) : 'primary', - elapsed, + elapsed: '·', bitrate: '·', health: 100, audio: false, @@ -610,15 +604,43 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) { return () => clearInterval(id); }, [isRec, recorder.id]); + // Tick elapsed every second while recording. Seed from liveStatus.duration + // (authoritative from the capture container) when available; fall back to + // wall-clock diff from recorder.started_at so the counter never freezes. + const [elapsedSecs, setElapsedSecs] = React.useState(0); + React.useEffect(() => { + if (!isRec) { setElapsedSecs(0); return; } + const base = () => { + if (liveStatus && liveStatus.duration != null) return liveStatus.duration; + if (recorder.started_at) return Math.floor((Date.now() - new Date(recorder.started_at).getTime()) / 1000); + return 0; + }; + // Snap to latest authoritative value immediately, then tick from there. + const anchor = { at: Date.now(), secs: base() }; + setElapsedSecs(anchor.secs); + const id = setInterval(() => { + setElapsedSecs(anchor.secs + Math.floor((Date.now() - anchor.at) / 1000)); + }, 1000); + return () => clearInterval(id); + // Re-anchor whenever liveStatus.duration arrives from the poll. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isRec, liveStatus && liveStatus.duration, recorder.started_at]); + const displayElapsed = React.useMemo(() => { - if (liveStatus && liveStatus.duration != null) { - const d = Math.max(0, liveStatus.duration); - return String(Math.floor(d / 3600)).padStart(2, '0') + ':' + - String(Math.floor((d % 3600) / 60)).padStart(2, '0') + ':' + - String(d % 60).padStart(2, '0'); + if (!isRec) return '·'; + const d = Math.max(0, elapsedSecs); + return String(Math.floor(d / 3600)).padStart(2, '0') + ':' + + String(Math.floor((d % 3600) / 60)).padStart(2, '0') + ':' + + String(d % 60).padStart(2, '0'); + }, [isRec, elapsedSecs]); + + // Show live fps when recording and signal is healthy; fall back to configured value. + const displayFramerate = React.useMemo(() => { + if (isRec && liveStatus && liveStatus.currentFps != null && liveStatus.currentFps > 0) { + return Number(liveStatus.currentFps).toFixed(2) + ' fps'; } - return recorder.elapsed; - }, [liveStatus, recorder.elapsed]); + return recorder.framerate || 'native'; + }, [isRec, liveStatus, recorder.framerate]); const displaySignal = liveStatus ? (liveStatus.signal || '·') @@ -709,12 +731,10 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) { {displaySignal} - {liveStatus?.currentFps != null && ( -
-
FPS
-
{Number(liveStatus.currentFps).toFixed(1)}
-
- )} +
+
Framerate
+
{displayFramerate}
+
{!isRec && ( From 46dc17ffb1e0d8480ad13ccd33f67e5ee25f4996 Mon Sep 17 00:00:00 2001 From: Zac Date: Tue, 2 Jun 2026 01:35:27 +0000 Subject: [PATCH 2/2] fix(web-ui): show live HLS preview for recording assets in library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live/in-progress assets had no thumbnail_s3_key, so AssetThumb fell through to FauxFrame (black box) and then an absolute red border div was drawn on top, producing the 'black box with red outline' symptom. Fix: when asset.status === 'live', render a new LiveThumb component instead of FauxFrame + border overlay. LiveThumb attaches hls.js (or native HLS on Safari) to /live//index.m3u8, shows a muted live video feed, and displays a 'Connecting…' placeholder with a record icon + live-colour border while the manifest loads. Falls back to a 'Recording…' placeholder if hls.js is unavailable or playback fails after retries. The red border overlay is removed from the non-live path; the LIVE badge rendered by AssetCard's thumb-status div still appears on top of the live video. Co-Authored-By: Claude Sonnet 4.6 --- services/web-ui/public/visuals.jsx | 104 ++++++++++++++++++++++++++++- 1 file changed, 102 insertions(+), 2 deletions(-) diff --git a/services/web-ui/public/visuals.jsx b/services/web-ui/public/visuals.jsx index 79f4470..374b69b 100644 --- a/services/web-ui/public/visuals.jsx +++ b/services/web-ui/public/visuals.jsx @@ -25,14 +25,114 @@ function AssetThumb({ asset, size = 'md' }) { ); } + // Live/recording assets: show a muted HLS live preview instead of a black + // box. The capture container writes HLS segments to /live//index.m3u8 + // while recording is in progress; no thumbnail_s3_key exists yet. + if (asset.status === 'live' && asset.id) { + return ; + } + const altText = asset.name ? `Thumbnail for ${asset.name}` : 'Asset thumbnail'; return (
{thumbUrl ? {altText} : } - {asset.status === 'live' && ( -
+
+ ); +} + +// Muted inline HLS preview for a live/recording asset tile. Attaches hls.js +// (or native HLS on Safari) to show the live feed inside the library card. +// Shows a "connecting…" spinner while the manifest loads, falls back to a +// placeholder with a record icon if hls.js is unavailable or playback fails. +function LiveThumb({ assetId, aspect }) { + const videoRef = React.useRef(null); + const [ready, setReady] = React.useState(false); + const [failed, setFailed] = React.useState(false); + + React.useEffect(() => { + const v = videoRef.current; + if (!v || !assetId) return; + const url = '/live/' + assetId + '/index.m3u8'; + let destroyed = false; + let hls = null; + let retryTimer = 0; + let retryCount = 0; + const MAX_RETRIES = 6; + + const clearRetry = () => { if (retryTimer) { clearTimeout(retryTimer); retryTimer = 0; } }; + + if (v.canPlayType('application/vnd.apple.mpegurl')) { + const tryLoad = () => { + if (destroyed) return; + v.removeAttribute('src'); + v.load(); + v.src = url; + v.play().catch(() => {}); + }; + v.addEventListener('playing', () => { if (!destroyed) { retryCount = 0; setReady(true); } }); + v.addEventListener('error', () => { + if (destroyed || retryCount >= MAX_RETRIES) { setFailed(true); return; } + retryCount++; + clearRetry(); + retryTimer = setTimeout(tryLoad, Math.min(1000 * retryCount, 8000)); + }); + tryLoad(); + return () => { destroyed = true; clearRetry(); }; + } + + if (!window.Hls) { setFailed(true); return; } + + const startHls = () => { + if (destroyed) return; + hls = new window.Hls({ liveSyncDurationCount: 2, lowLatencyMode: true, maxBufferLength: 10 }); + hls.loadSource(url); + hls.attachMedia(v); + hls.on(window.Hls.Events.MANIFEST_PARSED, () => { + if (!destroyed) { retryCount = 0; setReady(true); v.play().catch(() => {}); } + }); + hls.on(window.Hls.Events.ERROR, (_e, data) => { + if (!data.fatal) return; + try { hls.destroy(); } catch (_) {} + hls = null; + if (destroyed || retryCount >= MAX_RETRIES) { setFailed(true); return; } + retryCount++; + clearRetry(); + retryTimer = setTimeout(startHls, Math.min(1000 * retryCount, 8000)); + }); + }; + + startHls(); + return () => { destroyed = true; clearRetry(); if (hls) { try { hls.destroy(); } catch (_) {} } }; + }, [assetId]); + + return ( +
+