# Deltacast SDI Capture — Design Spec **Date:** 2026-06-01 **Status:** Approved **Approach:** Bridge binary (Option B2) --- ## Problem Dragonflight supports SDI ingest via Blackmagic DeckLink. Deltacast VideoMaster cards are a second hardware target. The VideoMaster SDK (v6.34.1) ships C++ headers and shared libraries but no FFmpeg demuxer plugin — there is no mainline FFmpeg `-f deltacast` input device. The `capture-manager.js` stub exists but falls back to a lavfi test card on all deployments. --- ## Approach Write a small C++ bridge binary (`deltacast-capture`) using the VideoMaster C++ Wrapper SDK. The bridge: 1. Detects signal format on startup, writes one JSON line to stderr 2. Streams raw YUV video frames to stdout 3. Streams raw PCM audio to a named FIFO `capture-manager.js` reads the JSON handshake, then spawns FFmpeg with `-f rawvideo -i pipe:0` (video from bridge stdout) and `-f s16le -i ` (audio from FIFO). The existing HEVC NVENC / ProRes encode pipeline is unchanged. --- ## Architecture ``` ┌─────────────────────────────────────────────────────────┐ │ capture container │ │ │ │ capture-manager.js │ │ │ │ │ ├─ spawn deltacast-capture --device 0 --port 0 │ │ │ --audio-pipe /tmp/dc-audio-{sessionId} │ │ │ │ │ │ │ ├─ stderr: JSON format line (one-time handshake) │ │ │ ├─ stdout: raw YUV frames (continuous) │ │ │ └─ FIFO: raw PCM audio (continuous) │ │ │ │ │ └─ spawn ffmpeg │ │ -f rawvideo -pix_fmt uyvy422 -s WxH -r FPS/1 │ │ -i pipe:0 ← piped from bridge stdout │ │ -f s16le -ar 48000 -ac │ │ -i /tmp/dc-audio-{sessionId} │ │ │ │ │ └─────────────────────────────────────────────────────────┘ ``` ### New files - `services/capture/deltacast-bridge/CMakeLists.txt` - `services/capture/deltacast-bridge/main.cpp` ### Modified files - `services/capture/src/capture-manager.js` — `_buildInputArgs()` deltacast branch; `start()` and `stop()` bridge lifecycle - `services/capture/Dockerfile` — SDK extraction stage, bridge build stage, runtime `.so` install --- ## The `deltacast-capture` Binary ### CLI ``` deltacast-capture --device Board index (0-based) --port RX port index (0-based) --audio-pipe Named FIFO path for PCM audio output [--signal-timeout ] [--audio-groups ] Number of SDI audio groups (2 groups = 8 channels) ``` ### Startup sequence 1. `Board::open(device, loopback_restore_cb)` 2. Disable loopback on `port` 3. `board.sdi().open_stream(rx_streamtype(port), VHD_SDI_STPROC_DISJOINED_VIDEO)` 4. Poll `wait_for_input()` up to `--signal-timeout` seconds 5. On timeout → write `{"error":"no signal","device":N,"port":N}` to stderr, exit 1 6. Detect `video_standard`, `clock_divisor`, `interface` → map to width/height/fps/pix_fmt/interlaced 7. Write one JSON line to stderr (flushed): ```json {"width":1920,"height":1080,"fps_num":25,"fps_den":1,"pix_fmt":"uyvy422","interlaced":false,"audio_channels":8,"audio_rate":48000,"device":0,"port":0} ``` 8. Set queue depth = 8, `rx_stream.start()` 9. Capture loop: `pop_slot()` → write video buffer to stdout → extract audio → write PCM to FIFO (background thread) 10. SIGTERM/SIGINT → set stop flag → flush, close FIFO, close stream/board, exit 0 ### Pixel format Default: `uyvy422` (4:2:2 8-bit, `VHD_SDI_BUFTYPE_VIDEO`). 10-bit (`v210`) is a future follow-up via `--pix-fmt v210`. ### Audio `sdi_slot.audio().extract(num_groups)` returns `std::vector`. Samples are written to the FIFO as interleaved s16le PCM at 48000 Hz in a background thread so the video loop never blocks on audio consumers. Default `--audio-groups 2` yields 8 channels (standard embedded SDI stereo pairs 1–4). --- ## `capture-manager.js` Changes ### `_buildInputArgs()` — deltacast branch Replace the existing lavfi-fallback stub with: ```js if (sourceType === 'deltacast') { const idx = parseInt(device, 10) || 0; const audioFifo = `/tmp/dc-audio-${sessionId}`; await execAsync(`mkfifo ${audioFifo}`); const bridge = spawn('deltacast-capture', [ '--device', String(idx), '--port', String(idx), // port == board index for single-port-per-recorder model '--audio-pipe', audioFifo, ], { stdio: ['ignore', 'pipe', 'pipe'] }); const fmt = await readFirstStderrLine(bridge, 35_000); // 35s timeout // fmt: { width, height, fps_num, fps_den, pix_fmt, interlaced, audio_channels, audio_rate } return { inputArgs: [ '-f', 'rawvideo', '-pix_fmt', fmt.pix_fmt, '-video_size', `${fmt.width}x${fmt.height}`, '-framerate', `${fmt.fps_num}/${fmt.fps_den}`, '-i', 'pipe:0', '-f', 's16le', '-ar', String(fmt.audio_rate), '-ac', String(fmt.audio_channels), '-i', audioFifo, ], isNetwork: false, bridgeProcess: bridge, audioFifo, interlaced: fmt.interlaced, }; } ``` `readFirstStderrLine(proc, timeoutMs)` is a small helper that returns a parsed JSON object from the first line emitted on `proc.stderr`, or throws on timeout or non-zero exit. ### `start()` changes - After `_buildInputArgs()` returns, store `bridgeProcess` and `audioFifo` on `this.state` - Spawn FFmpeg with `stdio: ['pipe', ...]` for stdin - `bridgeProcess.stdout.pipe(hiresProcess.stdin)` - Deinterlace: if `interlaced === true`, add `-vf yadif=mode=1:deint=1` (already present for `sourceType === 'sdi'`; extend that check to include `deltacast`) ### `stop()` changes - `if (processes.bridge) processes.bridge.kill('SIGINT')` - After process cleanup: `if (this.state.audioFifo) { try { fs.unlinkSync(this.state.audioFifo); } catch (_) {} }` ### HLS preview The existing `filter_complex split` SDI preview path works unchanged — the bridge→pipe is just a different `-i` source. Extend the `sourceType === 'sdi'` guard to `['sdi', 'deltacast'].includes(sourceType)`. --- ## Dockerfile Changes ```dockerfile # ── Stage 0: Extract VideoMaster SDK ───────────────────────────────────── FROM debian:bookworm AS sdk-extractor COPY videomaster-linux.x64-6.34.1-dev.tar.gz /tmp/ RUN mkdir -p /sdk && tar -xzf /tmp/videomaster-linux.x64-6.34.1-dev.tar.gz -C /sdk # ── Stage 1: Build deltacast-capture bridge ─────────────────────────────── FROM debian:bookworm AS bridge-builder RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential cmake ca-certificates \ && rm -rf /var/lib/apt/lists/* COPY --from=sdk-extractor /sdk /sdk COPY deltacast-bridge/ /bridge/ RUN cmake -S /bridge -B /bridge/build \ -DCMAKE_BUILD_TYPE=Release \ -DSDK_ROOT=/sdk \ && cmake --build /bridge/build -j$(nproc) # ── Stage 2: Build FFmpeg (unchanged) ───────────────────────────────────── FROM debian:bookworm AS ffmpeg-builder # ... existing content, no changes ... # ── Stage 3: Runtime ────────────────────────────────────────────────────── FROM node:20-bookworm # ... existing runtime deps ... COPY --from=bridge-builder /bridge/build/deltacast-capture /usr/local/bin/deltacast-capture COPY --from=sdk-extractor /sdk/lib/ /usr/local/lib/deltacast/ RUN ldconfig /usr/local/lib/deltacast && ldconfig ``` SDK `.so` files total ~4MB. The bridge binary adds ~200KB. --- ## Error Handling | Scenario | Bridge behavior | `capture-manager.js` response | |---|---|---| | No signal within timeout | Exit 1, `{"error":"no signal"}` on stderr | Throws — recorder stays idle, no asset created | | Invalid board/port | Exit 1, `{"error":"board N not found"}` | Same as above | | Bridge crash mid-capture | stdout closes → FFmpeg stdin EOF → FFmpeg exits cleanly | Existing stop handler fires; asset finalized with frames received so far | | Audio FIFO open stall | Bridge blocks on FIFO write-open until FFmpeg opens read-end | Guarded by 10s watchdog on bridge spawn; if FFmpeg fails to start, bridge is SIGKILL'd | | FIFO leftover on container crash | Stale file in `/tmp/` | Next `start()` uses a new `sessionId`-based path; harmless | --- ## Testing ### Without hardware (dev mode) The lavfi fallback is **removed** from the deltacast branch — a missing `deltacast-capture` binary will throw at spawn time (clear error). Developers run the existing test card by using `sourceType = 'sdi'` with a DeckLink card or `sourceType = 'srt'` with a test stream. The bridge binary can be tested standalone: ```bash mkfifo /tmp/test-audio deltacast-capture --device 0 --port 0 --audio-pipe /tmp/test-audio & # watch stderr for JSON line, then: cat /tmp/test-audio | ffprobe -f s16le -ar 48000 -ac 8 -i - ``` ### With hardware (post-implementation) 1. Create recorder: `source_type=deltacast`, `device=0`, `port=0` 2. Verify JSON handshake in capture container logs within signal timeout 3. Verify `signal=receiving` in recorder status 4. Record 30s clip → asset created, proxy + HLS generated 5. Test stop mid-record → file finalized correctly 6. Test no-signal → recorder stays idle, no asset created 7. Test container restart mid-record → asset finalized on restart via existing `finalize` endpoint --- ## Out of Scope - 10-bit (`v210`) pixel format — follow-up - `--audio-groups` UI control — follow-up - GPU extension SDK (`gpuextension-linux.x64-2.2.0-dev.zip`) — covers GPU-accelerated colorspace conversion on the card; not needed for basic capture - IP virtual card SDK (`ipvirtualcard`) — separate feature - Promoting bridge to a native FFmpeg `libavdevice` input device — future v2