dragonflight/docs/superpowers/specs/2026-06-01-deltacast-sdi-capture-design.md
zgaetano 298cb18914 docs: Deltacast SDI capture design spec (bridge approach)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 07:34:09 -04:00

10 KiB
Raw Blame History

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

  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):
    {"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<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 14).


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

# ── 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)

  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