diff --git a/services/capture/deltacast-bridge/main.c b/services/capture/deltacast-bridge/main.c index 8bf1477..1e1f76f 100644 --- a/services/capture/deltacast-bridge/main.c +++ b/services/capture/deltacast-bridge/main.c @@ -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); } diff --git a/services/capture/src/capture-manager.js b/services/capture/src/capture-manager.js index 3478754..5a31214 100644 --- a/services/capture/src/capture-manager.js +++ b/services/capture/src/capture-manager.js @@ -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; diff --git a/services/capture/src/index.js b/services/capture/src/index.js index 942f97c..8a2ae21 100644 --- a/services/capture/src/index.js +++ b/services/capture/src/index.js @@ -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. diff --git a/services/mam-api/src/routes/recorders.js b/services/mam-api/src/routes/recorders.js index a6bb778..db64dea 100644 --- a/services/mam-api/src/routes/recorders.js +++ b/services/mam-api/src/routes/recorders.js @@ -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 diff --git a/services/web-ui/public/screens-ingest.jsx b/services/web-ui/public/screens-ingest.jsx index 637be9a..42b6bd0 100644 --- a/services/web-ui/public/screens-ingest.jsx +++ b/services/web-ui/public/screens-ingest.jsx @@ -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 ( +
+
+ ); +} + /* ===== 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 = ; } + } else if (!isLive && feed.previewUrl) { + tileContent = ; } else { tileContent = ; }