docs: Deltacast SDI capture design spec (bridge approach)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9d6bbf8112
commit
298cb18914
1 changed files with 231 additions and 0 deletions
|
|
@ -0,0 +1,231 @@
|
|||
# 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
|
||||
Loading…
Reference in a new issue