# 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):
   ```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<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:

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