diff --git a/docs/superpowers/plans/2026-06-01-deltacast-sdi-capture-plan.md b/docs/superpowers/plans/2026-06-01-deltacast-sdi-capture-plan.md new file mode 100644 index 0000000..87cb6fb --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-deltacast-sdi-capture-plan.md @@ -0,0 +1,834 @@ +# 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 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 diff --git a/services/capture/Dockerfile b/services/capture/Dockerfile index 55f7067..1730c6c 100644 --- a/services/capture/Dockerfile +++ b/services/capture/Dockerfile @@ -1,4 +1,21 @@ -# ── Stage 1: Build FFmpeg with DeckLink + NVENC (HEVC/H264) support ───────── +# ── 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 ───────── # All-Intra HEVC NVENC is the master codec for growing-file ingest (see # docs/design/2026-05-29-all-intra-hevc-ingest.md). This stage gets the # nv-codec-headers (header-only, no driver / no full CUDA toolkit needed) @@ -132,6 +149,17 @@ RUN cd /usr/local/lib \ # Verify raw2bmx resolves its libs and runs in the final image. RUN raw2bmx -h >/dev/null 2>&1 && echo 'raw2bmx runtime OK' +# 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 + # Mount points the recorder lifecycle expects to exist. # /live — HLS preview output (bound from host LIVE_DIR by node-agent) # /growing — growing-file master output (bound from host /mnt/NVME/MAM/growing) diff --git a/services/capture/deltacast-bridge/CMakeLists.txt b/services/capture/deltacast-bridge/CMakeLists.txt new file mode 100644 index 0000000..31d71e4 --- /dev/null +++ b/services/capture/deltacast-bridge/CMakeLists.txt @@ -0,0 +1,27 @@ +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 +) \ No newline at end of file diff --git a/services/capture/deltacast-bridge/main.c b/services/capture/deltacast-bridge/main.c new file mode 100644 index 0000000..22cd516 --- /dev/null +++ b/services/capture/deltacast-bridge/main.c @@ -0,0 +1,310 @@ +/* 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: + fprintf(stderr, "{\"error\":\"port %u not supported (max 3)\"}\n", port); + return VHD_ST_RX0; /* caller will fail on signal lock */ + } +} + +/* ── 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) { + ULONG aw = 0; + while (aw < sz) { + ssize_t n = write(fd, buf + aw, sz - aw); + if (n <= 0) { atomic_store(&g_stop, 1); break; } + aw += (ULONG)n; + } + } + } + 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_BUFFERQUEUE_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) { + fprintf(stderr, "[video] VHD_LockSlotHandle error %lu — stopping\n", r); + break; + } + } + + /* ── Cleanup ─────────────────────────────────────────────────────── */ + VHD_StopStream(video_stream); + VHD_CloseStreamHandle(video_stream); + if (audio_tid) pthread_join(audio_tid, NULL); + VHD_CloseBoardHandle(board); + return 0; +} \ No newline at end of file diff --git a/services/capture/src/capture-manager.js b/services/capture/src/capture-manager.js index 9d17959..e2a38af 100644 --- a/services/capture/src/capture-manager.js +++ b/services/capture/src/capture-manager.js @@ -4,6 +4,48 @@ import { dirname } from 'node:path'; import { v4 as uuidv4 } from 'uuid'; import { createUploadStream } from './s3/client.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`))); + }); + }); +} + const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon'; // Growing-files mode: writes the master to a local SMB-backed share that the @@ -545,40 +587,45 @@ class CaptureManager { } // Deltacast SDI via VideoMaster SDK FFmpeg plugin. - // FFmpeg input format is 'deltacast', device address is 'deltacast://'. - // When the physical device is absent (/dev/deltacast missing), fall back - // to a lavfi test card so development and integration testing work without hardware. if (sourceType === 'deltacast') { - const idx = (typeof device === 'number' || /^\d+$/.test(String(device))) - ? parseInt(device, 10) - : 0; - const { existsSync } = await import('node:fs'); - const deviceNode = `/dev/deltacast${idx}`; - if (existsSync(deviceNode)) { - console.log(`[capture] Deltacast index ${idx} → ${deviceNode} (hardware)`); - return { - inputArgs: ['-f', 'deltacast', '-i', `deltacast://${idx}`], - isNetwork: false, - }; - } else { - // No hardware — lavfi test card with port label + timecode burn-in. - // Matches the deltacast-sdi-recorder standalone app fallback exactly so - // recorded files look right in the MAM library during dev. - console.warn(`[capture] Deltacast device ${deviceNode} not found — using lavfi test card for port ${idx}`); - const testSrc = [ - `testsrc2=size=1920x1080:rate=30`, - `drawtext=text='DELTACAST PORT ${idx} — TEST MODE':fontsize=48:fontcolor=white:x=(w-text_w)/2:y=(h-text_h)/2`, - `drawtext=text='%{localtime\\:%H\\:%M\\:%S}':fontsize=32:fontcolor=yellow:x=10:y=10`, - ].join(','); - return { - inputArgs: [ - '-f', 'lavfi', '-i', testSrc, - '-f', 'lavfi', '-i', 'sine=frequency=1000:sample_rate=48000', - '-map', '0:v:0', '-map', '1:a:0', - ], - isNetwork: false, - }; + const idx = (typeof device === 'number' || /^\d+$/.test(String(device))) + ? parseInt(device, 10) : 0; + const audioFifo = `/tmp/dc-audio-${this._sessionIdForBridge}`; + + const { execFileSync: _execFile } = await import('child_process'); + const { unlinkSync: _unlink, existsSync: _exists } = await import('node:fs'); + if (_exists(audioFifo)) { try { _unlink(audioFifo); } catch (_) {} } + try { _execFile('mkfifo', [audioFifo]); } catch (e) { + throw new Error(`Failed to create audio FIFO ${audioFifo}: ${e.message}`); } + + const bridge = spawn('deltacast-capture', [ + '--device', String(idx), + '--port', String(idx), + '--audio-pipe', audioFifo, + '--signal-timeout', '30', + ], { stdio: ['ignore', 'pipe', 'pipe'] }); + + const fmt = await readFirstStderrLine(bridge, 35_000); + bridge.stderr.on('data', (d) => console.error(`[deltacast-bridge] ${d.toString().trimEnd()}`)); + + 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, + }; } // Default: SDI via a pluggable source backend (issue #168). The backend @@ -844,7 +891,8 @@ exit "$BMXRC" const startedAt = new Date().toISOString(); - const { inputArgs, isNetwork } = await this._buildInputArgs({ + this._sessionIdForBridge = sessionId; + const { inputArgs, isNetwork, bridgeProcess = null, audioFifo = null, interlaced = false } = await this._buildInputArgs({ sourceType, sourceBackend, device, sourceUrl, listen, listenPort, streamKey, }); @@ -861,7 +909,8 @@ exit "$BMXRC" if (hiresCodecArgs) console.log('[capture] hires ffmpeg args:', hiresCodecArgs.join(' ')); - const sdiFilterArgs = (sourceType === 'sdi') ? ['-vf', 'yadif=mode=1:deint=1'] : []; + const isInterlacedSource = sourceType === 'sdi' || (sourceType === 'deltacast' && interlaced); + const sdiFilterArgs = isInterlacedSource ? ['-vf', 'yadif=mode=1:deint=1'] : []; // Master output destination (NON-growing path only). // @@ -892,7 +941,7 @@ exit "$BMXRC" // tee, so the live HLS preview is produced as a SECOND OUTPUT of the hires // ffmpeg: one decklink read -> yadif -> split -> [ProRes/S3] + [H.264/HLS]. let sdiHlsDir = null; - if (sourceType === 'sdi' && this._assetIdForHls) { + if ((sourceType === 'sdi' || sourceType === 'deltacast') && this._assetIdForHls) { const fsMod = await import('node:fs'); sdiHlsDir = '/live/' + this._assetIdForHls; try { fsMod.mkdirSync(sdiHlsDir, { recursive: true }); } catch (_) {} @@ -924,7 +973,7 @@ exit "$BMXRC" } else { // ── Finalized (non-growing) master: ffmpeg muxes the MOV directly ── let hiresArgs; - if (sourceType === 'sdi' && this._assetIdForHls) { + if ((sourceType === 'sdi' || sourceType === 'deltacast') && this._assetIdForHls) { hiresArgs = [ ...inputArgs, '-filter_complex', '[0:v]yadif=mode=1:deint=1,split=2[vhi][vlo]', @@ -949,6 +998,9 @@ exit "$BMXRC" hiresArgs = [ ...inputArgs, ...sdiFilterArgs, ...hiresCodecArgs, hiresOutput ]; } hiresProcess = spawn('ffmpeg', hiresArgs, { stdio: hiresStdio }); + if (bridgeProcess) { + bridgeProcess.stdout.pipe(hiresProcess.stdin); + } } // Growing-files: nothing to upload here (promotion worker handles S3). @@ -956,6 +1008,15 @@ exit "$BMXRC" // stop(), once ffmpeg has written the moov and exited cleanly — we can't // upload while recording because the file isn't a valid MOV until finalize. const processes = { hires: hiresProcess }; + if (bridgeProcess) { + processes.bridge = bridgeProcess; + bridgeProcess.on('exit', (code) => { + if (code !== 0 && code !== null) { + console.error(`[deltacast-bridge] exited with code ${code}`); + this.state.lastError = `deltacast bridge exited: code ${code}`; + } + }); + } const uploads = { hires: growingPath ? Promise.resolve({ growingPath }) : null }; // ── HLS tee for network sources (live preview in the UI) ────────── @@ -1023,6 +1084,7 @@ exit "$BMXRC" proxyKey, growingPath, localMasterPath, + audioFifo, startedAt, duration: 0, uploads, @@ -1077,6 +1139,7 @@ exit "$BMXRC" 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 (_) {} } // Wait for the master writer to finalize before we read/upload the file. await waitExit(processes.hires); @@ -1117,6 +1180,10 @@ exit "$BMXRC" console.error('Error during upload completion:', error); } + if (currentSession.audioFifo) { + try { unlinkSync(currentSession.audioFifo); } catch (_) {} + } + const stoppedAt = new Date().toISOString(); const startTime = new Date(currentSession.startedAt); const stopTime = new Date(stoppedAt); diff --git a/services/capture/src/routes/capture.js b/services/capture/src/routes/capture.js index 158c224..0a6cd45 100644 --- a/services/capture/src/routes/capture.js +++ b/services/capture/src/routes/capture.js @@ -325,9 +325,13 @@ router.post('/start', async (req, res) => { error: `${source_type.toUpperCase()} caller mode requires: source_url`, }); } + } 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, or rtmp`, + error: `Unknown source_type: ${source_type}. Must be sdi, srt, rtmp, or deltacast`, }); } diff --git a/services/web-ui/public/app.jsx b/services/web-ui/public/app.jsx index 1bcf73b..5a88667 100644 --- a/services/web-ui/public/app.jsx +++ b/services/web-ui/public/app.jsx @@ -23,6 +23,18 @@ function App() { try { localStorage.setItem('df.sidebar.collapsed', next ? '1' : '0'); } catch {} return next; }); + + // Sync route state with URL hash + React.useEffect(() => { + const parseHash = () => { + const hash = window.location.hash.slice(1); // remove # + const route = hash.startsWith('/') ? hash.slice(1) : hash || 'home'; + setRoute(route); + }; + parseHash(); + window.addEventListener('hashchange', parseHash); + return () => window.removeEventListener('hashchange', parseHash); + }, []); }, []); React.useEffect(() => { diff --git a/services/web-ui/public/styles-playout.css b/services/web-ui/public/styles-playout.css index 29116f8..d6a1757 100644 --- a/services/web-ui/public/styles-playout.css +++ b/services/web-ui/public/styles-playout.css @@ -63,7 +63,7 @@ /* ── Top row: PGM + right rail ───────────────────────────────────────────────── */ .po-top { display: grid; - grid-template-columns: 1fr 300px; + grid-template-columns: minmax(0, 960px) 300px; gap: 12px; align-items: start; }