# Deltacast SDI Capture — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Wire up Deltacast VideoMaster SDI cards in the capture service using a C bridge binary that streams raw video to FFmpeg via pipe, with embedded audio via a named FIFO. **Architecture:** A `deltacast-capture` C binary opens the VideoMaster board, waits for signal lock, emits a JSON format line to stderr, then streams raw UYVY video frames to stdout and 2-channel PCM audio to a named FIFO. `capture-manager.js` reads the JSON, spawns FFmpeg with `-f rawvideo -i pipe:0` for video and `-f s16le -i ` for audio, and pipes bridge stdout into FFmpeg stdin. Two concurrent SDK streams share the same board handle — `VHD_SDI_STPROC_DISJOINED_VIDEO` for video and `VHD_SDI_STPROC_DISJOINED_ANC` for audio. **Tech Stack:** Deltacast VideoMaster C SDK 6.34.1 (`libvideomasterhd.so`, `libvideomasterhd_audio.so`), C17, CMake, Node.js ES modules, Docker multi-stage build. --- ## File Map | Action | Path | Responsibility | |---|---|---| | Create | `services/capture/deltacast-bridge/CMakeLists.txt` | Build config for the bridge binary | | Create | `services/capture/deltacast-bridge/main.c` | Bridge: board open, signal detect, video stream, audio thread | | Modify | `services/capture/Dockerfile` | SDK extraction stage, bridge build stage, runtime .so install | | Modify | `services/capture/src/capture-manager.js` | `readFirstStderrLine` helper, deltacast `_buildInputArgs`, bridge lifecycle in `start()`/`stop()` | | Modify | `services/capture/src/routes/capture.js` | Accept `deltacast` as a valid `source_type` | --- ## Task 1: Bridge CMakeLists.txt **Files:** - Create: `services/capture/deltacast-bridge/CMakeLists.txt` - [ ] **Step 1: Create the CMakeLists.txt** ```cmake cmake_minimum_required(VERSION 3.16) project(deltacast-bridge C) set(CMAKE_C_STANDARD 17) set(SDK_ROOT "/sdk" CACHE PATH "Path to extracted VideoMaster SDK") add_executable(deltacast-capture main.c) target_include_directories(deltacast-capture PRIVATE ${SDK_ROOT}/include/videomaster ) target_link_directories(deltacast-capture PRIVATE ${SDK_ROOT}/lib ) target_link_libraries(deltacast-capture PRIVATE videomasterhd videomasterhd_audio pthread ) # Embed the SDK RPATH so the binary finds the .so at runtime set_target_properties(deltacast-capture PROPERTIES INSTALL_RPATH "/usr/local/lib/deltacast" BUILD_WITH_INSTALL_RPATH TRUE ) ``` - [ ] **Step 2: Commit** ```bash git add services/capture/deltacast-bridge/CMakeLists.txt git commit -m "build(capture): add CMakeLists for deltacast-capture bridge binary" ``` --- ## Task 2: Bridge main.c **Files:** - Create: `services/capture/deltacast-bridge/main.c` The binary: parses CLI args, opens the board, waits for signal lock, emits one JSON line to stderr, then spawns an audio thread writing to a FIFO and runs a video capture loop writing raw UYVY frames to stdout. - [ ] **Step 1: Create the bridge source file** ```c /* services/capture/deltacast-bridge/main.c * * Deltacast VideoMaster SDI capture bridge. * Writes raw UYVY video to stdout and stereo PCM to a named FIFO. * Emits one JSON line to stderr on signal lock before streaming starts. * * Usage: * deltacast-capture --device --port --audio-pipe * [--signal-timeout ] */ #include #include #include #include #include #include #include #include #include #include #include "VideoMasterHD_Core.h" #include "VideoMasterHD_Sdi.h" #include "VideoMasterHD_Sdi_Audio.h" /* ── Globals ─────────────────────────────────────────────────────────── */ static atomic_int g_stop = 0; static void on_signal(int s) { (void)s; atomic_store(&g_stop, 1); } /* ── Stream type by port index ───────────────────────────────────────── */ static ULONG rx_streamtype(unsigned port) { switch (port) { case 0: return VHD_ST_RX0; case 1: return VHD_ST_RX1; case 2: return VHD_ST_RX2; case 3: return VHD_ST_RX3; default: return VHD_ST_RX0; } } /* ── Loopback board property by port index ───────────────────────────── */ static ULONG loopback_prop(unsigned port) { switch (port) { case 0: return VHD_CORE_BP_PASSIVE_LOOPBACK_0; case 1: return VHD_CORE_BP_PASSIVE_LOOPBACK_1; case 2: return VHD_CORE_BP_PASSIVE_LOOPBACK_2; case 3: return VHD_CORE_BP_PASSIVE_LOOPBACK_3; default: return VHD_CORE_BP_PASSIVE_LOOPBACK_0; } } /* ── Video standard → width/height/fps/interlaced ───────────────────── */ typedef struct { int width, height, fps_num, fps_den; int interlaced; } VideoInfo; static VideoInfo video_info(VHD_VIDEOSTANDARD std, VHD_CLOCKDIVISOR div) { int ntsc = (div == VHD_CLOCKDIV_1001); switch (std) { case VHD_VIDEOSTD_S274M_1080p_25Hz: return (VideoInfo){1920,1080,25,1,0}; case VHD_VIDEOSTD_S274M_1080p_30Hz: return (VideoInfo){1920,1080,ntsc?30000:30,ntsc?1001:1,0}; case VHD_VIDEOSTD_S274M_1080p_24Hz: return (VideoInfo){1920,1080,ntsc?24000:24,ntsc?1001:1,0}; case VHD_VIDEOSTD_S274M_1080p_50Hz: return (VideoInfo){1920,1080,50,1,0}; case VHD_VIDEOSTD_S274M_1080p_60Hz: return (VideoInfo){1920,1080,ntsc?60000:60,ntsc?1001:1,0}; case VHD_VIDEOSTD_S274M_1080psf_24Hz: return (VideoInfo){1920,1080,ntsc?24000:24,ntsc?1001:1,0}; case VHD_VIDEOSTD_S274M_1080psf_25Hz: return (VideoInfo){1920,1080,25,1,0}; case VHD_VIDEOSTD_S274M_1080psf_30Hz: return (VideoInfo){1920,1080,ntsc?30000:30,ntsc?1001:1,0}; case VHD_VIDEOSTD_S274M_1080i_50Hz: return (VideoInfo){1920,1080,25,1,1}; case VHD_VIDEOSTD_S274M_1080i_60Hz: return (VideoInfo){1920,1080,ntsc?30000:30,ntsc?1001:1,1}; case VHD_VIDEOSTD_S296M_720p_50Hz: return (VideoInfo){1280,720,50,1,0}; case VHD_VIDEOSTD_S296M_720p_60Hz: return (VideoInfo){1280,720,ntsc?60000:60,ntsc?1001:1,0}; case VHD_VIDEOSTD_S296M_720p_25Hz: return (VideoInfo){1280,720,25,1,0}; case VHD_VIDEOSTD_S296M_720p_30Hz: return (VideoInfo){1280,720,ntsc?30000:30,ntsc?1001:1,0}; case VHD_VIDEOSTD_S296M_720p_24Hz: return (VideoInfo){1280,720,ntsc?24000:24,ntsc?1001:1,0}; case VHD_VIDEOSTD_3840x2160p_24Hz: return (VideoInfo){3840,2160,ntsc?24000:24,ntsc?1001:1,0}; case VHD_VIDEOSTD_3840x2160p_25Hz: return (VideoInfo){3840,2160,25,1,0}; case VHD_VIDEOSTD_3840x2160p_30Hz: return (VideoInfo){3840,2160,ntsc?30000:30,ntsc?1001:1,0}; case VHD_VIDEOSTD_3840x2160p_50Hz: return (VideoInfo){3840,2160,50,1,0}; case VHD_VIDEOSTD_3840x2160p_60Hz: return (VideoInfo){3840,2160,ntsc?60000:60,ntsc?1001:1,0}; case VHD_VIDEOSTD_S259M_NTSC_480: return (VideoInfo){720,480,ntsc?30000:30,ntsc?1001:1,1}; default: return (VideoInfo){1920,1080,25,1,0}; } } /* ── Audio thread ────────────────────────────────────────────────────── */ typedef struct { HANDLE board; unsigned port; ULONG video_std; ULONG clock_div; const char *fifo_path; } AudioArgs; static void *audio_thread(void *arg) { AudioArgs *a = (AudioArgs *)arg; HANDLE stream = NULL; ULONG r = VHD_OpenStreamHandle(a->board, rx_streamtype(a->port), VHD_SDI_STPROC_DISJOINED_ANC, NULL, &stream, NULL); if (r != VHDERR_NOERROR) { fprintf(stderr, "[audio] VHD_OpenStreamHandle failed: %lu\n", r); return NULL; } VHD_SetStreamProperty(stream, VHD_SDI_SP_VIDEO_STANDARD, a->video_std); VHD_SetStreamProperty(stream, VHD_SDI_SP_CLOCK_SYSTEM, a->clock_div); VHD_SetStreamProperty(stream, VHD_CORE_SP_TRANSFER_SCHEME, VHD_TRANSFER_SLAVED); /* Stereo pair, 16-bit, 48kHz on group 0 channel 0 */ ULONG max_samples = VHD_GetNbSamples((VHD_VIDEOSTANDARD)a->video_std, (VHD_CLOCKDIVISOR)a->clock_div, VHD_ASR_48000, 0); ULONG block_size = VHD_GetBlockSize(VHD_AF_16, VHD_AM_STEREO); ULONG buf_sz = (max_samples + 4) * block_size; /* +4 for 29.97 variation */ unsigned char *buf = calloc(1, buf_sz); if (!buf) { VHD_CloseStreamHandle(stream); return NULL; } VHD_AUDIOINFO ai; memset(&ai, 0, sizeof(ai)); ai.pAudioGroups[0].pAudioChannels[0].Mode = VHD_AM_STEREO; ai.pAudioGroups[0].pAudioChannels[0].BufferFormat = VHD_AF_16; ai.pAudioGroups[0].pAudioChannels[0].pData = buf; if (VHD_StartStream(stream) != VHDERR_NOERROR) { free(buf); VHD_CloseStreamHandle(stream); return NULL; } /* Open FIFO for writing — blocks until FFmpeg opens the read end */ int fd = open(a->fifo_path, O_WRONLY); if (fd < 0) { fprintf(stderr, "[audio] open FIFO failed: %s\n", strerror(errno)); VHD_StopStream(stream); VHD_CloseStreamHandle(stream); free(buf); return NULL; } HANDLE slot = NULL; while (!atomic_load(&g_stop)) { r = VHD_LockSlotHandle(stream, &slot); if (r == VHDERR_NOERROR) { ai.pAudioGroups[0].pAudioChannels[0].DataSize = buf_sz; if (VHD_SlotExtractAudio(slot, &ai) == VHDERR_NOERROR) { ULONG sz = ai.pAudioGroups[0].pAudioChannels[0].DataSize; if (sz > 0) write(fd, buf, sz); } VHD_UnlockSlotHandle(slot); } else if (r != VHDERR_TIMEOUT) { break; } } close(fd); VHD_StopStream(stream); VHD_CloseStreamHandle(stream); free(buf); return NULL; } /* ── Main ────────────────────────────────────────────────────────────── */ int main(int argc, char *argv[]) { unsigned device_id = 0; unsigned port_id = 0; int sig_timeout = 30; const char *audio_pipe = NULL; for (int i = 1; i < argc; i++) { if (!strcmp(argv[i], "--device") && i+1 < argc) device_id = (unsigned)atoi(argv[++i]); else if (!strcmp(argv[i], "--port") && i+1 < argc) port_id = (unsigned)atoi(argv[++i]); else if (!strcmp(argv[i], "--audio-pipe") && i+1 < argc) audio_pipe = argv[++i]; else if (!strcmp(argv[i], "--signal-timeout") && i+1 < argc) sig_timeout = atoi(argv[++i]); } signal(SIGINT, on_signal); signal(SIGTERM, on_signal); /* ── Init API ─────────────────────────────────────────────────── */ ULONG dll_ver, nb_boards; if (VHD_GetApiInfo(&dll_ver, &nb_boards) != VHDERR_NOERROR) { fprintf(stderr, "{\"error\":\"VHD_GetApiInfo failed\"}\n"); return 1; } if (device_id >= nb_boards) { fprintf(stderr, "{\"error\":\"board %u not found (%lu detected)\"}\n", device_id, nb_boards); return 1; } /* ── Open board ───────────────────────────────────────────────── */ HANDLE board = NULL; if (VHD_OpenBoardHandle(device_id, &board, NULL, 0) != VHDERR_NOERROR) { fprintf(stderr, "{\"error\":\"VHD_OpenBoardHandle failed for board %u\"}\n", device_id); return 1; } /* Disable passive (relay) loopback so RX is live */ VHD_SetBoardProperty(board, loopback_prop(port_id), FALSE); /* ── Wait for signal lock ──────────────────────────────────────── */ ULONG video_std = (ULONG)NB_VHD_VIDEOSTANDARDS; struct timespec deadline; clock_gettime(CLOCK_MONOTONIC, &deadline); deadline.tv_sec += sig_timeout; while (!atomic_load(&g_stop)) { struct timespec now; clock_gettime(CLOCK_MONOTONIC, &now); if (now.tv_sec > deadline.tv_sec || (now.tv_sec == deadline.tv_sec && now.tv_nsec >= deadline.tv_nsec)) break; VHD_GetChannelProperty(board, VHD_RX_CHANNEL, port_id, VHD_SDI_CP_VIDEO_STANDARD, &video_std); if (video_std != (ULONG)NB_VHD_VIDEOSTANDARDS) break; struct timespec ts = {0, 200000000L}; /* 200ms */ nanosleep(&ts, NULL); } if (atomic_load(&g_stop) || video_std == (ULONG)NB_VHD_VIDEOSTANDARDS) { fprintf(stderr, "{\"error\":\"no signal on board %u port %u within %ds\"}\n", device_id, port_id, sig_timeout); VHD_CloseBoardHandle(board); return 1; } ULONG clock_div = VHD_CLOCKDIV_1; VHD_GetChannelProperty(board, VHD_RX_CHANNEL, port_id, VHD_SDI_CP_CLOCK_DIVISOR, &clock_div); VideoInfo vi = video_info((VHD_VIDEOSTANDARD)video_std, (VHD_CLOCKDIVISOR)clock_div); /* ── Emit format JSON to stderr (one line, flushed) ─────────────── */ fprintf(stderr, "{\"width\":%d,\"height\":%d,\"fps_num\":%d,\"fps_den\":%d," "\"interlaced\":%s,\"pix_fmt\":\"uyvy422\"," "\"audio_channels\":2,\"audio_rate\":48000," "\"device\":%u,\"port\":%u}\n", vi.width, vi.height, vi.fps_num, vi.fps_den, vi.interlaced ? "true" : "false", device_id, port_id); fflush(stderr); /* ── Open video stream ───────────────────────────────────────────── */ HANDLE video_stream = NULL; if (VHD_OpenStreamHandle(board, rx_streamtype(port_id), VHD_SDI_STPROC_DISJOINED_VIDEO, NULL, &video_stream, NULL) != VHDERR_NOERROR) { fprintf(stderr, "{\"error\":\"VHD_OpenStreamHandle (video) failed\"}\n"); VHD_CloseBoardHandle(board); return 1; } VHD_SetStreamProperty(video_stream, VHD_SDI_SP_VIDEO_STANDARD, video_std); VHD_SetStreamProperty(video_stream, VHD_SDI_SP_CLOCK_SYSTEM, clock_div); VHD_SetStreamProperty(video_stream, VHD_CORE_SP_TRANSFER_SCHEME, VHD_TRANSFER_SLAVED); VHD_SetStreamProperty(video_stream, VHD_CORE_SP_SLOTS_QUEUE_DEPTH, 8); /* ── Launch audio thread (FIFO open blocks until FFmpeg connects) ── */ pthread_t audio_tid = 0; AudioArgs audio_args = { board, port_id, video_std, clock_div, audio_pipe }; if (audio_pipe) { pthread_create(&audio_tid, NULL, audio_thread, &audio_args); } /* ── Start video stream ──────────────────────────────────────────── */ if (VHD_StartStream(video_stream) != VHDERR_NOERROR) { atomic_store(&g_stop, 1); if (audio_tid) pthread_join(audio_tid, NULL); VHD_CloseStreamHandle(video_stream); VHD_CloseBoardHandle(board); return 1; } /* ── Video capture loop ──────────────────────────────────────────── */ HANDLE slot = NULL; while (!atomic_load(&g_stop)) { ULONG r = VHD_LockSlotHandle(video_stream, &slot); if (r == VHDERR_NOERROR) { BYTE *buf = NULL; ULONG sz = 0; if (VHD_GetSlotBuffer(slot, VHD_SDI_BT_VIDEO, &buf, &sz) == VHDERR_NOERROR) { ULONG written = 0; while (written < sz) { ssize_t n = write(STDOUT_FILENO, buf + written, sz - written); if (n <= 0) { atomic_store(&g_stop, 1); break; } written += (ULONG)n; } } VHD_UnlockSlotHandle(slot); } else if (r != VHDERR_TIMEOUT) { break; } } /* ── Cleanup ─────────────────────────────────────────────────────── */ VHD_StopStream(video_stream); VHD_CloseStreamHandle(video_stream); if (audio_tid) pthread_join(audio_tid, NULL); VHD_CloseBoardHandle(board); return 0; } ``` - [ ] **Step 2: Commit** ```bash git add services/capture/deltacast-bridge/main.c git commit -m "feat(capture): add deltacast-capture bridge binary source" ``` --- ## Task 3: Dockerfile — SDK extraction + bridge build + runtime **Files:** - Modify: `services/capture/Dockerfile` The existing Dockerfile has three logical sections: FFmpeg build, runtime. We add two new stages before FFmpeg and patch the runtime stage. - [ ] **Step 1: Read the current Dockerfile** Read `services/capture/Dockerfile` and verify it starts with `FROM debian:bookworm AS ffmpeg-builder`. - [ ] **Step 2: Prepend two new stages and patch runtime** The full new Dockerfile: ```dockerfile # ── Stage 0: Extract Deltacast 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 binary ─────────────────────── 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 with DeckLink + NVENC (HEVC/H264) support ────── # (unchanged — keep original content here) FROM debian:bookworm AS ffmpeg-builder # ... (rest of the existing ffmpeg-builder stage unchanged) ... # ── Stage 3: Runtime image ─────────────────────────────────────────────── FROM node:20-bookworm # Runtime deps for compiled ffmpeg libs (unchanged) RUN apt-get update && apt-get install -y --no-install-recommends \ libx264-164 libx265-199 libvpx7 libopus0 libmp3lame0 \ libsrt1.5-openssl libzmq5 libstdc++6 libc++1 libc++abi1 \ && rm -rf /var/lib/apt/lists/* # Copy compiled ffmpeg/ffprobe (unchanged) COPY --from=ffmpeg-builder /usr/local/bin/ffmpeg /usr/local/bin/ffmpeg COPY --from=ffmpeg-builder /usr/local/bin/ffprobe /usr/local/bin/ffprobe COPY --from=ffmpeg-builder /usr/local/lib/ /usr/local/lib/ # DeckLink runtime .so (unchanged) COPY lib/libDeckLinkAPI.so /usr/lib/libDeckLinkAPI.so COPY lib/libDeckLinkPreviewAPI.so /usr/lib/libDeckLinkPreviewAPI.so # Deltacast bridge binary + SDK runtime libs COPY --from=bridge-builder /bridge/build/deltacast-capture /usr/local/bin/deltacast-capture COPY --from=sdk-extractor /sdk/lib/libvideomasterhd.so.6.34.1 /usr/local/lib/deltacast/ COPY --from=sdk-extractor /sdk/lib/libvideomasterhd_audio.so.6.34.1 /usr/local/lib/deltacast/ RUN ln -sf libvideomasterhd.so.6.34.1 /usr/local/lib/deltacast/libvideomasterhd.so.6 \ && ln -sf libvideomasterhd.so.6.34.1 /usr/local/lib/deltacast/libvideomasterhd.so \ && ln -sf libvideomasterhd_audio.so.6.34.1 /usr/local/lib/deltacast/libvideomasterhd_audio.so.6 \ && ln -sf libvideomasterhd_audio.so.6.34.1 /usr/local/lib/deltacast/libvideomasterhd_audio.so \ && ldconfig /usr/local/lib/deltacast \ && ldconfig RUN mkdir -p /live /growing WORKDIR /app COPY package*.json ./ RUN npm install --omit=dev COPY . . EXPOSE 3001 CMD ["node", "src/index.js"] ``` **Implementation note:** Edit the existing Dockerfile. Prepend the two new FROM stages (sdk-extractor, bridge-builder) before the existing `FROM debian:bookworm AS ffmpeg-builder` line. Then in the final runtime stage, add the Deltacast `COPY` and `RUN` lines after the DeckLink `.so` lines (before the `RUN mkdir -p /live /growing` line). - [ ] **Step 3: Commit** ```bash git add services/capture/Dockerfile git commit -m "build(capture): add Deltacast SDK extraction and bridge build stages to Dockerfile" ``` --- ## Task 4: capture-manager.js — `readFirstStderrLine` helper **Files:** - Modify: `services/capture/src/capture-manager.js` (add helper near top, after imports) - [ ] **Step 1: Add the helper function after the existing imports (after line 6 `import { v4 as uuidv4 } from 'uuid';`)** ```js /** * Reads the first line from a spawned process's stderr stream. * Resolves with the parsed JSON object when the first '\n' arrives. * Rejects if the process exits with a non-zero code before emitting a line, * or if timeoutMs elapses. */ function readFirstStderrLine(proc, timeoutMs = 35_000) { return new Promise((resolve, reject) => { let buf = ''; let settled = false; const settle = (fn) => { if (settled) return; settled = true; fn(); }; const timer = setTimeout(() => { settle(() => reject(new Error(`deltacast-capture: timed out waiting for format JSON after ${timeoutMs}ms`))); }, timeoutMs); proc.stderr.setEncoding('utf8'); proc.stderr.on('data', (chunk) => { buf += chunk; const nl = buf.indexOf('\n'); if (nl === -1) return; const line = buf.slice(0, nl).trim(); clearTimeout(timer); try { const parsed = JSON.parse(line); if (parsed.error) { settle(() => reject(new Error(`deltacast-capture: ${parsed.error}`))); } else { settle(() => resolve(parsed)); } } catch (e) { settle(() => reject(new Error(`deltacast-capture: invalid JSON on stderr: ${line}`))); } }); proc.on('exit', (code) => { clearTimeout(timer); settle(() => reject(new Error(`deltacast-capture: exited with code ${code} before emitting format JSON`))); }); }); } ``` - [ ] **Step 2: Commit** ```bash git add services/capture/src/capture-manager.js git commit -m "feat(capture): add readFirstStderrLine helper for deltacast bridge handshake" ``` --- ## Task 5: capture-manager.js — Deltacast `_buildInputArgs` **Files:** - Modify: `services/capture/src/capture-manager.js` — replace the `deltacast` branch of `_buildInputArgs` (currently lines 160–191) - [ ] **Step 1: Replace the existing deltacast branch** Find the block starting with `// Deltacast SDI via VideoMaster SDK FFmpeg plugin.` and ending at the closing `}` of the `if (sourceType === 'deltacast')` block. Replace the entire `if (sourceType === 'deltacast') { ... }` block with: ```js if (sourceType === 'deltacast') { const idx = (typeof device === 'number' || /^\d+$/.test(String(device))) ? parseInt(device, 10) : 0; const audioFifo = `/tmp/dc-audio-${this._sessionIdForBridge}`; // Create the audio FIFO before spawning the bridge. const { execSync: _exec } = await import('child_process'); try { _exec(`mkfifo ${audioFifo}`); } catch (_) { /* may already exist */ } const bridge = spawn('deltacast-capture', [ '--device', String(idx), '--port', String(idx), '--audio-pipe', audioFifo, '--signal-timeout', '30', ], { stdio: ['ignore', 'pipe', 'pipe'] }); // Log bridge stderr after the first line (non-JSON diagnostic output) let firstLineDone = false; bridge.stderr.on('data', (d) => { if (firstLineDone) console.error(`[deltacast-bridge] ${d}`); else if (d.toString().includes('\n')) firstLineDone = true; }); const fmt = await readFirstStderrLine(bridge, 35_000); // fmt: { width, height, fps_num, fps_den, interlaced, pix_fmt, // audio_channels, audio_rate, device, port } 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, }; } ``` - [ ] **Step 2: Commit** ```bash git add services/capture/src/capture-manager.js git commit -m "feat(capture): replace deltacast _buildInputArgs stub with real bridge spawn" ``` --- ## Task 6: capture-manager.js — `start()` bridge lifecycle + `stop()` cleanup **Files:** - Modify: `services/capture/src/capture-manager.js` Four changes to `start()` and one to `stop()`. - [ ] **Step 1: Store session ID before `_buildInputArgs` call** In `start()`, before the `const { inputArgs, isNetwork } = await this._buildInputArgs(...)` call (currently around line 307), add: ```js this._sessionIdForBridge = sessionId; ``` - [ ] **Step 2: Store bridge state after `_buildInputArgs` returns** After `const { inputArgs, isNetwork } = await this._buildInputArgs(...)`, change the destructuring to also capture `bridgeProcess` and `audioFifo`: ```js const { inputArgs, isNetwork, bridgeProcess = null, audioFifo = null, interlaced = false } = await this._buildInputArgs({ sourceType, device, sourceUrl, listen, listenPort, streamKey, }); ``` - [ ] **Step 3: Pipe bridge stdout into FFmpeg stdin for deltacast** After `const hiresProcess = spawn('ffmpeg', hiresArgs, { stdio: hiresStdio });`, add: ```js // For deltacast, the bridge writes raw video to its stdout. // Pipe it into FFmpeg's stdin so FFmpeg reads -i pipe:0. if (bridgeProcess) { bridgeProcess.stdout.pipe(hiresProcess.stdin); } ``` - [ ] **Step 4: Add bridge to `processes` map and `audioFifo` to `currentSession`** Change the existing `const processes = { hires: hiresProcess };` line to: ```js const processes = { hires: hiresProcess }; if (bridgeProcess) processes.bridge = bridgeProcess; ``` And in the `this.state.currentSession = { ... }` object (near the end of `start()`), add: ```js audioFifo, ``` to the object literal (alongside `sourceType`, `device`, etc.). - [ ] **Step 5: Fix deinterlace filter to include deltacast interlaced signals** Find the line (currently ~321): ```js const sdiFilterArgs = (sourceType === 'sdi') ? ['-vf', 'yadif=mode=1:deint=1'] : []; ``` Replace with: ```js const isInterlacedSource = sourceType === 'sdi' || (sourceType === 'deltacast' && interlaced); const sdiFilterArgs = isInterlacedSource ? ['-vf', 'yadif=mode=1:deint=1'] : []; ``` - [ ] **Step 6: Include deltacast in the HLS split-output branch** Find the line (currently ~334): ```js if (sourceType === 'sdi' && this._assetIdForHls) { ``` Replace with: ```js if ((sourceType === 'sdi' || sourceType === 'deltacast') && this._assetIdForHls) { ``` - [ ] **Step 7: Kill bridge in `stop()` and clean up FIFO** In the `stop()` method, find the existing kill block: ```js if (processes.hires) processes.hires.kill('SIGINT'); if (processes.proxy) processes.proxy.kill('SIGINT'); if (processes.hls) { try { processes.hls.kill('SIGINT'); } catch (_) {} } ``` Add a bridge kill and FIFO cleanup: ```js if (processes.hires) processes.hires.kill('SIGINT'); if (processes.proxy) processes.proxy.kill('SIGINT'); if (processes.hls) { try { processes.hls.kill('SIGINT'); } catch (_) {} } if (processes.bridge) { try { processes.bridge.kill('SIGINT'); } catch (_) {} } ``` Then after the existing `await Promise.all(uploadPromises);` block (around line 462), add FIFO cleanup: ```js if (currentSession.audioFifo) { try { (await import('node:fs')).unlinkSync(currentSession.audioFifo); } catch (_) {} } ``` - [ ] **Step 8: Commit** ```bash git add services/capture/src/capture-manager.js git commit -m "feat(capture): wire bridge process lifecycle into start/stop for deltacast" ``` --- ## Task 7: routes/capture.js — Accept `deltacast` source_type **Files:** - Modify: `services/capture/src/routes/capture.js` (line 329) - [ ] **Step 1: Find the source_type validation block in `/start` handler (around line 318)** Current code: ```js } else { return res.status(400).json({ error: `Unknown source_type: ${source_type}. Must be sdi, srt, or rtmp`, }); } ``` This `else` branch fires when source_type isn't `sdi`, `srt`, or `rtmp`. Add `deltacast` to the accepted list. - [ ] **Step 2: Add deltacast validation before the else block** After the `} else if (source_type === 'srt' || source_type === 'rtmp') {` block, add: ```js } else if (source_type === 'deltacast') { if (device === undefined || device === null) { return res.status(400).json({ error: 'deltacast source requires: device (board/port index)' }); } } else { return res.status(400).json({ error: `Unknown source_type: ${source_type}. Must be sdi, srt, rtmp, or deltacast`, }); } ``` - [ ] **Step 3: Commit** ```bash git add services/capture/src/routes/capture.js git commit -m "feat(capture): accept deltacast as valid source_type in /start handler" ``` --- ## Task 8: Smoke test — verify the build and Node.js changes **Files:** None created. - [ ] **Step 1: Verify the bridge compiles on the capture host (or in Docker)** On the Deltacast machine (once it is available), run: ```bash cd services/capture tar -xzf ../../videomaster-linux.x64-6.34.1-dev.tar.gz -C /tmp/sdk cmake -S deltacast-bridge -B /tmp/bridge-build -DSDK_ROOT=/tmp/sdk -DCMAKE_BUILD_TYPE=Release cmake --build /tmp/bridge-build -j$(nproc) ls -lh /tmp/bridge-build/deltacast-capture ``` Expected: binary present, size ~50–200KB. Until the hardware machine is available, verify the CMakeLists.txt syntax is correct by running the configure step only: ```bash cmake -S services/capture/deltacast-bridge -B /tmp/bridge-test \ -DSDK_ROOT=C:/Users/zacga/Nextcloud/Claude/Projects/Dragonflight \ --check-system-vars 2>&1 | head -20 ``` - [ ] **Step 2: Verify capture-manager.js has no syntax errors** ```bash cd services/capture node --input-type=module < src/capture-manager.js 2>&1 | head -5 ``` Expected: no output (file imports fine) or a module-not-found error for uuid (acceptable — the file is correct). - [ ] **Step 3: Verify routes/capture.js has no syntax errors** ```bash node --input-type=module < services/capture/src/routes/capture.js 2>&1 | head -5 ``` Expected: no output or dependency error only. - [ ] **Step 4: Confirm deltacast recorder creation is rejected correctly without device param** Start the capture service locally (if possible) and POST: ```bash curl -s -X POST http://localhost:3001/capture/start \ -H 'Content-Type: application/json' \ -d '{"project_id":"test","clip_name":"test","source_type":"deltacast"}' | jq . ``` Expected response: ```json {"error":"deltacast source requires: device (board/port index)"} ``` - [ ] **Step 5: Final commit if any fixups were needed** ```bash git add -A git commit -m "fix(capture): deltacast smoke-test fixups" ``` --- ## Hardware Validation Checklist (run on the Deltacast machine) After the hardware machine is available: 1. Build the Docker image: `docker compose build capture` 2. Create a recorder with `source_type=deltacast`, `device=0` 3. Confirm capture container logs show the JSON format line within 5s of feed going live 4. Confirm recorder status shows `signal: "receiving"` 5. Record a 30s clip → verify asset created, proxy + HLS generated 6. Test stop mid-record → file finalized correctly 7. Test no-signal path → recorder stays idle, no asset created 8. Test container restart mid-record → existing asset finalized via `/finalize` endpoint