diff --git a/docs/superpowers/specs/2026-06-01-deltacast-sdi-capture-design.md b/docs/superpowers/specs/2026-06-01-deltacast-sdi-capture-design.md new file mode 100644 index 0000000..619b519 --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-deltacast-sdi-capture-design.md @@ -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 ` (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 │ +│ -i /tmp/dc-audio-{sessionId} │ +│ │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +### 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 Board index (0-based) + --port RX port index (0-based) + --audio-pipe Named FIFO path for PCM audio output + [--signal-timeout ] + [--audio-groups ] 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`. 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