diff --git a/services/capture/src/capture-manager.js b/services/capture/src/capture-manager.js index e92eeb7..71a7a8d 100644 --- a/services/capture/src/capture-manager.js +++ b/services/capture/src/capture-manager.js @@ -857,6 +857,11 @@ exit "$BMXRC" throw new Error('Capture already in progress'); } + // Stop the idle confidence monitor BEFORE touching the FIFO. A second + // reader on the video FIFO halves the capture rate (~29 fps) and desyncs + // audio — so the monitor must fully release the FIFO before recording. + this.stopIdlePreview(); + const sessionId = uuidv4(); const proxyExt = CONTAINER_EXT[proxyContainer] || 'mp4'; @@ -1125,60 +1130,84 @@ exit "$BMXRC" return this._formatSessionResponse(); } + // ── Idle confidence monitor ──────────────────────────────────────────── + // A low-rate (1 fps) single-JPEG confidence snapshot for the recorder tile + // when the recorder is NOT actively recording. + // + // CRITICAL: this must NEVER read the video FIFO while a recording is active. + // A second continuous reader on the same /dev/shm/deltacast/video-N.fifo + // splits the frames between the two readers, halving the capture rate to + // ~29 fps (the root cause of the out-of-sync / fast-playback bug). So the + // monitor: + // 1. runs ONLY when this.state.recording === false + // 2. opens the FIFO, grabs ONE frame, scales to a small JPEG, exits + // 3. sleeps 1s, repeats — yielding the FIFO completely between grabs + // 4. is fully stopped the instant a recording starts (see start()) async startIdlePreview() { - if (this._previewProc) return; // already running + if (this._previewTimer || this._previewProc) return; // already running + if (this.state.recording) return; // never run during an active recording const sourceType = process.env.SOURCE_TYPE; const recorderId = process.env.RECORDER_ID; if (!recorderId || !['deltacast', 'sdi'].includes(sourceType)) return; + if (sourceType !== 'deltacast') return; // SDI/blackmagic snapshot TBD const previewDir = `/live/preview-${recorderId}`; try { await fs.promises.mkdir(previewDir, { recursive: true }); } catch (_) {} - let inputArgs; - if (sourceType === 'deltacast') { - const size = process.env.DELTACAST_VIDEO_SIZE || '1920x1080'; - const fps = process.env.DELTACAST_FRAMERATE || '60000/1001'; - let cfg = {}; - try { cfg = JSON.parse(process.env.SOURCE_CONFIG || '{}'); } catch (_) {} - const port = cfg.port ?? 0; - const videoFifo = (process.env.DELTACAST_PIPE_DIR || '/dev/shm/deltacast') + `/video-${port}.fifo`; - inputArgs = ['-f', 'rawvideo', '-pix_fmt', 'uyvy422', '-s', size, '-r', fps, '-i', videoFifo]; - } else { - // SDI (blackmagic): not yet implemented — skip - return; - } + const size = process.env.DELTACAST_VIDEO_SIZE || '1920x1080'; + let cfg = {}; + try { cfg = JSON.parse(process.env.SOURCE_CONFIG || '{}'); } catch (_) {} + const port = cfg.port ?? 0; + const videoFifo = (process.env.DELTACAST_PIPE_DIR || '/dev/shm/deltacast') + `/video-${port}.fifo`; + const outJpg = previewDir + '/frame.jpg'; + const tmpJpg = previewDir + '/frame.tmp.jpg'; - const outputArgs = [ - '-an', - '-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'zerolatency', - '-b:v', '600k', '-maxrate', '800k', '-bufsize', '1200k', - '-g', '30', '-sc_threshold', '0', - '-hls_time', '1', '-hls_list_size', '4', - '-hls_flags', 'delete_segments+omit_endlist+independent_segments', - '-hls_segment_type', 'mpegts', - '-hls_segment_filename', previewDir + '/seg-%05d.ts', - '-f', 'hls', previewDir + '/index.m3u8', - ]; + this._previewStop = false; + console.log('[preview] starting 1fps confidence monitor for', recorderId); - console.log('[preview] starting idle preview for', recorderId); - this._previewProc = spawn('ffmpeg', [...inputArgs, ...outputArgs], { - stdio: ['ignore', 'ignore', 'pipe'], - }); - this._previewProc.stderr.on('data', () => { /* swallow */ }); - this._previewProc.on('exit', (code) => { - console.log('[preview] idle preview exited', code); - this._previewProc = null; - }); - this._previewProc.on('error', (e) => { - console.error('[preview] idle preview error:', e.message); - this._previewProc = null; + const grabOnce = () => new Promise((resolve) => { + // Never compete with an active recording. + if (this._previewStop || this.state.recording) return resolve(); + // -frames:v 1 reads exactly ONE frame then exits, releasing the FIFO. + // Read-rate is capped by -readrate 1 so the single-frame read consumes + // ~1 frame worth of FIFO data, not a burst. + const ff = spawn('ffmpeg', [ + '-y', + '-f', 'rawvideo', '-pix_fmt', 'uyvy422', '-s', size, + '-i', videoFifo, + '-frames:v', '1', + '-vf', 'scale=480:-2', + '-q:v', '5', + tmpJpg, + ], { stdio: ['ignore', 'ignore', 'ignore'] }); + this._previewProc = ff; + const killTimer = setTimeout(() => { try { ff.kill('SIGKILL'); } catch (_) {} }, 4000); + ff.on('exit', () => { + clearTimeout(killTimer); + this._previewProc = null; + // Atomic-ish swap so the served frame is never half-written. + fs.rename(tmpJpg, outJpg, () => resolve()); + }); + ff.on('error', () => { clearTimeout(killTimer); this._previewProc = null; resolve(); }); }); + + const loop = async () => { + while (!this._previewStop) { + await grabOnce(); + if (this._previewStop) break; + await new Promise(r => { this._previewTimer = setTimeout(r, 1000); }); + } + }; + loop(); } stopIdlePreview() { - if (!this._previewProc) return; - try { this._previewProc.kill('SIGTERM'); } catch (_) {} - this._previewProc = null; + this._previewStop = true; + if (this._previewTimer) { clearTimeout(this._previewTimer); this._previewTimer = null; } + if (this._previewProc) { + try { this._previewProc.kill('SIGKILL'); } catch (_) {} + this._previewProc = null; + } } async stop(sessionId) { diff --git a/services/mam-api/src/routes/recorders.js b/services/mam-api/src/routes/recorders.js index db64dea..916b5e2 100644 --- a/services/mam-api/src/routes/recorders.js +++ b/services/mam-api/src/routes/recorders.js @@ -303,9 +303,11 @@ router.get('/', async (req, res, next) => { })); // Append preview_url for deltacast/sdi recorders whose sidecar is running. + // 1 fps JPEG confidence snapshot (frame.jpg) — does NOT compete with the + // recorder for the video FIFO (a 2nd continuous reader halves capture fps). for (const r of rows) { if (r.container_id && (r.source_type === 'deltacast' || r.source_type === 'sdi')) { - r.preview_url = `/api/v1/recorders/${r.id}/preview/index.m3u8`; + r.preview_url = `/api/v1/recorders/${r.id}/preview/frame.jpg`; } } @@ -1103,6 +1105,7 @@ router.get('/:id/preview/:rest(*)', async (req, res, next) => { const ct = rest.endsWith('.m3u8') ? 'application/vnd.apple.mpegurl' : rest.endsWith('.ts') ? 'video/mp2t' + : rest.endsWith('.jpg') ? 'image/jpeg' : 'application/octet-stream'; res.set('Cache-Control', 'no-cache'); res.set('Content-Type', ct); diff --git a/services/web-ui/public/screens-ingest.jsx b/services/web-ui/public/screens-ingest.jsx index 42b6bd0..9f7df4a 100644 --- a/services/web-ui/public/screens-ingest.jsx +++ b/services/web-ui/public/screens-ingest.jsx @@ -489,6 +489,46 @@ function HlsPreview({ assetId, recorderId, muted = true, controls = false, class ); } +/* ===== Idle confidence monitor — 1 fps JPEG snapshot ===== */ +/* Refreshes a single JPEG once per second. Does NOT open the video FIFO as a + * continuous reader, so it never competes with / slows down an active capture. */ +function JpegSnapshotPreview({ url }) { + const [src, setSrc] = React.useState(null); + const [failed, setFailed] = React.useState(false); + + React.useEffect(() => { + if (!url) return; + let destroyed = false; + let timer = 0; + const tick = () => { + if (destroyed) return; + const bust = url + (url.includes('?') ? '&' : '?') + 't=' + Date.now(); + const img = new Image(); + img.onload = () => { if (!destroyed) { setSrc(bust); setFailed(false); } }; + img.onerror = () => { if (!destroyed) setFailed(true); }; + img.src = bust; + timer = setTimeout(tick, 1000); + }; + tick(); + return () => { destroyed = true; if (timer) clearTimeout(timer); }; + }, [url]); + + return ( +
+ {src && ( + signal + )} + {(!src || failed) && ( +
+ {failed ? 'no signal' : 'connecting…'} +
+ )} +
+ ); +} + /* ===== Idle signal preview (always-on HLS from sidecar) ===== */ function HlsPreviewUrl({ url }) { const videoRef = React.useRef(null); @@ -1326,7 +1366,7 @@ function MonitorTile({ feed, seed }) { tileContent = ; } } else if (!isLive && feed.previewUrl) { - tileContent = ; + tileContent = ; } else { tileContent = ; }