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:
Claude 2026-06-02 11:23:39 +00:00
parent 59294856a2
commit 858c9f7b97
5 changed files with 219 additions and 1 deletions

View file

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

View file

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

View file

@ -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.

View file

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

View file

@ -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 />;
}