231 lines
10 KiB
Markdown
231 lines
10 KiB
Markdown
# 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
|