fix(capture): replace continuous idle preview with 1fps JPEG snapshot to stop FIFO contention halving capture fps

This commit is contained in:
Zac Gaetano 2026-06-02 21:40:52 +00:00
parent de3e09f39e
commit 3eacb35c1e
3 changed files with 114 additions and 42 deletions

View file

@ -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) {

View file

@ -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);

View file

@ -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 (
<div style={{ position: 'relative', width: '100%', height: '100%', background: '#000', overflow: 'hidden' }}>
{src && (
<img src={src} alt="signal"
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
)}
{(!src || failed) && (
<div style={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center',
color: 'var(--text-3)', fontSize: 11, background: 'rgba(0,0,0,0.6)' }}>
{failed ? 'no signal' : 'connecting…'}
</div>
)}
</div>
);
}
/* ===== 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 = <HlsPreview assetId={lastAssetId} recorderId={lastRecorderId} />;
}
} else if (!isLive && feed.previewUrl) {
tileContent = <HlsPreviewUrl url={feed.previewUrl} />;
tileContent = <JpegSnapshotPreview url={feed.previewUrl} />;
} else {
tileContent = <FauxFrame />;
}