docs: Deltacast SDI capture implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
298cb18914
commit
96f4f2dd3b
1 changed files with 834 additions and 0 deletions
834
docs/superpowers/plans/2026-06-01-deltacast-sdi-capture-plan.md
Normal file
834
docs/superpowers/plans/2026-06-01-deltacast-sdi-capture-plan.md
Normal file
|
|
@ -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 <fifo>` 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 <N> --port <N> --audio-pipe <path>
|
||||
* [--signal-timeout <sec>]
|
||||
*/
|
||||
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <pthread.h>
|
||||
#include <signal.h>
|
||||
#include <stdatomic.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#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
|
||||
Loading…
Reference in a new issue