fix(capture): replace continuous idle preview with 1fps JPEG snapshot to stop FIFO contention halving capture fps
This commit is contained in:
parent
de3e09f39e
commit
3eacb35c1e
3 changed files with 114 additions and 42 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue