10 KiB
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:
- Detects signal format on startup, writes one JSON line to stderr
- Streams raw YUV video frames to stdout
- 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 <fifo> (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 <N> │
│ -i /tmp/dc-audio-{sessionId} │
│ <hevc_nvenc / prores / h264 encode args> │
│ <S3 pipe or growing-file output> │
└─────────────────────────────────────────────────────────┘
New files
services/capture/deltacast-bridge/CMakeLists.txtservices/capture/deltacast-bridge/main.cpp
Modified files
services/capture/src/capture-manager.js—_buildInputArgs()deltacast branch;start()andstop()bridge lifecycleservices/capture/Dockerfile— SDK extraction stage, bridge build stage, runtime.soinstall
The deltacast-capture Binary
CLI
deltacast-capture
--device <N> Board index (0-based)
--port <N> RX port index (0-based)
--audio-pipe <path> Named FIFO path for PCM audio output
[--signal-timeout <sec=30>]
[--audio-groups <N=2>] Number of SDI audio groups (2 groups = 8 channels)
Startup sequence
Board::open(device, loopback_restore_cb)- Disable loopback on
port board.sdi().open_stream(rx_streamtype(port), VHD_SDI_STPROC_DISJOINED_VIDEO)- Poll
wait_for_input()up to--signal-timeoutseconds - On timeout → write
{"error":"no signal","device":N,"port":N}to stderr, exit 1 - Detect
video_standard,clock_divisor,interface→ map to width/height/fps/pix_fmt/interlaced - Write one JSON line to stderr (flushed):
{"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} - Set queue depth = 8,
rx_stream.start() - Capture loop:
pop_slot()→ write video buffer to stdout → extract audio → write PCM to FIFO (background thread) - 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<VHD_AUDIOGROUP>. 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:
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, storebridgeProcessandaudioFifoonthis.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 forsourceType === 'sdi'; extend that check to includedeltacast)
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
# ── 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:
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)
- Create recorder:
source_type=deltacast,device=0,port=0 - Verify JSON handshake in capture container logs within signal timeout
- Verify
signal=receivingin recorder status - Record 30s clip → asset created, proxy + HLS generated
- Test stop mid-record → file finalized correctly
- Test no-signal → recorder stays idle, no asset created
- Test container restart mid-record → asset finalized on restart via existing
finalizeendpoint
Out of Scope
- 10-bit (
v210) pixel format — follow-up --audio-groupsUI 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
libavdeviceinput device — future v2