fix(deltacast-bridge): call VHD_SetBiDirCfg before board open + set channel SDI mode
ROOT CAUSE of 'connecting' hangs and intermittent port failures:
The DELTA-12G-e-h 8c is a bidirectional card. Without calling
VHD_SetBiDirCfg(board_index, VHD_BIDIR_80) before streaming, the
board remains in its default bi-dir config (likely 4RX/4TX) — so
RX stream opens fail with VHDERR_RESOURCEUNAVAILABLE on channels
configured as TX, causing random 'connecting' hangs per the SDK docs.
Per SDK Tools.cpp SetNbChannels() pattern:
1. Open temporary board handle
2. Check IS_BIDIR + channel counts
3. Call VHD_SetBiDirCfg(board_index, VHD_BIDIR_80) for 8ch bidir
4. Close temp handle, then open real board handle for streaming
Also add VHD_SetChannelProperty(VHD_CHANNEL_MODE_SDI) for ASI-type
channels per Sample_RX.cpp — required for 12G-ASI/3G-ASI channel
types to correctly detect incoming video standard.
🤖 Generated with Claude Code
This commit is contained in:
parent
59294856a2
commit
858c9f7b97
5 changed files with 219 additions and 1 deletions
|
|
@ -395,6 +395,41 @@ int main(int argc, char *argv[]) {
|
|||
return 1;
|
||||
}
|
||||
|
||||
/* ── Configure bi-directional channel mode before opening board ─────
|
||||
*
|
||||
* The DELTA-12G-e-h 8c is a bidirectional card. Unless we explicitly
|
||||
* call VHD_SetBiDirCfg(BrdId, VHD_BIDIR_80) the board may default to
|
||||
* a mixed RX/TX configuration (e.g. 4RX/4TX), which causes random RX
|
||||
* stream opens to fail with VHDERR_RESOURCEUNAVAILABLE and produces the
|
||||
* "connecting…" hang operators see when starting certain recorders.
|
||||
*
|
||||
* Per SDK sample Tools.cpp SetNbChannels(): open a temporary handle,
|
||||
* check IS_BIDIR and channel counts, call VHD_SetBiDirCfg if needed,
|
||||
* then close. The subsequent real board open will see all 8 as RX.
|
||||
*/
|
||||
{
|
||||
HANDLE tmp = NULL;
|
||||
if (VHD_OpenBoardHandle(device_id, &tmp, NULL, 0) == VHDERR_NOERROR) {
|
||||
ULONG nb_rx = 0, nb_tx = 0, is_bidir = 0;
|
||||
VHD_GetBoardProperty(tmp, VHD_CORE_BP_NB_RXCHANNELS, &nb_rx);
|
||||
VHD_GetBoardProperty(tmp, VHD_CORE_BP_NB_TXCHANNELS, &nb_tx);
|
||||
VHD_GetBoardProperty(tmp, VHD_CORE_BP_IS_BIDIR, &is_bidir);
|
||||
VHD_CloseBoardHandle(tmp);
|
||||
|
||||
if (is_bidir) {
|
||||
/* Set all channels to RX. For 8-channel bidir: VHD_BIDIR_80.
|
||||
* VHD_SetBiDirCfg takes the board INDEX, not a handle. */
|
||||
ULONG cfg = (nb_rx + nb_tx == 8) ? VHD_BIDIR_80 : VHD_BIDIR_40;
|
||||
ULONG r = VHD_SetBiDirCfg(device_id, cfg);
|
||||
if (r == VHDERR_NOERROR)
|
||||
fprintf(stderr, "[board] SetBiDirCfg(%lu) OK — %lu+%lu ch bidir configured all-RX\n",
|
||||
cfg, nb_rx, nb_tx);
|
||||
else
|
||||
fprintf(stderr, "[board] SetBiDirCfg warn rc=%lu (non-fatal)\n", r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Open board ONCE ─────────────────────────────────────────────── */
|
||||
HANDLE board = NULL;
|
||||
if (VHD_OpenBoardHandle(device_id, &board, NULL, 0) != VHDERR_NOERROR) {
|
||||
|
|
@ -403,9 +438,17 @@ int main(int argc, char *argv[]) {
|
|||
}
|
||||
fprintf(stderr, "[board] opened board %u with %d port(s)\n", device_id, port_count);
|
||||
|
||||
/* Disable passive loopback for each requested port (ports 0-3 only in SDK). */
|
||||
/* Per SDK samples: for 12G-ASI or 3G-ASI channel types the channel must be
|
||||
* explicitly switched to SDI mode. Without this, VHD_SDI_CP_VIDEO_STANDARD
|
||||
* polls return NB_VHD_VIDEOSTANDARDS (no signal) even when signal present.
|
||||
* Also disable passive loopback for ports 0-3 so RX doesn't loop to TX. */
|
||||
for (int pi = 0; pi < port_count; pi++) {
|
||||
unsigned p = ports[pi];
|
||||
ULONG chn_type = 0;
|
||||
if (VHD_GetChannelProperty(board, VHD_RX_CHANNEL, p, VHD_CORE_CP_TYPE, &chn_type) == VHDERR_NOERROR) {
|
||||
if (chn_type == VHD_CHNTYPE_3GSDI_ASI || chn_type == VHD_CHNTYPE_12GSDI_ASI)
|
||||
VHD_SetChannelProperty(board, VHD_RX_CHANNEL, p, VHD_CORE_CP_MODE, VHD_CHANNEL_MODE_SDI);
|
||||
}
|
||||
if (p < 4) VHD_SetBoardProperty(board, loopback_prop(p), FALSE);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { spawn, execFileSync } from 'child_process';
|
||||
import { mkdirSync, writeFileSync, createReadStream, statSync, unlinkSync } from 'node:fs';
|
||||
import fs from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { createUploadStream } from './s3/client.js';
|
||||
|
|
@ -1107,11 +1108,69 @@ exit "$BMXRC"
|
|||
return this._formatSessionResponse();
|
||||
}
|
||||
|
||||
async startIdlePreview() {
|
||||
if (this._previewProc) return; // already running
|
||||
const sourceType = process.env.SOURCE_TYPE;
|
||||
const recorderId = process.env.RECORDER_ID;
|
||||
if (!recorderId || !['deltacast', 'sdi'].includes(sourceType)) return;
|
||||
|
||||
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 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',
|
||||
];
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
stopIdlePreview() {
|
||||
if (!this._previewProc) return;
|
||||
try { this._previewProc.kill('SIGTERM'); } catch (_) {}
|
||||
this._previewProc = null;
|
||||
}
|
||||
|
||||
async stop(sessionId) {
|
||||
if (!this.state.recording || this.state.sessionId !== sessionId) {
|
||||
throw new Error('No active capture session or session ID mismatch');
|
||||
}
|
||||
|
||||
this.stopIdlePreview();
|
||||
|
||||
const { processes, currentSession } = this.state;
|
||||
|
||||
const isGrowing = !!currentSession.growingPath;
|
||||
|
|
|
|||
|
|
@ -23,6 +23,12 @@ app.use('/capture', captureRoutes);
|
|||
const server = app.listen(PORT, () => {
|
||||
console.log(`Wild Dragon Capture Service listening on port ${PORT}`);
|
||||
bootstrapAutoStart();
|
||||
// Auto-start idle signal preview for deltacast/sdi sidecars.
|
||||
// 3s delay lets the deltacast bridge FIFOs come up first.
|
||||
const _srcType = process.env.SOURCE_TYPE;
|
||||
if (process.env.RECORDER_ID && (_srcType === 'deltacast' || _srcType === 'sdi')) {
|
||||
setTimeout(() => captureManager.startIdlePreview(), 3000);
|
||||
}
|
||||
});
|
||||
|
||||
// Mapped from the env vars routes/recorders.js writes into the container.
|
||||
|
|
|
|||
|
|
@ -302,6 +302,13 @@ router.get('/', async (req, res, next) => {
|
|||
} catch (_) { /* leave started_at undefined */ }
|
||||
}));
|
||||
|
||||
// Append preview_url for deltacast/sdi recorders whose sidecar is running.
|
||||
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`;
|
||||
}
|
||||
}
|
||||
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
|
|
@ -1085,6 +1092,35 @@ function probeUdp(host, port) {
|
|||
}
|
||||
|
||||
|
||||
// GET /:id/preview/* — proxy idle signal preview HLS from /live/preview-{id}/ on the recorder's node.
|
||||
router.get('/:id/preview/:rest(*)', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const rest = req.params.rest;
|
||||
if (!rest || rest.includes('..')) return res.status(400).end();
|
||||
const rec = await pool.query('SELECT node_id FROM recorders WHERE id = $1', [id]);
|
||||
if (rec.rows.length === 0) return res.status(404).json({ error: 'Recorder not found' });
|
||||
|
||||
const ct = rest.endsWith('.m3u8') ? 'application/vnd.apple.mpegurl'
|
||||
: rest.endsWith('.ts') ? 'video/mp2t'
|
||||
: 'application/octet-stream';
|
||||
res.set('Cache-Control', 'no-cache');
|
||||
res.set('Content-Type', ct);
|
||||
|
||||
const target = await resolveNodeTarget(rec.rows[0].node_id);
|
||||
if (!target.remote) {
|
||||
return fs.readFile(`/live/preview-${id}/${rest}`, (err, data) => {
|
||||
if (err) return res.status(404).end();
|
||||
res.end(data);
|
||||
});
|
||||
}
|
||||
const base = String(target.apiUrl).replace(/\/$/, '');
|
||||
const upstream = await fetch(`${base}/live/preview-${id}/${rest}`).catch(() => null);
|
||||
if (!upstream || !upstream.ok) return res.status(upstream ? upstream.status : 502).end();
|
||||
res.end(Buffer.from(await upstream.arrayBuffer()));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /:id/live/* — reverse-proxy the live HLS preview from the recorder's node.
|
||||
// Remote recorders: segments live on the worker node, served by its node-agent
|
||||
// (/live/...). Local recorders: served from this host's /live mount. Browser
|
||||
|
|
|
|||
|
|
@ -489,6 +489,77 @@ function HlsPreview({ assetId, recorderId, muted = true, controls = false, class
|
|||
);
|
||||
}
|
||||
|
||||
/* ===== Idle signal preview (always-on HLS from sidecar) ===== */
|
||||
function HlsPreviewUrl({ url }) {
|
||||
const videoRef = React.useRef(null);
|
||||
const [err, setErr] = React.useState(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!url || !videoRef.current) return;
|
||||
const v = videoRef.current;
|
||||
let destroyed = false;
|
||||
let hls = null;
|
||||
let retryTimer = 0;
|
||||
let retryCount = 0;
|
||||
|
||||
const clearRetry = () => { if (retryTimer) { clearTimeout(retryTimer); retryTimer = 0; } };
|
||||
|
||||
if (v.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
const tryLoad = () => {
|
||||
if (destroyed) return;
|
||||
v.src = url;
|
||||
v.play().catch(() => {});
|
||||
};
|
||||
v.addEventListener('error', () => {
|
||||
retryCount++;
|
||||
clearRetry();
|
||||
retryTimer = setTimeout(tryLoad, Math.min(2000 * retryCount, 15000));
|
||||
setErr('no signal');
|
||||
});
|
||||
v.addEventListener('playing', () => { retryCount = 0; setErr(null); }, { once: false });
|
||||
tryLoad();
|
||||
return () => { destroyed = true; clearRetry(); };
|
||||
}
|
||||
|
||||
if (!window.Hls) { setErr('hls.js missing'); return; }
|
||||
|
||||
const startHls = () => {
|
||||
if (destroyed) return;
|
||||
hls = new window.Hls({ liveSyncDurationCount: 2, lowLatencyMode: true });
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(v);
|
||||
hls.on(window.Hls.Events.ERROR, (_e, data) => {
|
||||
if (data.fatal) {
|
||||
try { hls.destroy(); } catch (_) {}
|
||||
hls = null;
|
||||
retryCount++;
|
||||
setErr('no signal');
|
||||
clearRetry();
|
||||
retryTimer = setTimeout(startHls, Math.min(2000 * retryCount, 15000));
|
||||
}
|
||||
});
|
||||
hls.on(window.Hls.Events.MANIFEST_PARSED, () => { retryCount = 0; setErr(null); });
|
||||
};
|
||||
|
||||
startHls();
|
||||
v.play().catch(() => {});
|
||||
return () => { destroyed = true; clearRetry(); if (hls) { try { hls.destroy(); } catch (_) {} } };
|
||||
}, [url]);
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', width: '100%', height: '100%', background: '#000', overflow: 'hidden' }}>
|
||||
<video ref={videoRef} autoPlay playsInline muted
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||
{err && (
|
||||
<div style={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center',
|
||||
color: 'var(--text-3)', fontSize: 11, background: 'rgba(0,0,0,0.6)' }}>
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ===== Recorders ===== */
|
||||
function _normRecorder(r) {
|
||||
const cfg = r.source_config || {};
|
||||
|
|
@ -513,6 +584,7 @@ function _normRecorder(r) {
|
|||
framerate: r.recording_framerate || 'native',
|
||||
node: r.node_id ? r.node_id.slice(0, 8) : 'primary',
|
||||
capturePort,
|
||||
previewUrl: r.preview_url || null,
|
||||
elapsed: '·',
|
||||
bitrate: '·',
|
||||
health: 100,
|
||||
|
|
@ -1253,6 +1325,8 @@ function MonitorTile({ feed, seed }) {
|
|||
// HLS segments may still be hot right after stopping — keep player alive briefly
|
||||
tileContent = <HlsPreview assetId={lastAssetId} recorderId={lastRecorderId} />;
|
||||
}
|
||||
} else if (!isLive && feed.previewUrl) {
|
||||
tileContent = <HlsPreviewUrl url={feed.previewUrl} />;
|
||||
} else {
|
||||
tileContent = <FauxFrame />;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue