Compare commits

..

1 commit

56 changed files with 1151 additions and 6625 deletions

View file

@ -69,14 +69,6 @@ GOOGLE_ALLOWED_DOMAIN=
# the authenticator code (Google is treated as the first factor). Accounts without
# TOTP complete sign-in in one Google step.
# Framecache — shared memory ring buffer for SDI + network ingest fan-out.
# Size in GB. Tune per node based on available RAM and number of SDI inputs.
# Each 1080p59.94 source uses ~494MB (120-frame ring at 4.1MB/frame).
# Baratheon (251GB RAM): 60
# zampp1 (93GB RAM): 40
# zampp2 (18GB RAM): 8 (increase node RAM before deploying capture)
FC_SHM_SIZE_GB=40
# Playout / Master Control (MCR)
# Image tag the mam-api spawns when a channel starts. Build with:
# docker compose --profile build-only build playout

View file

@ -95,21 +95,12 @@ detect_gpu() {
return 1
}
# SDI capture card present? Blackmagic DeckLink or Deltacast.
# Checks (any hit ⇒ present), so a driver/PCI-enumeration race at onboard time
# can't silently drop the capture profile and break recorders:
# 1) lspci vendor match
# 2) Deltacast device nodes (/dev/deltacast*, /dev/delta-*)
# 3) Blackmagic device nodes (/dev/blackmagic*, /dev/decklink*)
# SDI capture card present? Blackmagic DeckLink or Deltacast, via lspci.
detect_sdi() {
if command -v lspci &>/dev/null && lspci 2>/dev/null | grep -iE 'blackmagic|deltacast' &>/dev/null; then
return 0
fi
if ls /dev/deltacast* /dev/delta-* &>/dev/null; then
return 0
fi
if ls /dev/blackmagic* /dev/decklink* &>/dev/null; then
return 0
if command -v lspci &>/dev/null; then
if lspci 2>/dev/null | grep -iE 'blackmagic|deltacast' &>/dev/null; then
return 0
fi
fi
return 1
}
@ -218,10 +209,6 @@ info "Writing $ENV_FILE"
echo "NODE_IP=$NODE_IP"
echo "AGENT_PORT=$AGENT_PORT"
echo "HEARTBEAT_MS=30000"
# Persist detected compose profiles so every subsequent `docker compose up`
# (manual or scripted) brings up the right services — capture/framecache must
# always run on SDI nodes or recorders silently fail. Comma-sep for COMPOSE_PROFILES.
echo "COMPOSE_PROFILES=$(echo $PROFILES | tr ' ' ',')"
[[ -n "$BMD_MODEL" ]] && echo "BMD_MODEL=$BMD_MODEL"
for v in REDIS_URL DATABASE_URL S3_ENDPOINT S3_BUCKET S3_ACCESS_KEY S3_SECRET_KEY S3_REGION; do
val="${!v:-}"

View file

@ -47,10 +47,6 @@ services:
environment:
MAM_API_URL: ${MAM_API_URL}
NODE_TOKEN: ${NODE_TOKEN:-}
# Shared cluster-read token: lets the primary mam-api fan-out read-only
# container/log queries to every node with one token (= mam-api's
# NODE_AGENT_TOKEN). Set identically across the cluster.
CLUSTER_READ_TOKEN: ${CLUSTER_READ_TOKEN:-}
NODE_ROLE: ${NODE_ROLE:-worker}
# NODE_NAME pins the cluster identity (heartbeat key). Set it per-node so
# cloned VMs that share /etc/hostname don't collide on the same
@ -64,13 +60,6 @@ services:
BMD_MODEL: ${BMD_MODEL:-}
BMD_DEVICE_PREFIX: ${BMD_DEVICE_PREFIX:-dv}
LIVE_DIR: ${LIVE_DIR:-/mnt/NVME/MAM/wild-dragon-live}
# Framecache service URL (on the wild-dragon-worker network)
FC_URL: ${FC_URL:-http://framecache:7435}
FRAMECACHE_IP: ${FRAMECACHE_IP:-172.18.91.223} # IP of the framecache host
# net_ingest binary — runs inside the framecache container via docker exec.
# node-agent has docker.sock so it can exec into the framecache container.
# Override with a host-installed path if preferred.
NET_INGEST_BIN: ${NET_INGEST_BIN:-docker exec framecache net_ingest}
# REPO_DIR: host path to the checked-out repo. The agent passes this to the
# one-shot driver-install container so install-driver.sh can read
# sdk/<vendor>/ and run deploy/install-driver.sh. Must match the host path
@ -81,7 +70,6 @@ services:
- /dev:/dev:ro
- /mnt/NVME/MAM/wild-dragon-live:/mnt/NVME/MAM/wild-dragon-live:ro
# Capture-driver deployment ("Capture Drivers / SDKs" in the Cluster admin
# Capture-driver deployment ("Capture Drivers / SDKs" in the Cluster admin
# screen): the agent itself does NOT run dkms/modprobe — it spawns a
# separate privileged ubuntu container that bind-mounts these host paths.
# The agent only needs to *see* the repo path so it can pass it through as
@ -91,7 +79,8 @@ services:
# /dev and /opt from the host (handled in the agent, not here) so DKMS /
# modprobe / ldconfig affect the host kernel.
- ${REPO_DIR:-/opt/wild-dragon}:${REPO_DIR:-/opt/wild-dragon}:ro
# (DeckLink devices are mounted dynamically if present)
devices:
- /dev/blackmagic:/dev/blackmagic
worker:
build: ./services/worker
@ -114,21 +103,10 @@ services:
# SDI capture service — only start on nodes with Blackmagic DeckLink cards
# Set BMD_DEVICE_0 in .env.worker to the actual device path, e.g. /dev/blackmagic/dv0
capture:
build:
context: .
dockerfile: services/capture/Dockerfile
build: ./services/capture
profiles: [capture]
restart: unless-stopped
runtime: nvidia
# Growing-files mode mounts an SMB/CIFS share inside the container
# (mount.cifs). That syscall needs CAP_SYS_ADMIN + DAC_READ_SEARCH and an
# unconfined AppArmor profile; without these the mount fails with
# "Unable to apply new capability set" and growing falls back to HEVC/S3.
cap_add:
- SYS_ADMIN
- DAC_READ_SEARCH
security_opt:
- apparmor:unconfined
environment:
REDIS_URL: ${REDIS_URL}
DATABASE_URL: ${DATABASE_URL}
@ -139,9 +117,9 @@ services:
CAPTURE_PORT: 3001
NVIDIA_VISIBLE_DEVICES: all
NVIDIA_DRIVER_CAPABILITIES: video,compute,utility
# (Devices are dynamically mounted by node-agent)
volumes:
- /dev/shm:/dev/shm
devices:
- ${BMD_DEVICE_0:-/dev/blackmagic/dv0}:/dev/blackmagic/dv0
- ${BMD_DEVICE_1:-/dev/blackmagic/dv1}:/dev/blackmagic/dv1
ports:
- "${CAPTURE_PORT:-7437}:3001"
networks:
@ -173,35 +151,6 @@ services:
networks:
- wild-dragon-worker
# Framecache — shared memory ring buffer for SDI + network ingest fan-out.
# Runs on every worker node that has capture sources (Blackmagic, Deltacast).
# IPC host mode lets all capture sidecars share /dev/shm with this container.
# FC_SHM_SIZE can be tuned per node in .env.worker:
# Baratheon (251GB RAM): FC_SHM_SIZE=64424509440 (60GB)
# zampp1 (93GB RAM): FC_SHM_SIZE=42949672960 (40GB)
# zampp2 (18GB RAM): FC_SHM_SIZE=8589934592 (8GB — increase RAM first)
framecache:
build: ./services/framecache
profiles: [capture]
restart: unless-stopped
init: true
ipc: host
shm_size: '${FC_SHM_SIZE_GB:-40}gb'
environment:
FC_PORT: 7435
ports:
- "7435:7435"
volumes:
- /dev/shm:/dev/shm
networks:
- wild-dragon-worker
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:7435/health"]
interval: 10s
timeout: 3s
retries: 3
start_period: 5s
networks:
wild-dragon-worker:
driver: bridge

View file

@ -1,221 +0,0 @@
# Unified Framecache — Implementation Plan
## Context
Replace the current named-FIFO-per-source architecture with a shared-memory
ring buffer (framecache) that fans raw video frames from any ingest source to
unlimited concurrent consumers with zero-copy reads.
**Approved design:** docs/design/framecache/DESIGN.md
**Branch:** feat/unified-framecache
**Roadmap (out of scope here):** RDMA cross-node, AJA, growing-file-while-recording browser playback
---
## Migration Strategy
Ship in 5 phases. Each phase is independently deployable and leaves the system
in a working state. Existing recording workflows are unaffected until Phase 5
cuts over.
---
## Phase 1 — Framecache Container (foundation)
**Goal:** Running framecache service with slot registry. No ingest writers yet.
### 1.1 — Create `services/framecache/` directory structure
```
services/framecache/
src/
framecache.c # main — slot manager + HTTP API
slot.c / slot.h # shm ring buffer lifecycle
registry.c # /dev/shm/framecache/registry.json writer
http.c # lightweight HTTP server (libmicrohttpd)
client/
fc_client.c / fc_client.h # consumer library
fc_client_node/
binding.cc # Node.js N-API addon
binding.gyp
Dockerfile
CMakeLists.txt
```
### 1.2 — Shared memory layout (slot.h)
Each slot lives at `/dev/shm/framecache/<slot_id>`:
```c
#define FC_MAGIC 0x46524D43 // "FRMC"
#define FC_RING_DEPTH 120 // ~2s at 59.94fps
#define FC_HEADER_SIZE 4096 // 4KB header block
typedef struct {
uint32_t magic;
uint32_t version; // = 1
uint32_t width;
uint32_t height;
uint32_t fps_num;
uint32_t fps_den;
uint32_t pixel_format; // FC_PIX_UYVY422 = 0
uint32_t frame_size; // width * height * 2
uint32_t ring_depth; // = FC_RING_DEPTH
_Atomic uint64_t write_cursor; // monotonically increasing frame index
_Atomic uint64_t dropped_frames;
uint8_t _pad[FC_HEADER_SIZE - 48];
} fc_header_t;
typedef struct {
uint64_t pts_us;
uint64_t wall_us;
uint32_t size;
uint8_t data[]; // frame_size bytes
} fc_frame_t;
```
Semaphore: `sem_open("/framecache-<slot_id>-write", ...)` — posted by writer
on each new frame, consumers `sem_timedwait` on it.
### 1.3 — HTTP API (port 7435)
```
POST /slots body: {slot_id, width, height, fps_num, fps_den, source_type}
creates shm region, writes registry entry
201 {slot_id, shm_path, sem_name}
GET /slots 200 [{slot_id, width, height, fps_num, fps_den,
source_type, write_cursor, dropped_frames,
current_fps}]
GET /slots/:id 200 slot detail
DELETE /slots/:id destroys shm + semaphore, removes registry entry, 204
GET /health 200 {status: "ok"}
```
### 1.4 — Registry file
Written to `/dev/shm/framecache/registry.json` on every slot create/delete.
### 1.5 — Dockerfile
```dockerfile
FROM debian:bookworm
RUN apt-get update && apt-get install -y \
build-essential cmake libmicrohttpd-dev \
&& rm -rf /var/lib/apt/lists/*
COPY . /src
RUN cmake -S /src -B /build -DCMAKE_BUILD_TYPE=Release \
&& cmake --build /build -j$(nproc)
EXPOSE 7435
CMD ["/build/framecache"]
```
### 1.6 — docker-compose.worker.yml addition
```yaml
framecache:
build: ./services/framecache
ipc: host
shm_size: '60gb'
environment:
FC_SHM_SIZE: ${FC_SHM_SIZE:-64424509440}
FC_PORT: 7435
ports:
- "7435:7435"
volumes:
- /dev/shm:/dev/shm
restart: unless-stopped
```
### 1.7 — Consumer library (fc_client.c)
```c
fc_slot_t *fc_open(const char *slot_id);
int fc_read_frame(fc_slot_t *slot, fc_frame_t **out, uint64_t timeout_ms);
void fc_close(fc_slot_t *slot);
```
**Commit:** `feat(framecache): phase 1 — framecache container + consumer library`
---
## Phase 2 — Deltacast Bridge writes to framecache
**Goal:** deltacast-bridge writes frames to framecache shm instead of named FIFOs.
Legacy FIFO path kept as compile-time fallback (`-DLEGACY_FIFO=ON`) until Phase 5.
On signal lock:
1. POST /slots to framecache HTTP API
2. shm_open + mmap the slot
3. Video thread writes frame into ring, advances write_cursor atomically, sem_post
4. Audio: keeps writing to audio FIFO (unchanged)
5. On shutdown: DELETE /slots/:id
**Commit:** `feat(framecache): phase 2 — deltacast-bridge writes to shm`
---
## Phase 3 — Blackmagic DeckLink Bridge
**Goal:** New decklink-bridge C program mirrors deltacast-bridge, replaces
ffmpeg -f decklink direct path.
- Uses IDeckLinkIterator to enumerate devices
- VideoInputFrameArrived callback calls fc_write_frame
- Registers slot on signal lock, deregisters on shutdown
- Audio stays in FIFO (same as deltacast)
**Commit:** `feat(framecache): phase 3 — decklink-bridge writes to shm`
---
## Phase 4 — capture-manager reads from framecache
**Goal:** Enables simultaneous growing + proxy + HLS from one SDI input.
- Node.js N-API addon wrapping fc_open/fc_read_frame/fc_close
- capture-manager opens THREE fc_client handles per slot (own cursor each):
1. Growing/master ffmpeg feed
2. Proxy ffmpeg feed
3. HLS preview ffmpeg feed
- Each gets a separate rawvideo pipe to ffmpeg
- Growing MXF workflow (raw2bmx orchestrator) completely unchanged
**Commit:** `feat(framecache): phase 4 — capture-manager reads from framecache`
---
## Phase 5 — Network ingest (RTMP/SRT) into framecache
**Goal:** RTMP and SRT sources decoded to raw UYVY422, written into framecache slots.
- net_ingest process per source: ffmpeg decodes to rawvideo, writes to slot
- capture-manager waits for slot, same fc_client consumer pattern
- Remove legacy FIFO code once all paths go through framecache
**Commit:** `feat(framecache): phase 5 — network ingest via framecache`
---
## Hardware / Deployment
| Node | RAM | /dev/shm | FC_SHM_SIZE |
|------|-----|----------|-------------|
| Baratheon | 251GB | 126GB | 60GB |
| zampp1 | 93GB | 47GB | 40GB |
| zampp2 | 18GB (upgrade) | 9.4GB | 8GB |
Ring buffer per 1080p59.94 source: ~494MB (120 frames × 4.1MB)
All recorder sidecars require `ipc: host`.
---
## Roadmap (not in this branch)
- Audio in framecache shm
- RDMA cross-node slot replication
- AJA hardware support
- Growing-file-while-recording browser HLS playback
- Mastercontrol/playout consumer

View file

@ -1,6 +1,6 @@
# ── Stage 0: Extract Deltacast VideoMaster SDK ───────────────────────────
FROM debian:bookworm AS sdk-extractor
COPY services/capture/videomaster-linux.x64-6.34.1-dev.tar.gz /tmp/
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 ───────────────────────
@ -9,42 +9,12 @@ 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 services/capture/deltacast-bridge/ /bridge/
RUN rm -rf /bridge/build && cmake -S /bridge -B /bridge/build \
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 1d: Build fc_pipe (framecache slot → stdout adapter) ──────────
# Spawned by capture-manager.js to pipe raw frames from a framecache slot
# into ffmpeg as a rawvideo pipe input. Statically linked against fc_client
# (no runtime dependency on the framecache container — just shm + semaphores).
FROM debian:bookworm AS fc-pipe-builder
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential cmake libmicrohttpd-dev \
&& rm -rf /var/lib/apt/lists/*
COPY services/framecache /fc-src
RUN rm -rf /fc-src/build && cmake -S /fc-src -B /fc-src/build \
-DCMAKE_BUILD_TYPE=Release \
&& cmake --build /fc-src/build --target fc_pipe -j$(nproc)
# ── Stage 1c: Build decklink-bridge binary ───────────────────────────────
FROM debian:bookworm AS decklink-bridge-builder
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential cmake ca-certificates g++ \
&& rm -rf /var/lib/apt/lists/*
# DeckLink SDK headers (for IDeckLinkInput etc.)
COPY services/capture/sdk/ /decklink-sdk/
# Shared fc_writer module from deltacast-bridge
COPY services/capture/deltacast-bridge/ /fc_writer/
# decklink-bridge source
COPY services/capture/decklink-bridge/ /decklink-bridge/
RUN rm -rf /decklink-bridge/build && cmake -S /decklink-bridge -B /decklink-bridge/build \
-DCMAKE_BUILD_TYPE=Release \
-DDECKLINK_SDK_DIR=/decklink-sdk \
-DDELTACAST_BRIDGE_DIR=/fc_writer \
&& cmake --build /decklink-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
@ -61,11 +31,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libzmq3-dev zlib1g-dev libstdc++-12-dev \
&& rm -rf /var/lib/apt/lists/*
# Copy in BMD DeckLink SDK headers and patch script
COPY services/capture/sdk/ /decklink-sdk/
COPY services/capture/patch_decklink.py /patch_decklink.py
COPY services/capture/decklink-sdk16.patch /decklink-sdk16.patch
COPY sdk/ /decklink-sdk/
COPY patch_decklink.py /patch_decklink.py
COPY decklink-sdk16.patch /decklink-sdk16.patch
# nv-codec-headers — just the ffnvcodec public headers + a pkg-config file.
# Pin to a tag known to work with FFmpeg 7.1 (n12.x series).
@ -160,8 +129,8 @@ 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
COPY services/capture/lib/libDeckLinkAPI.so /usr/lib/libDeckLinkAPI.so
COPY services/capture/lib/libDeckLinkPreviewAPI.so /usr/lib/libDeckLinkPreviewAPI.so
COPY lib/libDeckLinkAPI.so /usr/lib/libDeckLinkAPI.so
COPY lib/libDeckLinkPreviewAPI.so /usr/lib/libDeckLinkPreviewAPI.so
# bmx (raw2bmx / bmxtranswrap / mxf2raw) — the growing OP1a MXF writer used for
# the edit-while-record master. Copy the built binaries + shared libs; runtime
@ -182,12 +151,6 @@ 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
# DeckLink bridge binary
COPY --from=decklink-bridge-builder /decklink-bridge/build/decklink-bridge /usr/local/bin/decklink-bridge
# fc_pipe — framecache slot → stdout, spawned by capture-manager.js
COPY --from=fc-pipe-builder /fc-src/build/fc_pipe /usr/local/bin/fc_pipe
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 \
@ -203,9 +166,9 @@ RUN ln -sf libvideomasterhd.so.6.34.1 /usr/local/lib/deltacast/libvideomas
RUN mkdir -p /live /growing
WORKDIR /app
COPY services/capture/package*.json ./
COPY package*.json ./
RUN npm install --omit=dev
COPY services/capture/. .
COPY . .
EXPOSE 3001
CMD ["node", "src/index.js"]

View file

@ -0,0 +1,30 @@
#!/bin/bash
set -e
cd "$(dirname "$0")"
echo "=== Checking prerequisites ==="
if [ ! -f sdk/DeckLinkAPI.h ]; then
echo "ERROR: sdk/DeckLinkAPI.h not found."
echo ""
echo "Please download the Blackmagic DeckLink SDK 16.x from:"
echo " https://www.blackmagicdesign.com/developer/product/capture"
echo ""
echo "Then extract the Linux/include/ folder contents into:"
echo " $(pwd)/sdk/"
echo ""
echo "Required files: DeckLinkAPI.h DeckLinkAPIVersion.h DeckLinkAPIDispatch.cpp"
echo " LinuxCOM.h DeckLinkAPIModes.h DeckLinkAPITypes.h"
exit 1
fi
echo "SDK headers found:"
ls sdk/*.h sdk/*.cpp 2>/dev/null
echo ""
echo "=== Building capture container with DeckLink FFmpeg ==="
docker compose -f ../../docker-compose.yml build capture
echo ""
echo "=== Verifying DeckLink support in built image ==="
docker run --rm wild-dragon-capture ffmpeg -f decklink -list_devices true -i dummy 2>&1 | head -20

View file

@ -1,51 +0,0 @@
cmake_minimum_required(VERSION 3.16)
project(decklink-bridge CXX C)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_C_STANDARD 11)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall -Wextra -O2")
# Path to DeckLink SDK headers (services/capture/sdk/)
set(DECKLINK_SDK_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../sdk"
CACHE PATH "Path to Blackmagic DeckLink SDK headers")
# Path to Deltacast bridge (for fc_writer.h/c — shared writer module)
set(DELTACAST_BRIDGE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../deltacast-bridge"
CACHE PATH "Path to deltacast-bridge (contains fc_writer.h/c)")
# Legacy FIFO fallback option (mirrors deltacast-bridge option)
option(LEGACY_FIFO "Use named FIFOs instead of framecache shm" OFF)
# ── decklink-bridge executable ────────────────────────────────────────
add_executable(decklink-bridge
main.cpp
${DELTACAST_BRIDGE_DIR}/fc_writer.c # shared framecache writer
)
if(LEGACY_FIFO)
target_compile_definitions(decklink-bridge PRIVATE LEGACY_FIFO=1)
message(STATUS "decklink-bridge: LEGACY_FIFO mode enabled")
else()
message(STATUS "decklink-bridge: framecache shm mode enabled")
endif()
target_include_directories(decklink-bridge PRIVATE
${DECKLINK_SDK_DIR}
${DELTACAST_BRIDGE_DIR} # fc_writer.h
)
target_link_libraries(decklink-bridge PRIVATE
pthread
rt # shm_open, sem_open
dl # dlopen (used by DeckLinkAPIDispatch.cpp on Linux)
)
# DeckLink driver is linked at runtime via dlopen (no link-time .so needed).
# The SDK's DeckLinkAPIDispatch.cpp handles the dynamic loading.
set_target_properties(decklink-bridge PROPERTIES
INSTALL_RPATH "/usr/local/lib"
BUILD_WITH_INSTALL_RPATH TRUE
)
install(TARGETS decklink-bridge DESTINATION bin)

View file

@ -1,588 +0,0 @@
/**
* decklink-bridge/main.cpp
*
* Blackmagic DeckLink SDI shared multi-device bridge daemon.
*
* Opens one or more DeckLink devices and for each device:
* - Auto-detects the incoming signal format
* - Registers a framecache slot via HTTP API
* - Writes raw UYVY422 (bmdFormat8BitYUV) video frames into the shm ring
* - Writes PCM s16le audio to a named FIFO (audio-in-shm is roadmap)
*
* Slot ID format: "decklink-<node_id>-<device_index>"
* node_id comes from NODE_ID env var (set by node-agent), falls back to hostname.
*
* Usage:
* decklink-bridge --devices <csv> # device indices, e.g. "0,1"
* decklink-bridge --device <N> # single device compat alias
* [--fc-url http://framecache:7435]
* [--audio-pipe-dir /dev/shm/decklink]
* [--signal-timeout <sec>]
*
* For each device that acquires signal, emits one JSON line to stderr:
* {"device":N,"width":W,"height":H,"fps_num":N,"fps_den":D,
* "interlaced":false,"pix_fmt":"uyvy422",
* "audio_channels":2,"audio_rate":48000,
* "slot_id":"decklink-<node>-<N>"}
*
* Compile with -DLEGACY_FIFO=1 to fall back to writing a raw video FIFO
* instead of the framecache shm path.
*/
#include <algorithm>
#include <atomic>
#include <cerrno>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <vector>
#include <fcntl.h>
#include <pthread.h>
#include <signal.h>
#include <sys/stat.h>
#include <time.h>
#include <unistd.h>
#include "DeckLinkAPI.h"
#include "DeckLinkAPIDispatch.cpp"
#ifndef LEGACY_FIFO
extern "C" {
# include "fc_writer.h"
}
#endif
#ifndef F_SETPIPE_SZ
# define F_SETPIPE_SZ 1031
#endif
#define FC_URL_DEFAULT "http://localhost:7435"
#define AUDIO_PIPE_DIR "/dev/shm/decklink"
#define MAX_DEVICES 8
/* ── Global shutdown flag ──────────────────────────────────────────── */
static std::atomic<int> g_stop{0};
static void on_signal(int) { g_stop.store(1); }
/* ── Helpers ───────────────────────────────────────────────────────── */
static uint64_t now_us() {
struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
return (uint64_t)ts.tv_sec * 1000000ULL + ts.tv_nsec / 1000ULL;
}
static int write_all(int fd, const void *buf, size_t len) {
const uint8_t *p = static_cast<const uint8_t *>(buf);
size_t off = 0;
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
while (off < len) {
ssize_t n = write(fd, p + off, len - off);
if (n > 0) { off += (size_t)n; continue; }
if (n < 0 && errno == EINTR) continue;
if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
struct timespec ts{0, 1000000L};
nanosleep(&ts, nullptr);
continue;
}
fcntl(fd, F_SETFL, flags);
return -1;
}
fcntl(fd, F_SETFL, flags);
return 0;
}
/* ── Per-device state ──────────────────────────────────────────────── */
struct DeviceState {
int device_idx = 0;
IDeckLink *decklink = nullptr;
IDeckLinkInput *input = nullptr;
/* Signal properties (filled on first frame or format-change) */
int width = 0;
int height = 0;
int fps_num = 0;
int fps_den = 1;
int last_width = 0;
int last_height = 0;
int last_fps_num = 0;
int last_fps_den = 1;
bool interlaced = false;
std::atomic<bool> signal_reported{false};
std::string slot_id;
std::string fc_url;
std::string audio_fifo;
#ifndef LEGACY_FIFO
fc_writer_t *fc_writer = nullptr;
/* Guards fc_writer + format fields (width/height/fps/signal_reported)
* against concurrent access from DeckLink SDK callback threads:
* VideoInputFormatChanged and VideoInputFrameArrived can fire on
* different threads without mutual exclusion, and reopen_slot() does
* close-then-open on fc_writer. Without this lock a frame callback could
* call fc_writer_write() on a freed writer (use-after-free), or two
* reopen_slot() calls could double-free. */
pthread_mutex_t fc_lock = PTHREAD_MUTEX_INITIALIZER;
#else
int video_fifo_fd = -1;
std::string video_fifo;
#endif
/* Audio FIFO fd — opened once, reopened on EPIPE */
int audio_fd = -1;
pthread_t audio_tid{};
std::atomic<int> audio_stop{0};
uint64_t frame_seq = 0;
};
/* ── Audio thread ──────────────────────────────────────────────────── */
/* DeckLink audio arrives via VideoInputFrameArrived callback, not a
* separate stream. We write it from the callback directly (see below).
* This thread exists only to keep the FIFO open and provide silence
* when no frames are arriving (e.g. signal lost). */
static void *audio_silence_thread(void *arg) {
DeviceState *ds = static_cast<DeviceState *>(arg);
const int RATE = 48000;
const int CH = 2;
const int FPS = ds->fps_num > 0 ? ds->fps_num : 30;
const int FPS_DEN = ds->fps_den > 0 ? ds->fps_den : 1;
long samples = ((long)RATE * FPS_DEN + FPS / 2) / FPS;
size_t tick = (size_t)samples * (size_t)CH * 2; /* s16le */
std::vector<uint8_t> silence(tick, 0);
while (!g_stop.load() && !ds->audio_stop.load()) {
int fd = open(ds->audio_fifo.c_str(), O_WRONLY);
if (fd < 0) {
struct timespec ts{0, 200000000L};
nanosleep(&ts, nullptr);
continue;
}
fcntl(fd, F_SETPIPE_SZ, 1024 * 1024);
ds->audio_fd = fd;
long frame_ns = (long)(1000000000.0 * (double)FPS_DEN / (double)FPS);
struct timespec next;
clock_gettime(CLOCK_MONOTONIC, &next);
while (!g_stop.load() && !ds->audio_stop.load()) {
/* Only write silence if no real audio arrived recently.
* Real audio is written by VideoInputFrameArrived directly. */
if (write_all(ds->audio_fd, silence.data(), tick) < 0) {
fprintf(stderr, "[audio:%d] EPIPE — reopening\n", ds->device_idx);
break;
}
next.tv_nsec += frame_ns;
while (next.tv_nsec >= 1000000000L) { next.tv_nsec -= 1000000000L; next.tv_sec++; }
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
if (next.tv_sec > now.tv_sec ||
(next.tv_sec == now.tv_sec && next.tv_nsec > now.tv_nsec))
clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, &next, nullptr);
else
next = now;
}
ds->audio_fd = -1;
close(fd);
}
return nullptr;
}
/* ── IDeckLinkInputCallback implementation ─────────────────────────── */
class CaptureCallback : public IDeckLinkInputCallback {
public:
explicit CaptureCallback(DeviceState *ds) : m_ds(ds), m_refcount(1) {}
/* IUnknown */
HRESULT QueryInterface(REFIID, void **) override { return E_NOINTERFACE; }
ULONG AddRef() override { return ++m_refcount; }
ULONG Release() override {
ULONG r = --m_refcount;
if (r == 0) delete this;
return r;
}
/* IDeckLinkInputCallback */
HRESULT VideoInputFormatChanged(
BMDVideoInputFormatChangedEvents events,
IDeckLinkDisplayMode *newMode,
BMDDetectedVideoInputFormatFlags detectedFlags) override
{
/* Re-enable input with new mode — required for auto-detect to work */
m_ds->input->PauseStreams();
BMDDisplayMode mode = newMode->GetDisplayMode();
/* Detect interlaced */
BMDFieldDominance fd = newMode->GetFieldDominance();
m_ds->interlaced = (fd == bmdUpperFieldFirst || fd == bmdLowerFieldFirst);
/* Get width/height */
m_ds->width = (int)newMode->GetWidth();
m_ds->height = (int)newMode->GetHeight();
/* Get frame rate */
BMDTimeValue frameDuration; BMDTimeScale timeScale;
newMode->GetFrameRate(&frameDuration, &timeScale);
m_ds->fps_num = (int)timeScale;
m_ds->fps_den = (int)frameDuration;
m_ds->input->EnableVideoInput(mode, bmdFormat8BitYUV,
bmdVideoInputEnableFormatDetection);
m_ds->input->FlushStreams();
m_ds->input->StartStreams();
fprintf(stderr, "[decklink:%d] format changed: %dx%d %.4ffps %s\n",
m_ds->device_idx,
m_ds->width, m_ds->height,
m_ds->fps_den ? (double)m_ds->fps_num / m_ds->fps_den : 0.0,
m_ds->interlaced ? "interlaced" : "progressive");
/* Re-open framecache slot with new format */
this->reopen_slot();
return S_OK;
}
HRESULT VideoInputFrameArrived(
IDeckLinkVideoInputFrame *videoFrame,
IDeckLinkAudioInputPacket *audioPacket) override
{
if (g_stop.load()) return S_OK;
if (!videoFrame) return S_OK;
/* Detect format on first frame if format-change hasn't fired.
* Use atomic exchange so only ONE thread runs the first-frame init
* even if two frame callbacks race before signal_reported is set. */
bool exp = false;
if (m_ds->signal_reported.compare_exchange_strong(exp, true)) {
m_ds->width = (int)videoFrame->GetWidth();
m_ds->height = (int)videoFrame->GetHeight();
if (m_ds->fps_num == 0) {
m_ds->fps_num = 30000;
m_ds->fps_den = 1001;
}
this->reopen_slot();
}
/* ── Write video frame ──────────────────────────────────────── */
void *bytes = nullptr;
IDeckLinkVideoBuffer *videoBuffer = nullptr;
if (videoFrame->QueryInterface(IID_IDeckLinkVideoBuffer, (void**)&videoBuffer) == S_OK) {
videoBuffer->GetBytes(&bytes);
videoBuffer->Release();
} else {
fprintf(stderr, "[decklink:%d] ERROR: Failed to get IDeckLinkVideoBuffer interface\n", m_ds->device_idx);
return S_OK;
}
uint32_t sz = (uint32_t)(videoFrame->GetRowBytes() * videoFrame->GetHeight());
uint32_t frame_bytes_expected = (uint32_t)m_ds->width * (uint32_t)m_ds->height * 2;
if (sz != frame_bytes_expected) {
fprintf(stderr, "[decklink:%d] WARN: frame sz=%u != expected %u — skipping\n",
m_ds->device_idx, sz, frame_bytes_expected);
return S_OK;
}
uint64_t pts_us = 0;
if (m_ds->fps_num > 0) {
pts_us = m_ds->frame_seq * 1000000ULL
* (uint64_t)m_ds->fps_den
/ (uint64_t)m_ds->fps_num;
}
#ifndef LEGACY_FIFO
/* Lock so a concurrent VideoInputFormatChanged → reopen_slot() cannot
* free fc_writer between our null-check and the write (use-after-free). */
pthread_mutex_lock(&m_ds->fc_lock);
if (m_ds->fc_writer) {
fc_writer_write(m_ds->fc_writer,
static_cast<const uint8_t *>(bytes), sz, pts_us);
}
pthread_mutex_unlock(&m_ds->fc_lock);
#else
if (m_ds->video_fifo_fd >= 0) {
if (write_all(m_ds->video_fifo_fd,
static_cast<const uint8_t *>(bytes), sz) < 0) {
fprintf(stderr, "[decklink:%d] video FIFO EPIPE\n", m_ds->device_idx);
close(m_ds->video_fifo_fd);
m_ds->video_fifo_fd = open(m_ds->video_fifo.c_str(), O_WRONLY | O_NONBLOCK);
if (m_ds->video_fifo_fd >= 0)
fcntl(m_ds->video_fifo_fd, F_SETPIPE_SZ, 64 * 1024 * 1024);
}
}
#endif
m_ds->frame_seq++;
/* ── Write audio ────────────────────────────────────────────── */
if (audioPacket && m_ds->audio_fd >= 0) {
void *abytes = nullptr;
audioPacket->GetBytes(&abytes);
uint32_t sample_count = (uint32_t)audioPacket->GetSampleFrameCount();
uint32_t audio_sz = sample_count * 2 /* ch */ * 2 /* s16le bytes */;
if (abytes && audio_sz > 0) {
/* Non-fatal if pipe is full — silence thread provides fallback */
write_all(m_ds->audio_fd,
static_cast<const uint8_t *>(abytes), audio_sz);
}
}
/* Emit signal JSON once per device on first frame */
if (m_ds->frame_seq == 1) {
fprintf(stderr,
"{\"device\":%d,\"width\":%d,\"height\":%d,"
"\"fps_num\":%d,\"fps_den\":%d,"
"\"interlaced\":%s,"
"\"pix_fmt\":\"uyvy422\","
"\"audio_channels\":2,\"audio_rate\":48000,"
"\"slot_id\":\"%s\"}\n",
m_ds->device_idx,
m_ds->width, m_ds->height,
m_ds->fps_num, m_ds->fps_den,
m_ds->interlaced ? "true" : "false",
m_ds->slot_id.c_str());
fflush(stderr);
}
return S_OK;
}
private:
DeviceState *m_ds;
std::atomic<ULONG> m_refcount;
void reopen_slot() {
#ifndef LEGACY_FIFO
/* Serialize with frame writes and any concurrent reopen_slot() so we
* never double-free fc_writer or write to a half-closed one. */
pthread_mutex_lock(&m_ds->fc_lock);
// If already open with same format, do nothing.
if (m_ds->fc_writer &&
m_ds->width == m_ds->last_width &&
m_ds->height == m_ds->last_height &&
m_ds->fps_num == m_ds->last_fps_num &&
m_ds->fps_den == m_ds->last_fps_den)
{
pthread_mutex_unlock(&m_ds->fc_lock);
return;
}
if (m_ds->fc_writer) {
fc_writer_close(m_ds->fc_writer);
m_ds->fc_writer = nullptr;
}
if (m_ds->width > 0 && m_ds->height > 0 && m_ds->fps_num > 0) {
m_ds->fc_writer = fc_writer_open(
m_ds->fc_url.c_str(),
m_ds->slot_id.c_str(),
(uint32_t)m_ds->width, (uint32_t)m_ds->height,
(uint32_t)m_ds->fps_num, (uint32_t)m_ds->fps_den);
if (m_ds->fc_writer) {
m_ds->last_width = m_ds->width;
m_ds->last_height = m_ds->height;
m_ds->last_fps_num = m_ds->fps_num;
m_ds->last_fps_den = m_ds->fps_den;
} else {
fprintf(stderr, "[decklink:%d] framecache unavailable\n",
m_ds->device_idx);
}
}
pthread_mutex_unlock(&m_ds->fc_lock);
#endif
}
};
/* ── Parse comma-separated device list ────────────────────────────── */
static std::vector<int> parse_devices(const char *csv) {
std::vector<int> out;
char buf[256];
strncpy(buf, csv, sizeof buf - 1);
char *tok = strtok(buf, ",");
while (tok) { out.push_back(atoi(tok)); tok = strtok(nullptr, ","); }
return out;
}
/* ── Main ──────────────────────────────────────────────────────────── */
int main(int argc, char *argv[]) {
std::vector<int> device_indices;
int sig_timeout = 30;
const char *fc_url = getenv("FC_URL") ? getenv("FC_URL") : FC_URL_DEFAULT;
const char *audio_dir = AUDIO_PIPE_DIR;
const char *node_id = getenv("NODE_ID");
char hostname[256] = "local";
if (!node_id) { gethostname(hostname, sizeof hostname); node_id = hostname; }
for (int i = 1; i < argc; i++) {
if (!strcmp(argv[i], "--devices") && i+1 < argc)
device_indices = parse_devices(argv[++i]);
else if (!strcmp(argv[i], "--device") && i+1 < argc)
device_indices.push_back(atoi(argv[++i]));
else if (!strcmp(argv[i], "--fc-url") && i+1 < argc)
fc_url = argv[++i];
else if (!strcmp(argv[i], "--audio-pipe-dir") && i+1 < argc)
audio_dir = argv[++i];
else if (!strcmp(argv[i], "--signal-timeout") && i+1 < argc)
sig_timeout = atoi(argv[++i]);
}
if (device_indices.empty()) {
fprintf(stderr, "{\"error\":\"no devices specified — use --devices 0,1 or --device 0\"}\n");
return 1;
}
signal(SIGINT, on_signal);
signal(SIGTERM, on_signal);
signal(SIGPIPE, SIG_IGN);
/* Ensure audio pipe dir exists */
mkdir(audio_dir, 0755);
/* ── Enumerate DeckLink devices ─────────────────────────────────── */
IDeckLinkIterator *iterator = CreateDeckLinkIteratorInstance();
if (!iterator) {
fprintf(stderr, "{\"error\":\"CreateDeckLinkIteratorInstance failed — DeckLink driver not loaded?\"}\n");
return 1;
}
std::vector<IDeckLink *> all_devices;
IDeckLink *dl = nullptr;
while (iterator->Next(&dl) == S_OK) {
all_devices.push_back(dl);
}
iterator->Release();
fprintf(stderr, "[decklink] %zu device(s) detected\n", all_devices.size());
/* ── Set up per-device state ─────────────────────────────────────── */
std::vector<DeviceState> states(device_indices.size());
std::vector<CaptureCallback *> callbacks(device_indices.size(), nullptr);
for (size_t i = 0; i < device_indices.size(); i++) {
int idx = device_indices[i];
if (idx < 0 || (size_t)idx >= all_devices.size()) {
fprintf(stderr, "{\"error\":\"device index %d out of range (%zu detected)\"}\n",
idx, all_devices.size());
continue;
}
DeviceState &ds = states[i];
ds.device_idx = idx;
ds.fc_url = fc_url;
/* slot_id: "decklink-<node_id>-<device_idx>" */
char sid[128];
snprintf(sid, sizeof sid, "decklink-%s-%d", node_id, idx);
ds.slot_id = sid;
/* Audio FIFO path */
char apath[256];
snprintf(apath, sizeof apath, "%s/audio-%d.fifo", audio_dir, idx);
ds.audio_fifo = apath;
mkfifo(apath, 0666); /* ignore EEXIST */
#ifdef LEGACY_FIFO
/* Video FIFO (legacy path only) */
char vpath[256];
snprintf(vpath, sizeof vpath, "%s/video-%d.fifo", audio_dir, idx);
ds.video_fifo = vpath;
mkfifo(vpath, 0666);
int vfd = open(vpath, O_WRONLY | O_NONBLOCK);
if (vfd >= 0) fcntl(vfd, F_SETPIPE_SZ, 64 * 1024 * 1024);
ds.video_fifo_fd = vfd;
#endif
IDeckLink *decklink = all_devices[(size_t)idx];
ds.decklink = decklink;
/* Get IDeckLinkInput */
IDeckLinkInput *input = nullptr;
if (decklink->QueryInterface(IID_IDeckLinkInput,
reinterpret_cast<void **>(&input)) != S_OK) {
fprintf(stderr, "[decklink:%d] QueryInterface IDeckLinkInput failed\n", idx);
continue;
}
ds.input = input;
/* Install callback */
CaptureCallback *cb = new CaptureCallback(&ds);
callbacks[i] = cb;
input->SetCallback(cb);
/* Enable video with format detection — actual mode set on first
* VideoInputFormatChanged; use 1080i29.97 as a safe starting mode. */
HRESULT hr = input->EnableVideoInput(
bmdModeHD1080i5994,
bmdFormat8BitYUV,
bmdVideoInputEnableFormatDetection);
if (hr != S_OK) {
fprintf(stderr, "[decklink:%d] EnableVideoInput failed (0x%08x)\n", idx, (unsigned)hr);
continue;
}
/* Enable audio input — 48kHz stereo s16le */
input->EnableAudioInput(bmdAudioSampleRate48kHz,
bmdAudioSampleType16bitInteger, 2);
/* Start silence thread (keeps audio FIFO open) */
ds.fps_num = 30000; ds.fps_den = 1001; /* default until format detected */
pthread_create(&ds.audio_tid, nullptr, audio_silence_thread, &ds);
/* Start capture */
if (input->StartStreams() != S_OK) {
fprintf(stderr, "[decklink:%d] StartStreams failed\n", idx);
continue;
}
fprintf(stderr, "[decklink:%d] capture started, waiting for signal...\n", idx);
}
/* ── Run until shutdown ─────────────────────────────────────────── */
while (!g_stop.load()) {
struct timespec ts{0, 100000000L}; /* 100ms */
nanosleep(&ts, nullptr);
}
fprintf(stderr, "[decklink] shutdown signal received\n");
/* ── Cleanup ─────────────────────────────────────────────────────── */
for (size_t i = 0; i < states.size(); i++) {
DeviceState &ds = states[i];
if (ds.input) {
ds.input->StopStreams();
ds.input->DisableVideoInput();
ds.input->DisableAudioInput();
ds.input->SetCallback(nullptr);
}
ds.audio_stop.store(1);
if (ds.audio_tid) pthread_join(ds.audio_tid, nullptr);
#ifndef LEGACY_FIFO
if (ds.fc_writer) {
fc_writer_close(ds.fc_writer);
ds.fc_writer = nullptr;
}
#else
if (ds.video_fifo_fd >= 0) close(ds.video_fifo_fd);
#endif
if (ds.input) { ds.input->Release(); ds.input = nullptr; }
if (callbacks[i]) { callbacks[i]->Release(); callbacks[i] = nullptr; }
}
for (auto *d : all_devices) d->Release();
return 0;
}

View file

@ -4,19 +4,8 @@ set(CMAKE_C_STANDARD 17)
set(SDK_ROOT "/sdk" CACHE PATH "Path to extracted VideoMaster SDK")
# Legacy FIFO mode — set LEGACY_FIFO=ON to disable framecache shm writes
# and fall back to the original named-FIFO path.
option(LEGACY_FIFO "Use named FIFOs instead of framecache shm" OFF)
# Primary binary: deltacast-bridge (shared multi-port daemon)
add_executable(deltacast-bridge main.c fc_writer.c)
if(LEGACY_FIFO)
target_compile_definitions(deltacast-bridge PRIVATE LEGACY_FIFO=1)
message(STATUS "deltacast-bridge: LEGACY_FIFO mode enabled (shm disabled)")
else()
message(STATUS "deltacast-bridge: framecache shm mode enabled")
endif()
add_executable(deltacast-bridge main.c)
target_include_directories(deltacast-bridge PRIVATE
${SDK_ROOT}/include/videomaster
@ -30,7 +19,6 @@ target_link_libraries(deltacast-bridge PRIVATE
videomasterhd
videomasterhd_audio
pthread
rt # shm_open, sem_open
)
# Embed the SDK RPATH so the binary finds the .so at runtime

View file

@ -1,309 +0,0 @@
/**
* fc_writer.c Framecache slot writer for deltacast-bridge.
*
* Uses only POSIX + libc no external dependencies beyond what the bridge
* already links. HTTP calls are done with raw sockets (tiny GET/POST/DELETE)
* to avoid pulling in libcurl.
*/
#include "fc_writer.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <stdatomic.h>
#include <errno.h>
#include <fcntl.h>
#include <netdb.h>
#include <sys/mman.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <semaphore.h>
#include <time.h>
#include <unistd.h>
#include <netinet/in.h>
#include <arpa/inet.h>
/* Re-use the shared memory layout from the framecache service */
#define FC_MAGIC 0x46524D43u
#define FC_VERSION 1u
#define FC_RING_DEPTH 120u
#define FC_HEADER_SIZE 4096u
#define FC_FRAME_HDR_SIZE 24u
typedef struct {
uint32_t magic;
uint32_t version;
uint32_t width;
uint32_t height;
uint32_t fps_num;
uint32_t fps_den;
uint32_t pixel_format;
uint32_t frame_size;
uint32_t ring_depth;
uint32_t _reserved;
_Atomic uint64_t write_cursor;
_Atomic uint64_t dropped_frames;
char source_type[32];
char slot_id[64];
uint8_t _pad[FC_HEADER_SIZE - 112];
} fc_hdr_t;
typedef struct {
uint64_t pts_us;
uint64_t wall_us;
uint32_t size;
uint32_t _pad;
uint8_t data[];
} fc_frm_t;
struct fc_writer {
void *base;
size_t shm_size;
int shm_fd;
sem_t *sem;
char slot_id[64];
char fc_url[256]; /* base URL for DELETE on close */
char shm_path[128];
char sem_name[128];
};
/* ── tiny HTTP helper ──────────────────────────────────────────────── */
static int http_request(const char *method,
const char *host, int port, const char *path,
const char *body, /* NULL for GET/DELETE */
char *resp_buf, size_t resp_len)
{
struct sockaddr_in sa;
memset(&sa, 0, sizeof sa);
sa.sin_family = AF_INET;
sa.sin_port = htons((uint16_t)port);
struct hostent *he = gethostbyname(host);
if (!he) return -1;
memcpy(&sa.sin_addr, he->h_addr_list[0], (size_t)he->h_length);
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) return -1;
struct timeval tv = { .tv_sec = 5 };
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv);
if (connect(fd, (struct sockaddr *)&sa, sizeof sa) < 0) {
close(fd); return -1;
}
char req[4096];
int req_len;
if (body) {
req_len = snprintf(req, sizeof req,
"%s %s HTTP/1.0\r\n"
"Host: %s:%d\r\n"
"Content-Type: application/json\r\n"
"Content-Length: %zu\r\n"
"Connection: close\r\n\r\n"
"%s",
method, path, host, port, strlen(body), body);
} else {
req_len = snprintf(req, sizeof req,
"%s %s HTTP/1.0\r\n"
"Host: %s:%d\r\n"
"Connection: close\r\n\r\n",
method, path, host, port);
}
if (send(fd, req, (size_t)req_len, 0) < 0) { close(fd); return -1; }
int status = -1;
size_t got = 0;
char buf[8192];
ssize_t n;
while ((n = recv(fd, buf + got, sizeof buf - got - 1, 0)) > 0)
got += (size_t)n;
buf[got] = '\0';
/* Parse status line */
if (sscanf(buf, "HTTP/%*s %d", &status) != 1) status = -1;
/* Copy body (after \r\n\r\n) into resp_buf */
if (resp_buf && resp_len > 0) {
const char *body_start = strstr(buf, "\r\n\r\n");
if (body_start) {
strncpy(resp_buf, body_start + 4, resp_len - 1);
resp_buf[resp_len - 1] = '\0';
}
}
close(fd);
return status;
}
/* Parse "host:port" or just "host" from a URL like "http://host:port" */
static void parse_url(const char *url, char *host, size_t hlen, int *port)
{
const char *p = url;
if (strncmp(p, "http://", 7) == 0) p += 7;
*port = 7435;
const char *colon = strchr(p, ':');
if (colon) {
size_t n = (size_t)(colon - p);
if (n >= hlen) n = hlen - 1;
strncpy(host, p, n);
host[n] = '\0';
*port = atoi(colon + 1);
} else {
strncpy(host, p, hlen - 1);
host[hlen - 1] = '\0';
}
}
static int json_str(const char *json, const char *key, char *out, size_t len)
{
char pat[128];
snprintf(pat, sizeof pat, "\"%s\":", key);
const char *p = strstr(json, pat);
if (!p) return -1;
p += strlen(pat);
while (*p == ' ') p++;
if (*p != '"') return -1;
p++;
size_t i = 0;
while (*p && *p != '"' && i < len - 1) out[i++] = *p++;
out[i] = '\0';
return 0;
}
/* ── public API ────────────────────────────────────────────────────── */
fc_writer_t *fc_writer_open(const char *fc_url,
const char *slot_id,
uint32_t width, uint32_t height,
uint32_t fps_num, uint32_t fps_den)
{
char host[128]; int port;
parse_url(fc_url, host, sizeof host, &port);
/* POST /slots */
char body[512];
snprintf(body, sizeof body,
"{\"slot_id\":\"%s\","
"\"width\":%u,\"height\":%u,"
"\"fps_num\":%u,\"fps_den\":%u,"
"\"source_type\":\"deltacast\"}",
slot_id, width, height, fps_num, fps_den);
char resp[1024] = {0};
int status = http_request("POST", host, port, "/slots", body, resp, sizeof resp);
if (status == 409) {
/* Already exists, fetch slot details */
char path[256];
snprintf(path, sizeof path, "/slots/%s", slot_id);
fprintf(stderr, "[fc_writer:%s] GET %s\n", slot_id, path);
status = http_request("GET", host, port, path, NULL, resp, sizeof resp);
fprintf(stderr, "[fc_writer:%s] GET status=%d resp=%s\n", slot_id, status, resp);
}
if (status != 200 && status != 201) {
fprintf(stderr, "[fc_writer:%s] POST/GET /slots failed (HTTP %d): %s\n",
slot_id, status, resp);
return NULL;
}
char shm_path[128] = {0}, sem_name[128] = {0};
json_str(resp, "shm_path", shm_path, sizeof shm_path);
json_str(resp, "sem_name", sem_name, sizeof sem_name);
if (!shm_path[0] || !sem_name[0]) {
fprintf(stderr, "[fc_writer:%s] bad response (missing shm_path/sem_name)\n", slot_id);
return NULL;
}
/* mmap the shm file */
int fd = open(shm_path, O_RDWR);
if (fd < 0) {
fprintf(stderr, "[fc_writer:%s] open %s: %s\n", slot_id, shm_path, strerror(errno));
return NULL;
}
/* Read header to get frame_size */
fc_hdr_t hdr;
if (pread(fd, &hdr, sizeof hdr, 0) != sizeof hdr || hdr.magic != FC_MAGIC) {
fprintf(stderr, "[fc_writer:%s] bad shm header\n", slot_id);
close(fd); return NULL;
}
size_t total = (size_t)FC_HEADER_SIZE
+ (size_t)FC_RING_DEPTH * ((size_t)FC_FRAME_HDR_SIZE + hdr.frame_size);
void *base = mmap(NULL, total, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (base == MAP_FAILED) {
fprintf(stderr, "[fc_writer:%s] mmap: %s\n", slot_id, strerror(errno));
close(fd); return NULL;
}
sem_t *sem = sem_open(sem_name, 0);
if (sem == SEM_FAILED) {
fprintf(stderr, "[fc_writer:%s] sem_open %s: %s\n", slot_id, sem_name, strerror(errno));
munmap(base, total); close(fd); return NULL;
}
fc_writer_t *w = calloc(1, sizeof *w);
if (!w) { sem_close(sem); munmap(base, total); close(fd); return NULL; }
w->base = base;
w->shm_size = total;
w->shm_fd = fd;
w->sem = sem;
strncpy(w->slot_id, slot_id, sizeof w->slot_id - 1);
strncpy(w->fc_url, fc_url, sizeof w->fc_url - 1);
strncpy(w->shm_path, shm_path, sizeof w->shm_path - 1);
strncpy(w->sem_name, sem_name, sizeof w->sem_name - 1);
fprintf(stderr, "[fc_writer:%s] slot open (%ux%u %.2ffps shm=%s)\n",
slot_id, width, height,
fps_den ? (double)fps_num / fps_den : 0.0, shm_path);
return w;
}
void fc_writer_write(fc_writer_t *w,
const uint8_t *data, uint32_t size,
uint64_t pts_us)
{
fc_hdr_t *hdr = (fc_hdr_t *)w->base;
uint64_t cur = atomic_load_explicit(&hdr->write_cursor, memory_order_relaxed);
uint64_t idx = cur % FC_RING_DEPTH;
/* Locate frame in ring */
uint8_t *frames = (uint8_t *)w->base + FC_HEADER_SIZE;
fc_frm_t *frame = (fc_frm_t *)(frames + idx * ((size_t)FC_FRAME_HDR_SIZE + hdr->frame_size));
struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
uint64_t wall = (uint64_t)ts.tv_sec * 1000000ULL + ts.tv_nsec / 1000ULL;
frame->pts_us = pts_us;
frame->wall_us = wall;
frame->size = size < hdr->frame_size ? size : hdr->frame_size;
memcpy(frame->data, data, frame->size);
atomic_store_explicit(&hdr->write_cursor, cur + 1, memory_order_release);
sem_post(w->sem);
}
void fc_writer_close(fc_writer_t *w)
{
if (!w) return;
/* DELETE /slots/:id */
char host[128]; int port;
parse_url(w->fc_url, host, sizeof host, &port);
char path[192];
snprintf(path, sizeof path, "/slots/%s", w->slot_id);
http_request("DELETE", host, port, path, NULL, NULL, 0);
sem_close(w->sem);
munmap(w->base, w->shm_size);
close(w->shm_fd);
fprintf(stderr, "[fc_writer:%s] slot closed\n", w->slot_id);
free(w);
}

View file

@ -1,50 +0,0 @@
/**
* fc_writer.h Lightweight framecache slot writer for deltacast-bridge.
*
* Registers a slot with the framecache HTTP API on signal lock, then writes
* raw UYVY422 frames directly into the shared memory ring buffer.
*
* Compile with -DLEGACY_FIFO to disable shm writes and fall back to the
* original named-FIFO path (useful during transition / on nodes without the
* framecache container running).
*/
#pragma once
#include <stdint.h>
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef struct fc_writer fc_writer_t;
/**
* Register a slot with the framecache service and open the shm region for
* writing. fc_url is the HTTP base URL, e.g. "http://localhost:7435".
* slot_id must be unique per port, e.g. "deltacast-0-3" (device-port).
*
* Returns writer handle on success, NULL on failure (falls back to FIFO).
*/
fc_writer_t *fc_writer_open(const char *fc_url,
const char *slot_id,
uint32_t width, uint32_t height,
uint32_t fps_num, uint32_t fps_den);
/**
* Write one raw UYVY422 frame into the ring buffer.
* Non-blocking slow consumers are skipped, not waited on.
* pts_us: presentation timestamp in microseconds (0 if unknown).
*/
void fc_writer_write(fc_writer_t *w,
const uint8_t *data, uint32_t size,
uint64_t pts_us);
/**
* Deregister slot from framecache service and unmap shm.
*/
void fc_writer_close(fc_writer_t *w);
#ifdef __cplusplus
}
#endif

View file

@ -3,32 +3,20 @@
* Deltacast VideoMaster SDI shared multi-port bridge daemon.
*
* Opens the board ONCE, opens RX streams for all requested ports, and
* writes each port's video frames into a shared-memory framecache slot
* (and audio to a named FIFO audio-in-shm is a future roadmap item).
*
* Signal fan-out architecture:
* Board video_thread fc_writer /dev/shm/framecache/<slot>
* N consumers (recording, proxy,
* HLS preview) each read with
* their own cursor zero-copy,
* no bandwidth splitting.
* writes each port's video/audio to named FIFOs in a shared directory.
* One reader thread + one audio thread per port run concurrently.
*
* Usage:
* deltacast-bridge --device <N> --ports <csv>
* [--video-pipe-dir /dev/shm/deltacast]
* [--audio-pipe-dir /dev/shm/deltacast]
* [--fc-url http://framecache:7435]
* [--signal-timeout <sec>]
*
* Compat alias: --port <N> treated as --ports <N> (single port).
*
* For each port that acquires signal, emits one JSON line to stderr:
* {"port":N,"width":W,"height":H,"fps_num":N,"fps_den":D,
* "pix_fmt":"uyvy422","audio_rate":48000,"audio_channels":2,
* "slot_id":"deltacast-<device>-<port>"}
*
* Compile with -DLEGACY_FIFO=1 to disable shm writes and fall back to
* the original named-FIFO path (for nodes without framecache running).
* "pix_fmt":"uyvy422","audio_rate":48000,"audio_channels":2}
*
* Runs until SIGTERM/SIGINT, then closes all streams and the board.
*/
@ -49,17 +37,10 @@
#include "VideoMasterHD_Sdi.h"
#include "VideoMasterHD_Sdi_Audio.h"
#ifndef LEGACY_FIFO
# include "fc_writer.h"
#endif
#ifndef F_SETPIPE_SZ
#define F_SETPIPE_SZ 1031
#endif
/* Default framecache URL — overridden by FC_URL env var or --fc-url arg */
#define FC_URL_DEFAULT "http://localhost:7435"
/* ── Constants ────────────────────────────────────────────────────────── */
#define MAX_PORTS 8
@ -173,16 +154,11 @@ typedef struct {
VideoInfo vi;
char video_fifo[256];
char audio_fifo[256];
char slot_id[128]; /* framecache slot id: "deltacast-<dev>-<port>" */
char fc_url[256]; /* framecache HTTP base URL */
/* threads */
pthread_t video_tid;
pthread_t audio_tid;
/* streams (owned by threads, set before thread launch) */
HANDLE video_stream;
#ifndef LEGACY_FIFO
fc_writer_t *fc_writer; /* shm ring buffer writer (NULL = use FIFO fallback) */
#endif
} PortState;
/* ── Audio thread ──────────────────────────────────────────────────────
@ -276,42 +252,6 @@ static void *audio_thread(void *arg) {
}
fcntl(fd, F_SETPIPE_SZ, 1024 * 1024);
/* ── Flush the VHD audio slot backlog to the LIVE edge ──────────────
* While no reader is attached (recorder idle/standby), the open() above
* blocks but the VHD audio stream keeps running, so its internal slot
* queue fills with buffered audio. Without flushing, the first thing a
* newly-attached reader (the record ffmpeg) receives is that backlog
* several seconds of stale/sync-warmup audio that plays as leading
* silence and pushes the audio stream out of alignment with the live
* video. Drain all immediately-available slots (non-blocking via the
* SDK timeout) so we hand the reader the LIVE edge, frame-aligned with
* the video that fc_pipe is delivering right now. */
if (have_vhd_audio) {
/* Drain the QUEUED backlog only: keep discarding slots while each
* lock returns FAST (the board hands back already-buffered slots in
* well under a frame period). The first lock that takes ~a full frame
* period means the queue is empty and we're now waiting on a LIVE
* slot at that point we've reached the live edge, so stop WITHOUT
* consuming it (the inner loop will pick it up and write it). */
const long fast_ns = frame_ns / 2; /* "immediate" threshold */
int flushed = 0;
for (;;) {
struct timespec a, b;
clock_gettime(CLOCK_MONOTONIC, &a);
HANDLE fslot = NULL;
ULONG fr = VHD_LockSlotHandle(stream, &fslot);
clock_gettime(CLOCK_MONOTONIC, &b);
if (fr != VHDERR_NOERROR) break; /* TIMEOUT/error => drained */
long lock_ns = (b.tv_sec - a.tv_sec) * 1000000000L + (b.tv_nsec - a.tv_nsec);
VHD_UnlockSlotHandle(fslot);
if (lock_ns >= fast_ns) break; /* waited for a live slot => stop */
if (++flushed > 8192) break; /* hard safety cap */
}
if (flushed > 0)
fprintf(stderr, "[audio:%u] flushed %d stale slots on reader attach\n",
ps->port, flushed);
}
/* Reset wall-clock baseline after potentially blocking on open().
* Only used for the SILENCE fallback path (no hardware audio). */
struct timespec next;
@ -403,67 +343,10 @@ static void *audio_thread(void *arg) {
static void *video_thread(void *arg) {
PortState *ps = (PortState *)arg;
#ifndef LEGACY_FIFO
/* ── Framecache shm path (primary) ──────────────────────────────────
* Write frames directly into the shared memory ring buffer.
* Multiple consumers (growing recorder, proxy encoder, HLS preview)
* each hold their own read cursor and read independently no FIFO
* splitting, no bandwidth halving.
*
* The fc_writer was opened by main() after signal lock. If it is
* NULL the framecache service was unavailable and we fall through to
* the legacy FIFO path automatically.
*/
if (ps->fc_writer) {
uint64_t frame_seq = 0;
while (!atomic_load(&g_stop) && !atomic_load(&g_port_stop[ps->port])) {
HANDLE slot = NULL;
ULONG r = VHD_LockSlotHandle(ps->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 expected = (ULONG)ps->vi.width * (ULONG)ps->vi.height * 2;
if (sz != expected) {
fprintf(stderr,
"[video:%u] WARN: sz=%lu != expected %lu — packing mismatch, skipping\n",
ps->port, (unsigned long)sz, (unsigned long)expected);
VHD_UnlockSlotHandle(slot);
continue;
}
/* pts: frame index × frame duration in µs */
uint64_t pts_us = 0;
if (ps->vi.fps_num > 0) {
pts_us = frame_seq * 1000000ULL
* (uint64_t)ps->vi.fps_den
/ (uint64_t)ps->vi.fps_num;
}
fc_writer_write(ps->fc_writer, buf, (uint32_t)sz, pts_us);
frame_seq++;
}
VHD_UnlockSlotHandle(slot);
} else if (r != VHDERR_TIMEOUT) {
fprintf(stderr, "[video:%u] VHD_LockSlotHandle error %lu — stopping port\n",
ps->port, (unsigned long)r);
atomic_store(&g_port_stop[ps->port], 1);
break;
}
}
return NULL;
}
/* fc_writer == NULL → fall through to FIFO path */
fprintf(stderr, "[video:%u] fc_writer unavailable — falling back to FIFO\n", ps->port);
#endif /* !LEGACY_FIFO */
/* ── Legacy FIFO path ────────────────────────────────────────────────
* Kept as compile-time fallback (-DLEGACY_FIFO=1) or when the
* framecache service is not reachable at startup.
*
* Outer loop: reopen the FIFO writer each time a reader connects.
* EPIPE means the ffmpeg sidecar for this port died (session
* stop/restart), NOT a hardware fault. Reopen and block until the
* next recorder start; other ports are unaffected.
*/
/* Outer loop: reopen the FIFO writer each time a reader connects.
* Mirror the audio thread pattern EPIPE means the ffmpeg sidecar for
* this port died (session stop/restart), NOT a hardware fault. We reopen
* and block until the next recorder start; other ports are unaffected. */
while (!atomic_load(&g_stop) && !atomic_load(&g_port_stop[ps->port])) {
int fd = open(ps->video_fifo, O_WRONLY);
@ -476,8 +359,7 @@ static void *video_thread(void *arg) {
{
int pipe_sz = 64 * 1024 * 1024; /* 64 MB — ~16 frames of 1080p UYVY */
if (fcntl(fd, F_SETPIPE_SZ, pipe_sz) < 0) {
fprintf(stderr, "[video:%u] fcntl F_SETPIPE_SZ failed: %s\n",
ps->port, strerror(errno));
fprintf(stderr, "[video:%u] fcntl F_SETPIPE_SZ failed: %s\n", ps->port, strerror(errno));
}
}
@ -491,14 +373,14 @@ static void *video_thread(void *arg) {
if (VHD_GetSlotBuffer(slot, VHD_SDI_BT_VIDEO, &buf, &sz) == VHDERR_NOERROR) {
ULONG expected = (ULONG)ps->vi.width * (ULONG)ps->vi.height * 2;
if (sz != expected) {
fprintf(stderr,
"[video:%u] WARN: slot sz=%lu != expected %lu (w=%d h=%d) -- packing mismatch; skipping frame\n",
ps->port, (unsigned long)sz, (unsigned long)expected,
ps->vi.width, ps->vi.height);
fprintf(stderr, "[video:%u] WARN: slot sz=%lu != expected %lu (w=%d h=%d) -- packing mismatch; skipping frame\n",
ps->port, sz, expected, ps->vi.width, ps->vi.height);
VHD_UnlockSlotHandle(slot);
continue;
}
if (write_all(fd, buf, sz) < 0) {
/* EPIPE: sidecar died (session stop/restart).
* Break to outer loop reopen for next session. */
fprintf(stderr, "[video:%u] EPIPE — waiting for next reader\n", ps->port);
VHD_UnlockSlotHandle(slot);
break;
@ -507,7 +389,7 @@ static void *video_thread(void *arg) {
VHD_UnlockSlotHandle(slot);
} else if (r != VHDERR_TIMEOUT) {
fprintf(stderr, "[video:%u] VHD_LockSlotHandle error %lu — stopping port\n",
ps->port, (unsigned long)r);
ps->port, r);
atomic_store(&g_port_stop[ps->port], 1);
fatal = 1;
break;
@ -537,15 +419,12 @@ static int parse_ports(const char *csv, unsigned *ports, int max) {
/* ── Main ─────────────────────────────────────────────────────────────── */
int main(int argc, char *argv[]) {
unsigned device_id = 0;
unsigned ports[MAX_PORTS] = {0};
int port_count = 0;
int sig_timeout = 30;
const char *video_pipe_dir = "/dev/shm/deltacast";
const char *audio_pipe_dir = "/dev/shm/deltacast";
/* Framecache URL: CLI arg > FC_URL env var > default */
const char *fc_url_env = getenv("FC_URL");
const char *fc_url = fc_url_env ? fc_url_env : FC_URL_DEFAULT;
unsigned device_id = 0;
unsigned ports[MAX_PORTS] = {0};
int port_count = 0;
int sig_timeout = 30;
const char *video_pipe_dir = "/dev/shm/deltacast";
const char *audio_pipe_dir = "/dev/shm/deltacast";
for (int i = 1; i < argc; i++) {
if (!strcmp(argv[i], "--device") && i+1 < argc) {
@ -562,8 +441,6 @@ int main(int argc, char *argv[]) {
audio_pipe_dir = argv[++i];
} else if (!strcmp(argv[i], "--signal-timeout") && i+1 < argc) {
sig_timeout = atoi(argv[++i]);
} else if (!strcmp(argv[i], "--fc-url") && i+1 < argc) {
fc_url = argv[++i];
}
}
@ -724,37 +601,16 @@ int main(int argc, char *argv[]) {
"%s/video-%u.fifo", video_pipe_dir, ports[pi]);
snprintf(p->audio_fifo, sizeof(p->audio_fifo),
"%s/audio-%u.fifo", audio_pipe_dir, ports[pi]);
snprintf(p->slot_id, sizeof(p->slot_id),
"deltacast-%u-%u", device_id, ports[pi]);
strncpy(p->fc_url, fc_url, sizeof(p->fc_url) - 1);
/* Create audio FIFO (always needed — audio stays in FIFO for now). */
if (mkfifo(p->audio_fifo, 0666) != 0 && errno != EEXIST) {
fprintf(stderr, "[port:%u] mkfifo audio failed: %s\n", ports[pi], strerror(errno));
continue;
}
#ifndef LEGACY_FIFO
/* Open framecache slot for video frames.
* Fall back to FIFO if framecache is unreachable. */
p->fc_writer = fc_writer_open(p->fc_url, p->slot_id,
(uint32_t)p->vi.width, (uint32_t)p->vi.height,
(uint32_t)p->vi.fps_num, (uint32_t)p->vi.fps_den);
if (!p->fc_writer) {
fprintf(stderr, "[port:%u] framecache unavailable — creating video FIFO fallback\n",
ports[pi]);
if (mkfifo(p->video_fifo, 0666) != 0 && errno != EEXIST) {
fprintf(stderr, "[port:%u] mkfifo video failed: %s\n", ports[pi], strerror(errno));
continue;
}
}
#else
/* Legacy: always use video FIFO */
/* Create FIFOs (mkfifo; ignore EEXIST). */
if (mkfifo(p->video_fifo, 0666) != 0 && errno != EEXIST) {
fprintf(stderr, "[port:%u] mkfifo video failed: %s\n", ports[pi], strerror(errno));
continue;
}
#endif
if (mkfifo(p->audio_fifo, 0666) != 0 && errno != EEXIST) {
fprintf(stderr, "[port:%u] mkfifo audio failed: %s\n", ports[pi], strerror(errno));
continue;
}
/* Open video stream. */
HANDLE vs = NULL;
@ -788,23 +644,19 @@ int main(int argc, char *argv[]) {
continue;
}
/* Emit format JSON to stderr (one line per port on signal lock).
* Includes slot_id so node-agent / capture-manager can identify
* the framecache slot for this port. */
/* Emit format JSON to stderr (one line per port on signal lock). */
fprintf(stderr,
"{\"port\":%u,\"width\":%d,\"height\":%d,"
"\"fps_num\":%d,\"fps_den\":%d,"
"\"interlaced\":%s,"
"\"pix_fmt\":\"uyvy422\","
"\"audio_channels\":2,\"audio_rate\":48000,"
"\"device\":%u,"
"\"slot_id\":\"%s\"}\n",
"\"device\":%u}\n",
ports[pi],
p->vi.width, p->vi.height,
p->vi.fps_num, p->vi.fps_den,
p->vi.interlaced ? "true" : "false",
device_id,
p->slot_id);
device_id);
fflush(stderr);
/* Launch audio thread (blocks until reader connects to audio FIFO). */
@ -834,12 +686,6 @@ int main(int argc, char *argv[]) {
VHD_StopStream(ps[i].video_stream);
VHD_CloseStreamHandle(ps[i].video_stream);
}
#ifndef LEGACY_FIFO
if (ps[i].fc_writer) {
fc_writer_close(ps[i].fc_writer);
ps[i].fc_writer = NULL;
}
#endif
}
VHD_CloseBoardHandle(board);

View file

@ -1,5 +1,5 @@
import { spawn, execFileSync } from 'child_process';
import { mkdirSync, writeFileSync } from 'node:fs';
import { mkdirSync, writeFileSync, createReadStream, statSync, unlinkSync } from 'node:fs';
import fs from 'node:fs';
import { dirname } from 'node:path';
import { v4 as uuidv4 } from 'uuid';
@ -7,19 +7,13 @@ import { createUploadStream } from './s3/client.js';
const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';
// In standby mode the framecache slot has been warm for a long time — reduce
// pre-roll to 1s (just enough for fc_pipe to sync its read cursor).
// Override with PRE_ROLL_SECONDS env var if needed.
const _standbyMode = process.env.STANDBY === '1';
const PRE_ROLL_SECONDS = parseInt(process.env.PRE_ROLL_SECONDS || (_standbyMode ? '1' : '5'), 10);
// Growing-files mode: writes the master to a local SMB-backed share that the
// editor can mount, instead of streaming to S3 in real time. The promotion
// worker uploads the finalized file to S3 after the recording stops.
// Toggled per-recorder via `GROWING_ENABLED=true`, delivered per-session on
// /capture/start (read fresh from process.env at record time in start(), NOT
// cached here — standby sidecars boot with it false).
// Toggled per-recorder via `GROWING_ENABLED=true` on the capture container
// (see routes/recorders.js where the env is composed).
const GROWING_ENABLED = process.env.GROWING_ENABLED === 'true';
const GROWING_PATH = process.env.GROWING_PATH || '/growing';
// Approach A: when a CIFS source is supplied, this (privileged) container mounts
@ -39,18 +33,10 @@ function toUncShare(raw) {
if (!s.startsWith('//')) s = '//' + s.replace(/^\/+/, ''); // host/share -> //host/share
return s;
}
// Growing SMB params are read FRESH from process.env at mount time, NOT cached
// at module load. Standby capture containers boot with these unset and receive
// them per-session over /capture/start (capture.js sets process.env before
// captureManager.start()). Caching them in module-level consts at import time
// captured the empty boot values, so the mount silently no-op'd and growing
// fell back to S3 — producing .mov instead of the XDCAM HD422 .mxf.
const growingSmbConfig = () => ({
mount: toUncShare(process.env.GROWING_SMB_MOUNT || ''),
username: process.env.GROWING_SMB_USERNAME || '',
password: process.env.GROWING_SMB_PASSWORD || '',
vers: process.env.GROWING_SMB_VERS || '3.0',
});
const GROWING_SMB_MOUNT = toUncShare(process.env.GROWING_SMB_MOUNT || '');
const GROWING_SMB_USERNAME = process.env.GROWING_SMB_USERNAME || '';
const GROWING_SMB_PASSWORD = process.env.GROWING_SMB_PASSWORD || '';
const GROWING_SMB_VERS = process.env.GROWING_SMB_VERS || '3.0';
const SMB_CREDS_FILE = '/run/smb-creds';
// True when GROWING_PATH is already a mountpoint (e.g. a prior session left it
@ -65,31 +51,26 @@ function isMounted(path) {
// Returns true on success (or if already mounted), false on failure — callers
// fall back to S3 streaming so a recording is never lost.
function mountGrowingShare() {
const cfg = growingSmbConfig();
if (!cfg.mount) {
console.warn('[capture] growing requested but GROWING_SMB_MOUNT is empty — falling back to S3');
return false;
}
if (!GROWING_SMB_MOUNT) return false;
try {
if (isMounted(GROWING_PATH)) {
console.log('[capture] growing share already mounted at', GROWING_PATH);
return true;
}
try { mkdirSync(GROWING_PATH, { recursive: true }); } catch (_) {}
// Pass credentials inline rather than via a credentials= file. Some SMB
// servers (notably TrueNAS SMB3) reject the credentials-file form with
// EACCES (-13) — "cannot mount ... read-only" — even though the very same
// username/password mount inline and smbclient lists the share fine. Inline
// user=/password= is the reliable form here.
writeFileSync(
SMB_CREDS_FILE,
`username=${GROWING_SMB_USERNAME}\npassword=${GROWING_SMB_PASSWORD}\n`,
{ mode: 0o600 }
);
const opts = [
`username=${cfg.username}`,
`password=${cfg.password}`,
`credentials=${SMB_CREDS_FILE}`,
'uid=0', 'gid=0', 'file_mode=0664', 'dir_mode=0775',
`vers=${cfg.vers}`,
`vers=${GROWING_SMB_VERS}`,
].join(',');
execFileSync('mount', ['-t', 'cifs', cfg.mount, GROWING_PATH, '-o', opts],
execFileSync('mount', ['-t', 'cifs', GROWING_SMB_MOUNT, GROWING_PATH, '-o', opts],
{ stdio: ['ignore', 'ignore', 'pipe'] });
console.log('[capture] mounted CIFS growing share', cfg.mount, '->', GROWING_PATH);
console.log('[capture] mounted CIFS growing share', GROWING_SMB_MOUNT, '->', GROWING_PATH);
return true;
} catch (err) {
const stderr = err.stderr ? err.stderr.toString().trim() : err.message;
@ -100,6 +81,7 @@ function mountGrowingShare() {
// Best-effort unmount on session stop. Ignores "not mounted".
function unmountGrowingShare() {
if (!GROWING_SMB_MOUNT) return;
try {
if (isMounted(GROWING_PATH)) {
execFileSync('umount', [GROWING_PATH], { stdio: ['ignore', 'ignore', 'pipe'] });
@ -146,9 +128,6 @@ const VIDEO_CODECS = {
//
// -profile:v main10 / p010le: 10-bit 4:2:0 — the closest NVENC HEVC can get
// to ProRes 4:2:2 10-bit. If strict 4:2:2 is required, use prores_hq (CPU).
// GROWING-file variant: every frame an IDR (all-intra) so a still-growing
// file is decodable to its last complete frame. This is HEAVY — only used when
// growing-files is on (see hevcNvencArgs()).
hevc_nvenc: {
args: ['-c:v', 'hevc_nvenc', '-preset', 'p4', '-rc', 'vbr', '-bf', '0', '-forced-idr', '1', '-g', '600', '-force_key_frames', 'expr:1', '-profile:v', 'main10'],
bitrateControl: true,
@ -156,58 +135,6 @@ const VIDEO_CODECS = {
},
};
// HEVC/NVENC encode args, GOP structure chosen by mode.
// growing=false (normal record): efficient long-GOP (2s @ fps) HEVC. NVENC
// easily sustains 1080p59.94 10-bit here, so no frame drops → audio/video
// lengths stay locked. This is the DEFAULT for recorders.
// growing=true (edit-while-record): ALL-INTRA (every frame an IDR) so the
// growing file is decodable to its last written frame — the requirement for
// Premiere's growing-file refresh. Much heavier, only used when needed.
// `force_key_frames expr:1` (all-intra) is the ~4× compute path that was
// crippling realtime when applied to every recording; gating it on `growing`
// is the fix for the dropped-frame A/V drift.
// Parse a framerate that may be a rational like "60000/1001" (=59.94) OR a plain
// "59.94"/"60". Number.parseFloat("60000/1001") returns 60000 (stops at '/'),
// which made the GOP 120000 instead of ~120 — effectively open-GOP. Handle the
// rational form explicitly.
function parseFps(framerate, fallback = 60) {
if (framerate == null) return fallback;
const s = String(framerate).trim();
if (s.includes('/')) {
const [n, d] = s.split('/').map(Number);
if (Number.isFinite(n) && Number.isFinite(d) && d !== 0) return n / d;
}
const f = Number.parseFloat(s);
return Number.isFinite(f) && f > 0 ? f : fallback;
}
// Which physical GPU this sidecar's NVENC encodes should use. node-agent
// round-robins capture ports across the host's GPUs and passes the index here.
// We MUST select it explicitly with ffmpeg's `-gpu N` because the capture
// sidecars run Privileged (so they see every /dev/nvidiaN regardless of
// NVIDIA_VISIBLE_DEVICES) — without -gpu, nvenc defaults every session to GPU 0
// and all 8 ports pile onto one card → it falls below realtime → video freezes.
const CAPTURE_GPU_INDEX = (() => {
const v = process.env.CAPTURE_GPU_INDEX;
if (v == null || v === '' || v === 'all') return null;
const n = parseInt(v, 10);
return Number.isInteger(n) && n >= 0 ? n : null;
})();
// `-gpu N` must come BEFORE the input/encoder is initialized; ffmpeg accepts it
// as an encoder option right after -c:v. Returns [] when no pin is configured.
const nvencGpuSel = () => (CAPTURE_GPU_INDEX != null ? ['-gpu', String(CAPTURE_GPU_INDEX)] : []);
function hevcNvencArgs(framerate, growing) {
const base = ['-c:v', 'hevc_nvenc', ...nvencGpuSel(), '-preset', 'p4', '-rc', 'vbr', '-profile:v', 'main10'];
if (growing) {
return [...base, '-bf', '0', '-forced-idr', '1', '-g', '600', '-force_key_frames', 'expr:1'];
}
// Normal long-GOP: ~2s keyframe interval, 2 B-frames. Realtime-friendly.
const fps = parseFps(framerate, 60);
const gop = Math.max(2, Math.round(fps * 2));
return [...base, '-bf', '2', '-g', String(gop)];
}
// nvenc codecs available in the capture image. Used both to validate the master
// codec and (issue #164) as the GPU-availability signal for the HLS preview.
const NVENC_CODECS = new Set(['h264_nvenc', 'hevc_nvenc']);
@ -241,14 +168,14 @@ function gpuAvailableForPreview(masterCodec) {
function buildHlsVideoArgs(masterCodec, framerate) {
// Frames-per-segment for keyframe alignment. The SDI preview runs at the
// capture framerate; default to 30 (matches the test-card rate) when unknown.
const fps = parseFps(framerate, 30);
const fps = Number.parseFloat(framerate) || 30;
const segTime = 2; // matches -hls_time below
const gop = Math.max(1, Math.round(fps * segTime));
if (gpuAvailableForPreview(masterCodec)) {
// Low-latency NVENC preset (p1 + ll tune). forced-idr + a keyframe every GOP
// frames keeps segment boundaries on IDR frames so hls.js can sync cleanly.
return [
'-c:v', 'h264_nvenc', ...nvencGpuSel(), '-preset', 'p1', '-tune', 'll',
'-c:v', 'h264_nvenc', '-preset', 'p1', '-tune', 'll',
'-pix_fmt', 'yuv420p', '-b:v', '2M',
'-g', String(gop), '-forced-idr', '1', '-sc_threshold', '0',
];
@ -359,19 +286,9 @@ const CONTAINER_EXT = {
// bitrate (operator target, default 50 Mbps) match XDCAM HD422 essence. `-g 15`
// keeps a short GOP. Muxed to a raw `mpeg2video` elementary stream (no
// container) so raw2bmx ingests it via --mpeg2lg_*.
// AVC-Intra Class 100 — the growing essence that supports TRUE 1080p59.94.
// XDCAM HD422 (MPEG-2 422) cannot do 1080p59.94 (raw2bmx rejects 60000/1001),
// so for native 59.94p we use AVC-Intra 100 = H.264 High 4:2:2 Intra (10-bit,
// all-intra). NVENC h264 cannot produce 4:2:2, so this is libx264 (CPU). The
// essence is a raw H.264 stream (-f h264) wrapped by raw2bmx --avci100_1080p
// at -f 60000/1001, clip type op1a. Verified on-node: produces a valid
// "h264 / High 4:2:2 Intra / yuv422p10le / 60000/1001" MXF.
const GROWING_VIDEO_ELEMENTARY_ARGS = [
'-c:v', 'libx264', '-profile:v', 'high422', '-level', '4.2',
'-preset', 'ultrafast', '-tune', 'zerolatency',
'-pix_fmt', 'yuv422p10le',
'-x264-params', 'avcintra-class=100:bframes=0:keyint=1:scenecut=0',
'-aud', '1',
'-c:v', 'mpeg2video', '-pix_fmt', 'yuv422p',
'-dc', '10', '-g', '15', '-bf', '2',
];
const GROWING_DEFAULT_BITRATE = '25M';
const GROWING_EXT = 'mxf';
@ -421,33 +338,33 @@ function deriveGrowingRaster(resolution, framerate, scanHint = null) {
}
if (height == null) height = 1080; // default raster
// ffmpeg rate + raw2bmx rate strings. AVC-Intra 100 supports TRUE 1080p59.94,
// so a 1080p59.94 SDI feed is wrapped at its native 60000/1001 (no frame drop).
// ffmpeg rate + raw2bmx rate strings for the common broadcast rates.
function rates(fps) {
if (fps == null) return { ff: '60000/1001', raw: '60000/1001' };
if (Math.abs(fps - 59.94) < 0.2) return { ff: '60000/1001', raw: '60000/1001' };
if (Math.abs(fps - 29.97) < 0.05) return { ff: '30000/1001', raw: '30000/1001' };
if (fps == null) return { ff: '30000/1001', raw: '30000/1001' }; // 1080i59.94 default
if (Math.abs(fps - 59.94) < 0.2 || Math.abs(fps - 29.97) < 0.05)
return { ff: '30000/1001', raw: '30000/1001' };
if (Math.abs(fps - 60) < 0.05) return { ff: '60', raw: '60' };
if (Math.abs(fps - 50) < 0.05) return { ff: '50', raw: '50' };
if (Math.abs(fps - 50) < 0.05) return { ff: '25', raw: '25' }; // 1080i50 → 25 fps frames
if (Math.abs(fps - 25) < 0.05) return { ff: '25', raw: '25' };
if (Math.abs(fps - 24) < 0.2) return { ff: '24000/1001', raw: '24000/1001' };
if (Math.abs(fps - 30) < 0.05) return { ff: '30', raw: '30' };
return { ff: String(fps), raw: String(fps) };
}
// AVC-Intra wraps progressive natively. Deltacast reports progressive; honor it.
// Default scan: 1080 → interlaced (broadcast SDI default), 720/below → p.
// scanHint ('p'/'i') overrides this default so progressive Deltacast captures
// are wrapped as progressive MXFs.
if (scan == null) scan = scanHint || ((height >= 1080) ? 'i' : 'p');
const r = rates(fpsNum);
// AVC-Intra 100 raster flags. --avci100_1080p accepts true 1080p59.94 (verified).
let rawFlag;
if (height >= 1080) {
rawFlag = (scan === 'i') ? '--avci100_1080i' : '--avci100_1080p';
rawFlag = (scan === 'p') ? '--mpeg2lg_422p_hl_1080p' : '--mpeg2lg_422p_hl_1080i';
} else if (height >= 720) {
rawFlag = '--avci100_720p';
rawFlag = '--mpeg2lg_422p_hl_720p'; // 720 is always progressive
if (fpsNum == null) { r.ff = '60000/1001'; r.raw = '60000/1001'; }
} else {
rawFlag = '--mpeg2lg_422p_ml_576i';
rawFlag = '--mpeg2lg_422p_ml_576i'; // SD 576i (PAL); 25 fps
r.ff = '25'; r.raw = '25';
}
@ -556,14 +473,7 @@ function buildEncodeArgs({
const args = [];
if (isNetwork) args.push('-map', '0:v:0?', '-map', '0:a:0?');
// hevc_nvenc GOP structure is mode-dependent: all-intra only for growing
// files, efficient long-GOP for normal record (so NVENC stays realtime and
// doesn't drop frames). All other codecs use their static arg set.
if (codec === 'hevc_nvenc') {
args.push(...hevcNvencArgs(framerate, growing));
} else {
args.push(...v.args);
}
args.push(...v.args);
if (v.pixFmt) args.push('-pix_fmt', v.pixFmt);
if (v.bitrateControl && videoBitrate) args.push('-b:v', videoBitrate);
if (framerate && framerate !== 'native') args.push('-r', framerate);
@ -572,13 +482,18 @@ function buildEncodeArgs({
if (a.bitrateControl && audioBitrate) args.push('-b:a', audioBitrate);
if (audioChannels) args.push('-ac', String(audioChannels));
// Fragmented MOV/MP4 for direct S3 streaming (pipe:1 output — no seekable
// file on the worker disk). +frag_keyframe writes a moof/trun fragment per
// keyframe; +empty_moov puts a valid moov box at the start so the file is
// immediately parseable. Premiere Pro 25.x (2025) handles fragmented MOV
// natively. Growing-file masters use the same flags (written to SMB share).
// moov-atom placement is the difference between a Premiere-openable master and
// a "file cannot be opened" error.
//
// Finalized masters (the S3-piped recording that stops cleanly) must NOT be
// fragmented. Adobe Premiere's QuickTime/MOV importer reads the classic
// stco/stsz/stts sample tables in a single top-level moov; a fragmented MOV
// (moof/trun, empty sample tables) makes Premiere report "file cannot be
// opened." We write a clean, non-fragmented MOV instead. `+faststart` puts the
// moov before mdat on the second pass so the file is instantly
// seekable/streamable too.
if (fmt === 'mov' || fmt === 'mp4') {
args.push('-movflags', '+frag_keyframe+empty_moov+default_base_moof');
args.push('-movflags', '+faststart');
}
// ProRes-in-MOV must carry a QuickTime brand or some importers reject the tag.
args.push('-f', fmt);
@ -606,55 +521,6 @@ class CaptureManager {
* @private
*/
async _buildInputArgs({ sourceType, sourceBackend = 'blackmagic', device, port, board, sourceUrl, listen, listenPort, streamKey }) {
// ── Network sources via framecache (primary when FC_SLOT_ID is set) ──────
// node-agent starts net_ingest before the sidecar, which decodes the stream
// to raw UYVY422 and registers a framecache slot. We read from that slot via
// fc_pipe — same zero-copy path as SDI sources — enabling simultaneous
// growing + proxy + HLS from any network source.
if ((sourceType === 'srt' || sourceType === 'rtmp') && process.env.FC_SLOT_ID) {
const slotId = process.env.FC_SLOT_ID;
const fcPipeBin = process.env.FC_PIPE_BIN || 'fc_pipe';
const WAIT_MS = 60_000; /* network sources may take longer to connect */
const fcSize = process.env.DELTACAST_VIDEO_SIZE || '1920x1080';
const fcFps = process.env.DELTACAST_FRAMERATE || '30000/1001';
console.log(`[framecache] net slot=${slotId} size=${fcSize} fps=${fcFps}`);
const fcPipeProcess = spawn(fcPipeBin, [slotId, String(WAIT_MS)], {
stdio: ['ignore', 'pipe', 'pipe'],
});
// Pause stdout immediately so frames don't fill the OS pipe buffer (and
// block fc_pipe's write()) in the window between spawn here and the
// .pipe(ffmpeg.stdin) attach later in start(). .pipe() auto-resumes.
fcPipeProcess.stdout.pause();
fcPipeProcess.stderr.on('data', chunk => {
process.stderr.write(`[fc_pipe:${slotId}] ${chunk}`);
});
fcPipeProcess.on('error', err =>
console.error(`[fc_pipe:${slotId}] spawn error: ${err.message}`));
return {
inputArgs: [
// No -use_wallclock_as_timestamps — framecache delivers CFR frames
// at the original ingest rate; -framerate produces correct timestamps.
'-thread_queue_size', '512',
'-f', 'rawvideo',
'-pix_fmt', 'uyvy422',
'-video_size', fcSize,
'-framerate', fcFps,
'-i', 'pipe:0',
],
isNetwork: false, /* treat as raw source — no -map 0:v:0? needed */
bridgeProcess: fcPipeProcess,
audioFifo: null,
interlaced: false,
audioInputIndex: 0, /* network fc_pipe is video-only — no audio input */
_fcPipeProcess: fcPipeProcess,
};
}
// ── Legacy direct network paths (no framecache / net_ingest not running) ──
if (sourceType === 'srt') {
let url;
if (listen) {
@ -681,121 +547,99 @@ class CaptureManager {
return { inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-i', sourceUrl], isNetwork: true };
}
// ── Framecache path (primary for deltacast + blackmagic) ────────────────
// Deltacast SDI via shared bridge daemon (deltacast-bridge).
//
// When FC_SLOT_ID is set in the sidecar env (injected by node-agent from
// the bridge's format JSON), we use the framecache shm ring buffer as the
// video source instead of named FIFOs.
// The bridge daemon is started by node-agent (host process, direct /dev access)
// and writes each port's streams to named FIFOs in /dev/shm/deltacast/:
// /dev/shm/deltacast/video-<port>.fifo
// /dev/shm/deltacast/audio-<port>.fifo
//
// fc_pipe is a small C helper that opens the framecache slot as a consumer
// and writes raw UYVY422 frames to stdout. capture-manager spawns it and
// pipes its stdout to ffmpeg as a rawvideo input — same pattern as the
// existing FIFO path, but with zero-copy shm reads and independent per-
// consumer cursors. Multiple fc_pipe instances on the same slot each get
// their own cursor, enabling simultaneous growing + proxy + HLS from one
// SDI input without any frame splitting.
//
// Audio stays on the named FIFO path (audio fan-out via shm is a roadmap
// item).
//
// node-agent ALWAYS injects FC_SLOT_ID for SDI sidecars (deterministic
// `deltacast-<board>-<port>` / `decklink-<node>-<dev>`), so this is the sole
// SDI path. The old FC_SLOT_ID-absent legacy FIFO fallback was removed once
// framecache became mandatory on every capture node.
if ((sourceType === 'deltacast' || sourceType === 'sdi' || sourceType === 'blackmagic')
&& process.env.FC_SLOT_ID) {
const slotId = process.env.FC_SLOT_ID;
const fcPipeBin = process.env.FC_PIPE_BIN || 'fc_pipe';
const WAIT_MS = 30_000;
// Determine audio FIFO path based on source type
const idx = (typeof device === 'number' || /^\d+$/.test(String(device)))
// This sidecar just reads from those FIFOs. The bridge may still be starting
// up or waiting for signal lock, so we wait up to 30s for the FIFOs to appear
// before handing them to ffmpeg. The bridge process is managed by node-agent;
// bridgeProcess is null here (no per-sidecar bridge spawn).
if (sourceType === 'deltacast') {
const idx = (typeof device === 'number' || /^\d+$/.test(String(device)))
? parseInt(device, 10) : 0;
const portIdx = (sourceType === 'deltacast')
? ((typeof port === 'number' || /^\d+$/.test(String(port)))
? parseInt(port, 10) : idx)
: idx;
const portIdx = (typeof port === 'number' || /^\d+$/.test(String(port)))
? parseInt(port, 10) : idx;
let audioFifoPath;
if (sourceType === 'deltacast') {
const DC_PIPE_DIR = process.env.DELTACAST_PIPE_DIR || '/dev/shm/deltacast';
audioFifoPath = `${DC_PIPE_DIR}/audio-${portIdx}.fifo`;
} else {
const DL_AUDIO_DIR = process.env.DECKLINK_AUDIO_DIR || '/dev/shm/decklink';
audioFifoPath = `${DL_AUDIO_DIR}/audio-${portIdx}.fifo`;
}
const DC_PIPE_DIR = process.env.DELTACAST_PIPE_DIR || '/dev/shm/deltacast';
const videoFifo = `${DC_PIPE_DIR}/video-${portIdx}.fifo`;
const audioFifo = `${DC_PIPE_DIR}/audio-${portIdx}.fifo`;
// Wait up to 30s for the audio FIFO to exist (bridge starts asynchronously)
// Wait up to 30s for both FIFOs to exist (bridge starts asynchronously).
const { existsSync: _exists } = await import('node:fs');
const deadline = Date.now() + WAIT_MS;
const WAIT_MS = 30_000;
const POLL_MS = 500;
const deadline = Date.now() + WAIT_MS;
let videoReady = false;
let audioReady = false;
while (Date.now() < deadline) {
if (_exists(audioFifoPath)) break;
await new Promise(r => setTimeout(r, 500));
videoReady = _exists(videoFifo);
audioReady = _exists(audioFifo);
if (videoReady && audioReady) break;
await new Promise(r => setTimeout(r, POLL_MS));
}
if (!_exists(audioFifoPath)) {
if (!videoReady || !audioReady) {
throw new Error(
`audio FIFO not ready after ${WAIT_MS / 1000}s: ${audioFifoPath} ` +
`— is the bridge running?`
`deltacast bridge FIFOs not ready after ${WAIT_MS / 1000}s ` +
`(video=${videoReady} audio=${audioReady}) — is deltacast-bridge running?`
);
}
console.log(`[deltacast] port ${portIdx} FIFOs ready: ${videoFifo}, ${audioFifo}`);
// Video dimensions and fps come from env vars injected by node-agent
// (populated from the bridge's format JSON on signal lock).
const fcSize = process.env.DELTACAST_VIDEO_SIZE || '1920x1080';
const fcFps = process.env.DELTACAST_FRAMERATE || '60000/1001';
const fcInterlaced = process.env.DELTACAST_INTERLACED === '1';
console.log(`[framecache] slot=${slotId} size=${fcSize} fps=${fcFps} audio=${audioFifoPath}`);
// Spawn fc_pipe: opens the framecache slot with its own read cursor and
// streams raw UYVY422 frames to stdout. ffmpeg reads from the pipe as
// rawvideo input 0; audio FIFO is input 1 (same as before).
const fcPipeProcess = spawn(fcPipeBin, [slotId, String(WAIT_MS)], {
stdio: ['ignore', 'pipe', 'pipe'],
});
// Pause until piped to ffmpeg (avoids OS pipe-buffer fill stall — see
// the network path above for the full rationale).
fcPipeProcess.stdout.pause();
fcPipeProcess.stderr.on('data', chunk => {
process.stderr.write(`[fc_pipe:${slotId}] ${chunk}`);
});
fcPipeProcess.on('error', err => {
console.error(`[fc_pipe:${slotId}] spawn error: ${err.message}`);
});
// Resolution/fps are not known until the FIFO reader connects and starts
// receiving frames. We use sensible defaults here; ffmpeg's rawvideo demuxer
// will accept whatever the bridge writes once the pipe opens.
// The bridge daemon has already detected the signal and set up streams, so
// the FIFO content is ready-to-read as soon as the reader connects.
//
// NOTE: The format JSON emitted by the bridge on signal lock goes to the
// node-agent (which launched the bridge), not to this sidecar. The sidecar
// therefore uses fixed rawvideo params here. If per-port format introspection
// is needed in future, the node-agent should expose the fmt JSON via an API
// and capture-manager can query it before building inputArgs.
//
// For now, both video dimensions and framerate come from the recorder's
// configured values (passed to start() as `framerate` and implicit in the
// codec args). The rawvideo input is -video_size / -framerate from env or
// recorder config; ffmpeg tolerates a small mismatch in rawvideo (it just
// reads N bytes per frame based on the declared size).
//
// DELTACAST_VIDEO_SIZE / DELTACAST_FRAMERATE: set by node-agent in the
// sidecar env based on the bridge's per-port format JSON, if desired.
const dcSize = process.env.DELTACAST_VIDEO_SIZE || '1920x1080';
const dcFps = process.env.DELTACAST_FRAMERATE || '60000/1001';
const dcInterlaced = process.env.DELTACAST_INTERLACED === '1';
return {
inputArgs: [
// fc_pipe stdout → ffmpeg rawvideo input 0 (video).
// Both raw FIFOs are timestampless. ffmpeg opens input 0 (video) and
// input 1 (audio) at slightly different moments, so PTS-zeroing each
// stream's first byte would bake in a fixed A/V offset. Stamping each
// input by wall-clock ARRIVAL time aligns them by real time regardless
// of FIFO open order — the robust fix for the A/V start offset.
// Large thread_queue_size avoids "thread message queue blocking" on
// the high-bitrate raw video FIFO.
'-use_wallclock_as_timestamps', '1',
'-thread_queue_size', '512',
'-f', 'rawvideo',
'-pix_fmt', 'uyvy422',
'-video_size', fcSize,
'-framerate', fcFps,
'-i', 'pipe:0',
// Audio FIFO → ffmpeg input 1.
//
// Do NOT use -use_wallclock_as_timestamps here. The bridge feeds raw
// s16le at a steady 48000 samples/s off the SAME SDI clock as video,
// so letting ffmpeg derive audio PTS from the sample count keeps audio
// and video in one clock domain (no drift). Wallclock stamps audio by
// arrival wall-time instead — when the HEVC encoder dips under realtime
// the audio ends up 318% LONGER than the frame-count video, and the
// master aresample=async=1 then pads seconds of LEADING SILENCE to
// "align" them → the silent-head + start-stutter + apparent "no audio"
// regression (reverts commit d6b0b3a; restores 8e5405c/55a72af).
'-video_size', dcSize,
'-framerate', dcFps,
'-i', videoFifo,
'-use_wallclock_as_timestamps', '1',
'-thread_queue_size', '512',
'-f', 's16le',
'-ar', '48000',
'-ac', '2',
'-i', audioFifoPath,
'-i', audioFifo,
],
isNetwork: false,
bridgeProcess: fcPipeProcess, /* capture-manager pipes this to ffmpeg stdin */
audioFifo: audioFifoPath, /* flushed just before ffmpeg opens it (A/V align) */
interlaced: fcInterlaced,
audioInputIndex: 1, /* audio FIFO is ffmpeg input 1 */
_fcPipeProcess: fcPipeProcess, /* stored for clean stop */
isNetwork: false,
bridgeProcess: null, /* bridge is managed by node-agent, not this sidecar */
audioFifo: null, /* no per-session FIFO to clean up on stop */
interlaced: dcInterlaced,
};
}
@ -867,7 +711,7 @@ class CaptureManager {
...GROWING_VIDEO_ELEMENTARY_ARGS,
'-b:v', vb, '-minrate', vb, '-maxrate', vb, '-bufsize', vb,
'-r', ffRate,
'-f', 'h264', '@VF@',
'-f', 'mpeg2video', '@VF@',
// (b) PCM s16le audio → "$AF"
'-map', audioMap,
'-c:a', 'pcm_s16le', '-ar', '48000', '-ac', String(ach),
@ -915,13 +759,13 @@ class CaptureManager {
// on stop, so the inline Python dur-patch below overwrites the header Duration
// fields with the live frame count every 3s (Premiere reads the header
// Duration on each refresh; without the patch it sees duration=N/A).
const bmx = [
'raw2bmx', '-t', 'op1a', '-o', '"$OUT"', '-f', frameRate,
'--part', String(GROWING_PART_INTERVAL_FRAMES),
'--index-follows',
rawFlag, '"$VF"',
'-s', '48000', '-q', '16', '--audio-chan', String(ach), '--pcm', '"$AF"',
];
const bmx = [
'raw2bmx', '-t', 'rdd9', '-o', '"$OUT"', '-f', frameRate,
'--part', String(GROWING_PART_INTERVAL_FRAMES),
'--index-follows',
rawFlag, '"$VF"',
'-s', '48000', '-q', '16', '--audio-chan', String(ach), '--pcm', '"$AF"',
];
const bmxLine = bmx
.map((t) => (t.startsWith('"$') ? t : sh(t)))
.join(' ');
@ -937,26 +781,46 @@ const bmx = [
// overwrites them in-place. It is killed by the cleanup trap on exit.
const script = `
set -u
exec 9<&0
VF=$(mktemp -u /tmp/grow_v.XXXXXX); AF=$(mktemp -u /tmp/grow_a.XXXXXX)
OUT=${sh(outPath)}
mkfifo "$VF" "$AF"
cleanup() { rm -f "$VF" "$AF"; }
PATCHPID=
cleanup() { rm -f "$VF" "$AF"; [ -n "$PATCHPID" ] && kill "$PATCHPID" 2>/dev/null; }
trap cleanup EXIT
( exec 0<&9 9<&-; exec ${ffLine} ) &
FFPID=$!
exec 9<&-
exec ${bmxLine} >/tmp/raw2bmx.log 2>&1 &
# Prime both FIFOs read-write (non-blocking) to break the open-order deadlock.
exec 7<>"$VF" 8<>"$AF"
# raw2bmx: close priming FDs (no stray writer) before exec so it sees real EOF.
( exec 7>&- 8>&-; exec ${bmxLine} ) &
BMXPID=$!
# ffmpeg: also closes priming FDs; it opens its own write ends.
( exec 7>&- 8>&-; exec ${ffLine} ) &
FFPID=$!
# Forward a clean stop to ffmpeg; raw2bmx then gets EOF and finalizes the footer.
stop() { kill -INT "$FFPID" 2>/dev/null; }
trap stop INT TERM
# Drop the parent priming FDs once raw2bmx has opened BOTH FIFOs, so ffmpeg is
# the sole writer (its EOF reaches raw2bmx). If raw2bmx dies early, bail.
for i in $(seq 1 200); do
kill -0 "$BMXPID" 2>/dev/null || break
n=$(ls -l /proc/$BMXPID/fd 2>/dev/null | grep -c -- "$VF\\|$AF")
[ "\${n:-0}" -ge 2 ] && break
sleep 0.1
done
exec 7>&- 8>&-
# No header-duration patcher is needed. In this bmx v1.6 build, raw2bmx's rdd9
# writer with --part maintains a live, correct header Duration as the file grows
# (verified on-node: ffprobe reads a growing duration mid-write, e.g. 2.04s of a
# 10s clip while still recording). A patcher (the earlier dur-patch.py) was a
# no-op here it searched for Duration=-1, which rdd9 never writes and opening
# the file r+b while raw2bmx appends over CIFS only adds concurrency risk.
PATCHPID=
# Wait for ffmpeg (source end), then for raw2bmx to finalize the footer.
wait "$FFPID"; FFRC=$?
wait "$BMXPID"; BMXRC=$?
echo "[grow] ffmpeg rc=$FFRC raw2bmx rc=$BMXRC out=$OUT" >&2
exit "$BMXRC"
`;
return ['-c', script];
return ['-c', script];
}
/**
@ -1020,14 +884,8 @@ exit "$BMXRC"
// Approach A: if a CIFS source is configured, mount it now. A mount failure
// is non-fatal — we fall back to S3 streaming so the recording is never
// lost.
// Read growing flags FRESH from env at record time — standby sidecars boot
// with GROWING_ENABLED=false and receive the real value per-session over
// /capture/start (capture.js sets process.env before this runs). The old
// module-level `const GROWING_ENABLED` / `GROWING_SMB_MOUNT` captured the
// empty boot values, so growing never engaged and every "growing" record
// silently produced HEVC/S3 instead of the XDCAM HD422 MXF.
let growingActive = process.env.GROWING_ENABLED === 'true';
if (growingActive && growingSmbConfig().mount) {
let growingActive = GROWING_ENABLED;
if (growingActive && GROWING_SMB_MOUNT) {
if (!mountGrowingShare()) growingActive = false; // fall back to S3
}
// Growing master is always MXF OP1a / XDCAM HD422 written by raw2bmx (the
@ -1062,64 +920,17 @@ exit "$BMXRC"
// The stop handler sets needsProxy=true so the worker picks it up.
const proxyKey = null;
const startedAt = new Date().toISOString();
this._sessionIdForBridge = sessionId;
const { inputArgs, isNetwork, bridgeProcess = null, audioFifo = null, interlaced = false, audioInputIndex = 0,
} = await this._buildInputArgs({
const { inputArgs, isNetwork, bridgeProcess = null, audioFifo = null, interlaced = false } = await this._buildInputArgs({
sourceType, sourceBackend, device, port, board, sourceUrl, listen, listenPort, streamKey,
});
// ── Pre-roll + A/V alignment ─────────────────────────────────────────────
// The pre-roll drains the VIDEO pipe (fc_pipe) to discard unstable startup
// frames. In STANDBY the framecache slot is already warm, so there are no
// unstable frames — skip the video drain (draining only video while audio
// keeps buffering is exactly what offset the streams, giving "silent first
// second then clean").
if (bridgeProcess && !_standbyMode
&& (sourceType === 'deltacast' || sourceType === 'blackmagic' || sourceType === 'sdi')) {
console.log(`[capture] pre-rolling: discarding ${PRE_ROLL_SECONDS}s of frames`);
bridgeProcess.stdout.on('data', () => {});
await new Promise(r => setTimeout(r, PRE_ROLL_SECONDS * 1000));
bridgeProcess.stdout.removeAllListeners('data');
console.log(`[capture] pre-roll complete.`);
}
// FLUSH STALE AUDIO immediately before ffmpeg opens the FIFO. During standby
// the bridge keeps writing audio into the named FIFO while the idle-preview
// consumes only video, so the FIFO holds up to a full pipe buffer (~0.5s) of
// stale audio. Draining it here (right before the record ffmpeg attaches)
// makes audio start at the live edge, time-aligned with the first video
// frame — eliminating the leading silence + the ~0.5% audio-length surplus.
if (audioFifo) {
try {
const fsSync = await import('node:fs');
const fd = fsSync.openSync(audioFifo, fsSync.constants.O_RDONLY | fsSync.constants.O_NONBLOCK);
const tmp = Buffer.allocUnsafe(1 << 20);
let drained = 0;
for (;;) {
let n = 0;
try { n = fsSync.readSync(fd, tmp, 0, tmp.length, null); }
catch (e) { if (e.code === 'EAGAIN') break; throw e; }
if (n <= 0) break;
drained += n;
}
fsSync.closeSync(fd);
console.log(`[capture] flushed ${drained} bytes of stale standby audio before record`);
} catch (e) {
console.warn(`[capture] audio FIFO pre-flush skipped: ${e.message}`);
}
}
const startedAt = new Date().toISOString();
const recordingStartedAt = Date.now();
// Audio input index is returned EXPLICITLY by _buildInputArgs (audioInputIndex)
// rather than guessed from sourceType/FC_SLOT_ID — that guess was wrong for
// the legacy deltacast FIFO path (which has audio at input 1 but no FC_SLOT_ID),
// silently dropping audio. Each return path now declares its own audio input:
// - deltacast/blackmagic via framecache: audio FIFO = input 1
// - legacy deltacast FIFO: audio FIFO = input 1
// - network (framecache or legacy) + DeckLink-backend SDI: audio in input 0
const audioMap = `${audioInputIndex}:a:0?`;
// Audio input index: the deltacast shared bridge delivers video on input 0
// (video FIFO) and audio on input 1 (audio FIFO), so audioMap is '1:a:0?'.
// DeckLink SDI and network sources carry audio inside input 0.
const audioMap = (sourceType === 'deltacast') ? '1:a:0?' : '0:a:0?';
// Non-growing master: ffmpeg muxes the finalized MOV directly. Growing
// master: raw2bmx muxes the OP1a from elementary FIFOs (handled below via
@ -1134,31 +945,40 @@ exit "$BMXRC"
if (hiresCodecArgs) console.log('[capture] hires ffmpeg args:', hiresCodecArgs.join(' '));
const isInterlacedSource = sourceType === 'sdi'
|| (sourceType === 'deltacast' && interlaced)
|| ((sourceType === 'blackmagic') && interlaced);
const isInterlacedSource = sourceType === 'sdi' || (sourceType === 'deltacast' && interlaced);
const sdiFilterArgs = isInterlacedSource ? ['-vf', 'yadif=mode=1:deint=1'] : [];
// Master output destination.
// Master output destination (NON-growing path only).
//
// - Growing-files on → the growing OP1a MXF is written directly to the SMB
// share by raw2bmx (see the orchestrator below); ffmpeg only produces the
// elementary essence FIFOs + HLS preview.
// elementary essence FIFOs + HLS preview. `localMasterPath`/`hiresOutput`
// are unused in this case (the master path is `growingPath`).
//
// - Growing-files off → ffmpeg writes fragmented MOV to pipe:1 (stdout),
// which is piped directly into a multipart S3 upload. No local temp file,
// no worker disk consumed. Premiere Pro 25.x handles fragmented MOV natively.
const hiresOutput = growingPath ? growingPath : 'pipe:1';
// pipe:1 = ffmpeg stdout → S3 stream. bridgeProcess (fc_pipe) uses stdin.
const hiresStdio = bridgeProcess ? ['pipe', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe'];
// - Growing-files off → ffmpeg writes the MOV master to a LOCAL SEEKABLE
// temp file, then we upload to S3 on stop. We must NOT pipe the MOV muxer
// to S3 directly: the MOV/MP4 muxer cannot write to a non-seekable pipe
// without `empty_moov`, and an empty_moov/fragmented MOV is exactly what
// makes Adobe Premiere report "file cannot be opened" (no classic
// stco/stsz sample tables — samples live in moof/trun). A seekable file
// lets ffmpeg write a single contiguous moov with full sample tables and
// `+faststart` moves it to the front, producing a Premiere-native master.
const localMasterPath = growingPath
? null
: `/tmp/capture/${sessionId}.${hiresExt}`;
if (localMasterPath) {
try { mkdirSync(dirname(localMasterPath), { recursive: true }); }
catch (err) { console.error('[capture] could not create temp master dir:', err.message); }
}
const hiresOutput = localMasterPath;
// Deltacast reads from FIFOs (no stdin pipe needed). DeckLink pipes stdout.
const hiresStdio = ['ignore', 'ignore', 'pipe'];
// For SDI/framecache sources (including network via framecache) the live
// HLS preview is a SECOND OUTPUT of the hires ffmpeg.
const _viaFcPipeHls = !!process.env.FC_SLOT_ID;
// For SDI we cannot open the DeckLink device a second time for a preview
// 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' || sourceType === 'deltacast' || sourceType === 'blackmagic'
|| (_viaFcPipeHls && (sourceType === 'srt' || sourceType === 'rtmp')))
&& 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 (_) {}
@ -1188,97 +1008,57 @@ exit "$BMXRC"
interlaced: isInterlacedSource,
});
console.log('[capture] growing master via raw2bmx; orchestrator script length=' + orchArgs[1].length);
hiresProcess = spawn('bash', orchArgs, {
stdio: bridgeProcess ? ['pipe', 'ignore', 'pipe'] : ['ignore', 'ignore', 'pipe'],
detached: true,
});
// When video comes from fc_pipe, pipe its stdout to the bash orchestrator
// stdin (which the orchestrator forwards to the ffmpeg rawvideo input).
if (bridgeProcess && bridgeProcess.stdout && hiresProcess.stdin) {
// Swallow EPIPE/stream errors so a broken video pipe (e.g. the
// orchestrator exiting) can never crash the whole capture sidecar with
// an unhandled 'error' event ("Error: write EPIPE").
hiresProcess.stdin.on('error', (e) => {
if (e && e.code !== 'EPIPE') console.warn(`[capture] orchestrator stdin error: ${e.message}`);
});
bridgeProcess.stdout.on('error', (e) => {
console.warn(`[capture] fc_pipe stdout error: ${e && e.message}`);
});
bridgeProcess.stdout.pipe(hiresProcess.stdin);
bridgeProcess.on('exit', () => {
try { if (hiresProcess.stdin) hiresProcess.stdin.end(); } catch (_) {}
});
}
hiresProcess = spawn('bash', orchArgs, { stdio: ['ignore', 'ignore', 'pipe'], detached: true });
} else {
// ── Finalized (non-growing) master: ffmpeg muxes the MOV directly ──
let hiresArgs;
const isSdiLike = sourceType === 'sdi' || sourceType === 'deltacast' || sourceType === 'blackmagic';
// Network via framecache (fc_pipe) also produces its master + HLS as a
// single split ffmpeg, exactly like SDI — it reads pipe:0, not a URL.
const isNetFcPipe = !!process.env.FC_SLOT_ID && (sourceType === 'srt' || sourceType === 'rtmp');
if ((isSdiLike || isNetFcPipe) && this._assetIdForHls) {
if ((sourceType === 'sdi' || sourceType === 'deltacast') && this._assetIdForHls) {
const filterStr = isInterlacedSource
? '[0:v]yadif=mode=1:deint=1,split=2[vhi][vlo]'
: '[0:v]split=2[vhi][vlo]';
// Network fc_pipe is video-only (no audio input) — omit audio maps so
// ffmpeg doesn't fail trying to map a nonexistent audio stream.
const hasAudio = audioInputIndex >= 0 && !isNetFcPipe;
const masterAudioMap = hasAudio ? ['-map', audioMap] : [];
const masterAudioFilter = hasAudio
? ['-af', 'aresample=async=1:min_hard_comp=0.100000:first_pts=0'] : [];
const hlsAudioMap = hasAudio ? ['-map', audioMap] : [];
const hlsAudioCodec = hasAudio
? ['-c:a', 'aac', '-b:a', '128k', '-ar', '44100'] : [];
hiresArgs = [
...inputArgs,
'-filter_complex', filterStr,
// Output 0 — master (fragmented MOV streamed to S3 via pipe:1)
'-map', '[vhi]', ...masterAudioMap,
...masterAudioFilter,
// Output 0 — ProRes/MOV master (local temp, uploaded to S3 on stop)
'-map', '[vhi]', '-map', audioMap,
// Keep raw audio aligned to the video clock. The two raw FIFOs carry
// no timestamps; -af aresample=async lets ffmpeg stretch/squeeze audio
// to correct any tiny rate mismatch so A/V never drifts over a long
// take. Applies to this output's mapped audio stream.
'-af', 'aresample=async=1:min_hard_comp=0.100000:first_pts=0',
...hiresCodecArgs,
hiresOutput,
// Output 1 — low-latency H.264 HLS preview for the UI monitor
'-map', '[vlo]', ...hlsAudioMap,
// Output 1 — low-latency H.264 HLS preview for the UI monitor.
// GPU-encoded (h264_nvenc) when the GPU is attached to this sidecar,
// otherwise libx264 (issue #164). GOP is pinned to one IDR per HLS
// segment so segments start on keyframes (avoids black/flashing).
'-map', '[vlo]', '-map', audioMap,
...buildHlsVideoArgs(videoCodec, framerate),
...hlsAudioCodec,
'-c:a', 'aac', '-b:a', '128k', '-ar', '44100',
'-f', 'hls', '-hls_time', '2', '-hls_list_size', '15',
'-hls_flags', 'delete_segments+append_list+omit_endlist',
'-hls_segment_filename', sdiHlsDir + '/seg-%05d.ts',
sdiHlsDir + '/index.m3u8',
];
console.log('[HLS] SDI/framecache preview as 2nd output -> ' + sdiHlsDir);
console.log('[HLS] SDI preview as 2nd output -> ' + sdiHlsDir);
} else {
hiresArgs = [ ...inputArgs, ...sdiFilterArgs, ...hiresCodecArgs, hiresOutput ];
}
hiresProcess = spawn('ffmpeg', hiresArgs, { stdio: hiresStdio });
// When video comes from fc_pipe, pipe its stdout to ffmpeg stdin.
if (bridgeProcess && bridgeProcess.stdout && hiresProcess.stdin) {
bridgeProcess.stdout.pipe(hiresProcess.stdin);
bridgeProcess.on('exit', () => {
try { if (hiresProcess.stdin) hiresProcess.stdin.end(); } catch (_) {}
});
}
}
// Growing: promotion worker handles S3 upload after stop.
// Non-growing: start streaming stdout directly to S3 now (multipart upload
// completes when ffmpeg exits and closes the pipe).
// Growing-files: nothing to upload here (promotion worker handles S3).
// Non-growing: the master is uploaded from the finalized local file in
// 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.
// bridgeProcess is null for deltacast (bridge managed by node-agent on the host).
const processes = { hires: hiresProcess };
const uploads = {
hires: growingPath
? Promise.resolve({ growingPath })
: createUploadStream(S3_BUCKET, hiresKey, hiresProcess.stdout),
};
const uploads = { hires: growingPath ? Promise.resolve({ growingPath }) : null };
// ── HLS tee for legacy network sources (live preview in the UI) ──────────
// When network sources come via framecache (FC_SLOT_ID set), HLS preview is
// handled as a 2nd ffmpeg output in the hires process above (sdiHlsDir path).
// This tee is only for the legacy direct-URL network path (no framecache).
// ── HLS tee for network sources (live preview in the UI) ──────────
let hlsProcess = null;
let hlsDir = null;
if (isNetwork && !process.env.FC_SLOT_ID && this._assetIdForHls) {
if (isNetwork && this._assetIdForHls) {
try {
const fs = await import('node:fs');
hlsDir = '/live/' + this._assetIdForHls;
@ -1286,6 +1066,7 @@ exit "$BMXRC"
const hlsArgs = [
...inputArgs,
'-map', '0:v:0?', '-map', '0:a:0?',
// GPU-gated preview encode, same as the SDI 2nd-output path (#164).
...buildHlsVideoArgs(videoCodec, framerate),
'-c:a', 'aac', '-b:a', '128k', '-ar', '44100',
'-f', 'hls', '-hls_time', '2', '-hls_list_size', '15',
@ -1297,7 +1078,7 @@ exit "$BMXRC"
hlsProcess.stderr.on('data', (d) => { console.error('[HLS] ' + d); });
hlsProcess.on('exit', (c) => console.log('[HLS] exited ' + c));
processes.hls = hlsProcess;
console.log('[HLS] legacy-net tee started -> ' + hlsDir);
console.log('[HLS] tee started -> ' + hlsDir);
} catch (err) {
console.error('[HLS] tee failed:', err.message);
}
@ -1310,13 +1091,12 @@ exit "$BMXRC"
if (m) {
this.state.framesReceived = parseInt(m[1], 10);
this.state.lastFrameAt = new Date().toISOString();
// Use ffmpeg's own rolling fps value — it is a short-window average
// computed by ffmpeg itself and correctly reflects the true encode rate.
// The previous frame/elapsed cumulative calculation dragged low during
// startup and was permanently wrong for growing-path (bash orchestrator
// stderr doesn't emit frame= lines until ffmpeg flushes them).
const ffmpegFps = parseFloat(m[2]);
if (ffmpegFps > 0) this.state.currentFps = Math.round(ffmpegFps * 100) / 100;
if (this.state.recordingStartedAt) {
const elapsedSec = (Date.now() - this.state.recordingStartedAt) / 1000;
if (elapsedSec > 0) {
this.state.currentFps = Math.round((this.state.framesReceived / elapsedSec) * 100) / 100;
}
}
}
if (/Connection refused|No route to host|Connection failed|Input\/output error|Server returned|404 Not Found|Connection timed out/i.test(text)) {
this.state.lastError = text.trim().slice(0, 240);
@ -1346,10 +1126,10 @@ exit "$BMXRC"
hiresKey,
proxyKey,
growingPath,
localMasterPath,
audioFifo,
startedAt,
duration: 0,
_fcPipeProcess: bridgeProcess || null, /* fc_pipe process, if framecache path used */
uploads,
codecs: {
videoCodec, videoBitrate, framerate,
@ -1490,11 +1270,6 @@ 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 (_) {} }
// fc_pipe process (framecache consumer) — stop after ffmpeg so it sees EOF
// naturally via EPIPE when ffmpeg stdin closes. SIGTERM as belt-and-suspenders.
if (currentSession._fcPipeProcess) {
try { currentSession._fcPipeProcess.kill('SIGTERM'); } catch (_) {}
}
/* processes.bridge: removed — bridge is managed by node-agent, not per-session */
// Wait for the master writer to finalize before we read/upload the file.
@ -1506,11 +1281,30 @@ exit "$BMXRC"
unmountGrowingShare();
try {
// Non-growing: S3 upload was streaming from ffmpeg stdout — it completes
// when ffmpeg exits and closes the pipe (waitExit above ensures that).
// Growing: promotion worker handles S3.
const uploadPromises = [];
if (currentSession.uploads.hires) uploadPromises.push(currentSession.uploads.hires);
// Non-growing: upload the finalized local master file to S3 now that the
// moov has been written. Growing: the promotion worker handles S3.
if (currentSession.localMasterPath) {
let size = 0;
try { size = statSync(currentSession.localMasterPath).size; } catch (_) {}
if (size > 0) {
uploadPromises.push(
createUploadStream(
S3_BUCKET,
currentSession.hiresKey,
createReadStream(currentSession.localMasterPath),
).then(() => {
try { unlinkSync(currentSession.localMasterPath); } catch (_) {}
})
);
} else {
console.warn('[capture] local master is 0 bytes — skipping upload:', currentSession.localMasterPath);
}
} else if (currentSession.uploads.hires) {
uploadPromises.push(currentSession.uploads.hires);
}
if (currentSession.uploads.proxy) uploadPromises.push(currentSession.uploads.proxy);
await Promise.all(uploadPromises);
} catch (error) {

View file

@ -22,25 +22,12 @@ app.use('/capture', captureRoutes);
const server = app.listen(PORT, () => {
console.log(`Wild Dragon Capture Service listening on port ${PORT}`);
bootstrapAutoStart();
// Auto-start idle signal preview for deltacast/sdi sidecars.
// 3s delay lets the deltacast bridge FIFOs come up first.
const _srcType = process.env.SOURCE_TYPE;
const _standby = process.env.STANDBY === '1';
if (_standby) {
// Standby mode — sidecar pre-spawned at recorder create time.
// Don't auto-start a recording session; wait for POST /capture/start.
// Still start idle preview so the live signal thumbnail is visible.
console.log('[bootstrap] standby mode — waiting for /capture/start HTTP call');
if (process.env.RECORDER_ID && (_srcType === 'deltacast' || _srcType === 'sdi' || _srcType === 'blackmagic')) {
setTimeout(() => captureManager.startIdlePreview(), 3000);
}
} else {
// Legacy mode — env vars carry the session params, start immediately.
bootstrapAutoStart();
// Auto-start idle signal preview for deltacast/sdi sidecars.
// 3s delay lets the deltacast bridge FIFOs come up first.
if (process.env.RECORDER_ID && (_srcType === 'deltacast' || _srcType === 'sdi')) {
setTimeout(() => captureManager.startIdlePreview(), 3000);
}
if (process.env.RECORDER_ID && (_srcType === 'deltacast' || _srcType === 'sdi')) {
setTimeout(() => captureManager.startIdlePreview(), 3000);
}
});

View file

@ -301,34 +301,12 @@ router.post('/start', async (req, res) => {
project_id,
bin_id,
clip_name,
asset_id, // pre-created by mam-api in standby mode; skip asset creation when set
device,
source_type = 'sdi',
source_url,
listen = false,
listen_port,
stream_key,
// Codec params — accepted from body (standby mode) or fall back to container env vars
recording_codec,
recording_video_bitrate,
recording_framerate,
recording_audio_codec,
recording_audio_bitrate,
recording_audio_channels,
recording_container,
proxy_enabled,
proxy_codec,
proxy_video_bitrate,
proxy_framerate,
proxy_audio_codec,
proxy_audio_bitrate,
proxy_audio_channels,
proxy_container,
growing_enabled,
growing_smb_mount,
growing_smb_username,
growing_smb_password,
growing_smb_vers,
} = req.body;
if (!project_id || !clip_name) {
@ -338,9 +316,9 @@ router.post('/start', async (req, res) => {
}
// Source-specific validation
if (source_type === 'sdi' || source_type === 'blackmagic') {
if (source_type === 'sdi') {
if (device === undefined || device === null) {
return res.status(400).json({ error: 'SDI/blackmagic source requires: device' });
return res.status(400).json({ error: 'SDI source requires: device' });
}
} else if (source_type === 'srt' || source_type === 'rtmp') {
if (!listen && !source_url) {
@ -354,93 +332,48 @@ router.post('/start', async (req, res) => {
}
} else {
return res.status(400).json({
error: `Unknown source_type: ${source_type}. Must be sdi, blackmagic, srt, rtmp, or deltacast`,
error: `Unknown source_type: ${source_type}. Must be sdi, srt, rtmp, or deltacast`,
});
}
// If asset_id provided (standby mode — mam-api already created it), skip creation.
// Otherwise create the live asset here (legacy on-demand path).
let assetId = asset_id || null;
if (!assetId) {
try {
const mamResponse = await fetch(`${MAM_API_URL}/api/v1/assets`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(MAM_API_TOKEN ? { Authorization: `Bearer ${MAM_API_TOKEN}` } : { 'X-Requested-With': 'dragonflight-ui' }) },
body: JSON.stringify({
projectId: project_id,
binId: bin_id,
clipName: clip_name,
sourceType: source_type,
status: 'live',
}),
});
if (!mamResponse.ok) {
const errText = await mamResponse.text();
throw new Error(`MAM API returned ${mamResponse.status}: ${errText}`);
}
const asset = await mamResponse.json();
assetId = asset.id;
} catch (mamError) {
console.error('Failed to create live asset:', mamError.message);
return res.status(500).json({ error: `Could not create live asset: ${mamError.message}` });
// Create live asset in MAM API before starting capture
let assetId;
try {
const mamResponse = await fetch(`${MAM_API_URL}/api/v1/assets`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(MAM_API_TOKEN ? { Authorization: `Bearer ${MAM_API_TOKEN}` } : { 'X-Requested-With': 'dragonflight-ui' }) },
body: JSON.stringify({
projectId: project_id,
binId: bin_id,
clipName: clip_name,
sourceType: source_type,
status: 'live',
}),
});
if (!mamResponse.ok) {
const errText = await mamResponse.text();
throw new Error(`MAM API returned ${mamResponse.status}: ${errText}`);
}
}
// Helper: body value wins over container env var fallback
function bodyOr(bodyVal, envName) {
if (bodyVal !== undefined && bodyVal !== null && bodyVal !== '') return bodyVal;
const v = process.env[envName];
return (v === undefined || v === '') ? undefined : v;
const asset = await mamResponse.json();
assetId = asset.id;
} catch (mamError) {
console.error('Failed to create live asset:', mamError.message);
return res.status(500).json({ error: `Could not create live asset: ${mamError.message}` });
}
function bodyOrInt(bodyVal, envName) {
const v = bodyOr(bodyVal, envName);
if (v === undefined) return undefined;
const n = parseInt(v, 10);
return Number.isFinite(n) ? n : undefined;
}
function bodyOrBool(bodyVal, envName) {
if (bodyVal !== undefined && bodyVal !== null) return Boolean(bodyVal);
const v = process.env[envName];
if (v === undefined) return undefined;
return v === 'true' || v === '1' || v === 'yes';
}
// Inject body-supplied codec/session params into the process env so
// captureManager.start() picks them up via the existing env-read paths.
// This lets the standby container receive per-session params via HTTP.
if (growing_enabled !== undefined) process.env.GROWING_ENABLED = growing_enabled ? 'true' : 'false';
if (growing_smb_mount) process.env.GROWING_SMB_MOUNT = growing_smb_mount;
if (growing_smb_username) process.env.GROWING_SMB_USERNAME = growing_smb_username;
if (growing_smb_password) process.env.GROWING_SMB_PASSWORD = growing_smb_password;
if (growing_smb_vers) process.env.GROWING_SMB_VERS = growing_smb_vers;
const session = await captureManager.start({
projectId: project_id,
binId: bin_id || null,
clipName: clip_name,
device: device !== undefined ? device : parseInt(process.env.DEVICE_INDEX || '0', 10),
device,
sourceType: source_type,
sourceUrl: source_url,
listen,
listenPort: listen_port,
streamKey: stream_key,
assetId,
// Codec params: body wins, env falls back
videoCodec: bodyOr(recording_codec, 'RECORDING_CODEC') || 'prores_hq',
videoBitrate: bodyOr(recording_video_bitrate, 'RECORDING_VIDEO_BITRATE'),
framerate: bodyOr(recording_framerate, 'RECORDING_FRAMERATE'),
audioCodec: bodyOr(recording_audio_codec, 'RECORDING_AUDIO_CODEC') || 'pcm_s24le',
audioBitrate: bodyOr(recording_audio_bitrate, 'RECORDING_AUDIO_BITRATE'),
audioChannels: bodyOrInt(recording_audio_channels, 'RECORDING_AUDIO_CHANNELS') ?? 2,
container: bodyOr(recording_container, 'RECORDING_CONTAINER') || 'mov',
proxyEnabled: bodyOrBool(proxy_enabled, 'PROXY_ENABLED') ?? true,
proxyVideoCodec: bodyOr(proxy_codec, 'PROXY_CODEC') || 'h264',
proxyVideoBitrate: bodyOr(proxy_video_bitrate, 'PROXY_VIDEO_BITRATE') || '8M',
proxyFramerate: bodyOr(proxy_framerate, 'PROXY_FRAMERATE'),
proxyAudioCodec: bodyOr(proxy_audio_codec, 'PROXY_AUDIO_CODEC') || 'aac',
proxyAudioBitrate: bodyOr(proxy_audio_bitrate, 'PROXY_AUDIO_BITRATE') || '192k',
proxyAudioChannels: bodyOrInt(proxy_audio_channels, 'PROXY_AUDIO_CHANNELS') ?? 2,
proxyContainer: bodyOr(proxy_container, 'PROXY_CONTAINER') || 'mp4',
});
res.json(session);
@ -484,10 +417,7 @@ router.post('/stop', async (req, res) => {
const mamResponse = await fetch(`${MAM_API_URL}/api/v1/assets/${completedSession.assetId}/${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(MAM_API_TOKEN ? { Authorization: `Bearer ${MAM_API_TOKEN}` } : { 'X-Requested-With': 'dragonflight-ui' }),
},
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!mamResponse.ok) {

View file

@ -0,0 +1,330 @@
import express from 'express';
import { execSync, spawn } from 'child_process';
import captureManager from '../capture-manager.js';
import dgram from 'dgram';
import net from 'net';
function parseUrl(u) {
try {
const m = String(u).match(/^[a-z]+:\/\/([^:\/?#]+)(?::(\d+))?/i);
if (!m) return null;
return { host: m[1], port: parseInt(m[2] || '0', 10) };
} catch (_) { return null; }
}
async function checkReachable(host, port, sourceType) {
if (!port) return { ok: true };
if (sourceType === 'srt') return await udpSendProbe(host, port);
if (sourceType === 'rtmp') return await tcpConnectProbe(host, port);
return { ok: true };
}
function udpSendProbe(host, port) {
return new Promise((resolve) => {
const sock = dgram.createSocket('udp4');
let done = false;
const finish = (result) => { if (done) return; done = true; try { sock.close(); } catch (_) {} resolve(result); };
sock.on('error', (err) => {
const msg = String(err && err.message || err);
if (/EHOSTUNREACH|ENETUNREACH/i.test(msg)) {
finish({ ok: false, error: 'Host ' + host + ' is unreachable from the capture container (no route). Confirm the IP is correct and the machine is online.', diagnostic: msg });
} else if (/ECONNREFUSED|EPORTUNREACH/i.test(msg)) {
finish({ ok: false, error: 'Nothing is listening on UDP ' + host + ':' + port + '. In vMix, confirm the SRT output is Started and the port matches.', diagnostic: msg });
} else {
finish({ ok: false, error: 'UDP probe failed: ' + msg, diagnostic: msg });
}
});
sock.send(Buffer.from('Z-AMPP-PROBE'), port, host, () => {});
setTimeout(() => finish({ ok: true }), 1500);
});
}
function tcpConnectProbe(host, port) {
return new Promise((resolve) => {
const sock = new net.Socket();
let done = false;
const finish = (r) => { if (done) return; done = true; try { sock.destroy(); } catch (_) {} resolve(r); };
sock.setTimeout(2500);
sock.once('connect', () => finish({ ok: true }));
sock.once('timeout', () => finish({ ok: false, error: 'TCP connect to ' + host + ':' + port + ' timed out. Confirm the host is reachable and a TCP listener is running.' }));
sock.once('error', (err) => {
const msg = String(err && err.message || err);
if (/EHOSTUNREACH|ENETUNREACH/i.test(msg)) finish({ ok: false, error: 'Host ' + host + ' unreachable (no route).', diagnostic: msg });
else if (/ECONNREFUSED/i.test(msg)) finish({ ok: false, error: 'Nothing is listening on TCP ' + host + ':' + port + '.', diagnostic: msg });
else finish({ ok: false, error: 'TCP probe failed: ' + msg, diagnostic: msg });
});
sock.connect(port, host);
});
}
function classifyProbeError(raw, sourceType) {
const r = (raw || '').toLowerCase();
if (sourceType === 'srt') {
if (/connection .* failed: (input\/output|timer expired|connection setup failure)/i.test(raw)) {
return 'SRT handshake failed. In vMix: confirm the External Output is Started, Type=SRT, Mode=Listener, port matches, and any passphrase / stream ID is empty (or copied exactly).';
}
}
if (sourceType === 'rtmp') {
if (/connection refused/i.test(r)) return 'Nothing is listening on RTMP at this address. Start your RTMP source.';
if (/end-of-file|invalid data found/i.test(r)) return 'Got a TCP connection but no RTMP stream. Confirm the source is publishing and the path / stream-key match.';
}
return raw;
}
const router = express.Router();
const MAM_API_URL = process.env.MAM_API_URL || 'http://mam-api:3000';
/**
* GET /devices
* List available DeckLink devices
*/
router.get('/devices', (req, res) => {
try {
const devices = [];
let output = '';
try {
output = execSync('ffmpeg -f decklink -list_devices 1 -i dummy 2>&1', {
encoding: 'utf-8',
});
} catch (error) {
// ffmpeg returns non-zero, but stderr is still captured
output = error.stderr ? error.stderr.toString() : error.toString();
}
// Parse ffmpeg output for DeckLink device names
// Format: [decklink @ ...] "DeckLink Quad 2" (input #0)
const lines = output.split('\n');
let deviceIndex = 0;
for (const line of lines) {
const match = line.match(/^\s*\[decklink[^\]]*\]\s+"([^"]+)"/);
if (match) {
devices.push({
index: deviceIndex,
name: match[1],
});
deviceIndex++;
}
}
res.json({ devices });
} catch (error) {
console.error('Error listing devices:', error);
res.status(500).json({ error: 'Failed to list devices' });
}
});
/**
* GET /status
* Get current capture status
*/
router.get('/status', (req, res) => {
try {
const status = captureManager.getStatus();
res.json(status);
} catch (error) {
console.error('Error getting status:', error);
res.status(500).json({ error: 'Failed to get status' });
}
});
router.post('/probe', async (req, res) => {
try {
const { source_type = 'sdi', source_url, listen = false } = req.body || {};
if (source_type === 'sdi') {
try {
const raw = execSync('ffmpeg -hide_banner -f decklink -list_devices 1 -i dummy 2>&1', { encoding: 'utf-8', timeout: 5000 });
const devices = [];
for (const line of raw.split('\n')) {
const m = line.match(/\[decklink[^\]]*\]\s+"([^"]+)"/);
if (m) devices.push(m[1]);
}
return res.json({ ok: true, source_type, devices });
} catch (err) {
const out = (err.stderr || err.stdout || err.toString()).toString();
return res.json({ ok: false, source_type, error: out.slice(0, 800) });
}
}
if (listen) {
return res.json({ ok: false, source_type, error: 'Listener-mode sources cannot be probed standalone. Start the recorder and watch the signal indicator.' });
}
if (!source_url) return res.status(400).json({ error: 'source_url is required' });
// Pre-flight: parse host:port and check L3/L4 reachability so we can give
// an actionable error instead of the opaque libsrt "Input/output error".
const parsed = parseUrl(source_url);
if (!parsed) {
return res.json({ ok: false, source_type, source_url, error: 'Could not parse host:port from URL.' });
}
const reach = await checkReachable(parsed.host, parsed.port, source_type);
if (!reach.ok) {
return res.json({ ok: false, source_type, source_url, error: reach.error, diagnostic: reach.diagnostic });
}
let url = source_url;
if (source_type === 'srt' && !/mode=/.test(url)) {
url += (url.includes('?') ? '&' : '?') + 'mode=caller';
}
const args = ['-hide_banner','-v','error','-probesize','32M','-analyzeduration','8M','-rw_timeout','7000000','-i', url, '-show_streams','-show_format','-of','json'];
const ff = spawn('ffprobe', args);
let stdout = '', stderr = '';
ff.stdout.on('data', (c) => { stdout += c; });
ff.stderr.on('data', (c) => { stderr += c; });
const killer = setTimeout(() => { try { ff.kill('SIGKILL'); } catch (_) {} }, 10000);
ff.on('close', (code) => {
clearTimeout(killer);
if (code !== 0) {
const rawErr = (stderr || 'ffprobe failed').slice(0, 800);
const friendly = classifyProbeError(rawErr, source_type);
return res.json({ ok: false, source_type, source_url, error: friendly, diagnostic: rawErr });
}
try {
const parsed = JSON.parse(stdout);
const streams = (parsed.streams || []).map(s => ({
index: s.index, codec_type: s.codec_type, codec_name: s.codec_name,
width: s.width, height: s.height, pix_fmt: s.pix_fmt,
r_frame_rate: s.r_frame_rate, avg_frame_rate: s.avg_frame_rate,
sample_rate: s.sample_rate, channels: s.channels,
channel_layout: s.channel_layout, bit_rate: s.bit_rate,
}));
return res.json({ ok: true, source_type, source_url,
format: { format_name: parsed.format && parsed.format.format_name, duration: parsed.format && parsed.format.duration, bit_rate: parsed.format && parsed.format.bit_rate },
streams });
} catch (err) {
return res.json({ ok: false, source_type, source_url, error: 'Could not parse ffprobe output: ' + err.message });
}
});
} catch (error) {
console.error('Probe error:', error);
res.status(500).json({ error: error.message });
}
});
/**
* POST /start
* Start a new capture session
*
* Body (SDI):
* { project_id, clip_name, device, bin_id?, source_type? }
*
* Body (SRT/RTMP caller):
* { project_id, clip_name, source_type, source_url, bin_id? }
*
* Body (SRT/RTMP listener):
* { project_id, clip_name, source_type, listen: true, listen_port?, stream_key?, bin_id? }
*/
router.post('/start', async (req, res) => {
try {
const {
project_id,
bin_id,
clip_name,
device,
source_type = 'sdi',
source_url,
listen = false,
listen_port,
stream_key,
} = req.body;
if (!project_id || !clip_name) {
return res.status(400).json({
error: 'Missing required fields: project_id, clip_name',
});
}
// Source-specific validation
if (source_type === 'sdi') {
if (device === undefined || device === null) {
return res.status(400).json({ error: 'SDI source requires: device' });
}
} else if (source_type === 'srt' || source_type === 'rtmp') {
if (!listen && !source_url) {
return res.status(400).json({
error: `${source_type.toUpperCase()} caller mode requires: source_url`,
});
}
} else {
return res.status(400).json({
error: `Unknown source_type: ${source_type}. Must be sdi, srt, or rtmp`,
});
}
const session = await captureManager.start({
projectId: project_id,
binId: bin_id || null,
clipName: clip_name,
device,
sourceType: source_type,
sourceUrl: source_url,
listen,
listenPort: listen_port,
streamKey: stream_key,
});
res.json(session);
} catch (error) {
console.error('Error starting capture:', error);
res.status(500).json({ error: error.message });
}
});
/**
* POST /stop
* Stop the current capture session
* Body: { session_id }
*/
router.post('/stop', async (req, res) => {
try {
const { session_id } = req.body;
if (!session_id) {
return res.status(400).json({ error: 'Missing required field: session_id' });
}
const completedSession = await captureManager.stop(session_id);
// Register asset with mam-api.
// If proxyKey is null (SRT/RTMP source), set needsProxy=true so the
// worker generates a proxy from the hires file asynchronously.
try {
const mamResponse = await fetch(`${MAM_API_URL}/api/v1/assets`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectId: completedSession.projectId,
binId: completedSession.binId,
clipName: completedSession.clipName,
sourceType: completedSession.sourceType,
hiresKey: completedSession.hiresKey,
proxyKey: completedSession.proxyKey,
needsProxy: completedSession.proxyKey === null,
duration: completedSession.duration,
capturedAt: completedSession.startedAt,
}),
});
if (!mamResponse.ok) {
console.warn(
`MAM API registration returned ${mamResponse.status}: ${await mamResponse.text()}`,
);
}
} catch (mamError) {
console.warn('Failed to register asset with MAM API:', mamError.message);
}
res.json(completedSession);
} catch (error) {
console.error('Error stopping capture:', error);
res.status(500).json({ error: error.message });
}
});
export default router;

View file

@ -1,63 +0,0 @@
cmake_minimum_required(VERSION 3.16)
project(framecache C)
set(CMAKE_C_STANDARD 11)
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Wall -Wextra -O2")
# ── libmicrohttpd ────────────────────────────────────────────────────
find_library(MHD_LIB microhttpd REQUIRED)
find_path(MHD_INCLUDE microhttpd.h REQUIRED)
include_directories(${MHD_INCLUDE})
# ── framecache server ────────────────────────────────────────────────
add_executable(framecache
src/framecache.c
src/slot.c
src/registry.c
)
target_link_libraries(framecache ${MHD_LIB} rt pthread)
# ── fc_client static library (used by bridges + test) ───────────────
add_library(fc_client STATIC
client/fc_client.c
src/slot.c # client needs fc_slot_shm_size / fc_frame_at
)
target_include_directories(fc_client PUBLIC src client)
target_link_libraries(fc_client rt pthread)
# ── net_ingest — network source (RTMP/SRT) → framecache slot ─────────
# Spawned by node-agent when a network recorder starts.
# Decodes the network stream to raw UYVY422 via ffmpeg and writes frames
# into a framecache slot, giving capture-manager the same fc_pipe consumer
# interface as SDI sources.
add_executable(net_ingest
src/net_ingest.c
src/slot.c
)
target_include_directories(net_ingest PRIVATE src)
target_link_libraries(net_ingest rt pthread)
install(TARGETS net_ingest DESTINATION bin)
# ── fc_pipe — slot → stdout adapter (used by capture-manager.js) ─────
# Spawned by capture-manager as a child process; writes raw UYVY422
# frames from a framecache slot to stdout so ffmpeg reads them as
# rawvideo pipe input. Multiple fc_pipe instances on the same slot
# each get an independent cursor — zero-copy fan-out.
add_executable(fc_pipe
client/fc_pipe.c
)
target_link_libraries(fc_pipe fc_client)
target_include_directories(fc_pipe PRIVATE src client)
# ── test consumer (dev utility) ──────────────────────────────────────
if(BUILD_TESTS)
add_executable(fc_test_consumer
client/fc_test_consumer.c
)
target_link_libraries(fc_test_consumer fc_client)
target_include_directories(fc_test_consumer PRIVATE src client)
endif()
install(TARGETS framecache fc_pipe DESTINATION bin)
install(FILES client/fc_client.h src/slot.h DESTINATION include/framecache)
install(TARGETS fc_client DESTINATION lib)

View file

@ -1,31 +0,0 @@
# ── Build stage ─────────────────────────────────────────────────────
FROM debian:bookworm AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential cmake libmicrohttpd-dev \
&& rm -rf /var/lib/apt/lists/*
COPY . /src
RUN cmake -S /src -B /build \
-DCMAKE_BUILD_TYPE=Release \
&& cmake --build /build -j"$(nproc)"
# ── Runtime stage ────────────────────────────────────────────────────
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
libmicrohttpd12 wget \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /build/framecache /usr/local/bin/framecache
COPY --from=builder /build/net_ingest /usr/local/bin/net_ingest
# /dev/shm/framecache is created at runtime (tmpfs)
RUN mkdir -p /dev/shm/framecache
EXPOSE 7435
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s \
CMD wget -qO- http://localhost:7435/health || exit 1
CMD ["/usr/local/bin/framecache"]

View file

@ -1,210 +0,0 @@
/**
* fc_client.c Consumer-side framecache client implementation.
*/
#include "fc_client.h"
#include "../src/slot.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <semaphore.h>
#include <time.h>
#include <unistd.h>
#define SHM_DIR "/dev/shm/framecache"
#define SEM_PREFIX "/framecache-"
#define SEM_SUFFIX "-write"
struct fc_consumer {
int shm_fd;
void *base;
size_t shm_size;
sem_t *sem;
uint64_t read_cursor; /* consumer's own position in the ring */
uint64_t local_dropped; /* frames skipped by this consumer */
uint8_t *copy_buf; /* consumer-owned frame copy buffer (frame_size bytes) */
uint32_t frame_size; /* cached from header */
char slot_id[FC_MAX_SLOT_ID];
};
static uint64_t now_us(void)
{
struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
return (uint64_t)ts.tv_sec * 1000000ULL + ts.tv_nsec / 1000ULL;
}
fc_consumer_t *fc_consumer_open(const char *slot_id, uint64_t wait_ms)
{
char shm_path[128], sem_name[128];
snprintf(shm_path, sizeof shm_path, "%s/%s", SHM_DIR, slot_id);
snprintf(sem_name, sizeof sem_name, "%s%s%s", SEM_PREFIX, slot_id, SEM_SUFFIX);
uint64_t deadline = now_us() + wait_ms * 1000ULL;
int fd = -1;
while (1) {
fd = open(shm_path, O_RDONLY);
if (fd >= 0) break;
if (now_us() >= deadline) return NULL;
struct timespec ts = { .tv_nsec = 100000000 }; /* 100ms */
nanosleep(&ts, NULL);
}
/* Read header to get frame_size */
fc_header_t hdr;
if (pread(fd, &hdr, sizeof hdr, 0) != sizeof hdr || hdr.magic != FC_MAGIC) {
close(fd); return NULL;
}
size_t total = fc_slot_shm_size(hdr.frame_size);
void *base = mmap(NULL, total, PROT_READ, MAP_SHARED, fd, 0);
if (base == MAP_FAILED) { close(fd); return NULL; }
sem_t *sem = sem_open(sem_name, 0);
if (sem == SEM_FAILED) { munmap(base, total); close(fd); return NULL; }
fc_consumer_t *c = calloc(1, sizeof *c);
if (!c) { sem_close(sem); munmap(base, total); close(fd); return NULL; }
/* Consumer-owned copy buffer — fc_consumer_read copies the frame here and
* re-validates the cursor afterward, so a writer lapping a slow consumer
* cannot corrupt the frame the caller is using. */
c->copy_buf = malloc(hdr.frame_size);
if (!c->copy_buf) {
free(c); sem_close(sem); munmap(base, total); close(fd); return NULL;
}
c->shm_fd = fd;
c->base = base;
c->shm_size = total;
c->sem = sem;
c->frame_size = hdr.frame_size;
/* Start reading from the current write position so we don't replay old frames */
c->read_cursor = atomic_load_explicit(
&((fc_header_t *)base)->write_cursor, memory_order_acquire);
c->local_dropped = 0;
strncpy(c->slot_id, slot_id, FC_MAX_SLOT_ID - 1);
return c;
}
int fc_consumer_read(fc_consumer_t *c, fc_frame_ref_t *ref, uint64_t timeout_ms)
{
fc_header_t *hdr = (fc_header_t *)c->base;
int dropped = 0; /* set when this call skipped one or more frames */
/* ── Wait for new data ──────────────────────────────────────────────
* The semaphore is used ONLY as an edge-wakeup hint, never as a frame
* counter. The writer posts once per frame, but a consumer that skips
* frames (lap) or reads less often than the writer posts would otherwise
* leave the count climbing unbounded causing sem_timedwait to never
* block (100% CPU busy-spin) and eventually EOVERFLOW. So:
* - cursor-diff (write_cursor - read_cursor) is the SOURCE OF TRUTH for
* whether a frame is available.
* - we drain the semaphore to zero (sem_trywait loop) so the count never
* accumulates.
* - if no frame is available we block on ONE sem_timedwait for wakeup. */
for (;;) {
uint64_t write_cur = atomic_load_explicit(&hdr->write_cursor,
memory_order_acquire);
/* Lap detection: if the writer is more than ring_depth ahead, the
* oldest unread frames have been overwritten skip to the oldest
* still-valid frame. */
if (write_cur > c->read_cursor + hdr->ring_depth) {
uint64_t skipped = write_cur - c->read_cursor - hdr->ring_depth;
c->read_cursor = write_cur - hdr->ring_depth;
c->local_dropped += skipped;
/* NOTE: do NOT write hdr->dropped_frames here — the consumer maps
* the shm PROT_READ (read-only), so an atomic write would SIGSEGV.
* Per-consumer drops are tracked in c->local_dropped and exposed
* via fc_consumer_dropped(). The writer owns hdr->dropped_frames. */
dropped = 1;
}
if (c->read_cursor < write_cur) {
/* A frame is available — drain the semaphore so its count never
* accumulates, then read+copy below. */
while (sem_trywait(c->sem) == 0) { /* drain */ }
break;
}
/* No frame yet — drain stale posts, then block for a wakeup. */
while (sem_trywait(c->sem) == 0) { /* drain */ }
struct timespec abs_ts;
clock_gettime(CLOCK_REALTIME, &abs_ts);
abs_ts.tv_sec += (time_t)(timeout_ms / 1000);
abs_ts.tv_nsec += (long)((timeout_ms % 1000) * 1000000L);
if (abs_ts.tv_nsec >= 1000000000L) { abs_ts.tv_sec++; abs_ts.tv_nsec -= 1000000000L; }
int w = sem_timedwait(c->sem, &abs_ts);
if (w != 0) {
if (errno == ETIMEDOUT) {
/* Re-check the cursor once more before giving up — the writer
* may have advanced between our check and the wait. */
uint64_t wc2 = atomic_load_explicit(&hdr->write_cursor,
memory_order_acquire);
if (c->read_cursor < wc2) continue;
return FC_TIMEOUT;
}
if (errno == EINTR) continue;
return FC_ERROR;
}
/* Woken — loop to re-evaluate cursor-diff. */
}
/* ── Copy the frame into the consumer-owned buffer ──────────────────── */
fc_frame_t *frame = fc_frame_at(c->base, hdr->frame_size, c->read_cursor);
uint32_t fsz = frame->size;
if (fsz > hdr->frame_size) fsz = hdr->frame_size;
uint64_t pts = frame->pts_us;
uint64_t wall = frame->wall_us;
memcpy(c->copy_buf, frame->data, fsz);
/* ── Re-validate AFTER the copy ─────────────────────────────────────
* If the writer lapped us during the copy (overwrote this slot), the copy
* may be torn discard it and signal DROPPED so the caller reads again. */
uint64_t write_after = atomic_load_explicit(&hdr->write_cursor,
memory_order_acquire);
if (write_after > c->read_cursor + hdr->ring_depth) {
uint64_t skipped = write_after - c->read_cursor - hdr->ring_depth;
c->read_cursor = write_after - hdr->ring_depth;
c->local_dropped += skipped;
return FC_LAPPED; /* copy torn — ref not valid, caller reads again */
}
/* Copy is valid. */
ref->data = c->copy_buf;
ref->size = fsz;
ref->pts_us = pts;
ref->wall_us = wall;
ref->seq = c->read_cursor;
c->read_cursor++;
return dropped ? FC_DROPPED : FC_OK;
}
void fc_consumer_close(fc_consumer_t *c)
{
if (!c) return;
if (c->copy_buf) free(c->copy_buf);
sem_close(c->sem);
munmap(c->base, c->shm_size);
close(c->shm_fd);
free(c);
}
uint64_t fc_consumer_write_cursor(fc_consumer_t *c)
{
fc_header_t *hdr = (fc_header_t *)c->base;
return atomic_load(&hdr->write_cursor);
}
uint64_t fc_consumer_dropped(fc_consumer_t *c)
{
return c->local_dropped;
}

View file

@ -1,82 +0,0 @@
/**
* fc_client.h Consumer-side framecache client library.
*
* Usage:
* fc_consumer_t *c = fc_consumer_open("deltacast-zampp3-0");
* fc_frame_ref_t ref;
* while (fc_consumer_read(c, &ref, 2000) == FC_OK) {
* // ref.data valid until next fc_consumer_read call
* process_frame(ref.data, ref.size, ref.pts_us);
* }
* fc_consumer_close(c);
*
* Each consumer tracks its own read_cursor multiple consumers on the same
* slot are fully independent and never block each other or the writer.
*
* If a consumer falls more than ring_depth frames behind the writer its cursor
* is snapped to the latest frame and FC_DROPPED is returned once.
*/
#pragma once
#include <stdint.h>
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
/* Return codes */
#define FC_OK 0 /* valid frame returned in ref */
#define FC_TIMEOUT 1 /* no new frame within timeout_ms — ref not populated */
#define FC_DROPPED 2 /* valid frame returned in ref, BUT one or more older
* frames were skipped first (consumer fell behind).
* ref IS populated caller should USE the frame. */
#define FC_LAPPED 3 /* the copy was overwritten mid-read (writer lapped the
* consumer during memcpy). ref NOT populated caller
* should call fc_consumer_read again. */
#define FC_ERROR -1
typedef struct fc_consumer fc_consumer_t;
typedef struct {
const uint8_t *data; /* pointer to a CONSUMER-OWNED copy of the frame —
* stable until the next fc_consumer_read() call.
* (Previously a zero-copy pointer into the shm ring,
* which the writer could overwrite mid-use when it
* lapped a slow consumer. We now copy into the
* consumer's own buffer and re-validate the cursor
* AFTER the copy, so a lapped frame is discarded
* rather than streamed corrupt.) */
uint32_t size; /* bytes */
uint64_t pts_us; /* presentation timestamp (microseconds) */
uint64_t wall_us; /* wall clock at write time (microseconds) */
uint64_t seq; /* write_cursor value for this frame */
} fc_frame_ref_t;
/**
* Open a consumer handle for the named slot.
* Polls the slot shm file until it appears (up to wait_ms milliseconds).
* Returns NULL if slot not found within wait_ms or on error.
*/
fc_consumer_t *fc_consumer_open(const char *slot_id, uint64_t wait_ms);
/**
* Read the next frame.
* Blocks up to timeout_ms waiting for a new frame (via semaphore).
* Returns FC_OK, FC_TIMEOUT, FC_DROPPED, or FC_ERROR.
* On FC_OK or FC_DROPPED the ref fields are populated.
*/
int fc_consumer_read(fc_consumer_t *c, fc_frame_ref_t *ref, uint64_t timeout_ms);
/** Close the consumer handle. Does NOT destroy the slot. */
void fc_consumer_close(fc_consumer_t *c);
/** Current write_cursor of the slot (approximate — no lock). */
uint64_t fc_consumer_write_cursor(fc_consumer_t *c);
/** Frames dropped by this consumer since open. */
uint64_t fc_consumer_dropped(fc_consumer_t *c);
#ifdef __cplusplus
}
#endif

View file

@ -1,133 +0,0 @@
/**
* fc_pipe.c Framecache slot stdout pipe adapter.
*
* Opens a framecache slot as a consumer and writes raw video frames to
* stdout in a continuous stream. capture-manager.js spawns this process
* and feeds its stdout to ffmpeg as a rawvideo pipe input identical to
* the way DeckLink bridges currently pipe raw frames.
*
* Each consumer instance has its own independent read cursor, so multiple
* fc_pipe processes reading from the same slot never interfere with each
* other. This is how growing + proxy + HLS all read the same SDI signal
* simultaneously.
*
* Usage:
* fc_pipe <slot_id> [wait_ms]
*
* Writes raw UYVY422 frame data to stdout. Terminates on:
* - SIGTERM / SIGINT (clean stop from capture-manager)
* - stdout EPIPE (ffmpeg exited)
* - Slot disappears (bridge stopped)
*
* Exit codes:
* 0 clean stop (SIGTERM)
* 1 slot not found within wait_ms
* 2 stdout write error (EPIPE)
*/
#include "../src/slot.h"
#include "fc_client.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>
#include <stdint.h>
#include <unistd.h>
#include <errno.h>
#include <time.h>
#include <fcntl.h>
static volatile int g_stop = 0;
static void on_signal(int s) { (void)s; g_stop = 1; }
/* Write all bytes to fd. Returns 0 on success, -1 on EPIPE/error. */
static int write_all_fd(int fd, const void *buf, size_t len) {
const uint8_t *p = (const uint8_t *)buf;
size_t off = 0;
while (off < len) {
ssize_t n = write(fd, p + off, len - off);
if (n > 0) { off += (size_t)n; continue; }
if (n < 0 && errno == EINTR) continue;
return -1; /* EPIPE or other fatal error */
}
return 0;
}
int main(int argc, char *argv[]) {
if (argc < 2) {
fprintf(stderr, "Usage: %s <slot_id> [wait_ms]\n", argv[0]);
return 1;
}
const char *slot_id = argv[1];
uint64_t wait_ms = argc >= 3 ? (uint64_t)atoll(argv[2]) : 30000;
signal(SIGTERM, on_signal);
signal(SIGINT, on_signal);
signal(SIGPIPE, SIG_IGN); /* detect EPIPE via write() return value */
/* Set stdout to binary mode — no newline translation */
fcntl(STDOUT_FILENO, F_SETFL,
fcntl(STDOUT_FILENO, F_GETFL, 0) & ~O_NONBLOCK);
fprintf(stderr, "[fc_pipe] opening slot '%s' (wait %llums)\n",
slot_id, (unsigned long long)wait_ms);
fc_consumer_t *c = fc_consumer_open(slot_id, wait_ms);
if (!c) {
fprintf(stderr, "[fc_pipe] slot '%s' not found within %llums\n",
slot_id, (unsigned long long)wait_ms);
return 1;
}
fprintf(stderr, "[fc_pipe] slot open, streaming to stdout\n");
uint64_t frames_out = 0;
uint64_t total_dropped = 0;
while (!g_stop) {
fc_frame_ref_t ref;
int rc = fc_consumer_read(c, &ref, 2000 /* 2s timeout */);
if (rc == FC_TIMEOUT) continue;
if (rc == FC_ERROR) break;
if (rc == FC_LAPPED) {
/* Copy was torn (writer lapped us mid-read). No valid frame to
* write log and read again. */
total_dropped = fc_consumer_dropped(c);
fprintf(stderr, "[fc_pipe] WARNING: frame lapped mid-read — total dropped: %llu\n",
(unsigned long long)total_dropped);
continue;
}
if (rc == FC_DROPPED) {
/* Skipped one or more older frames, but THIS frame is valid — log
* and write it (do NOT continue). */
total_dropped = fc_consumer_dropped(c);
fprintf(stderr, "[fc_pipe] WARNING: consumer fell behind — total dropped: %llu\n",
(unsigned long long)total_dropped);
}
/* Write frame data to stdout (ref.data is a stable consumer-owned copy) */
if (write_all_fd(STDOUT_FILENO, ref.data, ref.size) < 0) {
if (!g_stop)
fprintf(stderr, "[fc_pipe] stdout EPIPE — ffmpeg exited\n");
break;
}
frames_out++;
/* Periodic stats to stderr (every 300 frames ≈ 5s at 60fps) */
if (frames_out % 300 == 0) {
fprintf(stderr, "[fc_pipe] frames=%llu dropped=%llu\n",
(unsigned long long)frames_out,
(unsigned long long)total_dropped);
}
}
fc_consumer_close(c);
fprintf(stderr, "[fc_pipe] done frames=%llu dropped=%llu\n",
(unsigned long long)frames_out,
(unsigned long long)total_dropped);
return 0;
}

View file

@ -1,74 +0,0 @@
/**
* fc_test_consumer.c Dev utility: attach to a framecache slot and print stats.
*
* Usage: fc_test_consumer <slot_id> [wait_ms]
*/
#include "fc_client.h"
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <time.h>
static volatile int g_run = 1;
static void on_sig(int s) { (void)s; g_run = 0; }
int main(int argc, char **argv)
{
if (argc < 2) {
fprintf(stderr, "Usage: %s <slot_id> [wait_ms]\n", argv[0]);
return 1;
}
const char *slot_id = argv[1];
uint64_t wait_ms = argc >= 3 ? (uint64_t)atoi(argv[2]) : 30000;
signal(SIGINT, on_sig);
signal(SIGTERM, on_sig);
fprintf(stderr, "Opening slot '%s' (wait up to %llums)...\n",
slot_id, (unsigned long long)wait_ms);
fc_consumer_t *c = fc_consumer_open(slot_id, wait_ms);
if (!c) {
fprintf(stderr, "Failed to open slot '%s'\n", slot_id);
return 1;
}
fprintf(stderr, "Slot opened. Reading frames (Ctrl+C to stop)...\n");
uint64_t total = 0, dropped = 0;
struct timespec t0;
clock_gettime(CLOCK_MONOTONIC, &t0);
while (g_run) {
fc_frame_ref_t ref;
int rc = fc_consumer_read(c, &ref, 2000);
if (rc == FC_TIMEOUT) continue;
if (rc == FC_ERROR) { fprintf(stderr, "read error\n"); break; }
if (rc == FC_LAPPED) { /* torn copy — no valid frame, read again */ continue; }
if (rc == FC_DROPPED) {
dropped = fc_consumer_dropped(c);
fprintf(stderr, "[WARN] consumer fell behind — total dropped: %llu\n",
(unsigned long long)dropped);
}
total++;
/* Print stats every 100 frames */
if (total % 100 == 0) {
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
double elapsed = (now.tv_sec - t0.tv_sec)
+ (now.tv_nsec - t0.tv_nsec) * 1e-9;
fprintf(stdout, "frames=%llu dropped=%llu fps=%.2f pts_us=%llu\n",
(unsigned long long)total,
(unsigned long long)fc_consumer_dropped(c),
total / elapsed,
(unsigned long long)ref.pts_us);
fflush(stdout);
}
}
fprintf(stderr, "Done. total=%llu dropped=%llu\n",
(unsigned long long)total,
(unsigned long long)fc_consumer_dropped(c));
fc_consumer_close(c);
return 0;
}

View file

@ -1,369 +0,0 @@
/**
* framecache.c Main entry point. HTTP API server + slot manager.
*
* Endpoints:
* POST /slots Create slot
* GET /slots List slots
* GET /slots/:id Get slot detail
* DELETE /slots/:id Destroy slot
* GET /health Health check
*
* Uses libmicrohttpd for the HTTP layer (single-threaded, poll-based).
*/
#include "slot.h"
#include "registry.h"
#include <time.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <signal.h>
#include <errno.h>
#include <sys/stat.h>
#include <microhttpd.h>
#ifndef FC_PORT_DEFAULT
#define FC_PORT_DEFAULT 7435
#endif
/* ── tiny JSON helpers ─────────────────────────────────────────────── */
static int json_get_uint(const char *json, const char *key, uint32_t *out)
{
char pat[128];
snprintf(pat, sizeof pat, "\"%s\":", key);
const char *p = strstr(json, pat);
if (!p) return -1;
p += strlen(pat);
while (*p == ' ' || *p == '\t') p++;
*out = (uint32_t)strtoul(p, NULL, 10);
return 0;
}
static int json_get_str(const char *json, const char *key,
char *out, size_t out_len)
{
char pat[128];
snprintf(pat, sizeof pat, "\"%s\":", key);
const char *p = strstr(json, pat);
if (!p) return -1;
p += strlen(pat);
while (*p == ' ' || *p == '\t') p++;
if (*p != '"') return -1;
p++;
size_t i = 0;
while (*p && *p != '"' && i < out_len - 1)
out[i++] = *p++;
out[i] = '\0';
return 0;
}
/* ── HTTP request accumulator ──────────────────────────────────────── */
typedef struct {
char *buf;
size_t len;
size_t cap;
} req_body_t;
static void req_body_free(req_body_t *r)
{
free(r->buf);
r->buf = NULL; r->len = 0; r->cap = 0;
}
/* ── response helpers ──────────────────────────────────────────────── */
static enum MHD_Result respond(struct MHD_Connection *conn,
unsigned int status,
const char *body)
{
struct MHD_Response *r = MHD_create_response_from_buffer(
strlen(body), (void *)body, MHD_RESPMEM_MUST_COPY);
MHD_add_response_header(r, "Content-Type", "application/json");
MHD_add_response_header(r, "Access-Control-Allow-Origin", "*");
enum MHD_Result rc = MHD_queue_response(conn, status, r);
MHD_destroy_response(r);
return rc;
}
/* ── slot → JSON ───────────────────────────────────────────────────── */
static void slot_to_json(struct fc_slot *s, char *buf, size_t len)
{
fc_header_t *hdr = fc_slot_header(s);
uint64_t wc = atomic_load(&hdr->write_cursor);
uint64_t df = atomic_load(&hdr->dropped_frames);
/* simple fps estimate — not perfect but good enough for status */
snprintf(buf, len,
"{"
"\"slot_id\":\"%s\","
"\"shm_path\":\"%s\","
"\"sem_name\":\"%s\","
"\"width\":%u,"
"\"height\":%u,"
"\"fps_num\":%u,"
"\"fps_den\":%u,"
"\"pixel_format\":\"UYVY422\","
"\"source_type\":\"%s\","
"\"frame_size\":%u,"
"\"ring_depth\":%u,"
"\"write_cursor\":%llu,"
"\"dropped_frames\":%llu"
"}",
fc_slot_id(s),
fc_slot_shm_path(s),
fc_slot_sem_name(s),
hdr->width, hdr->height,
hdr->fps_num, hdr->fps_den,
hdr->source_type,
hdr->frame_size,
hdr->ring_depth,
(unsigned long long)wc,
(unsigned long long)df
);
}
/* ── request handler ───────────────────────────────────────────────── */
static enum MHD_Result handle_request(
void *cls,
struct MHD_Connection *conn,
const char *url,
const char *method,
const char *version,
const char *upload_data,
size_t *upload_data_size,
void **con_cls)
{
(void)cls; (void)version;
/* First call: allocate body accumulator */
if (*con_cls == NULL) {
req_body_t *rb = calloc(1, sizeof *rb);
if (!rb) return MHD_NO;
*con_cls = rb;
return MHD_YES;
}
req_body_t *rb = (req_body_t *)*con_cls;
/* Accumulate POST body */
if (*upload_data_size > 0) {
size_t need = rb->len + *upload_data_size + 1;
if (need > rb->cap) {
rb->buf = realloc(rb->buf, need);
rb->cap = need;
}
memcpy(rb->buf + rb->len, upload_data, *upload_data_size);
rb->len += *upload_data_size;
rb->buf[rb->len] = '\0';
*upload_data_size = 0;
return MHD_YES;
}
enum MHD_Result rc;
char resp[4096];
/* GET /health */
if (strcmp(method, "GET") == 0 && strcmp(url, "/health") == 0) {
rc = respond(conn, MHD_HTTP_OK, "{\"status\":\"ok\"}");
goto done;
}
/* GET /slots
* Worst case: FC_MAX_SLOTS (256) × ~2KB/entry 512KB. A 64KB stack buffer
* would overflow at ~32 slots (and `pos` could pass `sizeof big`, making
* `sizeof big - pos` underflow to a huge size_t). Heap-allocate a buffer
* sized for the worst case and bound-check every append. */
if (strcmp(method, "GET") == 0 && strcmp(url, "/slots") == 0) {
size_t cap = (size_t)FC_MAX_SLOTS * 2100 + 64; /* worst case + brackets */
char *big = malloc(cap);
if (!big) {
rc = respond(conn, MHD_HTTP_INTERNAL_SERVER_ERROR,
"{\"error\":\"out of memory\"}");
goto done;
}
size_t pos = 0;
if (pos < cap) big[pos++] = '[';
int first = 1;
for (int i = 0; i < FC_MAX_SLOTS; i++) {
if (!g_registry[i].active) continue;
char entry[2100];
slot_to_json(g_registry[i].slot, entry, sizeof entry);
size_t elen = strlen(entry);
/* +2 for possible comma + closing bracket, +1 for NUL */
if (pos + elen + 3 >= cap) break; /* never overflow */
if (!first) big[pos++] = ',';
first = 0;
memcpy(big + pos, entry, elen);
pos += elen;
}
if (pos + 2 < cap) big[pos++] = ']';
big[pos] = '\0';
rc = respond(conn, MHD_HTTP_OK, big);
free(big);
goto done;
}
/* GET /slots/:id */
if (strcmp(method, "GET") == 0 &&
strncmp(url, "/slots/", 7) == 0 && strlen(url) > 7)
{
const char *id = url + 7;
struct fc_slot *s = registry_find(id);
if (!s) {
rc = respond(conn, MHD_HTTP_NOT_FOUND,
"{\"error\":\"slot not found\"}");
goto done;
}
slot_to_json(s, resp, sizeof resp);
rc = respond(conn, MHD_HTTP_OK, resp);
goto done;
}
/* POST /slots */
if (strcmp(method, "POST") == 0 && strcmp(url, "/slots") == 0) {
if (!rb->buf || rb->len == 0) {
rc = respond(conn, MHD_HTTP_BAD_REQUEST,
"{\"error\":\"empty body\"}");
goto done;
}
char slot_id[FC_MAX_SLOT_ID] = {0};
char source_type[32] = "unknown";
uint32_t width = 0, height = 0, fps_num = 0, fps_den = 0;
json_get_str(rb->buf, "slot_id", slot_id, sizeof slot_id);
json_get_str(rb->buf, "source_type", source_type, sizeof source_type);
json_get_uint(rb->buf, "width", &width);
json_get_uint(rb->buf, "height", &height);
json_get_uint(rb->buf, "fps_num", &fps_num);
json_get_uint(rb->buf, "fps_den", &fps_den);
if (!slot_id[0] || !width || !height || !fps_num || !fps_den) {
rc = respond(conn, MHD_HTTP_BAD_REQUEST,
"{\"error\":\"missing required fields: "
"slot_id, width, height, fps_num, fps_den\"}");
goto done;
}
if (registry_find(slot_id)) {
rc = respond(conn, MHD_HTTP_CONFLICT,
"{\"error\":\"slot already exists\"}");
goto done;
}
struct fc_slot *s = fc_slot_create(slot_id, width, height,
fps_num, fps_den,
FC_PIX_UYVY422, source_type);
if (!s) {
rc = respond(conn, MHD_HTTP_INTERNAL_SERVER_ERROR,
"{\"error\":\"failed to create slot\"}");
goto done;
}
registry_add(s);
snprintf(resp, sizeof resp,
"{\"slot_id\":\"%s\","
"\"shm_path\":\"%s\","
"\"sem_name\":\"%s\"}",
fc_slot_id(s),
fc_slot_shm_path(s),
fc_slot_sem_name(s));
rc = respond(conn, MHD_HTTP_CREATED, resp);
goto done;
}
/* DELETE /slots/:id */
if (strcmp(method, "DELETE") == 0 &&
strncmp(url, "/slots/", 7) == 0 && strlen(url) > 7)
{
const char *id = url + 7;
struct fc_slot *s = registry_find(id);
if (!s) {
rc = respond(conn, MHD_HTTP_NOT_FOUND,
"{\"error\":\"slot not found\"}");
goto done;
}
registry_remove(id);
fc_slot_destroy(s);
rc = respond(conn, MHD_HTTP_NO_CONTENT, "");
goto done;
}
rc = respond(conn, MHD_HTTP_NOT_FOUND, "{\"error\":\"not found\"}");
done:
req_body_free(rb);
free(rb);
*con_cls = NULL;
return rc;
}
static void request_completed(void *cls,
struct MHD_Connection *conn,
void **con_cls,
enum MHD_RequestTerminationCode toe)
{
(void)cls; (void)conn; (void)toe;
if (*con_cls) {
req_body_free((req_body_t *)*con_cls);
free(*con_cls);
*con_cls = NULL;
}
}
/* ── main ──────────────────────────────────────────────────────────── */
static volatile int g_running = 1;
static volatile int g_received_signal = 0;
static void on_signal(int sig) { g_received_signal = sig; g_running = 0; }
int main(void)
{
signal(SIGINT, on_signal);
signal(SIGTERM, on_signal);
signal(SIGPIPE, SIG_IGN);
/* Ensure /dev/shm/framecache exists */
mkdir("/dev/shm/framecache", 0755);
/* Write empty registry */
registry_write_json();
const char *port_str = getenv("FC_PORT");
uint16_t port = port_str ? (uint16_t)atoi(port_str) : FC_PORT_DEFAULT;
struct MHD_Daemon *daemon = MHD_start_daemon(
MHD_USE_SELECT_INTERNALLY,
port,
NULL, NULL,
handle_request, NULL,
MHD_OPTION_NOTIFY_COMPLETED, request_completed, NULL,
MHD_OPTION_END);
if (!daemon) {
fprintf(stderr, "[framecache] failed to start HTTP server on port %u\n", port);
return 1;
}
fprintf(stderr, "[framecache] listening on port %u\n", port);
while (g_running) {
struct timespec ts = { .tv_sec = 0, .tv_nsec = 100000000 }; /* 100ms */
nanosleep(&ts, NULL);
}
fprintf(stderr, "[framecache] shutting down (signal %d)\n", g_received_signal);
/* Destroy all active slots */
for (int i = 0; i < FC_MAX_SLOTS; i++) {
if (g_registry[i].active) {
registry_remove(g_registry[i].slot_id);
fc_slot_destroy(g_registry[i].slot);
}
}
MHD_stop_daemon(daemon);
return 0;
}

View file

@ -1,422 +0,0 @@
/**
* net_ingest.c Network source (RTMP/SRT) framecache slot ingest.
*
* Spawns ffmpeg to decode a network stream to raw UYVY422 on stdout, then
* reads those frames and writes them into a framecache slot via the shm
* ring buffer. Registers the slot with the framecache HTTP API on startup
* and deregisters on clean exit.
*
* Usage:
* net_ingest --url <srt://...|rtmp://...>
* --slot-id <recorder-uuid>
* --fc-url http://framecache:7435
* --width <W> --height <H>
* --fps-num <N> --fps-den <D>
* [--source-type srt|rtmp]
* [--listen] # SRT/RTMP listener mode
* [--listen-port <N>] # listener port (SRT default 9000, RTMP 1935)
* [--stream-key <k>] # RTMP stream key (default "stream")
*
* Emits one JSON line to stderr on first frame:
* {"slot_id":"<id>","width":W,"height":H,"fps_num":N,"fps_den":D,
* "source_type":"srt","pix_fmt":"uyvy422"}
*
* Exits 0 on clean stop (SIGTERM), 1 on error.
*
* The framecache slot stays alive between ffmpeg reconnects (listener mode):
* net_ingest keeps the slot open and restarts ffmpeg on disconnect.
*/
#include "slot.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <stdatomic.h>
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>
#include <netdb.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
/* Re-use fc_writer helpers inline (no external dep) */
#define FC_URL_DEFAULT "http://localhost:7435"
static volatile int g_stop = 0;
static void on_signal(int s) { (void)s; g_stop = 1; }
/* ── Tiny HTTP POST/DELETE (same approach as fc_writer.c) ─────────── */
static int http_req(const char *method, const char *host, int port,
const char *path, const char *body,
char *resp, size_t resp_len)
{
struct sockaddr_in sa;
memset(&sa, 0, sizeof sa);
sa.sin_family = AF_INET;
sa.sin_port = htons((uint16_t)port);
struct hostent *he = gethostbyname(host);
if (!he) return -1;
memcpy(&sa.sin_addr, he->h_addr_list[0], (size_t)he->h_length);
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) return -1;
struct timeval tv = { .tv_sec = 5 };
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv);
if (connect(fd, (struct sockaddr *)&sa, sizeof sa) < 0) { close(fd); return -1; }
char req[4096];
int rlen;
if (body)
rlen = snprintf(req, sizeof req,
"%s %s HTTP/1.0\r\nHost: %s:%d\r\n"
"Content-Type: application/json\r\nContent-Length: %zu\r\n"
"Connection: close\r\n\r\n%s",
method, path, host, port, strlen(body), body);
else
rlen = snprintf(req, sizeof req,
"%s %s HTTP/1.0\r\nHost: %s:%d\r\nConnection: close\r\n\r\n",
method, path, host, port);
send(fd, req, (size_t)rlen, 0);
int status = -1;
size_t got = 0;
char buf[8192];
ssize_t n;
while ((n = recv(fd, buf + got, sizeof buf - got - 1, 0)) > 0) got += (size_t)n;
buf[got] = '\0';
sscanf(buf, "HTTP/%*s %d", &status);
if (resp && resp_len) {
const char *b = strstr(buf, "\r\n\r\n");
if (b) { strncpy(resp, b + 4, resp_len - 1); resp[resp_len-1] = '\0'; }
}
close(fd);
return status;
}
static void parse_url(const char *url, char *host, size_t hl, int *port) {
const char *p = url;
if (!strncmp(p, "http://", 7)) p += 7;
*port = 7435;
const char *colon = strchr(p, ':');
if (colon) {
size_t n = (size_t)(colon - p) < hl ? (size_t)(colon - p) : hl - 1;
strncpy(host, p, n); host[n] = '\0';
*port = atoi(colon + 1);
} else { strncpy(host, p, hl - 1); host[hl-1] = '\0'; }
}
static int json_str(const char *j, const char *k, char *out, size_t len) {
char pat[128]; snprintf(pat, sizeof pat, "\"%s\":", k);
const char *p = strstr(j, pat); if (!p) return -1;
p += strlen(pat); while (*p == ' ') p++;
if (*p != '"') return -1; p++;
size_t i = 0;
while (*p && *p != '"' && i < len - 1) out[i++] = *p++;
out[i] = '\0'; return 0;
}
/* ── Frame size helpers ────────────────────────────────────────────── */
static inline size_t frame_bytes(uint32_t w, uint32_t h) {
return (size_t)w * h * 2; /* UYVY422 */
}
/* ── Register slot with framecache ────────────────────────────────── */
static int register_slot(const char *fc_url, const char *slot_id,
uint32_t w, uint32_t h,
uint32_t fps_num, uint32_t fps_den,
const char *source_type,
char *shm_path, size_t sp_len,
char *sem_name, size_t sn_len)
{
char host[128]; int port;
parse_url(fc_url, host, sizeof host, &port);
char body[512];
snprintf(body, sizeof body,
"{\"slot_id\":\"%s\",\"width\":%u,\"height\":%u,"
"\"fps_num\":%u,\"fps_den\":%u,\"source_type\":\"%s\"}",
slot_id, w, h, fps_num, fps_den, source_type);
char resp[1024] = {0};
int st = http_req("POST", host, port, "/slots", body, resp, sizeof resp);
if (st != 201) {
fprintf(stderr, "[net_ingest] POST /slots failed HTTP %d: %s\n", st, resp);
return -1;
}
json_str(resp, "shm_path", shm_path, sp_len);
json_str(resp, "sem_name", sem_name, sn_len);
return 0;
}
static void deregister_slot(const char *fc_url, const char *slot_id) {
char host[128]; int port;
parse_url(fc_url, host, sizeof host, &port);
char path[192]; snprintf(path, sizeof path, "/slots/%s", slot_id);
http_req("DELETE", host, port, path, NULL, NULL, 0);
}
/* ── Open shm + semaphore for writing ─────────────────────────────── */
#include <sys/mman.h>
#include <semaphore.h>
typedef struct {
void *base;
size_t size;
int fd;
sem_t *sem;
} ShmWriter;
static int shm_writer_open(const char *shm_path, const char *sem_name,
ShmWriter *sw)
{
sw->fd = open(shm_path, O_RDWR);
if (sw->fd < 0) return -1;
fc_header_t hdr;
if (pread(sw->fd, &hdr, sizeof hdr, 0) != sizeof hdr || hdr.magic != FC_MAGIC) {
close(sw->fd); return -1;
}
sw->size = fc_slot_shm_size(hdr.frame_size);
sw->base = mmap(NULL, sw->size, PROT_READ | PROT_WRITE, MAP_SHARED, sw->fd, 0);
if (sw->base == MAP_FAILED) { close(sw->fd); return -1; }
sw->sem = sem_open(sem_name, 0);
if (sw->sem == SEM_FAILED) { munmap(sw->base, sw->size); close(sw->fd); return -1; }
return 0;
}
static void shm_write_frame(ShmWriter *sw, const uint8_t *data,
uint32_t size, uint64_t pts_us)
{
fc_header_t *hdr = (fc_header_t *)sw->base;
uint64_t cur = atomic_load_explicit(&hdr->write_cursor, memory_order_relaxed);
fc_frame_t *frame = fc_frame_at(sw->base, hdr->frame_size, cur);
struct timespec ts; clock_gettime(CLOCK_REALTIME, &ts);
frame->pts_us = pts_us;
frame->wall_us = (uint64_t)ts.tv_sec * 1000000ULL + ts.tv_nsec / 1000ULL;
frame->size = size < hdr->frame_size ? size : hdr->frame_size;
memcpy(frame->data, data, frame->size);
atomic_store_explicit(&hdr->write_cursor, cur + 1, memory_order_release);
sem_post(sw->sem);
}
static void shm_writer_close(ShmWriter *sw) {
if (sw->sem) { sem_close(sw->sem); sw->sem = NULL; }
if (sw->base) { munmap(sw->base, sw->size); sw->base = NULL; }
if (sw->fd >= 0) { close(sw->fd); sw->fd = -1; }
}
/* ── Build ffmpeg args for network decode → rawvideo stdout ──────────
* All dynamic strings are written into CALLER-OWNED buffers (passed in) so
* there is no per-call strdup leak across listener reconnects. The video
* filter forces the EXACT target W:H (scale=W:H, not iw:ih) so a mid-stream
* source resolution change cannot desync the fixed-size frame reassembly
* ffmpeg's scaler always emits width*height*2 bytes per frame.
*
* Caller must provide:
* url_buf at least 320 bytes (built listener URL, or copied caller URL)
* vf_buf at least 64 bytes (scale/format filter)
*/
static int build_ffmpeg_args(
char **argv, int max_args,
const char *url, const char *source_type,
int listen, int listen_port, const char *stream_key,
uint32_t w, uint32_t h,
char *url_buf, size_t url_buf_len,
char *vf_buf, size_t vf_buf_len)
{
(void)max_args;
char port_str[16];
int i = 0;
argv[i++] = "ffmpeg";
argv[i++] = "-hide_banner";
argv[i++] = "-loglevel"; argv[i++] = "warning";
/* Input */
argv[i++] = "-probesize"; argv[i++] = "32M";
argv[i++] = "-analyzeduration"; argv[i++] = "10M";
argv[i++] = "-fflags"; argv[i++] = "+genpts";
if (!strcmp(source_type, "srt") && listen) {
snprintf(port_str, sizeof port_str, "%d", listen_port ? listen_port : 9000);
snprintf(url_buf, url_buf_len, "srt://0.0.0.0:%s?mode=listener", port_str);
argv[i++] = "-i"; argv[i++] = url_buf;
} else if (!strcmp(source_type, "rtmp") && listen) {
snprintf(port_str, sizeof port_str, "%d", listen_port ? listen_port : 1935);
snprintf(url_buf, url_buf_len, "rtmp://0.0.0.0:%s/live/%s",
port_str, stream_key ? stream_key : "stream");
argv[i++] = "-listen"; argv[i++] = "1";
argv[i++] = "-i"; argv[i++] = url_buf;
} else {
argv[i++] = "-i"; argv[i++] = (char *)url;
}
/* Force EXACT output dimensions so every frame is exactly w*h*2 bytes,
* even if the source resolution changes mid-stream (SRT/RTMP reconnect to
* a different encoder). This is the resync guarantee for the fixed-size
* frame reassembly loop in main(). */
snprintf(vf_buf, vf_buf_len, "scale=%u:%u,format=uyvy422", w, h);
/* Video output: raw UYVY422 to stdout */
argv[i++] = "-map"; argv[i++] = "0:v:0";
argv[i++] = "-vf"; argv[i++] = vf_buf;
argv[i++] = "-f"; argv[i++] = "rawvideo";
argv[i++] = "-pix_fmt"; argv[i++] = "uyvy422";
argv[i++] = "pipe:1";
argv[i] = NULL;
return i;
}
/* ── Main ──────────────────────────────────────────────────────────── */
int main(int argc, char *argv[]) {
const char *url = NULL;
const char *slot_id = NULL;
const char *fc_url = getenv("FC_URL") ? getenv("FC_URL") : FC_URL_DEFAULT;
const char *source_type = "srt";
uint32_t width = 1920, height = 1080;
uint32_t fps_num = 30000, fps_den = 1001;
int listen = 0, listen_port = 0;
const char *stream_key = "stream";
for (int i = 1; i < argc; i++) {
if (!strcmp(argv[i], "--url") && i+1 < argc) url = argv[++i];
else if (!strcmp(argv[i], "--slot-id") && i+1 < argc) slot_id = argv[++i];
else if (!strcmp(argv[i], "--fc-url") && i+1 < argc) fc_url = argv[++i];
else if (!strcmp(argv[i], "--source-type") && i+1 < argc) source_type = argv[++i];
else if (!strcmp(argv[i], "--width") && i+1 < argc) width = (uint32_t)atoi(argv[++i]);
else if (!strcmp(argv[i], "--height") && i+1 < argc) height = (uint32_t)atoi(argv[++i]);
else if (!strcmp(argv[i], "--fps-num") && i+1 < argc) fps_num = (uint32_t)atoi(argv[++i]);
else if (!strcmp(argv[i], "--fps-den") && i+1 < argc) fps_den = (uint32_t)atoi(argv[++i]);
else if (!strcmp(argv[i], "--listen")) listen = 1;
else if (!strcmp(argv[i], "--listen-port") && i+1 < argc) listen_port = atoi(argv[++i]);
else if (!strcmp(argv[i], "--stream-key") && i+1 < argc) stream_key = argv[++i];
}
if (!slot_id) {
fprintf(stderr, "[net_ingest] --slot-id required\n");
return 1;
}
if (!url && !listen) {
fprintf(stderr, "[net_ingest] --url or --listen required\n");
return 1;
}
signal(SIGTERM, on_signal);
signal(SIGINT, on_signal);
signal(SIGPIPE, SIG_IGN);
signal(SIGCHLD, SIG_DFL);
/* ── Register slot ──────────────────────────────────────────────── */
char shm_path[128] = {0}, sem_name[128] = {0};
if (register_slot(fc_url, slot_id, width, height, fps_num, fps_den,
source_type, shm_path, sizeof shm_path,
sem_name, sizeof sem_name) < 0) {
return 1;
}
ShmWriter sw = { .fd = -1 };
if (shm_writer_open(shm_path, sem_name, &sw) < 0) {
fprintf(stderr, "[net_ingest] failed to open shm %s\n", shm_path);
deregister_slot(fc_url, slot_id);
return 1;
}
size_t fsz = frame_bytes(width, height);
uint8_t *frame_buf = malloc(fsz);
if (!frame_buf) { shm_writer_close(&sw); deregister_slot(fc_url, slot_id); return 1; }
uint64_t frame_seq = 0;
int reported = 0;
fprintf(stderr, "[net_ingest] slot=%s %ux%u %.2ffps source=%s%s\n",
slot_id, width, height,
fps_den ? (double)fps_num / fps_den : 0.0,
source_type, listen ? " (listener)" : "");
/* Caller-owned arg buffers — reused each reconnect, no per-loop leak. */
char ff_url_buf[320];
char ff_vf_buf[64];
/* ── Outer reconnect loop (listener mode stays alive between sessions) */
while (!g_stop) {
/* Build ffmpeg argv (writes into ff_url_buf / ff_vf_buf, no strdup) */
char *ff_argv[64];
build_ffmpeg_args(ff_argv, 64, url, source_type,
listen, listen_port, stream_key, width, height,
ff_url_buf, sizeof ff_url_buf,
ff_vf_buf, sizeof ff_vf_buf);
/* Spawn ffmpeg with stdout pipe */
int pfd[2];
if (pipe(pfd) < 0) break;
pid_t pid = fork();
if (pid < 0) { close(pfd[0]); close(pfd[1]); break; }
if (pid == 0) {
/* Child: redirect stdout to pipe write end */
dup2(pfd[1], STDOUT_FILENO);
close(pfd[0]); close(pfd[1]);
execvp("ffmpeg", ff_argv);
_exit(127);
}
/* Parent: read from pipe read end */
close(pfd[1]);
int rfd = pfd[0];
size_t buf_off = 0;
while (!g_stop) {
ssize_t n = read(rfd, frame_buf + buf_off, fsz - buf_off);
if (n <= 0) break; /* ffmpeg exited or pipe closed */
buf_off += (size_t)n;
if (buf_off < fsz) continue; /* incomplete frame — keep reading */
/* Full frame assembled */
uint64_t pts_us = fps_num > 0
? frame_seq * 1000000ULL * fps_den / fps_num
: 0;
shm_write_frame(&sw, frame_buf, (uint32_t)fsz, pts_us);
frame_seq++;
buf_off = 0;
if (!reported) {
fprintf(stderr,
"{\"slot_id\":\"%s\",\"width\":%u,\"height\":%u,"
"\"fps_num\":%u,\"fps_den\":%u,"
"\"source_type\":\"%s\",\"pix_fmt\":\"uyvy422\"}\n",
slot_id, width, height, fps_num, fps_den, source_type);
fflush(stderr);
reported = 1;
}
}
close(rfd);
/* Reap ffmpeg child */
int wstatus;
kill(pid, SIGTERM);
waitpid(pid, &wstatus, 0);
if (!listen || g_stop) break;
/* Listener mode: wait 1s then reconnect */
fprintf(stderr, "[net_ingest] listener: waiting for next connection\n");
struct timespec ts = { .tv_sec = 1 };
nanosleep(&ts, NULL);
}
free(frame_buf);
shm_writer_close(&sw);
deregister_slot(fc_url, slot_id);
fprintf(stderr, "[net_ingest] done frames=%llu\n", (unsigned long long)frame_seq);
return 0;
}

View file

@ -1,108 +0,0 @@
/**
* registry.c In-memory slot registry + JSON persistence.
*/
#include "registry.h"
#include "slot.h"
#include <stdio.h>
#include <string.h>
#include <time.h>
fc_registry_entry_t g_registry[FC_MAX_SLOTS];
int g_registry_count = 0;
static const char *REGISTRY_JSON = "/dev/shm/framecache/registry.json";
void registry_add(struct fc_slot *slot)
{
for (int i = 0; i < FC_MAX_SLOTS; i++) {
if (!g_registry[i].active) {
g_registry[i].active = 1;
g_registry[i].slot = slot;
strncpy(g_registry[i].slot_id, fc_slot_id(slot),
FC_MAX_SLOT_ID - 1);
g_registry_count++;
registry_write_json();
return;
}
}
fprintf(stderr, "[framecache] registry full (%d slots)\n", FC_MAX_SLOTS);
}
void registry_remove(const char *slot_id)
{
for (int i = 0; i < FC_MAX_SLOTS; i++) {
if (g_registry[i].active &&
strncmp(g_registry[i].slot_id, slot_id, FC_MAX_SLOT_ID) == 0)
{
g_registry[i].active = 0;
g_registry[i].slot = NULL;
g_registry[i].slot_id[0] = '\0';
g_registry_count--;
registry_write_json();
return;
}
}
}
struct fc_slot *registry_find(const char *slot_id)
{
for (int i = 0; i < FC_MAX_SLOTS; i++) {
if (g_registry[i].active &&
strncmp(g_registry[i].slot_id, slot_id, FC_MAX_SLOT_ID) == 0)
{
return g_registry[i].slot;
}
}
return NULL;
}
void registry_write_json(void)
{
FILE *f = fopen(REGISTRY_JSON, "w");
if (!f) return;
fprintf(f, "{\n \"version\": 1,\n \"slots\": {\n");
int first = 1;
for (int i = 0; i < FC_MAX_SLOTS; i++) {
if (!g_registry[i].active) continue;
fc_header_t *hdr = fc_slot_header(g_registry[i].slot);
char ts[32];
time_t now = time(NULL);
struct tm *t = gmtime(&now);
strftime(ts, sizeof ts, "%Y-%m-%dT%H:%M:%SZ", t);
if (!first) fprintf(f, ",\n");
first = 0;
fprintf(f,
" \"%s\": {\n"
" \"shm_path\": \"%s\",\n"
" \"sem_name\": \"%s\",\n"
" \"width\": %u,\n"
" \"height\": %u,\n"
" \"fps_num\": %u,\n"
" \"fps_den\": %u,\n"
" \"pixel_format\": \"UYVY422\",\n"
" \"source_type\": \"%s\",\n"
" \"frame_size\": %u,\n"
" \"ring_depth\": %u,\n"
" \"created_at\": \"%s\"\n"
" }",
g_registry[i].slot_id,
fc_slot_shm_path(g_registry[i].slot),
fc_slot_sem_name(g_registry[i].slot),
hdr->width, hdr->height,
hdr->fps_num, hdr->fps_den,
hdr->source_type,
hdr->frame_size,
hdr->ring_depth,
ts
);
}
fprintf(f, "\n }\n}\n");
fclose(f);
}

View file

@ -1,21 +0,0 @@
#pragma once
#include "slot.h"
/* Maximum number of concurrent slots */
#define FC_MAX_SLOTS 256
/* Registry entry (in-memory) */
typedef struct {
int active;
struct fc_slot *slot;
char slot_id[FC_MAX_SLOT_ID];
} fc_registry_entry_t;
/* Global registry — managed by framecache.c */
extern fc_registry_entry_t g_registry[FC_MAX_SLOTS];
extern int g_registry_count;
void registry_add(struct fc_slot *slot);
void registry_remove(const char *slot_id);
struct fc_slot *registry_find(const char *slot_id);
void registry_write_json(void); /* writes /dev/shm/framecache/registry.json */

View file

@ -1,216 +0,0 @@
/**
* slot.c Framecache slot lifecycle: create, destroy, open.
*/
#include "slot.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <fcntl.h>
#include <time.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
#define SHM_DIR "/dev/shm/framecache"
#define SEM_PREFIX "/framecache-"
#define SEM_SUFFIX "-write"
/* ── helpers ─────────────────────────────────────────────────────────── */
static void build_paths(const char *slot_id,
char *shm_path, size_t sp_len,
char *sem_name, size_t sn_len)
{
snprintf(shm_path, sp_len, "%s/%s", SHM_DIR, slot_id);
snprintf(sem_name, sn_len, "%s%s%s", SEM_PREFIX, slot_id, SEM_SUFFIX);
}
/* ── server-side: create / destroy ───────────────────────────────────── */
/**
* Create a new slot. Allocates and initialises the shm region.
* Returns handle on success, NULL on error (errno set).
*/
struct fc_slot *fc_slot_create(const char *slot_id,
uint32_t width, uint32_t height,
uint32_t fps_num, uint32_t fps_den,
uint32_t pixel_format,
const char *source_type)
{
char shm_path[128], sem_name[128];
build_paths(slot_id, shm_path, sizeof shm_path, sem_name, sizeof sem_name);
uint32_t frame_size = width * height * 2; /* UYVY422 */
size_t total = fc_slot_shm_size(frame_size);
/* Ensure directory exists */
mkdir(SHM_DIR, 0755);
/* Create shm file */
int fd = open(shm_path, O_RDWR | O_CREAT | O_TRUNC, 0666);
if (fd < 0) {
perror("[framecache] open shm");
return NULL;
}
if (ftruncate(fd, (off_t)total) < 0) {
perror("[framecache] ftruncate");
close(fd);
unlink(shm_path);
return NULL;
}
void *base = mmap(NULL, total, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (base == MAP_FAILED) {
perror("[framecache] mmap");
close(fd);
unlink(shm_path);
return NULL;
}
memset(base, 0, total);
/* Initialise header */
fc_header_t *hdr = (fc_header_t *)base;
hdr->magic = FC_MAGIC;
hdr->version = FC_VERSION;
hdr->width = width;
hdr->height = height;
hdr->fps_num = fps_num;
hdr->fps_den = fps_den;
hdr->pixel_format = pixel_format;
hdr->frame_size = frame_size;
hdr->ring_depth = FC_RING_DEPTH;
atomic_store(&hdr->write_cursor, 0);
atomic_store(&hdr->dropped_frames, 0);
strncpy(hdr->source_type, source_type ? source_type : "unknown",
sizeof hdr->source_type - 1);
strncpy(hdr->slot_id, slot_id, sizeof hdr->slot_id - 1);
/* Create semaphore */
sem_unlink(sem_name); /* remove stale */
sem_t *sem = sem_open(sem_name, O_CREAT | O_EXCL, 0666, 0);
if (sem == SEM_FAILED) {
perror("[framecache] sem_open");
munmap(base, total);
close(fd);
unlink(shm_path);
return NULL;
}
struct fc_slot *s = calloc(1, sizeof *s);
if (!s) {
sem_close(sem); sem_unlink(sem_name);
munmap(base, total);
close(fd);
unlink(shm_path);
return NULL;
}
s->shm_fd = fd;
s->base = base;
s->shm_size = total;
s->sem = sem;
strncpy(s->slot_id, slot_id, sizeof s->slot_id - 1);
strncpy(s->shm_path, shm_path, sizeof s->shm_path - 1);
strncpy(s->sem_name, sem_name, sizeof s->sem_name - 1);
fprintf(stderr, "[framecache] slot created: %s (%ux%u %.2ffps %zuMB)\n",
slot_id, width, height,
fps_den ? (double)fps_num / fps_den : 0.0,
total / 1024 / 1024);
return s;
}
/**
* Destroy a slot: unmap, close fd, delete files, free handle.
*/
void fc_slot_destroy(struct fc_slot *s)
{
if (!s) return;
sem_close(s->sem);
sem_unlink(s->sem_name);
munmap(s->base, s->shm_size);
close(s->shm_fd);
unlink(s->shm_path);
fprintf(stderr, "[framecache] slot destroyed: %s\n", s->slot_id);
free(s);
}
/* ── writer: called by ingest bridges ───────────────────────────────── */
/**
* Write one frame into the ring. Never blocks advances write_cursor
* atomically and posts the semaphore. Slow consumers will be skipped.
*/
void fc_slot_write_frame(struct fc_slot *s,
const uint8_t *data, uint32_t size,
uint64_t pts_us)
{
fc_header_t *hdr = (fc_header_t *)s->base;
uint64_t cur = atomic_load_explicit(&hdr->write_cursor, memory_order_relaxed);
fc_frame_t *frame = fc_frame_at(s->base, hdr->frame_size, cur);
frame->pts_us = pts_us;
frame->wall_us = (uint64_t)({ struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
ts.tv_sec * 1000000ULL + ts.tv_nsec / 1000; });
frame->size = size < hdr->frame_size ? size : hdr->frame_size;
memcpy(frame->data, data, frame->size);
atomic_store_explicit(&hdr->write_cursor, cur + 1, memory_order_release);
sem_post(s->sem);
}
/* ── client-side open / read / close (also used by capture-manager) ── */
/**
* Open an existing slot for reading.
* Returns NULL if slot not found or header magic mismatch.
*/
struct fc_slot *fc_slot_open(const char *slot_id)
{
char shm_path[128], sem_name[128];
build_paths(slot_id, shm_path, sizeof shm_path, sem_name, sizeof sem_name);
int fd = open(shm_path, O_RDONLY);
if (fd < 0) return NULL;
/* Read header first to get frame_size */
fc_header_t tmp_hdr;
if (pread(fd, &tmp_hdr, sizeof tmp_hdr, 0) != sizeof tmp_hdr) {
close(fd); return NULL;
}
if (tmp_hdr.magic != FC_MAGIC) {
close(fd); return NULL;
}
size_t total = fc_slot_shm_size(tmp_hdr.frame_size);
void *base = mmap(NULL, total, PROT_READ, MAP_SHARED, fd, 0);
if (base == MAP_FAILED) { close(fd); return NULL; }
sem_t *sem = sem_open(sem_name, 0);
if (sem == SEM_FAILED) { munmap(base, total); close(fd); return NULL; }
struct fc_slot *s = calloc(1, sizeof *s);
if (!s) { sem_close(sem); munmap(base, total); close(fd); return NULL; }
s->shm_fd = fd;
s->base = base;
s->shm_size = total;
s->sem = sem;
strncpy(s->slot_id, slot_id, sizeof s->slot_id - 1);
strncpy(s->shm_path, shm_path, sizeof s->shm_path - 1);
strncpy(s->sem_name, sem_name, sizeof s->sem_name - 1);
return s;
}
/**
* Close a client-side slot handle. Does not destroy the slot.
*/
void fc_slot_close(struct fc_slot *s)
{
if (!s) return;
sem_close(s->sem);
munmap(s->base, s->shm_size);
close(s->shm_fd);
free(s);
}

View file

@ -1,106 +0,0 @@
/**
* slot.h Framecache shared memory slot definitions.
*
* Layout per slot (/dev/shm/framecache/<slot_id>):
* [fc_header_t 4KB aligned]
* [fc_frame_t × ring_depth each FC_FRAME_HDR_SIZE + frame_size bytes]
*
* Writer advances write_cursor atomically and posts the named semaphore.
* Each consumer tracks its own read_cursor independently writer never blocks.
*/
#pragma once
#include <stdint.h>
#include <stdatomic.h>
#include <semaphore.h>
#define FC_MAGIC 0x46524D43u /* "FRMC" */
#define FC_VERSION 1u
#define FC_RING_DEPTH 120u /* ~2s at 59.94fps */
#define FC_HEADER_SIZE 4096u /* 4KB header block */
#define FC_FRAME_HDR_SIZE 24u /* pts_us(8) + wall_us(8) + size(4) + pad(4) */
#define FC_MAX_SLOT_ID 64u
/* Internal handle used by both server (writer) and client (reader) */
struct fc_slot {
int shm_fd;
void *base;
size_t shm_size;
sem_t *sem;
char slot_id[FC_MAX_SLOT_ID];
char shm_path[128];
char sem_name[128];
};
/* Pixel format codes */
#define FC_PIX_UYVY422 0u
typedef struct {
uint32_t magic; /* FC_MAGIC */
uint32_t version; /* FC_VERSION */
uint32_t width;
uint32_t height;
uint32_t fps_num;
uint32_t fps_den;
uint32_t pixel_format; /* FC_PIX_UYVY422 */
uint32_t frame_size; /* width * height * 2 */
uint32_t ring_depth; /* FC_RING_DEPTH */
uint32_t _reserved;
_Atomic uint64_t write_cursor; /* monotonically increasing frame index */
_Atomic uint64_t dropped_frames;
char source_type[32]; /* "deltacast" | "blackmagic" | "srt" | "rtmp" */
char slot_id[FC_MAX_SLOT_ID];
uint8_t _pad[FC_HEADER_SIZE - 144];
} fc_header_t;
/* Per-frame metadata + data (variable length — use fc_frame_at() accessor) */
typedef struct {
uint64_t pts_us;
uint64_t wall_us;
uint32_t size;
uint32_t _pad;
uint8_t data[]; /* frame_size bytes */
} fc_frame_t;
/* Compile-time size check */
// _Static_assert(sizeof(fc_header_t) == FC_HEADER_SIZE,
// "fc_header_t must be exactly FC_HEADER_SIZE bytes");
_Static_assert(sizeof(fc_frame_t) == FC_FRAME_HDR_SIZE,
"fc_frame_t header must be exactly FC_FRAME_HDR_SIZE bytes");
/* Function declarations */
struct fc_slot *fc_slot_create(const char *slot_id,
uint32_t width, uint32_t height,
uint32_t fps_num, uint32_t fps_den,
uint32_t pixel_format,
const char *source_type);
void fc_slot_destroy(struct fc_slot *s);
struct fc_slot *fc_slot_open(const char *slot_id);
void fc_slot_close(struct fc_slot *s);
void fc_slot_write_frame(struct fc_slot *s,
const uint8_t *data, uint32_t size,
uint64_t pts_us);
/* Accessor functions — inline now that struct fc_slot is defined above */
static inline fc_header_t *fc_slot_header(struct fc_slot *s) { return (fc_header_t *)s->base; }
static inline const char *fc_slot_id(struct fc_slot *s) { return s->slot_id; }
static inline const char *fc_slot_shm_path(struct fc_slot *s) { return s->shm_path; }
static inline const char *fc_slot_sem_name(struct fc_slot *s) { return s->sem_name; }
/**
* Compute total shm size for a slot given frame_size.
* = FC_HEADER_SIZE + ring_depth * (FC_FRAME_HDR_SIZE + frame_size)
*/
static inline size_t fc_slot_shm_size(uint32_t frame_size) {
return (size_t)FC_HEADER_SIZE
+ (size_t)FC_RING_DEPTH * ((size_t)FC_FRAME_HDR_SIZE + frame_size);
}
/**
* Return pointer to frame at ring index idx within a mapped shm base.
*/
static inline fc_frame_t *fc_frame_at(void *base, uint32_t frame_size, uint64_t idx) {
uint8_t *frames = (uint8_t *)base + FC_HEADER_SIZE;
return (fc_frame_t *)(frames + (idx % FC_RING_DEPTH)
* ((size_t)FC_FRAME_HDR_SIZE + frame_size));
}

View file

@ -1,10 +1,7 @@
FROM node:22-slim
# unzip/tar → SDK upload extraction (see routes/sdk.js)
# smbclient → query the growing-files SMB share's real free space for the
# storage/Mount-health card (mam-api never mounts the share, so
# `df` would report the local overlay, not the NAS quota).
# unzip/tar needed for SDK upload extraction (see routes/sdk.js)
RUN apt-get update \
&& apt-get install -y --no-install-recommends unzip tar ca-certificates smbclient \
&& apt-get install -y --no-install-recommends unzip tar ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package*.json ./

View file

@ -1,7 +0,0 @@
-- Add 'starting' and 'stopping' to recorder_schedules status check constraint
ALTER TABLE recorder_schedules DROP CONSTRAINT recorder_schedules_status_check;
ALTER TABLE recorder_schedules
ADD CONSTRAINT recorder_schedules_status_check
CHECK (status IN ('pending','running','completed','failed','cancelled','starting','stopping'));

View file

@ -1,38 +0,0 @@
-- Migration 036: Recorders become physical hardware, not user-created rows.
--
-- A recorder now maps 1:1 to a physical capture port: (node_id, device_index).
-- mam-api auto-provisions one row per port from each node-agent heartbeat's
-- capabilities (deltacast/blackmagic arrays). Rows are NEVER deleted by the
-- operator — they're discovered, enabled/disabled, and configured in place.
-- This removes the delete/create churn that orphaned standby sidecars and
-- caused capture-port (EADDRINUSE) collisions.
--
-- New columns:
-- label : optional friendly name overlaid on the hardware identity
-- (e.g. "Aurora" for zampp3-dc0). NULL → UI shows node+port name.
-- enabled : operator opt-in. false (default) = no standby sidecar, port idle.
-- true = persistent standby sidecar kept up (idle-preview), ready
-- to record. Toggled by the Enable/Disable button.
-- auto_provisioned : true when the row was created by heartbeat discovery
-- (vs a legacy manually-created recorder). Informational.
--
-- Identity:
-- UNIQUE(node_id, device_index) is the structural guarantee that two
-- recorders can never share a capture port — the root-cause fix for the
-- collisions. Partial unique index (WHERE both are non-null) so any legacy
-- rows without a node/device don't violate it.
ALTER TABLE recorders
ADD COLUMN IF NOT EXISTS label TEXT DEFAULT NULL,
ADD COLUMN IF NOT EXISTS enabled BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS auto_provisioned BOOLEAN NOT NULL DEFAULT false;
-- One recorder per physical port. Partial so pre-existing rows lacking a
-- node_id/device_index (e.g. network sources) are unaffected.
CREATE UNIQUE INDEX IF NOT EXISTS recorders_node_device_uniq
ON recorders (node_id, device_index)
WHERE node_id IS NOT NULL AND device_index IS NOT NULL;
-- Fast lookup of a node's ports during heartbeat reconciliation.
CREATE INDEX IF NOT EXISTS recorders_node_id_idx
ON recorders (node_id);

View file

@ -1,9 +1,8 @@
import express from 'express';
import pool from '../db/pool.js';
import { requireAuth } from '../middleware/auth.js';
const router = express.Router();
// Protected by requireAuth — AMPP Script Task must use an API token (Bearer Auth).
// No session auth — called from AMPP Script Task inside broadcast network
/**
* GET /api/v1/ampp/folder-for/:filename
@ -15,7 +14,7 @@ const router = express.Router();
* 200: { folder_id: "abc123" }
* 404: { error: "..." } (file not uploaded through Dragon-Wind handle gracefully)
*/
router.get('/folder-for/:filename', requireAuth, async (req, res, next) => {
router.get('/folder-for/:filename', async (req, res, next) => {
try {
const { filename } = req.params;
const result = await pool.query(

View file

@ -685,33 +685,8 @@ router.get('/:id/filmstrip', async (req, res, next) => {
if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
const { filmstrip_s3_key } = r.rows[0];
if (!filmstrip_s3_key) return res.json({ url: null, ready: false });
// Serve the filmstrip JSON THROUGH the API (with retry) instead of handing
// the browser a signed URL. The RustFS object store intermittently returns
// NoSuchKey on GET for keys that List/Head confirm exist — a single browser
// fetch then blanks the strip. Retrying server-side (where the GET succeeds
// within a couple attempts) makes filmstrips reliable, and avoids the S3
// CORS gap on the signed-URL path.
let lastErr = null;
for (let attempt = 0; attempt < 4; attempt++) {
try {
const obj = await s3Client.send(new GetObjectCommand({ Bucket: getS3Bucket(), Key: filmstrip_s3_key }));
const body = await obj.Body.transformToString();
res.set('Cache-Control', 'public, max-age=86400');
res.set('Content-Type', 'application/json');
return res.send(body);
} catch (e) {
lastErr = e;
const code = e?.name || e?.Code || '';
// Only retry the known transient store inconsistency; fail fast otherwise.
if (!/NoSuchKey|NotFound|404/i.test(code)) break;
await new Promise(r => setTimeout(r, 150 * (attempt + 1)));
}
}
// All retries missed — report not-ready so the UI shows its graceful
// fallback rather than a hard error.
console.warn(`[assets] filmstrip GET failed for ${id} (${filmstrip_s3_key}): ${lastErr?.name || lastErr?.message}`);
return res.json({ url: null, ready: false, transient: true });
const url = await getSignedUrlForObject(filmstrip_s3_key);
res.json({ url, ready: true });
} catch (err) { next(err); }
});
@ -883,9 +858,65 @@ router.get('/:id/live-path', async (req, res, next) => {
// - ETag + Last-Modified for conditional requests (304 on repeat visits)
// - Cache-Control: private, max-age=3600 so the browser caches segments
// and doesn't re-fetch them on every seek within a session
// Issue #143 — RustFS returns empty bodies for ranged GETs whose start offset
// is past ~5.9 MB on single-file proxy MP4s. Confirmed via direct S3 probe:
// HEAD reports correct size, full GET (`bytes=0-`) works perfectly, but
// `bytes=8179166-` returns 206 + the right Content-Range header and a zero-
// byte body. A streaming GET from 0 reads cleanly *through* the broken zone.
//
// RustFS issue #143 (empty body on ranged GETs past ~5.9 MB) was fixed in
// RustFS 1.0.0-alpha.94 (PR #2493). Standard ranged GETs used throughout.
// Workaround until the proxy worker emits HLS (planned v1.2.1): stream the
// proxy from offset 0, skip bytes the client didn't ask for, stop after the
// requested end. Browser sees a normal 206 + Content-Range. Mem stays flat;
// extra RustFS-to-mam-api bandwidth = (end+1 - actual-range) per seek.
//
// Small head-of-file ranges below RUSTFS_RANGE_SAFE_START are handled by a
// direct ranged GET — saves the streaming-from-0 cost on the common case of
// initial moov + first-segment fetch.
async function* stitchedS3Stream(key, startByte, endByte) {
// Yields buffers covering exactly [startByte, endByte] inclusive.
//
// RustFS only mis-serves a ranged GET when the *start* offset of the
// request is past ~5.8 MB. So we pull the object in 4 MB windows whose
// START offsets always stay below the broken threshold:
// - We anchor every chunk's start at a multiple of RUSTFS_SAFE_CHUNK
// (0, 4 MB, 8 MB, …).
// - Wait — that puts later starts past the threshold.
// Instead: skip directly to the chunk containing `startByte`, but request
// it as `bytes=anchorStart-end` where anchorStart < threshold. Since the
// bug only bites when the *request start* offset is large, we never issue
// a single GET whose Range start is past the broken zone — we instead
// exploit that a low-offset GET that *continues past* the threshold reads
// cleanly (confirmed by the bytes=0- full-GET probe).
//
// Practically: one GET from 0 that streams up through endByte, dropping
// the bytes below startByte as they arrive. Memory stays flat; we pay
// (endByte+1) bytes of RustFS-to-mam-api bandwidth per request.
const res = await s3Client.send(new GetObjectCommand({
Bucket: getS3Bucket(),
Key: key,
Range: `bytes=0-${endByte}`,
}));
let consumed = 0; // bytes seen so far from S3
let totalEmitted = 0;
for await (const buf of res.Body) {
const bufStart = consumed; // file offset of buf[0]
const bufEnd = consumed + buf.length - 1;
consumed += buf.length;
if (bufEnd < startByte) continue; // entirely before window
const sliceFrom = Math.max(0, startByte - bufStart);
const sliceTo = Math.min(buf.length, endByte - bufStart + 1);
if (sliceTo > sliceFrom) {
yield buf.subarray(sliceFrom, sliceTo);
totalEmitted += sliceTo - sliceFrom;
}
if (bufEnd >= endByte) break;
}
if (totalEmitted === 0) {
throw new Error(`RustFS returned empty body for ${key} bytes=0-${endByte}`);
}
}
router.get('/:id/video', async (req, res, next) => {
try {
@ -966,11 +997,39 @@ router.get('/:id/video', async (req, res, next) => {
if (etag) headers['ETag'] = etag;
if (lastModified) headers['Last-Modified'] = lastModified.toUTCString();
const s3Res = await s3Client.send(new GetObjectCommand({
Bucket: getS3Bucket(), Key: key, Range: `bytes=${start}-${end}`,
}));
// For small head-of-file ranges (entirely below the broken threshold)
// a direct ranged GET works and saves the streaming-from-0 cost.
const RUSTFS_RANGE_SAFE_START = parseInt(process.env.RUSTFS_RANGE_SAFE_START || String(5_500_000), 10);
if (start < RUSTFS_RANGE_SAFE_START && end < RUSTFS_RANGE_SAFE_START) {
const s3Res = await s3Client.send(new GetObjectCommand({
Bucket: getS3Bucket(), Key: key, Range: `bytes=${start}-${end}`,
}));
res.writeHead(206, headers);
s3Res.Body.pipe(res);
return;
}
// Otherwise: stream from offset 0, drop bytes below `start`, stop at
// `end`. Browser sees a normal 206; mam-api stays memory-flat.
res.writeHead(206, headers);
s3Res.Body.pipe(res);
try {
for await (const buf of stitchedS3Stream(key, start, end)) {
// res.write returns false when backpressure builds — pause and wait.
if (!res.write(buf)) {
await new Promise(r => res.once('drain', r));
}
if (res.destroyed) return;
}
res.end();
} catch (err) {
console.error(`[video] stitch failed for ${key}:`, err.message);
if (!res.headersSent) {
res.writeHead(500, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' });
res.end('Upstream storage error');
} else {
res.destroy(err);
}
}
} catch (err) { next(err); }
});

View file

@ -72,54 +72,6 @@ function dockerRequest(path, method = 'GET', body = null) {
});
}
// Fetch a container's logs via the Docker socket and return PLAIN TEXT. The
// Docker /logs endpoint returns a multiplexed stream (8-byte stdcopy headers
// prefix each chunk for non-TTY containers), NOT JSON — so dockerRequest()'s
// JSON.parse always yielded null ('(no logs)'). Here we collect the raw bytes
// and strip the stdcopy framing so the UI gets readable log lines.
function dockerLogs(containerId, tail = 200) {
return new Promise((resolve, reject) => {
const opts = {
socketPath: '/var/run/docker.sock',
path: `/v1.41/containers/${encodeURIComponent(containerId)}/logs?stdout=1&stderr=1&tail=${tail}&timestamps=1`,
method: 'GET',
};
const req = http.request(opts, (res) => {
const chunks = [];
res.on('data', d => chunks.push(d));
res.on('end', () => {
try {
const buf = Buffer.concat(chunks);
resolve(demuxDockerStream(buf));
} catch (e) { resolve(''); }
});
});
req.on('error', reject);
req.setTimeout(6000, () => { req.destroy(); reject(new Error('Docker socket timeout')); });
req.end();
});
}
// Strip Docker's stdcopy multiplexing headers (8 bytes per frame: [stream type,
// 0,0,0, big-endian uint32 length]). TTY containers send raw text with no
// framing; detect that and pass through. Returns a UTF-8 string.
function demuxDockerStream(buf) {
if (!buf || buf.length === 0) return '';
// Heuristic: a valid stdcopy frame has byte0 in {0,1,2} and bytes 1-3 == 0.
const looksFramed = buf.length >= 8 && buf[0] <= 2 && buf[1] === 0 && buf[2] === 0 && buf[3] === 0;
if (!looksFramed) return buf.toString('utf8');
const out = [];
let off = 0;
while (off + 8 <= buf.length) {
const len = buf.readUInt32BE(off + 4);
off += 8;
if (len <= 0) continue;
out.push(buf.toString('utf8', off, Math.min(off + len, buf.length)));
off += len;
}
return out.join('');
}
router.get('/', async (req, res, next) => {
try {
const r = await pool.query(
@ -144,84 +96,46 @@ router.get('/', async (req, res, next) => {
router.get('/containers', async (req, res, next) => {
try {
const nodesRes = await pool.query(
`SELECT id, hostname, api_url,
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
FROM cluster_nodes
ORDER BY registered_at ASC`
);
const tasks = nodesRes.rows.map(async node => {
const isOnline = Number(node.stale_seconds) < 120;
if (!isOnline) return [];
const localHostname = process.env.NODE_HOSTNAME || os.hostname();
const isLocal = node.hostname === localHostname || !node.api_url;
try {
let rawContainers = [];
if (isLocal) {
rawContainers = await dockerRequest('/containers/json?all=true') || [];
} else {
const resp = await fetch(`${node.api_url}/containers`, {
headers: agentAuthHeaders(),
signal: AbortSignal.timeout(4000),
});
if (resp.ok) rawContainers = await resp.json();
}
if (!Array.isArray(rawContainers)) return [];
return rawContainers.map(c => {
const rawName = (c.Names && c.Names[0] || '').replace(/^\//, '');
const name = rawName.replace(/^wild-dragon-/, '').replace(/-\d+$/, '');
return {
id: c.Id.slice(0, 12),
name,
image: (c.Image || '').replace(/^sha256:/, '').slice(0, 40),
state: c.State,
status: c.Status,
uptime: (c.Status || '').replace(/\s*\(.*\)/, '').trim(),
healthy: (c.Status || '').includes('healthy'),
node_hostname: node.hostname,
node_id: node.id,
};
});
} catch (err) {
console.warn(`[cluster] failed to fetch containers from ${node.hostname}:`, err.message);
return [];
const containers = await dockerRequest('/containers/json?all=true');
if (!Array.isArray(containers)) return res.json([]);
const out = await Promise.all(containers.map(async c => {
const rawName = (c.Names[0] || '').replace(/^\//, '');
const name = rawName.replace(/^wild-dragon-/, '').replace(/-\d+$/, '');
const ports = (c.Ports || [])
.filter(p => p.PublicPort)
.map(p => `${p.PublicPort}${p.PrivatePort}`)
.join(', ');
// Live memory usage requires a per-container stats call (the list endpoint
// doesn't include it). One extra Docker call each, but the list is small.
// memory_stats.usage includes page cache; subtract it to match `docker stats`.
let memBytes = null;
if (c.State === 'running') {
try {
const stats = await dockerRequest(`/containers/${c.Id}/stats?stream=false`);
const ms = stats && stats.memory_stats;
if (ms && typeof ms.usage === 'number') {
const cache = (ms.stats && ms.stats.cache) || 0;
memBytes = ms.usage - cache;
}
} catch (_) { memBytes = null; }
}
});
const results = await Promise.all(tasks);
const flattened = results.flat();
res.json(flattened);
} catch (err) { next(err); }
});
router.get('/containers/:nodeId/:containerId/logs', requireAdmin, async (req, res, next) => {
try {
const { nodeId, containerId } = req.params;
const node = await resolveNode(nodeId);
if (!node) return res.status(404).json({ error: 'Node not found' });
const localHostname = process.env.NODE_HOSTNAME || os.hostname();
const isLocal = node.hostname === localHostname || !node.api_url;
if (isLocal) {
const tail = Math.min(parseInt(req.query.tail, 10) || 200, 2000);
const logs = await dockerLogs(containerId, tail);
res.json({ logs: logs || '(no logs)' });
} else {
const resp = await fetch(`${node.api_url}/sidecar/${containerId}/logs`, {
headers: agentAuthHeaders(),
signal: AbortSignal.timeout(6000),
});
if (!resp.ok) return res.status(resp.status).json({ error: 'Failed to fetch remote logs' });
const data = await resp.json();
res.json(data);
}
} catch (err) { next(err); }
return {
id: c.Id.slice(0, 12),
name,
image: (c.Image || '').replace(/^sha256:/, '').slice(0, 40),
state: c.State,
uptime: (c.Status || '').replace(/\s*\(.*\)/, '').trim(),
healthy: (c.Status || '').includes('healthy'),
ports,
cpu: 0,
memBytes,
};
}));
res.json(out);
} catch (err) {
if (err.code === 'ENOENT' || err.code === 'EACCES') return res.json([]);
next(err);
}
});
router.post('/containers/:nameOrId/restart', async (req, res, next) => {
@ -291,74 +205,10 @@ router.post('/heartbeat', async (req, res, next) => {
metrics != null ? JSON.stringify(metrics) : null,
]
);
// Auto-provision recorder rows from this node's capture hardware. One row
// per physical port, keyed (node_id, device_index). Discovery only — it
// never enables, records, or deletes; the operator opts a port in via the
// Enable button. Non-fatal so a reconcile hiccup never drops a heartbeat.
reconcileRecordersForNode(r.rows[0]).catch(e =>
console.warn(`[recorders] auto-provision for ${hostname} failed (non-fatal): ${e.message}`));
res.json(r.rows[0]);
} catch (err) { next(err); }
});
// Discover capture ports from a node's heartbeat capabilities and upsert one
// recorder row per port. Idempotent via UNIQUE(node_id, device_index): a row
// is created the first time a port is seen (disabled, no sidecar) and left
// untouched on every subsequent heartbeat — operator config/label/enabled
// state is preserved. Ports that vanish are NOT deleted (node may be briefly
// offline); the UI greys them via the node's last_seen.
async function reconcileRecordersForNode(node) {
if (!node || !node.id) return;
const cap = node.capabilities || {};
// Each entry: { source_type, device_index }. Deltacast uses 'port', DeckLink
// uses 'index'; both become device_index (the capture-port offset).
const ports = [];
for (const d of (cap.deltacast || [])) {
const idx = d.index ?? d.port;
if (Number.isInteger(idx)) ports.push({ source_type: 'deltacast', device_index: idx });
}
for (const b of (cap.blackmagic || [])) {
const idx = b.index;
if (Number.isInteger(idx)) ports.push({ source_type: 'blackmagic', device_index: idx });
}
if (ports.length === 0) return;
// Default master codec for newly-discovered ports. SDI capture at 1080p59.94
// CANNOT be encoded in realtime on CPU (ProRes/x264 fall behind → dropped
// frames → short, fast-playing files). Nodes with an NVENC-capable GPU default
// to GPU HEVC; only GPU-less nodes fall back to CPU ProRes.
const hasGpu = Array.isArray(cap.gpus) && cap.gpus.length > 0;
const defaultCodec = hasGpu ? 'hevc_nvenc' : 'prores_hq';
for (const p of ports) {
// INSERT … ON CONFLICT DO NOTHING: create-once. Never overwrite an existing
// row (preserves label, enabled, codec config, status). source_config keeps
// the legacy {port}/{device} shape the capture pipeline already reads.
const srcCfg = p.source_type === 'deltacast'
? { port: p.device_index }
: { device: p.device_index };
await pool.query(
`INSERT INTO recorders
(node_id, device_index, source_type, source_config, name, enabled, auto_provisioned,
recording_codec, recording_container, recording_video_bitrate, recording_audio_channels)
VALUES ($1, $2, $3::source_type, $4, $5, false, true, $6, 'mov', '25M', 2)
ON CONFLICT (node_id, device_index) WHERE node_id IS NOT NULL AND device_index IS NOT NULL
DO NOTHING`,
[
node.id,
p.device_index,
p.source_type,
JSON.stringify(srcCfg),
// Deterministic hardware name; the operator can set a friendly `label`.
`${node.hostname}-${p.source_type === 'deltacast' ? 'dc' : 'bmd'}${p.device_index}`,
defaultCodec,
]
);
}
}
router.get('/devices/blackmagic/signal', async (req, res, next) => {
try {
const nodesResult = await pool.query(

View file

@ -154,7 +154,7 @@ const RECORDER_FIELDS = [
'proxy_audio_codec', 'proxy_audio_bitrate', 'proxy_audio_channels',
'proxy_container',
'project_id', 'node_id', 'device_index',
'growing_enabled', 'label',
'growing_enabled',
];
function pickRecorderFields(body) {
@ -198,7 +198,7 @@ function validateRecorderConfig(cfg, nodeHasGpu = null) {
// NVENC requires a GPU on the target node. Only a hard error when we know the
// node lacks one; unknown capability is left as a soft pass.
if (GPU_CODECS.includes(codec) && nodeHasGpu === false) {
return `Invalid combo: codec ${cfg.recording_codec} requires an NVIDIA GPU, but the target node reports no GPU. Assign this recorder to a GPU node.`;
return `Invalid combo: codec ${cfg.recording_codec} requires an NVIDIA GPU, but the target node reports no GPU. Choose a software codec (e.g. prores_hq, dnxhr_hq, h264) or assign a GPU node.`;
}
return null;
@ -227,133 +227,6 @@ async function nodeHasGpuCapability(nodeId) {
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
// Build the stable env array for a standby sidecar. Contains everything a
// capture container needs EXCEPT session params (CLIP_NAME, ASSET_ID,
// PROJECT_ID, BIN_ID) which arrive via HTTP on /capture/start.
function buildStandbyEnv(recorder) {
const s3Endpoint = process.env.S3_ENDPOINT || '';
const s3Bucket = getS3Bucket();
const s3AccessKey = process.env.S3_ACCESS_KEY || '';
const s3SecretKey = process.env.S3_SECRET_KEY || '';
const mamApiUrl = process.env.MAM_API_URL || 'http://mam-api:3000';
const externalMamApiUrl = `http://${process.env.NODE_IP || '172.18.91.200'}:${process.env.PORT_MAM_API || 47432}`;
const liveDir = process.env.LIVE_DIR || '/mnt/NVME/MAM/wild-dragon-live';
const sourceConfig = recorder.source_config || {};
const deviceIndex = recorder.device_index ?? sourceConfig.device ?? 0;
return [
`S3_ENDPOINT=${s3Endpoint}`,
`S3_BUCKET=${s3Bucket}`,
`S3_ACCESS_KEY=${s3AccessKey}`,
`S3_SECRET_KEY=${s3SecretKey}`,
`S3_REGION=${process.env.S3_REGION || 'us-east-1'}`,
// Use external URL — capture container runs on worker host network
`MAM_API_URL=${externalMamApiUrl}`,
`RECORDER_ID=${recorder.id}`,
`SOURCE_TYPE=${recorder.source_type}`,
`SOURCE_CONFIG=${JSON.stringify(sourceConfig)}`,
`DEVICE_INDEX=${deviceIndex}`,
`RECORDING_CODEC=${recorder.recording_codec || 'hevc_nvenc'}`,
`RECORDING_RESOLUTION=${recorder.recording_resolution || 'native'}`,
`RECORDING_VIDEO_BITRATE=${recorder.recording_video_bitrate || ''}`,
`RECORDING_FRAMERATE=${recorder.recording_framerate || ''}`,
`RECORDING_AUDIO_CODEC=${recorder.recording_audio_codec || 'pcm_s24le'}`,
`RECORDING_AUDIO_BITRATE=${recorder.recording_audio_bitrate || ''}`,
`RECORDING_AUDIO_CHANNELS=${recorder.recording_audio_channels ?? 2}`,
`RECORDING_CONTAINER=${recorder.recording_container || 'mov'}`,
`PROXY_ENABLED=${recorder.proxy_enabled !== false ? 'true' : 'false'}`,
`PROXY_CODEC=${recorder.proxy_codec || 'h264_nvenc'}`,
`PROXY_RESOLUTION=${recorder.proxy_resolution || '1920x1080'}`,
`PROXY_VIDEO_BITRATE=${recorder.proxy_video_bitrate || '2M'}`,
`PROXY_FRAMERATE=${recorder.proxy_framerate || ''}`,
`PROXY_AUDIO_CODEC=${recorder.proxy_audio_codec || 'aac'}`,
`PROXY_AUDIO_BITRATE=${recorder.proxy_audio_bitrate || '128k'}`,
`PROXY_AUDIO_CHANNELS=${recorder.proxy_audio_channels ?? 2}`,
`PROXY_CONTAINER=${recorder.proxy_container || 'mp4'}`,
`GROWING_ENABLED=false`,
`GROWING_PATH=/growing`,
`GROWING_SMB_MOUNT=`,
`LIVE_DIR=${liveDir}`,
`MAM_API_TOKEN=${process.env.CAPTURE_TOKEN || ''}`,
`STANDBY=1`,
`PRE_ROLL_SECONDS=1`,
];
}
// Source types that run a long-lived standby sidecar (idle-preview container
// kept up 24/7 so `record` is a sub-second HTTP call, not a Docker cold start).
const STANDBY_SOURCE_TYPES = ['deltacast', 'sdi', 'blackmagic'];
// Provision (or re-provision) the single persistent standby sidecar for one
// recorder by asking its node's agent to create the idle container. Idempotent
// at the node-agent layer (one container per capture port). Updates the
// recorder row with the new container_id + status='standby'. Returns:
// { ok, containerId?, reason? }
// Non-fatal by contract — the caller logs/aggregates; a recorder is still
// usable via the on-demand spawn fallback in /start if this fails.
async function ensureStandbySidecar(recorder) {
if (!recorder.node_id || !STANDBY_SOURCE_TYPES.includes(recorder.source_type)) {
return { ok: false, reason: 'not a standby source / no node' };
}
const { remote: isRemote, apiUrl: targetNodeApiUrl } =
await resolveNodeTarget(recorder.node_id).catch(() => ({ remote: false }));
if (!isRemote || !targetNodeApiUrl) {
return { ok: false, reason: 'node not remote/reachable' };
}
const capturePort = SIDECAR_PORT_BASE + (recorder.device_index || 0);
const useGpu = GPU_CODECS.includes(recorder.recording_codec);
const standbyRes = await fetch(`${targetNodeApiUrl}/sidecar/standby`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
image: 'wild-dragon-capture:latest',
env: buildStandbyEnv(recorder),
capturePort,
sourceType: recorder.source_type,
useGpu,
gpuUuid: recorder.gpu_uuid || null,
}),
signal: AbortSignal.timeout(15000),
});
if (!standbyRes.ok) {
return { ok: false, reason: `node-agent returned ${standbyRes.status}` };
}
const { containerId } = await standbyRes.json();
await pool.query(
`UPDATE recorders SET container_id = $1, status = 'standby', updated_at = NOW() WHERE id = $2`,
[containerId, recorder.id]
);
recorder.container_id = containerId;
recorder.status = 'standby';
console.log(`[recorders] standby sidecar spawned for ${recorder.id}: ${containerId}`);
return { ok: true, containerId };
}
// Tear down a recorder's standby sidecar (Disable). Asks the node-agent to
// remove the container, then clears container_id and sets status='stopped'.
// Best-effort on the node-agent call — even if the delete fails we still clear
// the row so the operator isn't stuck; the force-free-port logic on the next
// Enable will reclaim a stray container. Returns { ok, reason? }.
async function teardownStandbySidecar(recorder) {
if (recorder.node_id && recorder.container_id) {
const { remote: isRemote, apiUrl: targetNodeApiUrl } =
await resolveNodeTarget(recorder.node_id).catch(() => ({ remote: false }));
if (isRemote && targetNodeApiUrl) {
await fetch(`${targetNodeApiUrl}/sidecar/${recorder.container_id}`, {
method: 'DELETE',
signal: AbortSignal.timeout(15000),
}).catch(e => console.warn(`[recorders] sidecar teardown for ${recorder.id} failed (clearing anyway): ${e.message}`));
}
}
await pool.query(
`UPDATE recorders SET container_id = NULL, status = 'stopped', current_session_id = NULL, updated_at = NOW() WHERE id = $1`,
[recorder.id]
);
recorder.container_id = null;
recorder.status = 'stopped';
return { ok: true };
}
// Issue #162 — after a local-spawn stop, wait for the capture container to
// finalize its master. The asset row was pre-created at start with
// status='live' (display_name = current_session_id); the ingest/finalize step
@ -468,9 +341,9 @@ router.post('/', async (req, res, next) => {
recording_audio_codec: 'pcm_s24le',
recording_audio_channels: 2,
recording_container: 'mov',
proxy_enabled: true,
proxy_codec: 'h264_nvenc',
proxy_resolution: '1920x1080',
proxy_enabled: true,
proxy_codec: 'h264',
proxy_resolution: '1920x1080',
proxy_video_bitrate: '2M',
proxy_audio_codec: 'aac',
proxy_audio_bitrate: '128k',
@ -501,111 +374,7 @@ router.post('/', async (req, res, next) => {
values
);
const recorder = result.rows[0];
// Spawn a standby sidecar immediately for SDI/deltacast/blackmagic recorders
// that have an assigned node, so the container + bridge are ready before the
// user hits record. Non-fatal — recorder is still usable if this fails.
await ensureStandbySidecar(recorder).catch(e =>
console.warn(`[recorders] standby spawn failed for ${recorder.id} (non-fatal): ${e.message}`));
res.status(201).json(recorder);
} catch (err) {
next(err);
}
});
// POST /reconcile-standby - (re)provision the persistent standby sidecar for
// every SDI/deltacast recorder that should have one. Standby sidecars are
// created on recorder-create and kept up 24/7 (RestartPolicy=unless-stopped),
// but if they're externally removed (manual cleanup, node redeploy, a wiped
// /dev/shm) nothing recreates them — the recorder then falls back to the slow
// on-demand spawn on /start, which can collide on the capture port. This
// endpoint re-warms them so all recorders return to the fast standby path.
//
// Optional body: { force: true } recreates even recorders that currently claim
// a container_id (the node-agent is idempotent per capture port, so a stale id
// is replaced cleanly). Without force, only recorders with no container_id are
// (re)provisioned.
router.post('/reconcile-standby', requireRecorderEdit, async (req, res, next) => {
try {
const force = !!(req.body && req.body.force);
const { rows } = await pool.query(
`SELECT * FROM recorders
WHERE source_type = ANY($1)
AND node_id IS NOT NULL
ORDER BY name`,
[STANDBY_SOURCE_TYPES]
);
const results = [];
for (const recorder of rows) {
if (!force && recorder.container_id) {
results.push({ id: recorder.id, name: recorder.name, ok: true, skipped: 'already has container_id' });
continue;
}
try {
const r = await ensureStandbySidecar(recorder);
results.push({ id: recorder.id, name: recorder.name, ...r });
} catch (e) {
results.push({ id: recorder.id, name: recorder.name, ok: false, reason: e.message });
}
}
const provisioned = results.filter(r => r.ok && r.containerId).length;
res.json({ provisioned, total: rows.length, results });
} catch (err) {
next(err);
}
});
// POST /:id/enable - Operator opt-in. Brings up the persistent standby sidecar
// (idle-preview, kept up 24/7) so the port is ready to record in <1s. Sets
// enabled=true. Idempotent: if already enabled with a live container the
// node-agent's force-free-port logic replaces any stale container cleanly.
router.post('/:id/enable', requireRecorderEdit, async (req, res, next) => {
try {
const { id } = req.params;
const { rows } = await pool.query('SELECT * FROM recorders WHERE id = $1', [id]);
if (rows.length === 0) return res.status(404).json({ error: 'Recorder not found' });
const recorder = rows[0];
if (!STANDBY_SOURCE_TYPES.includes(recorder.source_type)) {
return res.status(400).json({ error: `Source type "${recorder.source_type}" does not support standby/enable` });
}
if (!recorder.node_id) {
return res.status(409).json({ error: 'Recorder has no assigned node (hardware offline?) — cannot enable' });
}
const r = await ensureStandbySidecar(recorder);
if (!r.ok) {
return res.status(502).json({ error: `Could not start standby sidecar: ${r.reason || 'unknown'}` });
}
await pool.query('UPDATE recorders SET enabled = true, updated_at = NOW() WHERE id = $1', [id]);
recorder.enabled = true;
res.json(recorder);
} catch (err) {
next(err);
}
});
// POST /:id/disable - Operator opt-out. Stops & removes the standby sidecar,
// freeing the capture port, and sets enabled=false. Config (codec, label,
// growing) is preserved on the row for the next enable. Refuses while the
// recorder is actively recording — stop it first.
router.post('/:id/disable', requireRecorderEdit, async (req, res, next) => {
try {
const { id } = req.params;
const { rows } = await pool.query('SELECT * FROM recorders WHERE id = $1', [id]);
if (rows.length === 0) return res.status(404).json({ error: 'Recorder not found' });
const recorder = rows[0];
if (recorder.status === 'recording') {
return res.status(409).json({ error: 'Recorder is recording — stop it before disabling' });
}
await teardownStandbySidecar(recorder);
await pool.query('UPDATE recorders SET enabled = false, updated_at = NOW() WHERE id = $1', [id]);
recorder.enabled = false;
res.json(recorder);
res.status(201).json(result.rows[0]);
} catch (err) {
next(err);
}
@ -793,7 +562,7 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
`DEVICE_INDEX=${deviceIndex}`,
// Recording codec controls
`RECORDING_CODEC=${recorder.recording_codec || 'hevc_nvenc'}`,
`RECORDING_CODEC=${recorder.recording_codec || 'prores_hq'}`,
`RECORDING_RESOLUTION=${recorder.recording_resolution || 'native'}`,
`RECORDING_VIDEO_BITRATE=${recorder.recording_video_bitrate || ''}`,
`RECORDING_FRAMERATE=${recorder.recording_framerate || ''}`,
@ -804,7 +573,7 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
// Proxy codec controls
`PROXY_ENABLED=${recorder.proxy_enabled !== false ? 'true' : 'false'}`,
`PROXY_CODEC=${recorder.proxy_codec || 'h264_nvenc'}`,
`PROXY_CODEC=${recorder.proxy_codec || 'h264'}`,
`PROXY_RESOLUTION=${recorder.proxy_resolution || '1920x1080'}`,
`PROXY_VIDEO_BITRATE=${recorder.proxy_video_bitrate || '2M'}`,
`PROXY_FRAMERATE=${recorder.proxy_framerate || ''}`,
@ -835,12 +604,6 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
if (dcCount) env.push(`DELTACAST_PORT_COUNT=${dcCount}`);
}
// Framecache slot has been warm since the bridge started — 1s pre-roll is
// sufficient. Avoids a 5s startup lag on both on-demand and standby spawns.
if (['deltacast', 'sdi', 'blackmagic'].includes(sourceType)) {
env.push('PRE_ROLL_SECONDS=1');
}
if (sourceType === 'srt' || sourceType === 'rtmp') {
env.push(`LISTEN=${isListener ? '1' : '0'}`);
if (isListener) {
@ -873,88 +636,10 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
}
let containerId;
const capturePort = SIDECAR_PORT_BASE + (deviceIndex || 0);
// ── Standby fast-path ───────────────────────────────────────────────
// If the recorder is already in standby (sidecar running idle), send the
// session params to its /capture/start HTTP endpoint instead of spawning
// a new container. This eliminates Docker create/start latency and bridge
// startup time — the user hits record and ffmpeg starts in <1s.
const isStandby = recorder.status === 'standby' && recorder.container_id;
if (isStandby) {
const captureStartUrl = isRemote
? `http://${(await resolveNodeTarget(recorder.node_id)).ip}:${capturePort}/capture/start`
: `http://localhost:${capturePort}/capture/start`;
try {
const startBody = {
project_id: takeProjectId,
bin_id: null,
clip_name: clipName,
asset_id: assetIdLive,
source_type: sourceType,
device: deviceIndex,
// Codec params — sidecar already has these in env but we send them
// anyway so a config change on the recorder takes effect immediately.
recording_codec: recorder.recording_codec,
recording_video_bitrate: recorder.recording_video_bitrate,
recording_framerate: recorder.recording_framerate,
recording_audio_codec: recorder.recording_audio_codec,
recording_audio_bitrate: recorder.recording_audio_bitrate,
recording_audio_channels: recorder.recording_audio_channels,
recording_container: recorder.recording_container,
proxy_enabled: recorder.proxy_enabled,
proxy_codec: recorder.proxy_codec,
proxy_video_bitrate: recorder.proxy_video_bitrate,
proxy_framerate: recorder.proxy_framerate,
proxy_audio_codec: recorder.proxy_audio_codec,
proxy_audio_bitrate: recorder.proxy_audio_bitrate,
proxy_audio_channels: recorder.proxy_audio_channels,
proxy_container: recorder.proxy_container,
growing_enabled: growingEnabled,
growing_smb_mount: smbMount,
growing_smb_username: growingInfra.growing_smb_username || '',
growing_smb_password: growingInfra.growing_smb_password || '',
growing_smb_vers: growingInfra.growing_smb_vers || '3.0',
};
const captureRes = await fetch(captureStartUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(startBody),
signal: AbortSignal.timeout(15000),
});
if (captureRes.ok) {
containerId = recorder.container_id;
console.log(`[recorders] standby→recording: ${id} via HTTP /capture/start`);
} else {
const detail = await captureRes.json().catch(() => ({}));
console.warn(`[recorders] standby /capture/start returned ${captureRes.status}: ${JSON.stringify(detail)} — falling back to spawn`);
// Fall through to on-demand spawn below
}
} catch (e) {
console.warn(`[recorders] standby HTTP start failed: ${e.message} — falling back to spawn`);
// Fall through to on-demand spawn below
}
}
// If standby HTTP start failed and a stale container_id exists, kill it
// before spawning a new one — otherwise the new container gets EADDRINUSE
// because the old container is still holding the capture port.
if (!containerId && isStandby && recorder.container_id) {
console.log(`[recorders] killing stale standby container ${recorder.container_id} before respawn`);
try {
if (isRemote) {
await fetch(`${targetNodeApiUrl}/sidecar/${recorder.container_id}`, {
method: 'DELETE',
signal: AbortSignal.timeout(10000),
}).catch(() => {});
} else {
await dockerApi('DELETE', `/containers/${recorder.container_id}?force=true`).catch(() => {});
}
} catch (_) {}
}
if (!containerId && isRemote) {
if (isRemote) {
// Remote node: delegate container lifecycle to that node's agent.
const capturePort = SIDECAR_PORT_BASE + (deviceIndex || 0);
const sidecarRes = await fetch(`${targetNodeApiUrl}/sidecar/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@ -973,7 +658,7 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
}
const sidecarData = await sidecarRes.json();
containerId = sidecarData.containerId;
} else if (!containerId) {
} else {
// Local spawn via Docker socket.
const { portBindings, exposedPorts } = buildPortConfig(sourceType, sourceConfig);
const alias = `recorder-${id}`;
@ -1093,69 +778,8 @@ router.post('/:id/stop', requireRecorderEdit, async (req, res, next) => {
return res.json(result.rows[0]);
}
const { remote: isRemote, apiUrl: targetNodeApiUrl, ip: nodeIp } = await resolveNodeTarget(recorder.node_id);
const deviceIndex = recorder.device_index ?? (recorder.source_config?.device ?? 0);
const capturePort = SIDECAR_PORT_BASE + deviceIndex;
const isStandby = recorder.status === 'standby';
const { remote: isRemote, apiUrl: targetNodeApiUrl } = await resolveNodeTarget(recorder.node_id);
// ── Standby sidecar stop path ─────────────────────────────────────────
// If the recorder was in standby (container stays alive between sessions),
// stop only the capture session via HTTP — don't kill the container.
// The container returns to idle-preview mode and is ready for the next
// /start call immediately.
//
// If NOT in standby (legacy on-demand spawn), use the old docker-stop path.
const isStandbySource = STANDBY_SOURCE_TYPES.includes(recorder.source_type);
if (isStandbySource && recorder.container_id) {
// Call /capture/stop on the running sidecar.
// Return immediately — S3 upload streams to completion asynchronously.
const captureStopUrl = isRemote
? `http://${nodeIp}:${capturePort}/capture/stop`
: `http://localhost:${capturePort}/capture/stop`;
// Get session_id from the sidecar's status (it tracks its own sessionId).
let sessionId = null;
try {
const statusRes = await fetch(
isRemote ? `http://${nodeIp}:${capturePort}/capture/status` : `http://localhost:${capturePort}/capture/status`,
{ signal: AbortSignal.timeout(3000) }
);
if (statusRes.ok) {
const s = await statusRes.json();
sessionId = s.sessionId || null;
}
} catch (_) {}
if (sessionId) {
// Fire-and-forget — the S3 upload completes in the background inside
// the sidecar. The /capture/stop route calls /assets/:id/finalize when
// done, so the asset transitions from 'live' → 'processing' automatically.
fetch(captureStopUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId }),
signal: AbortSignal.timeout(185000),
}).then(r => {
if (!r.ok) console.warn(`[recorders] /capture/stop returned ${r.status} for ${id}`);
else console.log(`[recorders] standby stop completed for ${id}`);
}).catch(e => {
console.error(`[recorders] /capture/stop error for ${id}: ${e.message}`);
});
} else {
console.warn(`[recorders] could not get sessionId for ${id} — sidecar may not be recording`);
}
// Container stays alive in standby — keep container_id, set status='standby'
const updateResult = await pool.query(
`UPDATE recorders SET status = 'standby', current_session_id = NULL, updated_at = NOW()
WHERE id = $1 RETURNING *`,
[id]
);
return res.json(updateResult.rows[0]);
}
// ── Legacy path: on-demand container, kill it on stop ────────────────
if (isRemote) {
const stopRes = await fetch(`${targetNodeApiUrl}/sidecar/${recorder.container_id}`, {
method: 'DELETE',
@ -1166,7 +790,9 @@ router.post('/:id/stop', requireRecorderEdit, async (req, res, next) => {
}
} else {
// Issue #162 — stop local container in the background so the HTTP stop
// request returns immediately.
// request returns immediately. The container teardown (SIGTERM -> ffmpeg
// exit -> S3 upload -> post-stop callback) takes up to 180s for large files,
// which would otherwise timeout the browser/API connection.
const containerId = recorder.container_id;
(async () => {
try {
@ -1177,6 +803,7 @@ router.post('/:id/stop', requireRecorderEdit, async (req, res, next) => {
}
} catch (e) {
console.error('[recorders] failed local background stop:', e.message);
// Attempt finalize and cleanup even if stop call timed out
await waitForFinalize(recorder).catch(() => {});
await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {});
}
@ -1263,34 +890,18 @@ router.get('/:id/status', async (req, res, next) => {
duration = Math.floor((Date.now() - new Date(container.State.StartedAt).getTime()) / 1000);
try {
const _devIdx2 = recorder.device_index ?? (recorder.source_config?.device ?? 0);
const _statusPort = SIDECAR_PORT_BASE + _devIdx2;
const captureRes = await fetch(`http://localhost:${_statusPort}/capture/status`, { signal: AbortSignal.timeout(2000) });
const captureRes = await fetch(`http://recorder-${id}:3001/capture/status`, { signal: AbortSignal.timeout(2000) });
if (captureRes.ok) live = await captureRes.json();
} catch (_) { /* not ready yet */ }
}
// Recording state and signal come from the capture sidecar's session, NOT
// from whether its standby CONTAINER happens to be running. A running
// standby container is NOT "recording" and its signal is NOT "stopped" —
// it's idle. Only when live.recording is true do we surface the real
// session signal/duration; otherwise the row is idle with no elapsed.
const isRecording = !!(live && live.recording);
if (isRecording) {
signal = live.signal || 'connecting';
signalKnown = true;
} else {
signal = 'idle';
signalKnown = false;
}
const sessionDuration = isRecording && live.duration != null ? live.duration : 0;
if (isRunning) signal = 'receiving';
if (!isRunning) signal = 'stopped';
if (live && live.signal) { signal = live.signal; signalKnown = true; }
res.json({
// recording = sidecar is actively capturing a session; standby container
// up but idle reports its own status (not 'recording').
status: isRecording ? 'recording' : (isRunning ? recorder.status : 'stopped'),
recording: isRecording,
duration: sessionDuration,
status: isRunning ? 'recording' : 'stopped',
duration,
containerId: recorder.container_id,
signal,
signalKnown,
@ -1382,11 +993,10 @@ router.post('/probe', async (req, res) => {
// Validate URL up-front so we don't even let the capture service see junk.
let parsed = null;
let proto = '';
if (url) {
try { parsed = new URL(url); }
catch { return res.status(400).json({ error: 'Invalid URL' }); }
proto = (parsed.protocol || '').replace(':', '').toLowerCase();
const proto = (parsed.protocol || '').replace(':', '').toLowerCase();
if (!ALLOWED_PROBE_SCHEMES.has(proto)) {
return res.status(400).json({ error: `Scheme "${proto}" is not permitted for probe (#104)` });
}
@ -1399,7 +1009,6 @@ router.post('/probe', async (req, res) => {
if (parsed.hostname === 'mam-api' || parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1') {
return res.status(403).json({ error: 'Internal probe target is not permitted' });
}
}
// Try the capture service first (5s timeout)
try {
@ -1424,6 +1033,7 @@ router.post('/probe', async (req, res) => {
}
const host = parsed.hostname;
const proto = (parsed.protocol || '').replace(':', '').toLowerCase();
const isUdp = proto === 'srt' || source_type === 'srt';
const port = parseInt(parsed.port, 10) || (isUdp ? 9000 : 1935);

View file

@ -5,13 +5,12 @@
import express from 'express';
import fs from 'node:fs';
import { promisify } from 'node:util';
import { exec as execCb, execFile as execFileCb } from 'node:child_process';
import { exec as execCb } from 'node:child_process';
import { HeadBucketCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
import pool from '../db/pool.js';
import { s3Client, getS3Bucket } from '../s3/client.js';
const exec = promisify(execCb);
const execFile = promisify(execFileCb);
const router = express.Router();
// Defaults mirrored from settings.js so the overview never returns nulls.
@ -79,21 +78,14 @@ async function probeS3Bucket() {
if (!bucket) { out.error = 'no bucket configured'; return out; }
const started = Date.now();
// Hard cap the whole probe so the admin "Mount health" card never hangs on
// "Probing…" when S3 is slow/unreachable. Without this, the SDK's default
// retry/backoff can block the request for tens of seconds.
const withTimeout = (p, ms) => Promise.race([
p,
new Promise((_, rej) => setTimeout(() => rej(new Error('probe timed out after ' + ms + 'ms')), ms)),
]);
try {
await withTimeout(s3Client.send(new HeadBucketCommand({ Bucket: bucket })), 5000);
await s3Client.send(new HeadBucketCommand({ Bucket: bucket }));
out.reachable = true;
out.method = 'HeadBucket';
} catch (headErr) {
// Fall back to a 0-key list for stores that don't expose HeadBucket.
try {
await withTimeout(s3Client.send(new ListObjectsV2Command({ Bucket: bucket, MaxKeys: 0 })), 5000);
await s3Client.send(new ListObjectsV2Command({ Bucket: bucket, MaxKeys: 0 }));
out.reachable = true;
out.method = 'ListObjectsV2';
} catch (listErr) {
@ -104,42 +96,6 @@ async function probeS3Bucket() {
return out;
}
// Query the growing-files SMB share's real capacity WITHOUT mounting it.
// mam-api never mounts the CIFS share, so df on the container path reports the
// local overlay (tens of GB), not the multi-TB NAS volume. `smbclient -c du`
// returns "<N> blocks of size 1024. <M> blocks available" for the share, which
// is the actual free/total the operator cares about.
async function probeSmbShare({ mount, username, password, vers }) {
const out = { reachable: false, free_bytes: null, total_bytes: null, error: null };
if (!mount) { out.error = 'no smb mount configured'; return out; }
// Normalize smb://host/share or \\host\share → //host/share for smbclient.
let unc = String(mount).trim().replace(/\\/g, '/').replace(/^smb:\/\//i, '//');
if (!unc.startsWith('//')) unc = '//' + unc.replace(/^\/+/, '');
const user = `${username || ''}%${password || ''}`;
const args = [
unc, '-U', user,
...(vers ? ['-m', `SMB${String(vers).replace(/\./g, '_').replace(/_0$/, '')}`] : []),
'-c', 'du',
];
try {
// execFile avoids shell-quoting the password. 6s cap so a dead NAS can't hang.
const { stdout } = await execFile('smbclient', args, { timeout: 6000 });
// " 1890828485120 blocks of size 1024. 1890776477696 blocks available"
const m = stdout.match(/(\d+)\s+blocks of size\s+(\d+)\.\s+(\d+)\s+blocks available/i);
if (m) {
const blockSize = parseInt(m[2], 10) || 1024;
out.total_bytes = parseInt(m[1], 10) * blockSize;
out.free_bytes = parseInt(m[3], 10) * blockSize;
out.reachable = true;
} else {
out.error = 'could not parse smbclient du output';
}
} catch (err) {
out.error = (err.stderr ? String(err.stderr).trim() : err.message).slice(0, 200);
}
return out;
}
// GET /api/v1/storage/overview
// Consolidated read-only view of the storage subsystem for the admin UI.
router.get('/overview', async (req, res, next) => {
@ -152,29 +108,6 @@ router.get('/overview', async (req, res, next) => {
const containerPath = growingRaw.growing_path || '/growing';
const mount = await probeGrowingPath(containerPath);
// Real capacity comes from the SMB share itself, NOT the local container
// path (mam-api never mounts the share, so df reports the tiny overlay).
// Query the NAS quota directly via smbclient when a mount is configured.
let smbFree = null, smbTotal = null, smbReachable = false, capacityError = mount.error;
if (growingEnabled) {
const creds = await readSettings(['growing_smb_username', 'growing_smb_password', 'growing_smb_vers']);
const smb = await probeSmbShare({
mount: growingRaw.growing_smb_mount,
username: creds.growing_smb_username,
password: creds.growing_smb_password,
vers: creds.growing_smb_vers,
});
if (smb.reachable) {
smbFree = smb.free_bytes; smbTotal = smb.total_bytes; smbReachable = true; capacityError = null;
} else {
// Fall back to the local-path df numbers, but surface why the share
// probe failed so the card can show it.
smbFree = mount.free_bytes; smbTotal = mount.total_bytes; capacityError = smb.error || mount.error;
}
} else {
smbFree = mount.free_bytes; smbTotal = mount.total_bytes;
}
// S3 — bucket name comes from the live client (env or DB-loaded), not
// a fresh DB read, so we report exactly what the running client uses.
const s3 = await probeS3Bucket();
@ -192,12 +125,9 @@ router.get('/overview', async (req, res, next) => {
promote_after_seconds: parseInt(growingRaw.growing_promote_after_seconds, 10) || 8,
exists: mount.exists,
writable: mount.writable,
// free/total now reflect the actual SMB share (smbclient du) when a
// mount is configured; smb_reachable says whether that probe succeeded.
free_bytes: smbFree,
total_bytes: smbTotal,
smb_reachable: smbReachable,
error: capacityError,
free_bytes: mount.free_bytes,
total_bytes: mount.total_bytes,
error: mount.error,
},
s3: {
endpoint: s3SettingsRaw.s3_endpoint || process.env.S3_ENDPOINT || '',

View file

@ -34,7 +34,7 @@ router.post('/', async (req, res, next) => {
`INSERT INTO users (username, password_hash, display_name, role)
VALUES ($1, $2, $3, $4)
RETURNING id, username, display_name, role, created_at`,
[username.trim(), hash, display_name || username.trim(), role || 'viewer']
[username.trim(), hash, display_name || username.trim(), role || 'admin']
);
res.status(201).json(rows[0]);
} catch (err) {

View file

@ -2,20 +2,8 @@ import { NodeHttpHandler } from '@smithy/node-http-handler';
import { S3Client, GetObjectCommand, DeleteObjectCommand, HeadBucketCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { Upload } from '@aws-sdk/lib-storage';
import http from 'node:http';
import https from 'node:https';
import pool from '../db/pool.js';
// Dedicated keep-alive agents with a high socket ceiling. Without these the
// SDK uses Node's default agents (effectively short-lived, low reuse); when the
// API proxies media (/video, /hls pipe the full S3 body through Express) those
// long-lived streaming sockets starve control-plane calls (DeleteObject, the
// proxy worker's master download), which then time out → assets stuck in
// 'processing', "s3 delete failed", and dead browser playback. A large pool +
// keep-alive lets streams and control ops coexist.
const _s3HttpAgent = new http.Agent({ keepAlive: true, maxSockets: 256, maxFreeSockets: 32, timeout: 120_000 });
const _s3HttpsAgent = new https.Agent({ keepAlive: true, maxSockets: 256, maxFreeSockets: 32, timeout: 120_000 });
// ── Mutable config ────────────────────────────────────────────────────────────
let _cfg = {
endpoint: process.env.S3_ENDPOINT || '',
@ -35,17 +23,9 @@ function buildClient(cfg) {
secretAccessKey: cfg.secretKey,
},
forcePathStyle: true,
// Keep-alive agents (above) prevent socket starvation between media streams
// and control-plane ops. requestTimeout is generous so the proxy worker's
// full-master download (hundreds of MB) doesn't abort mid-transfer and leave
// the asset stuck in 'processing'; connectionTimeout stays short so a dead
// endpoint fails fast rather than hanging /video.
requestHandler: new NodeHttpHandler({
httpAgent: _s3HttpAgent,
httpsAgent: _s3HttpsAgent,
requestTimeout: 300_000,
connectionTimeout: 10_000,
}),
// Hard request/connection timeouts so a stalled RustFS GET can't hang the
// /video and /hls endpoints forever (the original browser-playback hang).
requestHandler: new NodeHttpHandler({ requestTimeout: 30_000, connectionTimeout: 10_000 }),
requestChecksumCalculation: 'WHEN_REQUIRED',
responseChecksumValidation: 'WHEN_REQUIRED',
});

View file

@ -137,11 +137,7 @@ async function tick() {
// Orphaned live assets: recorder stopped but asset still 'live'.
// Happens when the capture sidecar crashes before finalize() runs.
// Grace window is measured from when the RECORDER was last updated
// (i.e. when it transitioned to stopped), not from asset creation.
// This prevents a race where the scheduler fires before the capture
// container's finalize POST lands (can take 30-60s on large files).
const ORPHAN_GRACE_SECONDS = parseInt(process.env.ORPHAN_GRACE_SECONDS || '120', 10);
// Mark error immediately so the library doesn't show "Recording" forever.
const orphanResult = await client.query(
`UPDATE assets a
SET status = 'error', updated_at = NOW()
@ -149,9 +145,7 @@ async function tick() {
WHERE a.status = 'live'
AND a.display_name = r.current_session_id
AND r.status = 'stopped'
AND r.updated_at < NOW() - ($1 || ' seconds')::INTERVAL
RETURNING a.id, a.display_name`,
[ORPHAN_GRACE_SECONDS]
RETURNING a.id, a.display_name`
);
if (orphanResult.rows.length > 0) {
for (const row of orphanResult.rows) {

View file

@ -1,7 +1,6 @@
import http from 'http';
import os from 'os';
import fs from 'fs';
import crypto from 'crypto';
import { spawn } from 'child_process';
const MAM_API_URL = (process.env.MAM_API_URL || 'http://localhost:3000').replace(/\/$/, '');
@ -26,53 +25,6 @@ const LIVE_DIR = process.env.LIVE_DIR || '/mnt/NVME/MAM/wild-dragon-live';
const REPO_DIR = process.env.REPO_DIR || '/opt/wild-dragon';
const VERSION = '1.4.0';
// Number of GPUs to spread capture encodes across. Each capture sidecar runs
// ~2 NVENC sessions (master HEVC + HLS preview); with NVIDIA_VISIBLE_DEVICES=all
// and no -gpu selector, ffmpeg's nvenc puts EVERY session on physical GPU 0, so
// 8 ports = 16 sessions hammering one card → it falls below realtime → the
// framecache ring laps → video freezes/stutters then recovers. Pinning each
// sidecar to GPU (port % CAPTURE_GPU_COUNT) spreads the load across all cards.
// GPU count for spreading capture encodes. The node-agent image has no
// nvidia-smi, and the startup probeGpusViaSmi cache can be empty, so count the
// /dev/nvidiaN device nodes directly (same approach the heartbeat uses — these
// are visible because the node-agent runs privileged with /dev bound).
// CAPTURE_GPU_COUNT / GPU_COUNT env override everything.
function detectGpuCount() {
const envN = parseInt(process.env.CAPTURE_GPU_COUNT || process.env.GPU_COUNT || '0', 10) || 0;
if (envN > 0) return envN;
if (Array.isArray(_gpuCache) && _gpuCache.length > 0) return _gpuCache.length;
let n = 0;
for (let i = 0; i < 16; i++) {
try { fs.accessSync(`/dev/nvidia${i}`, fs.constants.F_OK); n++; }
catch (_) { break; }
}
return n > 0 ? n : 1; // no GPU nodes → single-device fallback ('all')
}
// Choose the NVIDIA_VISIBLE_DEVICES value for a capture sidecar. An explicit
// per-recorder gpuUuid always wins; otherwise round-robin by capture port so
// consecutive Deltacast ports land on different physical GPUs. With a single
// device visible, the container's nvenc device 0 == the chosen physical GPU.
const SIDECAR_BASE_PORT = 7438;
function pickVisibleDevices(gpuUuid, capturePort) {
if (gpuUuid != null && String(gpuUuid).trim() !== '') return String(gpuUuid).trim();
const count = detectGpuCount();
if (count <= 1) return 'all';
const idx = Number.isFinite(capturePort) ? ((capturePort - SIDECAR_BASE_PORT) % count + count) % count : 0;
return String(idx);
}
// Build the Docker DeviceRequests entry matching a NVIDIA_VISIBLE_DEVICES value.
// 'all' → grant every GPU (Count -1). A specific index/UUID → grant ONLY that
// device via DeviceIDs, so the container can't see (and nvenc can't fall back
// to) any other card. A blanket Count:-1 would silently override the env var.
function gpuDeviceRequest(visibleDevices) {
if (!visibleDevices || visibleDevices === 'all') {
return { Driver: 'nvidia', Count: -1, Capabilities: [['gpu']] };
}
return { Driver: 'nvidia', DeviceIDs: [String(visibleDevices)], Capabilities: [['gpu']] };
}
// Capture-driver vendor allowlist. NOTHING outside this set is ever passed to
// the host installer — the value is only ever used to pick a script arg, never
// interpolated into a shell string.
@ -119,201 +71,13 @@ const DC_BRIDGE_BIN = process.env.DELTACAST_BRIDGE_BIN || 'deltacast-bridge';
const DC_PORTS_CSV = process.env.DELTACAST_PORTS || '0,1,2,3,4,5,6,7';
const DC_BOARD = process.env.DELTACAST_BOARD || '0';
// Framecache URL — passed to all bridge processes so they can register slots.
// Set FC_URL in .env.worker (default: http://framecache:7435 within the
// wild-dragon-worker Docker network).
const FC_URL = process.env.FC_URL || 'http://framecache:7435';
// Node identity for framecache slot IDs (e.g. "decklink-zampp3-0").
// Set NODE_NAME in .env.worker so slot IDs are stable across restarts.
const FC_NODE_ID = process.env.NODE_NAME || process.env.HOSTNAME || 'local';
let _dcBridge = null; // ChildProcess | null
let _dcSidecarCount = 0; // active deltacast sidecars on this node
// Map containerId -> sourceType so stop() can decrement the deltacast counter.
const _containerSourceType = new Map();
// port -> fmt JSON from bridge stderr (inject into sidecar env + slot_id)
// port -> fmt JSON from bridge stderr (inject into sidecar env)
const _dcPortFmt = new Map();
// ── Network ingest ────────────────────────────────────────────────────────
// One net_ingest process per active network recorder (SRT/RTMP).
// Decodes the stream to raw UYVY422 and writes into a framecache slot so
// capture-manager can use fc_pipe — the same consumer path as SDI sources.
const NET_INGEST_BIN = process.env.NET_INGEST_BIN || 'net_ingest';
// containerId → ChildProcess for cleanup on sidecar stop
const _netIngestProcs = new Map();
function startNetIngest(containerId, { sourceType, sourceUrl, listen, listenPort, streamKey,
width = 1920, height = 1080,
fpsNum = 30000, fpsDen = 1001 }) {
const slotId = `net-${containerId}`;
const args = [
'--slot-id', slotId,
'--fc-url', FC_URL,
'--source-type', sourceType,
'--width', String(width),
'--height', String(height),
'--fps-num', String(fpsNum),
'--fps-den', String(fpsDen),
];
if (listen) {
args.push('--listen');
if (listenPort) args.push('--listen-port', String(listenPort));
if (streamKey) args.push('--stream-key', streamKey);
} else if (sourceUrl) {
args.push('--url', sourceUrl);
}
console.log(`[net-ingest:${slotId}] launching: ${NET_INGEST_BIN} ${args.join(' ')}`);
const proc = spawn(NET_INGEST_BIN, args, {
stdio: ['ignore', 'ignore', 'pipe'],
env: { ...process.env, FC_URL },
});
proc.stderr.setEncoding('utf8');
proc.stderr.on('data', chunk => {
for (const line of chunk.split('\n')) {
const t = line.trim();
if (t) console.log(`[net-ingest:${slotId}] ${t}`);
}
});
proc.on('error', err => console.error(`[net-ingest:${slotId}] spawn error: ${err.message}`));
proc.on('exit', (c, s) => {
console.log(`[net-ingest:${slotId}] exited code=${c} signal=${s}`);
// The map key may have been remapped from the temp id to the real
// containerId after spawn. Delete by PROCESS IDENTITY, not the captured
// key, so the entry can't leak after an unexpected crash.
for (const [key, entry] of _netIngestProcs) {
if (entry.proc === proc) { _netIngestProcs.delete(key); break; }
}
});
_netIngestProcs.set(containerId, { proc, slotId });
return slotId;
}
function stopNetIngest(containerId) {
const entry = _netIngestProcs.get(containerId);
if (!entry) return;
console.log(`[net-ingest:${entry.slotId}] stopping`);
try { entry.proc.kill('SIGTERM'); } catch (_) {}
_netIngestProcs.delete(containerId);
}
// ── DeckLink bridge ───────────────────────────────────────────────────────
// One decklink-bridge container per node, managing all DeckLink devices.
// Mirrors the deltacast-bridge singleton pattern.
const DL_AUDIO_DIR = process.env.DECKLINK_AUDIO_DIR || '/dev/shm/decklink';
let _dlBridgeId = null; // containerId | null
let _dlSidecarCount = 0;
// device_idx -> fmt JSON from bridge stderr
const _dlDevFmt = new Map();
async function _dlBridgeRunning() {
if (!_dlBridgeId) return false;
try {
const res = await dockerApi('GET', `/containers/${_dlBridgeId}/json`);
return res.status === 200 && res.data.State?.Running;
} catch (_) { return false; }
}
/**
* Connect to container stderr stream and parse format JSONs.
*/
function _attachDlBridgeLogs(containerId) {
const options = {
socketPath: '/var/run/docker.sock',
path: `/v1.43/containers/${containerId}/attach?stderr=1&stream=1`,
method: 'POST',
};
const req = http.request(options, (res) => {
res.on('data', (chunk) => {
// Docker multiplexed stream header: [1/2, 0, 0, 0, size_32be]
let offset = 0;
while (offset + 8 <= chunk.length) {
const size = chunk.readUInt32BE(offset + 4);
const end = offset + 8 + size;
if (end > chunk.length) break;
const text = chunk.toString('utf8', offset + 8, end);
for (const line of text.split('\n')) {
const t = line.trim();
if (!t || !t.startsWith('{')) continue;
try {
const f = JSON.parse(t);
if (typeof f.device === 'number') _dlDevFmt.set(f.device, f);
} catch (_) {}
}
offset = end;
}
});
});
req.on('error', (err) => console.error(`[dl-bridge] log attach error: ${err.message}`));
req.end();
}
async function startDecklinkBridge(deviceIndices) {
if (await _dlBridgeRunning()) return;
const devCsv = Array.isArray(deviceIndices) ? deviceIndices.join(',') : String(deviceIndices || '0');
const DL_IMAGE = 'wild-dragon-capture:latest';
const DL_BIN = '/usr/local/bin/decklink-bridge';
// Pass correct IP to containerized bridge. Default falls back to framecache:7435.
const _fcUrl = process.env.FRAMECACHE_IP ? `http://${process.env.FRAMECACHE_IP}:7435` : FC_URL;
const bridgeArgs = [
'--devices', devCsv,
'--fc-url', _fcUrl,
'--audio-pipe-dir', DL_AUDIO_DIR,
];
console.log(`[dl-bridge] spawning containerized bridge for devices: ${devCsv}`);
const spec = {
Image: DL_IMAGE,
Entrypoint: [DL_BIN],
Cmd: bridgeArgs,
Env: [`NODE_ID=${FC_NODE_ID}`, `FC_URL=${_fcUrl}`],
HostConfig: {
NetworkMode: 'host',
Privileged: true,
Binds: ['/dev:/dev', '/dev/shm:/dev/shm'],
RestartPolicy: { Name: 'unless-stopped' },
},
};
try {
const createRes = await dockerApi('POST', '/containers/create?name=decklink-bridge', spec);
if (createRes.status !== 201 && createRes.status !== 409) {
console.error('[dl-bridge] create failed:', createRes.data);
return;
}
const containerId = createRes.status === 409 ? 'decklink-bridge' : createRes.data.Id;
const startRes = await dockerApi('POST', `/containers/${containerId}/start`);
if (startRes.status !== 204 && startRes.status !== 304) {
console.error('[dl-bridge] start failed:', startRes.data);
return;
}
_dlBridgeId = containerId;
_attachDlBridgeLogs(containerId);
console.log(`[dl-bridge] running in container ${containerId}`);
} catch (err) {
console.error(`[dl-bridge] spawn error: ${err.message}`);
}
}
async function stopDecklinkBridge() {
if (!_dlBridgeId) return;
console.log('[dl-bridge] stopping container');
try {
await dockerApi('POST', `/containers/${_dlBridgeId}/stop?t=5`);
await dockerApi('DELETE', `/containers/${_dlBridgeId}?force=true`);
} catch (err) {
console.error(`[dl-bridge] stop error: ${err.message}`);
}
_dlBridgeId = null;
}
function _dcBridgeRunning() {
return _dcBridge !== null && _dcBridge.exitCode === null && _dcBridge.signalCode === null;
}
@ -358,14 +122,12 @@ function startDeltacastBridge() {
'--ports', DC_PORTS_CSV,
'--video-pipe-dir', DC_PIPE_DIR,
'--audio-pipe-dir', DC_PIPE_DIR,
'--fc-url', FC_URL,
];
console.log(`[dc-bridge] launching: ${DC_BRIDGE_BIN} ${args.join(' ')}`);
const proc = spawn(DC_BRIDGE_BIN, args, {
stdio: ['ignore', 'ignore', 'pipe'],
detached: false,
env: { ...process.env, FC_URL, NODE_ID: FC_NODE_ID },
});
proc.stderr.setEncoding('utf8');
@ -499,14 +261,7 @@ async function handleSidecarStart(body, res) {
gpuUuid = null,
} = body;
// Reclaim the capture port before spawning, so an on-demand start can never
// collide (EADDRINUSE) with a stale/standby container already on that port.
await freeCapturePort(capturePort);
const binds = [`${LIVE_DIR}:/live`];
// Always mount /dev/shm so the sidecar can access framecache slots.
if (fs.existsSync('/dev/shm')) binds.push('/dev/shm:/dev/shm');
if (sourceType === 'sdi') binds.unshift('/dev/blackmagic:/dev/blackmagic');
if (sourceType === 'deltacast') {
// Bind each /dev/deltacast* node that exists on the host into the container.
@ -514,6 +269,8 @@ async function handleSidecarStart(body, res) {
try {
const dcEntries = fs.readdirSync('/dev').filter(n => /^deltacast\d+$/.test(n));
for (const d of dcEntries) binds.push(`/dev/${d}:/dev/${d}`);
// VideoMaster SDK needs the board IPC shared-memory segment mounted too.
if (fs.existsSync('/dev/shm/deltacast')) binds.push('/dev/shm/deltacast:/dev/shm/deltacast');
} catch (_) { /* /dev always exists */ }
}
@ -522,16 +279,13 @@ async function handleSidecarStart(body, res) {
if (useGpu) {
// Issue #167 — per-recorder GPU affinity. A gpuUuid (UUID string or
// numeric index) pins the sidecar to exactly that device; otherwise
// Round-robin the encode across all GPUs by capture port (or honor an
// explicit per-recorder gpuUuid). Prevents all sidecars piling onto GPU 0.
var startVisibleDevices = pickVisibleDevices(gpuUuid, capturePort);
sidecarEnv.push(`NVIDIA_VISIBLE_DEVICES=${startVisibleDevices}`);
// NVIDIA_VISIBLE_DEVICES=all exposes every GPU on the host (legacy
// behavior — for a single-GPU node like zampp2 / L4 this equals GPU 0).
const visibleDevices = (gpuUuid != null && String(gpuUuid).trim() !== '')
? String(gpuUuid).trim()
: 'all';
sidecarEnv.push(`NVIDIA_VISIBLE_DEVICES=${visibleDevices}`);
sidecarEnv.push('NVIDIA_DRIVER_CAPABILITIES=video,compute,utility');
// Privileged sidecars see every /dev/nvidiaN regardless of
// NVIDIA_VISIBLE_DEVICES, so also tell ffmpeg explicitly which GPU to
// encode on via CAPTURE_GPU_INDEX (capture-manager adds `-gpu N`).
if (startVisibleDevices !== 'all') sidecarEnv.push(`CAPTURE_GPU_INDEX=${startVisibleDevices}`);
console.log(`[gpu] sidecar port ${capturePort} → NVIDIA_VISIBLE_DEVICES=${startVisibleDevices}`);
}
const hostConfig = {
@ -540,12 +294,12 @@ async function handleSidecarStart(body, res) {
Binds: binds,
};
if (useGpu) {
// Tell Docker to use the NVIDIA container runtime for this container.
// Equivalent to `docker run --gpus all` / `--runtime=nvidia`.
hostConfig.Runtime = 'nvidia';
// CRITICAL: scope DeviceRequests to the SAME single GPU as
// NVIDIA_VISIBLE_DEVICES. A blanket Count:-1 (all GPUs) OVERRIDES the env
// var, so every sidecar got all 3 cards and nvenc piled onto GPU 0. Pass
// the specific DeviceIDs so the container truly sees only its one GPU.
hostConfig.DeviceRequests = [gpuDeviceRequest(startVisibleDevices)];
hostConfig.DeviceRequests = [
{ Driver: 'nvidia', Count: -1, Capabilities: [['gpu']] },
];
}
const spec = {
@ -554,147 +308,36 @@ async function handleSidecarStart(body, res) {
HostConfig: hostConfig,
};
// Always inject FC_URL so capture-manager can find the framecache service.
sidecarEnv.push(`FC_URL=${FC_URL}`);
// Network sources (SRT/RTMP): launch net_ingest to decode stream into
// a framecache slot, then inject FC_SLOT_ID so capture-manager reads
// from the slot via fc_pipe (same path as SDI sources).
if (sourceType === 'srt' || sourceType === 'rtmp') {
const _srcCfg = (env.find(e => e.startsWith('SOURCE_CONFIG=')) || '').slice(14);
let _netCfg = {};
try { _netCfg = JSON.parse(_srcCfg); } catch (_) {}
const _listen = !!(body.listen || _netCfg.listen);
const _listenPort = body.listenPort || _netCfg.listenPort || 0;
const _streamKey = body.streamKey || _netCfg.streamKey || 'stream';
const _srcUrl = body.sourceUrl || _netCfg.url || '';
// Width/height/fps from recorder config if available; defaults used otherwise.
// net_ingest will auto-scale via ffmpeg -vf scale=iw:ih.
const _w = _netCfg.width || 1920;
const _h = _netCfg.height || 1080;
const _fpsNum = _netCfg.fps_num || 30000;
const _fpsDen = _netCfg.fps_den || 1001;
// containerId not known yet — we start net_ingest just before container
// start and use a temporary slot ID based on a timestamp.
const _tempId = `${sourceType}-${Date.now()}`;
const _slotId = startNetIngest(_tempId, {
sourceType: sourceType,
sourceUrl: _srcUrl,
listen: _listen,
listenPort: _listenPort,
streamKey: _streamKey,
width: _w,
height: _h,
fpsNum: _fpsNum,
fpsDen: _fpsDen,
});
sidecarEnv.push(`FC_SLOT_ID=${_slotId}`);
hostConfig.IpcMode = 'host';
// Store temp id so we can remap to real containerId on create success
body._netIngestTempId = _tempId;
}
// Deltacast: ensure the shared bridge daemon is running on the HOST before
// starting the sidecar. The bridge writes frames to the framecache shm ring;
// the sidecar reads via the consumer library (fc_client).
// starting the sidecar. The sidecar reads FIFOs produced by the bridge;
// it does NOT open the board handle itself (no BufMngr.c:781 race).
if (sourceType === 'deltacast') {
_dcSidecarCount++;
startDeltacastBridge();
// Inject per-port signal format so capture-manager uses real dimensions/fps
const _srcCfg = (env.find(e => e.startsWith('SOURCE_CONFIG=')) || '').slice(14);
let _portNum = NaN;
try { _portNum = JSON.parse(_srcCfg).port; } catch (_) {}
if (!Number.isFinite(_portNum)) _portNum = 0;
// FC_SLOT_ID is DETERMINISTIC — the deltacast-bridge builds it as
// "deltacast-<board>-<port>" (both known here), so we construct it
// directly and DO NOT wait for the bridge's async format JSON. This is
// the fix for the cold-start race where _dcPortFmt was still empty on
// first recorder start. FC_SLOT_ID is now MANDATORY — the legacy
// FIFO-video fallback in capture-manager was removed, so a missing slot
// id would hard-fail rather than silently degrade.
const _slotId = `deltacast-${DC_BOARD}-${_portNum}`;
sidecarEnv.push(`FC_SLOT_ID=${_slotId}`);
// Format (width/height/fps) is best-effort enrichment from the bridge's
// stderr JSON if it has already arrived; capture-manager has sane
// defaults and waits for the slot to appear regardless.
if (_dcPortFmt.has(_portNum)) {
if (Number.isFinite(_portNum) && _dcPortFmt.has(_portNum)) {
const _fmt = _dcPortFmt.get(_portNum);
const _fps = (_fmt.fps_den && _fmt.fps_den !== 1) ? `${_fmt.fps_num}/${_fmt.fps_den}` : String(_fmt.fps_num);
sidecarEnv.push(`DELTACAST_VIDEO_SIZE=${_fmt.width}x${_fmt.height}`);
sidecarEnv.push(`DELTACAST_FRAMERATE=${_fps}`);
sidecarEnv.push(`DELTACAST_INTERLACED=${_fmt.interlaced ? '1' : '0'}`);
console.log(`[dc-bridge] port ${_portNum} fmt: ${_fmt.width}x${_fmt.height} ${_fps} slot=${_slotId}`);
} else {
console.log(`[dc-bridge] port ${_portNum} slot=${_slotId} (fmt not yet available — using defaults)`);
console.log(`[dc-bridge] port ${_portNum} fmt: ${_fmt.width}x${_fmt.height} ${_fps} interlaced=${_fmt.interlaced}`);
}
hostConfig.IpcMode = 'host';
}
// DeckLink: ensure decklink-bridge is running on the HOST.
if (sourceType === 'sdi' || sourceType === 'blackmagic') {
_dlSidecarCount++;
const _bmdDevices = [];
try {
const _bmdDir = '/dev/blackmagic';
const _bmdEntries = fs.readdirSync(_bmdDir).filter(n => /^(dv|io)\d+$/.test(n));
_bmdEntries.forEach((_, i) => _bmdDevices.push(i));
} catch (_) { _bmdDevices.push(0); }
await startDecklinkBridge(_bmdDevices);
const _srcCfg = (env.find(e => e.startsWith('SOURCE_CONFIG=')) || '').slice(14);
let _devIdx = NaN;
try { _devIdx = JSON.parse(_srcCfg).device ?? JSON.parse(_srcCfg).index; } catch (_) {}
if (!Number.isFinite(_devIdx)) _devIdx = parseInt((env.find(e => e.startsWith('BMD_DEVICE_INDEX=')) || '=0').split('=')[1]) || 0;
// FC_SLOT_ID is DETERMINISTIC — decklink-bridge builds it as
// "decklink-<NODE_ID>-<device_idx>". Construct it directly (no wait on
// async fmt JSON). FC_NODE_ID matches what node-agent passes to the
// bridge via the NODE_ID env var.
const _slotId = `decklink-${FC_NODE_ID}-${_devIdx}`;
sidecarEnv.push(`FC_SLOT_ID=${_slotId}`);
if (_dlDevFmt.has(_devIdx)) {
const _fmt = _dlDevFmt.get(_devIdx);
const _fps = (_fmt.fps_den && _fmt.fps_den !== 1) ? `${_fmt.fps_num}/${_fmt.fps_den}` : String(_fmt.fps_num);
sidecarEnv.push(`DELTACAST_VIDEO_SIZE=${_fmt.width}x${_fmt.height}`);
sidecarEnv.push(`DELTACAST_FRAMERATE=${_fps}`);
sidecarEnv.push(`DELTACAST_INTERLACED=${_fmt.interlaced ? '1' : '0'}`);
console.log(`[dl-bridge] device ${_devIdx} fmt: ${_fmt.width}x${_fmt.height} ${_fps} slot=${_slotId}`);
} else {
console.log(`[dl-bridge] device ${_devIdx} slot=${_slotId} (fmt not yet available — using defaults)`);
}
hostConfig.IpcMode = 'host';
}
// Single cleanup for ALL failure paths (create fail, start fail, throw):
// decrements the right bridge counter (stopping the bridge when it hits 0)
// AND stops any net_ingest started for this request. Previously only the
// deltacast counter was decremented — blackmagic count and net_ingest leaked
// on every failed start, eventually stranding the bridge / ingest forever.
const _cleanupOnFailure = async () => {
if (sourceType === 'deltacast') {
_dcSidecarCount--;
if (_dcSidecarCount <= 0) { _dcSidecarCount = 0; stopDeltacastBridge(); }
} else if (sourceType === 'sdi' || sourceType === 'blackmagic') {
_dlSidecarCount--;
if (_dlSidecarCount <= 0) { _dlSidecarCount = 0; await stopDecklinkBridge(); }
} else if (sourceType === 'srt' || sourceType === 'rtmp') {
// net_ingest may be keyed by the temp id (create not yet succeeded) or
// the real containerId (remapped). Stop whichever exists.
if (body._netIngestTempId) stopNetIngest(body._netIngestTempId);
if (containerId) stopNetIngest(containerId);
}
};
let containerId;
try {
const createRes = await dockerApi('POST', '/containers/create', spec);
if (createRes.status !== 201) {
await _cleanupOnFailure();
return jsonResponse(res, 502, { error: 'Failed to create container', details: createRes.data });
try {
const createRes = await dockerApi('POST', '/containers/create', spec);
if (createRes.status !== 201) {
if (sourceType === 'deltacast') {
_dcSidecarCount--;
if (_dcSidecarCount <= 0) { _dcSidecarCount = 0; stopDeltacastBridge(); }
}
return jsonResponse(res, 502, { error: 'Failed to create container', details: createRes.data });
}
containerId = createRes.data.Id;
const _u = (env.find(e => e.startsWith('MAM_API_URL=')) || '').slice(12);
@ -702,25 +345,21 @@ async function handleSidecarStart(body, res) {
console.log(`[sidecar-start] ${containerId} image=${image} src=${sourceType} MAM_API_URL=${_u} token=${_tok}`);
const startRes = await dockerApi('POST', `/containers/${containerId}/start`);
if (startRes.status !== 204) {
if (sourceType === 'deltacast') {
_dcSidecarCount--;
if (_dcSidecarCount <= 0) { _dcSidecarCount = 0; stopDeltacastBridge(); }
}
await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {});
await _cleanupOnFailure();
return jsonResponse(res, 502, { error: 'Failed to start container', details: startRes.data });
}
if (sourceType === 'deltacast') _containerSourceType.set(containerId, 'deltacast');
if (sourceType === 'sdi' || sourceType === 'blackmagic') _containerSourceType.set(containerId, 'blackmagic');
if (sourceType === 'srt' || sourceType === 'rtmp') {
_containerSourceType.set(containerId, sourceType);
// Remap net_ingest from temp id to real containerId
if (body._netIngestTempId && _netIngestProcs.has(body._netIngestTempId)) {
const entry = _netIngestProcs.get(body._netIngestTempId);
_netIngestProcs.delete(body._netIngestTempId);
_netIngestProcs.set(containerId, entry);
}
}
jsonResponse(res, 201, { containerId, capturePort });
} catch (err) {
await _cleanupOnFailure();
if (sourceType === 'deltacast') {
_dcSidecarCount--;
if (_dcSidecarCount <= 0) { _dcSidecarCount = 0; stopDeltacastBridge(); }
}
throw err;
}
} catch (err) {
@ -728,217 +367,23 @@ async function handleSidecarStart(body, res) {
}
}
// Strip Docker's stdcopy multiplexing framing (8-byte header per frame for
// non-TTY containers: [streamType,0,0,0, uint32be length]) and return clean
// UTF-8. The old version just deleted control bytes, which left stray header
// remnants (e.g. the length byte) at line starts.
function _demuxDocker(buf) {
if (!buf || buf.length === 0) return '';
const framed = buf.length >= 8 && buf[0] <= 2 && buf[1] === 0 && buf[2] === 0 && buf[3] === 0;
if (!framed) return buf.toString('utf8');
const out = [];
let off = 0;
while (off + 8 <= buf.length) {
const len = buf.readUInt32BE(off + 4);
off += 8;
if (len <= 0) continue;
out.push(buf.toString('utf8', off, Math.min(off + len, buf.length)));
off += len;
}
return out.join('');
}
async function fetchContainerLogs(containerId, tail = 200) {
async function fetchContainerLogs(containerId) {
return await new Promise((resolve) => {
const options = {
socketPath: '/var/run/docker.sock',
path: `/v1.43/containers/${containerId}/logs?stdout=1&stderr=1&tail=${tail}&timestamps=1`,
path: `/v1.43/containers/${containerId}/logs?stdout=1&stderr=1&tail=200`,
method: 'GET',
};
const req = http.request(options, res => {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => resolve(_demuxDocker(Buffer.concat(chunks))));
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8').replace(/[\x00-\x08]/g, '')));
});
req.on('error', () => resolve('(log fetch failed)'));
req.end();
});
}
// ── Standby: pre-spawn a sidecar at recorder create time ─────────────────
// Like handleSidecarStart but sets STANDBY=1 so the capture container boots
// into idle-preview mode instead of starting a recording session immediately.
// The bridge is started here (warms it up for zero-lag on first /start call).
// Per-session params (CLIP_NAME, ASSET_ID, PROJECT_ID) are NOT in the env —
// they arrive via HTTP POST /capture/start when the user hits record.
// Force-free a capture port before binding a new sidecar to it. With
// NetworkMode=host, two capture containers requesting the same PORT collide
// with EADDRINUSE — the exact failure that orphaned/duplicated sidecars caused.
// We enumerate ALL capture containers (running or not), read each one's PORT
// env, and force-remove any bound to this capturePort. Idempotent and safe:
// the only thing on that port should be a sidecar we're about to replace.
async function freeCapturePort(capturePort) {
try {
// all=1 so we also catch Exited/Created stragglers still holding the name.
const listRes = await dockerApi('GET', '/containers/json?all=1');
if (listRes.status !== 200 || !Array.isArray(listRes.data)) return;
for (const c of listRes.data) {
// NOTE: do NOT pre-filter on c.Image here. After `wild-dragon-capture:latest`
// is rebuilt, the Docker list API reports older containers' .Image as the
// bare image ID (e.g. "226f9c953799") instead of the tag, so a regex on the
// tag silently SKIPS those orphans — they keep holding the host port and the
// replacement sidecar dies with EADDRINUSE ("connecting forever"). Identify
// capture sidecars by their PORT env (+ inspected Config.Image) instead,
// which survives a tag rebuild.
try {
const insp = await dockerApi('GET', `/containers/${c.Id}/json`);
if (insp.status !== 200) continue;
const cfg = insp.data?.Config || {};
const cenv = cfg.Env || [];
const portEnv = cenv.find(e => e.startsWith('PORT='));
const p = portEnv ? parseInt(portEnv.split('=')[1], 10) : NaN;
if (p !== capturePort) continue;
// Config.Image (from inspect) preserves the original "wild-dragon-capture:..."
// string even after a tag rebuild — use it as a sanity guard so we only ever
// remove our own capture sidecars, never an unrelated host-net container that
// happens to expose the same PORT env.
const cfgImg = cfg.Image || '';
if (!/wild-dragon-capture/.test(cfgImg)) continue;
console.log(`[sidecar] force-freeing capture port ${capturePort}: removing stale container ${c.Id.slice(0, 12)} (image=${cfgImg})`);
await dockerApi('DELETE', `/containers/${c.Id}?force=true`).catch(() => {});
} catch (_) { /* container vanished mid-scan — fine */ }
}
} catch (e) {
console.warn(`[sidecar] freeCapturePort(${capturePort}) scan failed (continuing): ${e.message}`);
}
}
async function handleSidecarStandby(body, res) {
try {
const {
image = 'wild-dragon-capture:latest',
env = [],
capturePort = 3001,
sourceType = 'sdi',
useGpu = false,
gpuUuid = null,
} = body;
// Reclaim the port first so a re-Enable (or a stale container surviving a
// node-agent restart) can never collide on bind.
await freeCapturePort(capturePort);
const binds = [`${LIVE_DIR}:/live`];
if (fs.existsSync('/dev/shm')) binds.push('/dev/shm:/dev/shm');
if (sourceType === 'sdi' || sourceType === 'blackmagic') binds.unshift('/dev/blackmagic:/dev/blackmagic');
if (sourceType === 'deltacast') {
try {
const dcEntries = fs.readdirSync('/dev').filter(n => /^deltacast\d+$/.test(n));
for (const d of dcEntries) binds.push(`/dev/${d}:/dev/${d}`);
} catch (_) {}
}
const sidecarEnv = [...env, `PORT=${capturePort}`, 'STANDBY=1'];
var standbyVisibleDevices = 'all';
if (useGpu) {
// Same round-robin GPU spread as the start path (see pickVisibleDevices).
standbyVisibleDevices = pickVisibleDevices(gpuUuid, capturePort);
sidecarEnv.push(`NVIDIA_VISIBLE_DEVICES=${standbyVisibleDevices}`);
sidecarEnv.push('NVIDIA_DRIVER_CAPABILITIES=video,compute,utility');
if (standbyVisibleDevices !== 'all') sidecarEnv.push(`CAPTURE_GPU_INDEX=${standbyVisibleDevices}`);
console.log(`[gpu] standby sidecar port ${capturePort} → NVIDIA_VISIBLE_DEVICES=${standbyVisibleDevices}`);
}
sidecarEnv.push(`FC_URL=${FC_URL}`);
const hostConfig = { NetworkMode: 'host', Privileged: true, Binds: binds };
if (useGpu) {
hostConfig.Runtime = 'nvidia';
// Scope to the single chosen GPU (see gpuDeviceRequest) — a blanket
// Count:-1 overrides NVIDIA_VISIBLE_DEVICES and re-piles everything on GPU 0.
hostConfig.DeviceRequests = [gpuDeviceRequest(standbyVisibleDevices)];
}
// Warm up the bridge and inject FC_SLOT_ID (same as handleSidecarStart).
if (sourceType === 'deltacast') {
_dcSidecarCount++;
startDeltacastBridge();
const _srcCfg = (env.find(e => e.startsWith('SOURCE_CONFIG=')) || '').slice(14);
let _portNum = NaN;
try { _portNum = JSON.parse(_srcCfg).port; } catch (_) {}
if (!Number.isFinite(_portNum)) _portNum = 0;
const _slotId = `deltacast-${DC_BOARD}-${_portNum}`;
sidecarEnv.push(`FC_SLOT_ID=${_slotId}`);
if (_dcPortFmt.has(_portNum)) {
const _fmt = _dcPortFmt.get(_portNum);
const _fps = (_fmt.fps_den && _fmt.fps_den !== 1) ? `${_fmt.fps_num}/${_fmt.fps_den}` : String(_fmt.fps_num);
sidecarEnv.push(`DELTACAST_VIDEO_SIZE=${_fmt.width}x${_fmt.height}`);
sidecarEnv.push(`DELTACAST_FRAMERATE=${_fps}`);
sidecarEnv.push(`DELTACAST_INTERLACED=${_fmt.interlaced ? '1' : '0'}`);
}
hostConfig.IpcMode = 'host';
}
if (sourceType === 'sdi' || sourceType === 'blackmagic') {
_dlSidecarCount++;
const _bmdDevices = [];
try {
const _bmdEntries = fs.readdirSync('/dev/blackmagic').filter(n => /^(dv|io)\d+$/.test(n));
_bmdEntries.forEach((_, i) => _bmdDevices.push(i));
} catch (_) { _bmdDevices.push(0); }
await startDecklinkBridge(_bmdDevices);
const _srcCfg = (env.find(e => e.startsWith('SOURCE_CONFIG=')) || '').slice(14);
let _devIdx = NaN;
try { _devIdx = JSON.parse(_srcCfg).device ?? JSON.parse(_srcCfg).index; } catch (_) {}
if (!Number.isFinite(_devIdx)) _devIdx = parseInt((env.find(e => e.startsWith('BMD_DEVICE_INDEX=')) || '=0').split('=')[1]) || 0;
const _slotId = `decklink-${FC_NODE_ID}-${_devIdx}`;
sidecarEnv.push(`FC_SLOT_ID=${_slotId}`);
if (_dlDevFmt.has(_devIdx)) {
const _fmt = _dlDevFmt.get(_devIdx);
const _fps = (_fmt.fps_den && _fmt.fps_den !== 1) ? `${_fmt.fps_num}/${_fmt.fps_den}` : String(_fmt.fps_num);
sidecarEnv.push(`DELTACAST_VIDEO_SIZE=${_fmt.width}x${_fmt.height}`);
sidecarEnv.push(`DELTACAST_FRAMERATE=${_fps}`);
sidecarEnv.push(`DELTACAST_INTERLACED=${_fmt.interlaced ? '1' : '0'}`);
}
hostConfig.IpcMode = 'host';
}
const _cleanupOnFailure = async () => {
if (sourceType === 'deltacast') {
_dcSidecarCount--;
if (_dcSidecarCount <= 0) { _dcSidecarCount = 0; stopDeltacastBridge(); }
} else if (sourceType === 'sdi' || sourceType === 'blackmagic') {
_dlSidecarCount--;
if (_dlSidecarCount <= 0) { _dlSidecarCount = 0; await stopDecklinkBridge(); }
}
};
let containerId;
try {
const createRes = await dockerApi('POST', '/containers/create', { Image: image, Env: sidecarEnv, HostConfig: hostConfig });
if (createRes.status !== 201) {
await _cleanupOnFailure();
return jsonResponse(res, 502, { error: 'Failed to create standby container', details: createRes.data });
}
containerId = createRes.data.Id;
console.log(`[sidecar-standby] ${containerId} image=${image} src=${sourceType}`);
const startRes = await dockerApi('POST', `/containers/${containerId}/start`);
if (startRes.status !== 204) {
await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {});
await _cleanupOnFailure();
return jsonResponse(res, 502, { error: 'Failed to start standby container', details: startRes.data });
}
if (sourceType === 'deltacast') _containerSourceType.set(containerId, 'deltacast');
if (sourceType === 'sdi' || sourceType === 'blackmagic') _containerSourceType.set(containerId, 'blackmagic');
jsonResponse(res, 201, { containerId, capturePort });
} catch (err) {
await _cleanupOnFailure();
throw err;
}
} catch (err) {
jsonResponse(res, 500, { error: err.message });
}
}
async function handleSidecarStop(containerId, res) {
try {
console.log(`[sidecar-stop] stopping ${containerId} (grace 180s)...`);
@ -954,23 +399,16 @@ async function handleSidecarStop(containerId, res) {
console.log(`[sidecar-stop] ==== capture logs for ${containerId} ====\n${logs}\n[sidecar-stop] ==== end logs ====`);
await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {});
// Bridge lifecycle: decrement sidecar count; stop bridge when last sidecar stops.
const _srcType = _containerSourceType.get(containerId);
_containerSourceType.delete(containerId);
if (_srcType === 'deltacast') {
// Deltacast bridge lifecycle: decrement sidecar count; stop bridge when last.
if (_containerSourceType.get(containerId) === 'deltacast') {
_containerSourceType.delete(containerId);
_dcSidecarCount--;
if (_dcSidecarCount <= 0) {
_dcSidecarCount = 0;
stopDeltacastBridge();
}
} else if (_srcType === 'blackmagic') {
_dlSidecarCount--;
if (_dlSidecarCount <= 0) {
_dlSidecarCount = 0;
await stopDecklinkBridge();
}
} else if (_srcType === 'srt' || _srcType === 'rtmp') {
stopNetIngest(containerId);
} else {
_containerSourceType.delete(containerId);
}
} catch (err) {
console.error(`[sidecar-stop] background cleanup failed for ${containerId}:`, err.message);
@ -984,15 +422,6 @@ async function handleSidecarStop(containerId, res) {
}
}
async function handleSidecarLogs(containerId, res) {
try {
const logs = await fetchContainerLogs(containerId);
jsonResponse(res, 200, { logs: logs || '(no logs)' });
} catch (err) {
jsonResponse(res, 500, { error: err.message });
}
}
async function handleSidecarStatus(containerId, res) {
try {
const inspectRes = await dockerApi('GET', `/containers/${containerId}/json`);
@ -1028,27 +457,11 @@ async function handleSidecarStatus(containerId, res) {
// When NODE_TOKEN is configured, privileged control endpoints (driver install)
// require a matching `Authorization: Bearer <NODE_TOKEN>`. mam-api forwards the
// node's stored token. If NODE_TOKEN is empty (dev), auth is not enforced.
//
// A SHARED cluster-read token (CLUSTER_READ_TOKEN) is ALSO accepted so the
// primary mam-api can fan-out read-only cluster queries (container list, logs)
// to every node with ONE token, rather than tracking each node's bound token.
// It only grants the same endpoints NODE_TOKEN does; set it identically on
// mam-api (NODE_AGENT_TOKEN) and every node-agent.
const CLUSTER_READ_TOKEN = process.env.CLUSTER_READ_TOKEN || '';
function _bearerEq(token, secret) {
if (!secret || token.length !== secret.length) return false;
try { return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(secret)); }
catch (_) { return false; }
}
function checkAgentAuth(req) {
if (!NODE_TOKEN && !CLUSTER_READ_TOKEN) return true;
if (!NODE_TOKEN) return true;
const hdr = req.headers['authorization'] || '';
const m = /^Bearer\s+(.+)$/i.exec(hdr);
if (!m) return false;
const token = m[1];
return _bearerEq(token, NODE_TOKEN) || _bearerEq(token, CLUSTER_READ_TOKEN);
return !!m && m[1] === NODE_TOKEN;
}
// ── Driver/SDK install ────────────────────────────────────────────────────
@ -1570,7 +983,7 @@ function serveLiveFile(pathname, res) {
}
// ── HTTP server ───────────────────────────────────────────────────────────
const server = http.createServer(async (req, res) => {
const server = http.createServer((req, res) => {
const { pathname } = new URL(req.url, 'http://localhost');
if (req.method === 'GET' && pathname === '/health') {
@ -1584,11 +997,6 @@ const server = http.createServer(async (req, res) => {
ip: getIp(),
}));
} else if (req.method === 'POST' && pathname === '/sidecar/standby') {
readBody(req)
.then(body => handleSidecarStandby(body, res))
.catch(() => jsonResponse(res, 400, { error: 'Invalid request body' }));
} else if (req.method === 'POST' && pathname === '/sidecar/start') {
readBody(req)
.then(body => handleSidecarStart(body, res))
@ -1603,15 +1011,6 @@ const server = http.createServer(async (req, res) => {
const id = pathname.slice('/sidecar/'.length, -'/status'.length);
handleSidecarStatus(id, res);
} else if (req.method === 'GET' && pathname === '/containers') {
if (!checkAgentAuth(req)) return jsonResponse(res, 401, { error: 'Unauthorized' });
const cRes = await dockerApi('GET', '/containers/json?all=true');
jsonResponse(res, cRes.status, cRes.data);
} else if (req.method === 'GET' && /^\/sidecar\/[^/]+\/logs$/.test(pathname)) {
const id = pathname.slice('/sidecar/'.length, -'/logs'.length);
handleSidecarLogs(id, res);
} else if (req.method === 'GET' && pathname === '/driver/status') {
if (!checkAgentAuth(req)) return jsonResponse(res, 401, { error: 'Unauthorized' });
handleDriverStatus(res);

View file

@ -81,7 +81,7 @@ function App() {
capture: ['Ingest', 'Capture'], monitors: ['Ingest', 'Monitors'],
jobs: ['Jobs'], editor: ['Editor'], playout: ['Operations', 'Playout'],
users: ['Admin', 'Users & Groups'], tokens: ['Admin', 'Tokens'],
containers: ['Admin', 'Containers'], logs: ['Admin', 'Logs'], cluster: ['Admin', 'Cluster'],
containers: ['Admin', 'Containers'], cluster: ['Admin', 'Cluster'],
settings: ['Admin', 'Settings'],
};
return (labels[route] || ['Home']).map(label => ({ label }));
@ -112,7 +112,7 @@ function App() {
// Admin-only destinations. Non-admins who reach one (deep link, keyboard
// router, stale tab) get bounced home instead of a broken/forbidden page.
// The API enforces the same rules this is just UX.
const ADMIN_ROUTES = new Set(['users', 'containers', 'logs', 'cluster', 'settings']);
const ADMIN_ROUTES = new Set(['users', 'containers', 'cluster', 'settings']);
const isAdmin = window.ZAMPP_DATA?.ME?.role === 'admin';
const effectiveRoute = (ADMIN_ROUTES.has(route) && !isAdmin) ? 'home' : route;
@ -123,7 +123,7 @@ function App() {
switch (effectiveRoute) {
case 'home': content = <Home navigate={navigate} />; break;
case 'dashboard': content = <Dashboard navigate={navigate} />; break;
case 'library': content = <Library navigate={navigate} onOpenAsset={setOpenAsset} openProject={openProject} onClearProject={() => setOpenProject(null)} onOpenProject={openProjectFromAnywhere} />; break;
case 'library': content = <Library navigate={navigate} onOpenAsset={setOpenAsset} openProject={openProject} onClearProject={() => setOpenProject(null)} />; break;
case 'projects': content = <Projects navigate={navigate} onOpenProject={openProjectFromAnywhere} />; break;
case 'upload': content = <Upload navigate={navigate} />; break;
case 'recorders': content = <Recorders navigate={navigate} onNew={() => setShowNewRecorder(true)} />; break;
@ -137,7 +137,6 @@ function App() {
case 'tokens': content = <Tokens />; break;
case 'billing': content = <TokensParody />; break;
case 'containers':content = <Containers />; break;
case 'logs': content = <Logs />; break;
case 'cluster': content = <Cluster />; break;
case 'settings': content = <Settings />; break;
default: content = <Home navigate={navigate} />;

View file

@ -14,8 +14,6 @@ const ICONS = {
token: <><circle cx="8" cy="15" r="4" /><path d="M10.85 12.15L19 4" /><path d="M18 5l3 3" /><path d="M15 8l3 3" /></>,
dollar: <><line x1="12" y1="2" x2="12" y2="22" /><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" /></>,
container: <><rect x="3" y="6" width="18" height="12" rx="1" /><path d="M3 10h18" /><circle cx="7" cy="14" r="1" fill="currentColor" /><circle cx="11" cy="14" r="1" fill="currentColor" /></>,
server: <><rect x="3" y="4" width="18" height="7" rx="1.5" /><rect x="3" y="13" width="18" height="7" rx="1.5" /><circle cx="7" cy="7.5" r="1" fill="currentColor" /><circle cx="7" cy="16.5" r="1" fill="currentColor" /></>,
file: <><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" /><path d="M14 3v6h6" /></>,
cluster: <><circle cx="12" cy="5" r="2.5" /><circle cx="5" cy="19" r="2.5" /><circle cx="19" cy="19" r="2.5" /><path d="M12 7.5l-6.5 9M12 7.5l6.5 9M7.5 19h9" /></>,
settings: <><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z" /></>,
search: <><circle cx="11" cy="11" r="7" /><path d="M21 21l-4.3-4.3" /></>,

View file

@ -418,6 +418,7 @@ function NewRecorderModal({ open, onClose }) {
{[
{ id: 'hevc', label: 'HEVC Master (MOV)', codec: 'hevc_nvenc', bitrate: '25' },
{ id: 'h264', label: 'H.264 Proxy-friendly (MP4)', codec: 'h264_nvenc', bitrate: '25' },
{ id: 'dnxhr', label: 'DNxHR HQ (MOV)', codec: 'dnxhr_hq', bitrate: '145' },
].map(p => (
<button key={p.id}
className={`btn ghost sm${recCodec === p.codec ? ' active' : ''}`}
@ -436,8 +437,15 @@ function NewRecorderModal({ open, onClose }) {
onChange={e => setRecCodec(e.target.value)} disabled={growingOn}
style={{ appearance: 'auto', opacity: growingOn ? 0.6 : 1 }}>
{growingOn && <option value="h264_growing">XDCAM HD422 (MXF OP1a, growing)</option>}
<option value="hevc_nvenc">HEVC (NVENC, GPU)</option>
<option value="hevc_nvenc">All-Intra HEVC (NVENC, GPU, growing)</option>
<option value="h264_nvenc">H.264 (NVENC, GPU)</option>
<option value="prores_hq">ProRes 422 HQ (4:2:2, CPU)</option>
<option value="prores">ProRes 422</option>
<option value="prores_lt">ProRes 422 LT</option>
<option value="prores_proxy">ProRes 422 Proxy</option>
<option value="dnxhr_hq">DNxHR HQ</option>
<option value="libx264">H.264 (x264, CPU)</option>
<option value="libx265">H.265 (x265, CPU)</option>
</select>
</div>
{showBitrate ? (

View file

@ -1041,22 +1041,14 @@ function Containers() {
flashTimerRef.current = setTimeout(() => setRestartFlashSafe(null), ms);
};
// load(showSpinner): on first load / manual refresh we blank the list to show
// the spinner; the background poll passes false so the table doesn't flicker.
function load(showSpinner = true) {
if (showSpinner) setContainers(null);
function load() {
setContainers(null);
window.ZAMPP_API.fetch('/cluster/containers')
.then(data => setContainers(Array.isArray(data) ? data : (data.containers || [])))
.catch(() => setContainers(c => (c == null ? [] : c)));
.catch(() => setContainers([]));
}
React.useEffect(() => {
load();
// Poll every 5s so the cross-cluster view stays live (containers start/stop,
// nodes come and go) without the operator hitting Refresh.
const id = setInterval(() => load(false), 5000);
return () => clearInterval(id);
}, []);
React.useEffect(() => { load(); }, []);
const running = (containers || []).filter(c => c.state === 'running').length;
@ -1064,12 +1056,7 @@ function Containers() {
const logsModal = logsModalState;
const setLogsModal = setLogsModalState;
const showLogs = (c) => {
setLogsModal({ ...c, logs: null }); // Show loading state
window.ZAMPP_API.fetch(`/cluster/containers/${c.node_id}/${c.id}/logs`)
.then(d => setLogsModal(p => ({ ...p, logs: d.logs || '(no logs)' })))
.catch(e => setLogsModal(p => ({ ...p, logs: `Error: ${e.message}` })));
};
const showLogs = (c) => setLogsModal(c);
const restartContainer = async (c) => {
if (!(await confirm({ title: 'Restart container?', message: 'Restart container "' + c.name + '"?\nIn-flight requests will be dropped.', confirmLabel: 'Restart' }))) return;
@ -1127,9 +1114,16 @@ function Containers() {
<button className="icon-btn" aria-label="Close" onClick={() => setLogsModal(null)}><Icon name="x" /></button>
</div>
<div className="modal-body">
<pre className="mono" style={{ display: 'block', background: 'var(--bg-2)', padding: 10, borderRadius: 5, fontSize: 11, overflow: 'auto', maxHeight: 400, whiteSpace: 'pre-wrap' }}>
{logsModal.logs || 'Loading logs…'}
</pre>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginBottom: 8 }}>
Live log streaming over the websocket isn't wired yet. SSH to the host that runs this container and tail the logs directly:
</div>
<code className="mono" style={{ display: 'block', background: 'var(--bg-2)', padding: 10, borderRadius: 5, fontSize: 11.5, overflowX: 'auto' }}>
docker compose logs -f {logsModal.name}
</code>
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 10 }}>
Or grab the last 200 lines:&nbsp;
<span className="mono">docker logs --tail 200 {logsModal.name}</span>
</div>
</div>
<div className="modal-foot">
<button className="btn ghost sm" onClick={() => {
@ -1154,7 +1148,6 @@ function Containers() {
{containers !== null && containers.length > 0 && (
<div className="panel">
<div className="container-row head">
<div>Node</div>
<div>Container</div>
<div>Image</div>
<div>State</div>
@ -1165,7 +1158,6 @@ function Containers() {
</div>
{containers.map(c => (
<div key={c.id || c.name} className="container-row">
<div style={{ fontWeight: 500, fontSize: 13 }}>{c.node_hostname}</div>
<div>
<div style={{ fontWeight: 500, fontSize: 13 }}>{c.name}</div>
<div className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>up {c.uptime}</div>
@ -1196,145 +1188,7 @@ function Containers() {
</div>
))}
</div>
)}
</div>
</div>
);
}
//
// Logs cluster-wide log viewer. Left: every container across every node
// (grouped by node, polled). Right: the selected container's logs, fetched from
// /cluster/containers/:nodeId/:id/logs (raw Docker stream, demuxed server-side),
// auto-refreshed while live-follow is on. One place to read any container's logs
// across the whole cluster without SSHing into a box.
//
function Logs() {
const [containers, setContainers] = React.useState(null);
const [selected, setSelected] = React.useState(null); // {id, name, node_id, node_hostname}
const [logText, setLogText] = React.useState('');
const [loadingLogs, setLoadingLogs] = React.useState(false);
const [follow, setFollow] = React.useState(true);
const [filter, setFilter] = React.useState('');
const preRef = React.useRef(null);
const loadContainers = React.useCallback((spin = false) => {
if (spin) setContainers(null);
window.ZAMPP_API.fetch('/cluster/containers')
.then(d => setContainers(Array.isArray(d) ? d : (d.containers || [])))
.catch(() => setContainers(c => (c == null ? [] : c)));
}, []);
React.useEffect(() => {
loadContainers(true);
const id = setInterval(() => loadContainers(false), 8000);
return () => clearInterval(id);
}, [loadContainers]);
const fetchLogs = React.useCallback((c) => {
if (!c) return;
setLoadingLogs(true);
window.ZAMPP_API.fetch(`/cluster/containers/${c.node_id}/${c.id}/logs?tail=500`)
.then(d => { setLogText(d.logs || '(no logs)'); })
.catch(e => setLogText('Error fetching logs: ' + (e.message || e)))
.finally(() => setLoadingLogs(false));
}, []);
// Fetch on select + poll while follow is on.
React.useEffect(() => {
if (!selected) return;
fetchLogs(selected);
if (!follow) return;
const id = setInterval(() => fetchLogs(selected), 3000);
return () => clearInterval(id);
}, [selected, follow, fetchLogs]);
// Auto-scroll to bottom on new logs when following.
React.useEffect(() => {
if (follow && preRef.current) preRef.current.scrollTop = preRef.current.scrollHeight;
}, [logText, follow]);
// Group containers by node for the left rail.
const groups = React.useMemo(() => {
const m = new Map();
for (const c of (containers || [])) {
const k = c.node_hostname || 'unknown';
if (!m.has(k)) m.set(k, []);
m.get(k).push(c);
}
for (const list of m.values()) list.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
return [...m.entries()].sort((a, b) => a[0].localeCompare(b[0]));
}, [containers]);
const shownLog = React.useMemo(() => {
if (!filter.trim()) return logText;
const f = filter.toLowerCase();
return logText.split('\n').filter(l => l.toLowerCase().includes(f)).join('\n');
}, [logText, filter]);
return (
<div className="page">
<div className="page-header">
<h1>Logs</h1>
<span className="subtitle">Container logs across the whole cluster</span>
<div className="spacer" />
<button className="btn ghost sm" onClick={() => loadContainers(true)}><Icon name="refresh" />Refresh</button>
</div>
<div className="page-body">
<div className="logs-layout">
{/* Left rail: container picker, grouped by node */}
<div className="logs-rail panel">
{containers === null && <div className="logs-rail-empty">Loading</div>}
{containers !== null && containers.length === 0 && <div className="logs-rail-empty">No containers</div>}
{groups.map(([node, list]) => (
<div key={node} className="logs-rail-group">
<div className="logs-rail-node"><Icon name="server" size={11} />{node}</div>
{list.map(c => (
<button key={c.id || c.name}
className={'logs-rail-item' + (selected && selected.id === c.id ? ' active' : '')}
onClick={() => setSelected(c)}>
<span className={'logs-rail-dot ' + (c.state === 'running' ? 'on' : 'off')} />
<span className="logs-rail-name">{c.name}</span>
</button>
))}
</div>
))}
</div>
{/* Right pane: log viewer */}
<div className="logs-view panel">
{!selected ? (
<div className="logs-view-empty">
<Icon name="file" size={26} />
<div>Select a container to view its logs</div>
</div>
) : (
<>
<div className="logs-view-head">
<div className="logs-view-title">
<span className="logs-view-name">{selected.name}</span>
<span className="logs-view-node mono">{selected.node_hostname}</span>
</div>
<div className="spacer" />
<input className="field-input logs-filter" placeholder="Filter lines…"
value={filter} onChange={e => setFilter(e.target.value)} />
<label className="logs-follow" title="Auto-refresh + scroll">
<input type="checkbox" checked={follow} onChange={e => setFollow(e.target.checked)} />
Follow
</label>
<button className="btn ghost sm" onClick={() => fetchLogs(selected)} disabled={loadingLogs}>
<Icon name="refresh" size={12} />{loadingLogs ? '…' : ''}
</button>
<button className="icon-btn" title="Copy logs" aria-label="Copy logs"
onClick={() => { if (navigator.clipboard) navigator.clipboard.writeText(logText).catch(() => {}); }}>
<Icon name="copy" size={13} />
</button>
</div>
<pre ref={preRef} className="logs-view-pre mono">{shownLog || (loadingLogs ? 'Loading…' : '(no logs)')}</pre>
</>
)}
</div>
</div>
)}
</div>
</div>
);
@ -2722,8 +2576,6 @@ function GrowingSettingsCard() {
growing_smb_mount: cfg.growing_smb_mount,
growing_smb_username: cfg.growing_smb_username,
growing_smb_vers: cfg.growing_smb_vers,
// UI edits the delay in HOURS; storage stays in seconds (the auto-promotion
// scanner reads growing_promote_after_seconds). Convert hours seconds.
growing_promote_after_seconds: cfg.growing_promote_after_seconds,
};
if (clearPwd) body.growing_smb_password_clear = true;
@ -2777,22 +2629,8 @@ function GrowingSettingsCard() {
<SField label="SMB share URL (for editors)">
<input className="field-input mono" value={cfg.growing_smb_url || ''} onChange={e => set('growing_smb_url', e.target.value)} placeholder="smb://10.0.0.25/mam-growing" />
</SField>
<SField label="Auto-promote to S3 after (hours)">
<input className="field-input mono" type="number" min="0" step="0.25"
value={(() => {
const secs = parseFloat(cfg.growing_promote_after_seconds);
return Number.isFinite(secs) ? +(secs / 3600).toFixed(2).replace(/\.?0+$/, '') : '';
})()}
onChange={e => {
const hours = parseFloat(e.target.value);
set('growing_promote_after_seconds', Number.isFinite(hours) ? String(Math.round(hours * 3600)) : '');
}}
placeholder="12" />
<div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 4 }}>
Growing clips left on the SMB share are uploaded to S3 automatically once they've
been idle this long. Set 0 to promote almost immediately. You can also right-click any
asset in the Library "Move to S3" to promote it on demand.
</div>
<SField label="Promote-to-S3 idle threshold (seconds)">
<input className="field-input mono" type="number" value={cfg.growing_promote_after_seconds || ''} onChange={e => set('growing_promote_after_seconds', e.target.value)} placeholder="8" />
</SField>
<SettingsMsg msg={msg} />
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>

View file

@ -102,22 +102,18 @@ function AssetDetail({ asset, onClose }) {
setFilmFrames([]);
setFilmstripLoading(true);
// The API now serves the filmstrip frames JSON directly (with server-side
// retry around the flaky object store) instead of returning a signed URL.
// Response is either the frames array, or { ready:false } when unavailable.
window.ZAMPP_API.fetch('/assets/' + assetId + '/filmstrip')
.then(function(r) {
if (cancelled) return;
// New shape: bare array of base64 frames.
if (Array.isArray(r) && r.length) { setFilmFrames(r); return; }
// Legacy/empty shape: { url } (older API) or { ready:false }.
if (r && r.url) {
return fetch(r.url)
.then(function(res) { return res.json(); })
.then(function(frames) {
if (!cancelled && Array.isArray(frames) && frames.length) setFilmFrames(frames);
});
}
if (!r || !r.url) { setFilmstripLoading(false); return; }
// Fetch the JSON array of base64 frames from the signed S3 URL
return fetch(r.url)
.then(function(res) { return res.json(); })
.then(function(frames) {
if (!cancelled && Array.isArray(frames) && frames.length) {
setFilmFrames(frames);
}
});
})
.catch(function() {})
.finally(function() { if (!cancelled) setFilmstripLoading(false); });
@ -247,11 +243,7 @@ function AssetDetail({ asset, onClose }) {
setDownloading(true);
window.ZAMPP_API.fetch('/assets/' + assetId + '/hires')
.then(function(r) {
if (!r || !r.url) {
if (window.toast) window.toast.error('No hi-res source available for this asset.');
else window.alert('No hi-res source available for this asset.');
return;
}
if (!r || !r.url) { window.alert('No hi-res source available for this asset.'); return; }
const a = document.createElement('a');
a.href = r.url;
a.download = r.filename || (asset.name + '.' + (r.ext || 'mov'));
@ -261,10 +253,7 @@ function AssetDetail({ asset, onClose }) {
a.click();
document.body.removeChild(a);
})
.catch(function(e) {
if (window.toast) window.toast.error('Download failed: ' + (e.message || 'unknown error'));
else window.alert('Download failed: ' + (e.message || 'unknown error'));
})
.catch(function(e) { window.alert('Download failed: ' + (e.message || 'unknown error')); })
.finally(function() { setDownloading(false); });
};
@ -290,10 +279,7 @@ function AssetDetail({ asset, onClose }) {
}))) return;
window.ZAMPP_API.fetch('/assets/' + assetId + '?hard=true', { method: 'DELETE' })
.then(function() { onClose && onClose(); })
.catch(function(e) {
if (window.toast) window.toast.error('Delete failed: ' + e.message);
else window.alert('Delete failed: ' + e.message);
});
.catch(function(e) { window.alert('Delete failed: ' + e.message); });
};
const retryProcessing = function() {
@ -301,13 +287,9 @@ function AssetDetail({ asset, onClose }) {
setRetrying(true);
window.ZAMPP_API.fetch('/assets/' + assetId + '/retry', { method: 'POST' })
.then(function() {
if (window.toast) window.toast.success('Re-queued for processing. The proxy worker will pick it up shortly; refresh in a minute to see the player.');
else window.alert('Re-queued for processing. The proxy worker will pick it up shortly; refresh in a minute to see the player.');
})
.catch(function(e) {
if (window.toast) window.toast.error('Retry failed: ' + (e.message || 'unknown error'));
else window.alert('Retry failed: ' + (e.message || 'unknown error'));
window.alert('Re-queued for processing. The proxy worker will pick it up shortly; refresh in a minute to see the player.');
})
.catch(function(e) { window.alert('Retry failed: ' + (e.message || 'unknown error')); })
.finally(function() { setRetrying(false); });
};
@ -316,26 +298,16 @@ function AssetDetail({ asset, onClose }) {
setReprocessing(type);
window.ZAMPP_API.fetch('/assets/' + assetId + '/reprocess?type=' + type, { method: 'POST' })
.then(function() {
if (window.toast) window.toast.success((type === 'proxy' ? 'Proxy' : 'Thumbnail') + ' job queued. Refresh in a moment to see the result.');
else window.alert((type === 'proxy' ? 'Proxy' : 'Thumbnail') + ' job queued. Refresh in a moment to see the result.');
})
.catch(function(e) {
if (window.toast) window.toast.error('Reprocess failed: ' + (e.message || 'unknown error'));
else window.alert('Reprocess failed: ' + (e.message || 'unknown error'));
window.alert((type === 'proxy' ? 'Proxy' : 'Thumbnail') + ' job queued. Refresh in a moment to see the result.');
})
.catch(function(e) { window.alert('Reprocess failed: ' + (e.message || 'unknown error')); })
.finally(function() { setReprocessing(null); });
};
const regenFilmstrip = function() {
window.ZAMPP_API.fetch('/assets/' + assetId + '/reprocess?type=filmstrip', { method: 'POST' })
.then(function() {
if (window.toast) window.toast.success('Filmstrip job queued: it will appear automatically when ready.');
else window.alert('Filmstrip job queued: it will appear automatically when ready.');
})
.catch(function(e) {
if (window.toast) window.toast.error('Failed to queue filmstrip: ' + (e.message || 'unknown error'));
else window.alert('Failed to queue filmstrip: ' + (e.message || 'unknown error'));
});
.then(function() { window.alert('Filmstrip job queued: it will appear automatically when ready.'); })
.catch(function(e) { window.alert('Failed to queue filmstrip: ' + (e.message || 'unknown error')); });
};
// Map a /assets/:id/comments row into the legacy shape the consumer
@ -380,8 +352,7 @@ function AssetDetail({ asset, onClose }) {
setComments(function(c) { return [...c, _normalizeComment(row)]; });
})
.catch(function(e) {
if (window.toast) window.toast.error('Could not post comment: ' + (e.message || 'unknown error'));
else window.alert('Could not post comment: ' + (e.message || 'unknown error'));
window.alert('Could not post comment: ' + (e.message || 'unknown error'));
setNewComment(text);
});
};
@ -403,10 +374,7 @@ function AssetDetail({ asset, onClose }) {
.then(function() {
setComments(function(prev) { return prev.filter(x => x.id !== c.id); });
})
.catch(function(e) {
if (window.toast) window.toast.error('Delete failed: ' + e.message);
else window.alert('Delete failed: ' + e.message);
});
.catch(function(e) { window.alert('Delete failed: ' + e.message); });
};
const visibleComments = comments.filter(function(c) { return showResolved || !c.resolved; });

View file

@ -601,233 +601,39 @@ function HlsPreviewUrl({ url }) {
}
/* ===== Recorders ===== */
// Per-recorder config editor. Recorders are physical ports this PATCHes the
// existing row in place (never delete/recreate), so codec/growing/label/project
// changes persist across enable/disable. If the recorder is currently ENABLED,
// saving bounces its standby sidecar (disableenable) so the new env takes
// effect; the operator is told. Refuses while recording.
function RecorderConfigModal({ recorder, onClose, onSaved }) {
const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
const GROWING_CODEC = 'hevc_nvenc';
const BITRATE_CODECS = new Set(['hevc_nvenc', 'h264_nvenc', 'libx264', 'libx265', 'dnxhd', 'dnxhr_hq']);
const [label, setLabel] = React.useState(recorder.label || '');
const [codec, setCodec] = React.useState(recorder.recording_codec || 'hevc_nvenc');
// Seed bitrate from the stored value; fall back to a mode-appropriate default
// (50 Mbps for growing XDCAM HD422, 25 Mbps for a GPU master).
const _seedBitrate = (recorder.recording_video_bitrate || (recorder.growing_enabled === true ? '50' : '25')).replace(/M$/i, '');
const [bitrate, setBitrate] = React.useState(_seedBitrate);
const [growing, setGrowing] = React.useState(recorder.growing_enabled === true);
const [projectId, setProjectId] = React.useState(recorder.project_id || PROJECTS[0]?.id || '');
const [saving, setSaving] = React.useState(false);
const [err, setErr] = React.useState(null);
const isRec = recorder.status === 'recording';
const showBitrate = growing || BITRATE_CODECS.has(codec);
const submit = () => {
if (saving || isRec) return;
setSaving(true); setErr(null);
// Growing forces the XDCAM/HEVC master path on the backend; send the GPU
// master codec so the row is coherent if growing is later turned off.
const effCodec = growing ? GROWING_CODEC : codec;
const body = {
label: label.trim() || null,
recording_codec: effCodec,
growing_enabled: growing,
project_id: projectId || null,
};
if (showBitrate && bitrate) body.recording_video_bitrate = String(bitrate).replace(/M$/i, '') + 'M';
window.ZAMPP_API.fetch('/recorders/' + recorder.id, { method: 'PATCH', body: JSON.stringify(body) })
.then(async () => {
// If enabled, bounce the standby sidecar so the new env is applied.
if (recorder.enabled) {
await window.ZAMPP_API.fetch('/recorders/' + recorder.id + '/disable', { method: 'POST' }).catch(() => {});
await window.ZAMPP_API.fetch('/recorders/' + recorder.id + '/enable', { method: 'POST' }).catch(() => {});
}
setSaving(false);
onSaved();
})
.catch(e => { setSaving(false); setErr(e.message || 'Save failed'); });
};
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" style={{ width: 460 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div>
<div style={{ fontSize: 15, fontWeight: 600 }}>Configure recorder</div>
<div className="mono" style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 2 }}>
{recorder.hwName}{recorder.capturePort ? ' · ' + recorder.capturePort : ''}
</div>
</div>
<button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
</div>
<div className="modal-body">
{isRec && (
<div style={{ marginBottom: 12, padding: 10, background: 'var(--danger-soft, rgba(255,80,80,0.1))', borderRadius: 6, fontSize: 12, color: 'var(--danger)' }}>
Recorder is recording stop it before changing config.
</div>
)}
<div className="field">
<label className="field-label">Label (friendly name)</label>
<input className="field-input" value={label} disabled={isRec}
onChange={e => setLabel(e.target.value)} maxLength={60}
placeholder={recorder.hwName} />
<div className="mono" style={{ fontSize: 10.5, color: 'var(--text-3)', marginTop: 4 }}>
Blank = show hardware name ({recorder.hwName})
</div>
</div>
{/* Recording mode — clean segmented control instead of a tickbox. */}
<div className="field">
<label className="field-label">Recording mode</label>
<div className="rec-mode-seg" role="tablist">
<button type="button" role="tab"
className={'rec-mode-opt' + (!growing ? ' active' : '')}
disabled={isRec} onClick={() => setGrowing(false)}>
<Icon name="video" size={14} />
<div className="rec-mode-txt">
<span className="rec-mode-name">Standard</span>
<span className="rec-mode-desc">GPU master library</span>
</div>
</button>
<button type="button" role="tab"
className={'rec-mode-opt' + (growing ? ' active' : '')}
disabled={isRec} onClick={() => setGrowing(true)}>
<Icon name="edit" size={14} />
<div className="rec-mode-txt">
<span className="rec-mode-name">Growing</span>
<span className="rec-mode-desc">Edit while recording</span>
</div>
</button>
</div>
<div className="rec-mode-hint">
{growing
? 'Writes a growing XDCAM HD422 MXF (OP1a) to the SMB share so editors can cut the clip live in Premiere.'
: 'Encodes a GPU master (HEVC/H.264) streamed straight to the library on stop.'}
</div>
</div>
{/* Standard mode: GPU codec + bitrate. Growing mode: bitrate only
(codec is fixed to XDCAM HD422 MXF, but the target bitrate of the
growing essence is still operator-tunable). */}
{!growing ? (
<div className="rec-cfg-grid">
<div className="field">
<label className="field-label">Video codec</label>
<select className="field-input" value={codec}
onChange={e => setCodec(e.target.value)} disabled={isRec}
style={{ appearance: 'auto' }}>
<option value="hevc_nvenc">HEVC (NVENC, GPU)</option>
<option value="h264_nvenc">H.264 (NVENC, GPU)</option>
</select>
</div>
{showBitrate && (
<div className="field">
<label className="field-label">Bitrate (Mbps)</label>
<input className="field-input" type="number" min="1" max="400" step="1"
value={bitrate} disabled={isRec}
onChange={e => setBitrate(e.target.value)} />
</div>
)}
</div>
) : (
<div className="field">
<label className="field-label">XDCAM HD422 bitrate (Mbps)</label>
<input className="field-input" type="number" min="1" max="400" step="1"
value={bitrate} disabled={isRec}
onChange={e => setBitrate(e.target.value)} />
<div className="mono" style={{ fontSize: 10.5, color: 'var(--text-3)', marginTop: 4 }}>
Target bitrate of the growing MXF essence. Broadcast XDCAM HD422 is 50 Mbps; raise for higher quality.
</div>
</div>
)}
<div className="field">
<label className="field-label">Default project</label>
<select className="field-input" value={projectId} disabled={isRec}
onChange={e => setProjectId(e.target.value)} style={{ appearance: 'auto' }}>
<option value="">(none)</option>
{PROJECTS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
{err && <div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 4 }}>{err}</div>}
</div>
<div className="modal-foot">
<button className="btn ghost sm" onClick={onClose}>Cancel</button>
<button className="btn primary sm" onClick={submit} disabled={saving || isRec}>
{saving ? 'Saving…' : 'Save config'}
</button>
</div>
</div>
</div>
);
}
function _normRecorder(r) {
const cfg = r.source_config || {};
// Surface the physical capture port. Recorders are now hardware-bound: one row
// per (node, port), so device_index is authoritative. For Deltacast cfg.port,
// for Blackmagic SDI cfg.device (/dev/blackmagic/io0) slice the trailing idx.
let portIdx = r.device_index;
// Surface the capture port for SDI / Deltacast recorders so the recorder card
// can show which physical input the recorder is bound to. For Deltacast,
// cfg.port is the bridge port index (0-7). For Blackmagic SDI, cfg.device
// is something like /dev/blackmagic/dv0 we slice off the trailing index.
let capturePort = null;
if (r.source_type === 'deltacast') {
portIdx = portIdx ?? cfg.port;
capturePort = portIdx != null ? `Port ${portIdx}` : null;
} else if (r.source_type === 'sdi' || r.source_type === 'blackmagic') {
if (portIdx == null) {
const m = String(cfg.device || '').match(/(\d+)$/);
if (m) portIdx = parseInt(m[1], 10);
}
capturePort = portIdx != null ? `SDI ${portIdx}` : null;
capturePort = cfg.port != null ? `Port ${cfg.port}` : null;
} else if (r.source_type === 'sdi') {
const dev = cfg.device || '';
const m = dev.match(/(\d+)$/);
if (m) capturePort = `SDI ${m[1]}`;
}
return {
...r,
// Friendly label overlays the deterministic hardware name; fall back to name.
displayName: (r.label && r.label.trim()) || r.name,
hwName: r.name,
label: r.label || null,
enabled: r.enabled === true,
autoProvisioned: r.auto_provisioned === true,
source: r.source_type || '·',
url: cfg.url || cfg.address || cfg.srt_url || cfg.rtmp_url || r.source_type || '·',
codec: r.recording_codec || '·',
res: r.recording_resolution || '·',
framerate: r.recording_framerate || 'native',
growing: r.growing_enabled === true,
nodeId: r.node_id || null,
node: r.node_id ? r.node_id.slice(0, 8) : 'primary',
deviceIndex: portIdx ?? null,
capturePort,
previewUrl: r.preview_url || null,
elapsed: '·',
bitrate: '·',
health: 100,
audio: false,
};
}
// Resolve a node_id to a friendly hostname + online state from the cluster
// snapshot (ZAMPP_DATA.NODES, refreshed by the admin/cluster polls). Recorders
// group under their physical node; an offline node greys its whole group.
function _nodeMeta(nodeId) {
const nodes = window.ZAMPP_DATA?.NODES || [];
const n = nodes.find(x => x.id === nodeId || x.dbId === nodeId);
if (!n) return { hostname: nodeId ? nodeId.slice(0, 8) : 'unassigned', online: false };
const lastSeen = n.last_seen_at || n.last_seen;
const online = (n.status === 'online') ||
(lastSeen ? (Date.now() - new Date(lastSeen).getTime() < 90000) : false);
return { hostname: n.hostname || (nodeId ? nodeId.slice(0, 8) : 'node'), online };
}
function Recorders({ navigate, onNew }) {
const [recorders, setRecorders] = React.useState(window.ZAMPP_DATA?.RECORDERS || []);
// Per-recorder config editor (codec / growing / label). Null = closed.
const [configRecorder, setConfigRecorder] = React.useState(null);
// Bump when the cluster snapshot updates so the node-grouping re-derives
// online/offline state without waiting for the recorder list to change.
const [nodesTick, setNodesTick] = React.useState(0);
const refresh = React.useCallback(() => {
window.ZAMPP_API.fetch('/recorders')
@ -842,24 +648,12 @@ function Recorders({ navigate, onNew }) {
if (err && err.message && err.message.includes('Unauthenticated')) return;
window.DF_LOG.warn('[recorders] poll error:', err?.message);
});
// ALSO refresh the cluster-node snapshot the recorder page groups by node
// and shows each node's online/offline state via ZAMPP_DATA.NODES. Without
// this the snapshot goes stale while idling here (nodes wrongly show offline
// even though they're heartbeating). Best-effort; failure leaves last-known.
window.ZAMPP_API.fetch('/cluster')
.then(nodes => {
if (Array.isArray(nodes)) {
window.ZAMPP_DATA.NODES = nodes;
setNodesTick(t => t + 1);
}
})
.catch(() => {});
}, []);
React.useEffect(() => {
refresh();
const id = setInterval(refresh, 10000);
// Any screen that enables/disables/records a recorder dispatches
// Any screen that creates/starts/stops/deletes a recorder dispatches
// df:recorders-changed; refresh immediately instead of waiting for the tick.
const onChange = () => refresh();
window.addEventListener('df:recorders-changed', onChange);
@ -871,35 +665,12 @@ function Recorders({ navigate, onNew }) {
const liveCount = recorders.filter(r => r.status === 'recording').length;
const errCount = recorders.filter(r => r.status === 'error').length;
const enabledCount = recorders.filter(r => r.enabled).length;
// Group recorders by physical node. Recorders are hardware: one row per
// (node, port). Each group is sorted by capture-port index for a stable,
// physical layout. Network/legacy recorders (no node) fall into 'unassigned'.
const groups = React.useMemo(() => {
const byNode = new Map();
for (const r of recorders) {
const key = r.nodeId || '__unassigned__';
if (!byNode.has(key)) byNode.set(key, []);
byNode.get(key).push(r);
}
const out = [];
for (const [nodeId, list] of byNode) {
list.sort((a, b) => (a.deviceIndex ?? 999) - (b.deviceIndex ?? 999));
const meta = nodeId === '__unassigned__'
? { hostname: 'Network / unassigned', online: true }
: _nodeMeta(nodeId);
out.push({ nodeId, meta, list });
}
out.sort((a, b) => a.meta.hostname.localeCompare(b.meta.hostname));
return out;
}, [recorders, nodesTick]);
return (
<div className="page">
<div className="page-header">
<h1>Recorders</h1>
<span className="subtitle">Physical capture ports one per SDI / Deltacast input</span>
<span className="subtitle">Live ingest from SRT, RTMP, and SDI sources</span>
<div className="spacer" />
{(liveCount > 0 || errCount > 0) && (
<div className="status-pip">
@ -907,55 +678,26 @@ function Recorders({ navigate, onNew }) {
<span>{liveCount > 0 ? liveCount + ' recording' : ''}{errCount > 0 ? (liveCount > 0 ? ' · ' : '') + errCount + ' error' : ''}</span>
</div>
)}
<span className="badge neutral" title="Enabled recorders have a live standby sidecar">{enabledCount} enabled</span>
<button className="btn ghost sm" onClick={refresh}><Icon name="refresh" />Refresh</button>
<button className="btn primary" onClick={onNew}><Icon name="plus" />New recorder</button>
</div>
<div className="page-body">
{recorders.length === 0 ? (
<div className="recorder-empty-state">
<Icon name="server" size={28} />
<div className="recorder-empty-title">No capture hardware discovered yet</div>
<div className="recorder-empty-sub">
Recorders are auto-detected from each node's SDI / Deltacast ports as it heartbeats.
</div>
<div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>
No recorders configured.
<div style={{ marginTop: 12 }}><button className="btn primary" onClick={onNew}><Icon name="plus" />Add recorder</button></div>
</div>
) : (
groups.map(g => (
<div key={g.nodeId} className={'recorder-rack' + (g.meta.online ? '' : ' is-offline')}>
<div className="recorder-rack-head">
<span className="recorder-rack-icon"><Icon name="server" size={15} /></span>
<div className="recorder-rack-id">
<span className="recorder-rack-host">{g.meta.hostname}</span>
<span className={'recorder-rack-state ' + (g.meta.online ? 'online' : 'offline')}>
<span className="recorder-rack-dot" />
{g.meta.online ? 'online' : 'offline'}
</span>
</div>
<div className="spacer" />
<span className="recorder-rack-ports mono">{g.list.length} {g.list.length === 1 ? 'port' : 'ports'}</span>
</div>
<div className="recorders-list">
{g.list.map(r => (
<RecorderRow key={r.id} recorder={r} nodeOnline={g.meta.online}
onRefresh={refresh} onConfigure={() => setConfigRecorder(r)} />
))}
</div>
</div>
))
<div className="recorders-list">
{recorders.map(r => <RecorderRow key={r.id} recorder={r} onRefresh={refresh} />)}
</div>
)}
</div>
{configRecorder && (
<RecorderConfigModal
recorder={configRecorder}
onClose={() => setConfigRecorder(null)}
onSaved={() => { setConfigRecorder(null); refresh(); window.dispatchEvent(new CustomEvent('df:recorders-changed')); }}
/>
)}
</div>
);
}
function RecorderRow({ recorder: initialRecorder, onRefresh, onConfigure, nodeOnline }) {
function RecorderRow({ recorder: initialRecorder, onRefresh }) {
const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
const [recorder, setRecorder] = React.useState(initialRecorder);
const [pending, setPending] = React.useState(false);
@ -987,19 +729,19 @@ function RecorderRow({ recorder: initialRecorder, onRefresh, onConfigure, nodeOn
return () => clearInterval(id);
}, [isRec, recorder.id]);
// Tick elapsed every second while recording. Seed ONLY from the capture
// sidecar's session duration (liveStatus.duration) never from
// recorder.started_at, which is the standby CONTAINER's boot time (hours old)
// and made standby/just-started rows show bogus 1hr+ elapsed. Until the first
// /status poll lands we show 0 rather than guessing from a stale field.
// Tick elapsed every second while recording. Seed from liveStatus.duration
// (authoritative from the capture container) when available; fall back to
// wall-clock diff from recorder.started_at so the counter never freezes.
const [elapsedSecs, setElapsedSecs] = React.useState(0);
React.useEffect(() => {
if (!isRec) { setElapsedSecs(0); return; }
const base = (liveStatus && liveStatus.recording && liveStatus.duration != null)
? liveStatus.duration
: 0;
const base = () => {
if (liveStatus && liveStatus.duration != null) return liveStatus.duration;
if (recorder.started_at) return Math.floor((Date.now() - new Date(recorder.started_at).getTime()) / 1000);
return 0;
};
// Snap to latest authoritative value immediately, then tick from there.
const anchor = { at: Date.now(), secs: base };
const anchor = { at: Date.now(), secs: base() };
setElapsedSecs(anchor.secs);
const id = setInterval(() => {
setElapsedSecs(anchor.secs + Math.floor((Date.now() - anchor.at) / 1000));
@ -1007,7 +749,7 @@ function RecorderRow({ recorder: initialRecorder, onRefresh, onConfigure, nodeOn
return () => clearInterval(id);
// Re-anchor whenever liveStatus.duration arrives from the poll.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isRec, liveStatus && liveStatus.recording, liveStatus && liveStatus.duration]);
}, [isRec, liveStatus && liveStatus.duration, recorder.started_at]);
const displayElapsed = React.useMemo(() => {
if (!isRec) return '·';
@ -1017,16 +759,20 @@ function RecorderRow({ recorder: initialRecorder, onRefresh, onConfigure, nodeOn
String(d % 60).padStart(2, '0');
}, [isRec, elapsedSecs]);
// Signal is only meaningful while recording. A standby recorder isn't
// "stopped" (that red state falsely implied lost signal on idle ports) it's
// simply idle, so show a neutral dot. Only trust liveStatus.signal when the
// sidecar reports it's actually recording.
const displaySignal = (isRec && liveStatus && liveStatus.recording)
? (liveStatus.signal || 'connecting…')
: (isRec ? 'connecting…' : 'idle');
// Show live fps when recording and signal is healthy; fall back to configured value.
const displayFramerate = React.useMemo(() => {
if (isRec && liveStatus && liveStatus.currentFps != null && liveStatus.currentFps > 0) {
return Number(liveStatus.currentFps).toFixed(2) + ' fps';
}
return recorder.framerate || 'native';
}, [isRec, liveStatus, recorder.framerate]);
const displaySignal = liveStatus
? (liveStatus.signal || '·')
: (isRec ? 'connecting…' : '·');
const signalColor = displaySignal === 'receiving' ? 'var(--success)'
: (displaySignal === 'lost' || displaySignal === 'error') ? 'var(--danger)'
: displaySignal === 'stopped' ? 'var(--danger)'
: 'var(--text-3)';
const toggle = () => {
@ -1059,60 +805,44 @@ function RecorderRow({ recorder: initialRecorder, onRefresh, onConfigure, nodeOn
.catch(e => { setPending(false); setErr(e.message || 'Failed'); setRecorder(initialRecorder); });
};
const isEnabled = recorder.enabled === true;
const offline = nodeOnline === false;
// Enable = bring up the persistent standby sidecar (ready to record).
// Disable = tear it down, freeing the capture port. Recorders are NEVER
// deleted they're physical ports. Disable is the teardown action.
const setEnabled = (next) => {
if (pending) return;
setPending(true); setErr(null);
const ep = next ? 'enable' : 'disable';
window.ZAMPP_API.fetch('/recorders/' + recorder.id + '/' + ep, { method: 'POST' })
const handleDelete = async () => {
if (!(await confirm({ title: 'Delete recorder?', message: 'Delete recorder "' + recorder.name + '"?\nThis will stop any active recording and cannot be undone.' }))) return;
window.ZAMPP_API.fetch('/recorders/' + recorder.id, { method: 'DELETE' })
.then(() => {
setPending(false);
onRefresh();
window.dispatchEvent(new CustomEvent('df:recorders-changed'));
window.dispatchEvent(new CustomEvent('df:assets-changed'));
})
.catch(e => { setPending(false); setErr(e.message || (ep + ' failed')); });
.catch(e => setErr(e.message || 'Delete failed'));
};
return (
<div className={'recorder-row ' + recorder.status + (isEnabled ? (isRec ? '' : ' is-armed') : ' is-disabled')}>
<div className={'recorder-row ' + recorder.status}>
{confirmModal}
<div className="recorder-preview">
{isRec && recorder.live_asset_id
? <HlsPreview assetId={recorder.live_asset_id} recorderId={recorder.id} />
: isRec
? <LiveStrip seed={recorder.id.length * 3} count={6} />
: <div className="recorder-empty"><Icon name={recorder.status === 'error' ? 'alert' : (isEnabled ? 'video' : 'power')} size={20} style={{ opacity: 0.4 }} /></div>}
: <div className="recorder-empty"><Icon name={recorder.status === 'error' ? 'alert' : 'video'} size={20} style={{ opacity: 0.4 }} /></div>}
</div>
<div className="recorder-info">
<div className="recorder-titleline">
<span className="recorder-name">{recorder.displayName}</span>
{recorder.label && (
<span className="recorder-hw mono" title="Hardware name">{recorder.hwName}</span>
)}
</div>
<div className="recorder-badges">
{isRec
? <span className="badge live"><StatusDot status="recording" /> RECORDING</span>
: isEnabled
? <span className="badge success">ENABLED</span>
: <span className="badge neutral">DISABLED</span>}
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontWeight: 600, fontSize: 13 }}>{recorder.name}</span>
<span className={'badge ' + badgeForStatus(recorder.status)}>
<StatusDot status={recorder.status} /> {recorder.status.toUpperCase()}
</span>
<span className="badge outline">{recorder.source}</span>
{recorder.capturePort && (
<span className="badge recorder-port-chip" title="Capture port">
<span className="badge outline" title="Capture port" style={{ background: 'rgba(74,158,255,0.12)', borderColor: 'rgba(74,158,255,0.4)', color: 'var(--accent)' }}>
<Icon name="signal" size={10} style={{ marginRight: 4, verticalAlign: -1 }} />{recorder.capturePort}
</span>
)}
{recorder.growing && <span className="badge accent" title="Growing-file (edit-while-record)">GROWING</span>}
</div>
<div className="recorder-sub mono">{recorder.url}</div>
<div className="recorder-sub">
<span>{recorder.codec}</span><span className="recorder-sub-sep">·</span>
<span>{recorder.res}</span><span className="recorder-sub-sep">·</span>
<span>{recorder.framerate}</span>
<span>{recorder.codec}</span><span>·</span>
<span>{recorder.res}</span>
</div>
{err && <div style={{ marginTop: 4, fontSize: 11, color: 'var(--danger)' }}>{err}</div>}
{liveStatus?.lastError && isRec && (
@ -1131,68 +861,58 @@ function RecorderRow({ recorder: initialRecorder, onRefresh, onConfigure, nodeOn
{displaySignal}
</div>
</div>
<div className="recorder-stat">
<div className="stat-label">Framerate</div>
<div className="stat-val mono">{displayFramerate}</div>
</div>
</div>
<div className="recorder-actions">
{/* Record controls only when ENABLED (standby sidecar up) and not recording. */}
{isEnabled && !isRec && (
<div className="recorder-take">
{!isRec && (
<>
{PROJECTS.length > 0 && (
<select
className="field-input recorder-take-project"
className="field-input"
value={takeProjectId}
onChange={e => setTakeProjectId(e.target.value)}
disabled={pending}
style={{ appearance: 'auto' }}
style={{ width: 160, padding: '5px 8px', fontSize: 12, appearance: 'auto' }}
title="Project clips go to"
>
{PROJECTS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
)}
<input
className="field-input recorder-take-clip"
className="field-input"
value={clipName}
onChange={e => setClipName(e.target.value)}
placeholder="Clip name (optional)"
disabled={pending}
maxLength={80}
onKeyDown={e => { if (e.key === 'Enter') toggle(); }}
style={{ width: 160, padding: '5px 8px', fontSize: 12 }}
title="Letters, numbers, spaces, dot, dash, underscore. Blank = auto timestamp."
/>
</div>
</>
)}
<div className="recorder-controls">
{isRec ? (
<button className="btn danger sm recorder-rec-btn" onClick={toggle} disabled={pending}>
{isRec
? <button className="btn danger sm" onClick={toggle} disabled={pending}>
{pending ? '…' : <><span className="rec-dot" />Stop</>}
</button>
) : isEnabled ? (
<button className="btn subtle sm recorder-rec-btn" onClick={toggle} disabled={pending}>
: <button className="btn subtle sm" onClick={toggle} disabled={pending}>
{pending ? '…' : <><span className="rec-dot" style={{ background: 'var(--live)' }} />Record</>}
</button>
) : null}
{/* Enable / Disable — the lifecycle control. Hidden while recording. */}
{!isRec && (
isEnabled
? <button className="btn ghost sm recorder-life-btn" onClick={() => setEnabled(false)} disabled={pending} title="Stop the standby sidecar and free the capture port">
<Icon name="power" size={12} />Disable
</button>
: <button className="btn primary sm recorder-life-btn is-enable" onClick={() => setEnabled(true)} disabled={pending || offline}
title={offline ? 'Node offline — cannot enable' : 'Start the standby sidecar so this port is ready to record'}>
<Icon name="power" size={12} />Enable
</button>
)}
<button className="icon-btn recorder-cfg-btn" onClick={onConfigure} title="Configure recorder" aria-label="Configure recorder">
<Icon name="settings" />
</button>
</div>
</button>}
<button className="icon-btn" onClick={handleDelete} title="Delete recorder" aria-label="Delete recorder" style={{ color: 'var(--text-3)' }}>
<Icon name="x" />
</button>
</div>
</div>
);
}
function badgeForStatus(s) {
return { recording: 'live', armed: 'accent', idle: 'neutral', error: 'danger', offline: 'neutral', stopped: 'neutral' }[s] || 'neutral';
}
/* ===== Capture ===== */
function _captureSignalChip(sig) {
@ -1238,6 +958,11 @@ function CapturePortChip({ port, sigEntry }) {
<span style={{ fontSize: 9.5, fontWeight: 700, letterSpacing: '0.05em', color }}>
{label}
</span>
{sigEntry && sigEntry.currentFps != null && (
<span style={{ fontSize: 9.5, color: 'var(--text-4)', fontFamily: 'var(--font-mono)' }}>
{Number(sigEntry.currentFps).toFixed(1)} fps
</span>
)}
</div>
</div>
</div>

View file

@ -1,9 +1,6 @@
// screens-library.jsx
function buildBinTree(f){const m={};f.forEach(b=>{m[b.id]={...b,children:[]};});const r=[];f.forEach(b=>{if(b.parent_id&&m[b.parent_id])m[b.parent_id].children.push(m[b.id]);else r.push(m[b.id]);});return r;}
function collectDescendantIds(id,f){const s=new Set([id]);let c=true;while(c){c=false;f.forEach(b=>{if(b.parent_id&&s.has(b.parent_id)&&!s.has(b.id)){s.add(b.id);c=true;}});}return s;}
function Library({ navigate, onOpenAsset, openProject, onClearProject, onOpenProject }) {
function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
const [bins, setBins] = React.useState(window.ZAMPP_DATA?.BINS || []);
const BINS = bins; // legacy local name; keep so the rest of the function reads unchanged
@ -17,8 +14,6 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject, onOpenPro
var normalized = (list || []).map(function(b) { return { ...b, count: b.asset_count != null ? b.asset_count : (b.count || 0), icon: b.type || 'grid' }; });
if (!openProject) window.ZAMPP_DATA.BINS = normalized;
setBins(normalized);
// Auto-expand all bins so nested children are always visible
setExpandedBins(function(prev) { var s = new Set(prev); normalized.forEach(function(b){ s.add(b.id); }); return s; });
})
.catch(function() {});
}, [openProject]);
@ -30,44 +25,21 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject, onOpenPro
return function() { window.removeEventListener('df:bins-changed', onBinsChanged); };
}, [refreshBins]);
const [creatingChildOf, setCreatingChildOf] = React.useState(null);
// Start with all bins expanded so nested children are visible immediately
const [expandedBins, setExpandedBins] = React.useState(() => new Set((window.ZAMPP_DATA?.BINS||[]).map(b=>b.id)));
const createBin = () => {
if (!openProject) {
if (window.toast) window.toast.error('Open a project first (Projects → click a project), then create a bin inside it.');
else window.alert('Open a project first (Projects → click a project), then create a bin inside it.');
return;
}
setCreatingChildOf(null); setNewBinName(''); setCreatingBin(true);
};
const createSubBin = (parentId) => {
if (!openProject) return;
setCreatingChildOf(parentId); setNewBinName(''); setCreatingBin(true);
};
const toggleBinExpanded = (binId) => {
setExpandedBins(prev => { const s = new Set(prev); s.has(binId) ? s.delete(binId) : s.add(binId); return s; });
if (!openProject) { window.alert('Open a project first (Projects → click a project), then create a bin inside it.'); return; }
setNewBinName(''); setCreatingBin(true);
};
const submitBin = (name) => {
if (!name || !name.trim()) { setCreatingBin(false); setCreatingChildOf(null); return; }
if (!name || !name.trim()) { setCreatingBin(false); return; }
setCreatingBin(false);
const parentId = creatingChildOf;
setCreatingChildOf(null);
window.ZAMPP_API.fetch('/bins', {
method: 'POST',
body: JSON.stringify({ project_id: openProject.id, name: name.trim(), parent_id: parentId || null }),
body: JSON.stringify({ project_id: openProject.id, name: name.trim() }),
})
.then(() => window.ZAMPP_API.fetch('/bins?project_id=' + openProject.id))
.then(list => {
const n = (list||[]).map(b=>({...b,count:b.asset_count||0,icon:b.type||'grid'}));
setBins(n);
if (parentId) setExpandedBins(prev => { const s=new Set(prev); s.add(parentId); return s; });
})
.catch(e => {
if (window.toast) window.toast.error('Could not create bin: ' + e.message);
else window.alert('Could not create bin: ' + e.message);
});
.then(list => setBins((list || []).map(b => ({ ...b, count: b.asset_count || 0, icon: b.type || 'grid' }))))
.catch(e => window.alert('Could not create bin: ' + e.message));
};
const [view, setView] = React.useState('grid');
const [filter, setFilter] = React.useState('all'); // 'all', 'ready', 'processing', 'live', 'error', 'recent'
@ -313,13 +285,12 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject, onOpenPro
assets = assets.filter(function(a) { return a.status === filter; });
}
if (search) assets = assets.filter(function(a) { return a.name.toLowerCase().includes(search.toLowerCase()); });
if (selectedBinId) { var sids=collectDescendantIds(selectedBinId,BINS); assets=assets.filter(function(a){return sids.has(a.bin_id);}); }
if (selectedBinId) assets = assets.filter(function(a) { return a.bin_id === selectedBinId; });
const activeBin = selectedBinId ? BINS.find(b => b.id === selectedBinId) : null;
const displayTitle = activeBin
? (openProject ? openProject.name + ' · ' : '') + activeBin.name
: (openProject ? openProject.name : 'All Assets');
const binTree=React.useMemo(function(){return buildBinTree(BINS);},[BINS]);
const errorCount = ALL_ASSETS.filter(function(a) { return a.status === 'error'; }).length;
const recentCount = ALL_ASSETS.filter(function(a) { return (Date.now() - new Date(a.created_at)) < 86400000; }).length;
@ -338,7 +309,7 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject, onOpenPro
{PROJECTS.slice(0, 8).map(function(p) {
return (
<div key={p.id} className={`rail-item ${openProject && openProject.id === p.id ? 'active' : ''}`} style={{ cursor: 'pointer' }}
onClick={function() { if (onOpenProject) onOpenProject(p); }}
onClick={function() { navigate('projects'); }}
onContextMenu={function(e) { openProjectCtx(p, e); }}>
<span className="rail-color-dot" style={{ background: p.color }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
@ -358,30 +329,45 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject, onOpenPro
</button>
</div>
<div className="rail-list">
{creatingBin && (
<div style={{ padding: '4px 6px', display: 'flex', gap: 4, alignItems: 'center' }}>
<input
className="field-input"
autoFocus
value={newBinName}
onChange={function(e) { setNewBinName(e.target.value); }}
onKeyDown={function(e) {
if (e.key === 'Enter') submitBin(newBinName);
if (e.key === 'Escape') { setCreatingBin(false); }
}}
onBlur={function() { submitBin(newBinName); }}
placeholder="Bin name"
style={{ fontSize: 12, height: 26, padding: '0 6px', flex: 1 }}
/>
</div>
)}
{!creatingBin && BINS.length === 0 ? (
<div style={{ fontSize: 11, color: 'var(--text-3)', padding: '6px 8px', fontStyle: 'italic' }}>
{openProject ? 'No bins yet: click + to create one.' : 'Open a project to manage bins.'}
</div>
) : (
<BinTreeNodes nodes={binTree} depth={0}
selectedBinId={selectedBinId} setSelectedBinId={setSelectedBinId}
draggingAssetId={draggingAssetId} dragOverBinId={dragOverBinId}
onBinDragOver={onBinDragOver} onBinDrop={onBinDrop} onBinDragLeave={onBinDragLeave}
expandedBins={expandedBins} toggleBinExpanded={toggleBinExpanded}
creatingBin={creatingBin} creatingChildOf={creatingChildOf}
newBinName={newBinName} setNewBinName={setNewBinName}
submitBin={submitBin} setCreatingBin={setCreatingBin} setCreatingChildOf={setCreatingChildOf}
createSubBin={createSubBin} openProject={openProject} />
)}
{creatingBin && creatingChildOf === null && (
<div style={{ padding: '4px 6px', display: 'flex', gap: 4, alignItems: 'center' }}>
<input className="field-input" autoFocus value={newBinName}
onChange={function(e) { setNewBinName(e.target.value); }}
onKeyDown={function(e) { if (e.key==='Enter') submitBin(newBinName); if (e.key==='Escape') { setCreatingBin(false); } }}
onBlur={function() { submitBin(newBinName); }}
placeholder="Bin name" style={{ fontSize: 12, height: 26, padding: '0 6px', flex: 1 }} />
</div>
)}
) : BINS.map(function(b) {
const isActive = selectedBinId === b.id;
const isDragTarget = draggingAssetId !== null && dragOverBinId === b.id;
return (
<div key={b.id}
className={'rail-item' + (isActive ? ' active' : '') + (isDragTarget ? ' droppable' : '')}
onClick={function() { setSelectedBinId(isActive ? null : b.id); }}
onDragOver={function(e) { onBinDragOver(b.id, e); }}
onDrop={function(e) { onBinDrop(b.id, e); }}
onDragLeave={onBinDragLeave}
style={{ cursor: 'pointer' }}
title={isActive ? 'Click to clear bin filter' : 'Filter to this bin'}>
<Icon name={binIcon(b.icon)} size={13} className="rail-icon" />
<span>{b.name}</span>
<span className="rail-count">{b.count}</span>
</div>
);
})}
</div>
</div>
<div>
@ -610,8 +596,7 @@ function AssetContextMenu({ asset, x, y, bins, onClose, onChanged, onOpen, onRen
window.ZAMPP_API.fetch('/assets/' + asset.id + '/promote', { method: 'POST' })
.then(function() {
if (onChanged) onChanged();
if (window.toast) window.toast.success('Promotion job queued. The file is being uploaded to S3 in the background.');
else window.alert('Promotion job queued. The file is being uploaded to S3 in the background.');
window.alert('Promotion job queued. The file is being uploaded to S3 in the background.');
})
.catch(function(e) { alert('Promotion failed: ' + e.message); });
};
@ -888,6 +873,5 @@ function DownloadWarningModal({ asset, onClose, onConfirm }) {
);
}
function BinTreeNodes(p){var nodes=p.nodes,depth=p.depth,selId=p.selectedBinId,setSel=p.setSelectedBinId;var drag=p.draggingAssetId,over=p.dragOverBinId;var onOver=p.onBinDragOver,onDrop=p.onBinDrop,onLeave=p.onBinDragLeave;var expanded=p.expandedBins,toggle=p.toggleBinExpanded;var creat=p.creatingBin,parentOf=p.creatingChildOf;var newName=p.newBinName,setName=p.setNewBinName;var submit=p.submitBin,setCreat=p.setCreatingBin,setParent=p.setCreatingChildOf;var createSub=p.createSubBin,proj=p.openProject;if(!nodes||!nodes.length)return null;return nodes.map(function(b){var act=selId===b.id,idt=drag!==null&&over===b.id,hasC=b.children&&b.children.length>0,exp=expanded.has(b.id),isCk=creat&&parentOf===b.id,ind=depth*14;return React.createElement(React.Fragment,{key:b.id},React.createElement("div",{className:"rail-item"+(act?" active":"")+(idt?" droppable":""),onClick:function(){setSel(act?null:b.id);},onDragOver:function(e){onOver(b.id,e);},onDrop:function(e){onDrop(b.id,e);},onDragLeave:onLeave,style:{cursor:"pointer",paddingLeft:8+ind}},React.createElement("span",{style:{display:"inline-flex",alignItems:"center",width:16,height:16,flexShrink:0,marginRight:2,color:hasC?"var(--text-3)":"transparent",transition:"transform 120ms",transform:hasC&&exp?"rotate(90deg)":"none"},onClick:function(e){if(!hasC)return;e.stopPropagation();toggle(b.id);}},hasC&&React.createElement("svg",{width:8,height:8,viewBox:"0 0 8 8",fill:"currentColor"},React.createElement("path",{d:"M2 1l4 3-4 3V1z"}))),React.createElement(Icon,{name:binIcon(b.icon),size:13,className:"rail-icon",style:{marginRight:4}}),React.createElement("span",{style:{flex:1,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}},b.name),React.createElement("span",{className:"rail-count"},b.count),proj&&React.createElement("button",{className:"icon-btn bin-add-child-btn","aria-label":"Create sub-bin",onClick:function(e){e.stopPropagation();createSub(b.id);},style:{opacity:0,transition:"opacity 100ms",marginLeft:2,flexShrink:0},onFocus:function(e){e.currentTarget.style.opacity="1";},onBlur:function(e){e.currentTarget.style.opacity="";}},React.createElement(Icon,{name:"plus",size:10}))),isCk&&React.createElement("div",{style:{paddingLeft:8+ind+14,paddingRight:6,paddingTop:4,paddingBottom:4,display:"flex",gap:4,alignItems:"center"}},React.createElement("input",{className:"field-input",autoFocus:true,value:newName,onChange:function(e){setName(e.target.value);},onKeyDown:function(e){if(e.key==="Enter")submit(newName);if(e.key==="Escape"){setCreat(false);setParent(null);}},onBlur:function(){submit(newName);},placeholder:"Sub-bin name",style:{fontSize:12,height:26,padding:"0 6px",flex:1}})),hasC&&exp&&React.createElement(BinTreeNodes,Object.assign({},p,{nodes:b.children,depth:depth+1})));});}
window.Library = Library;
window.AssetCard = AssetCard;

View file

@ -38,7 +38,6 @@ const NAV_SECTIONS = [
{ id: "tokens", label: "Tokens", icon: "token" },
{ id: "billing", label: "Billing", icon: "dollar" },
{ id: "containers", label: "Containers", icon: "container" },
{ id: "logs", label: "Logs", icon: "file" },
{ id: "cluster", label: "Cluster", icon: "cluster" },
{ id: "settings", label: "Settings", icon: "settings" },
],

View file

@ -292,38 +292,37 @@
text-align: center;
margin-top: 8px;
}
/* Logo wrapper — large hero with orange pulse halo. */
/* Logo wrapper holds the animated pulse halo behind the image. */
.launcher-logo-wrap {
position: relative;
display: inline-grid;
place-items: center;
width: 120px;
height: 120px;
width: 52px;
height: 52px;
flex-shrink: 0;
}
.launcher-logo-pulse {
position: absolute;
width: 180px;
height: 180px;
width: 80px;
height: 80px;
border-radius: 50%;
background: radial-gradient(circle, rgba(232, 130, 28, 0.35) 0%, rgba(232, 130, 28, 0.08) 55%, transparent 70%);
animation: logoPulse 2.8s ease-in-out infinite;
background: radial-gradient(circle, var(--accent-soft) 0%, transparent 70%);
animation: logoPulse 3s ease-in-out infinite;
z-index: 0;
}
@keyframes logoPulse {
0%, 100% { transform: scale(1); opacity: 0.7; }
50% { transform: scale(1.18); opacity: 1; }
0%, 100% { transform: scale(1); opacity: 0.6; }
50% { transform: scale(1.15); opacity: 1; }
}
.launcher-logo {
position: relative;
z-index: 1;
width: 110px;
height: 110px;
width: 52px;
height: 52px;
object-fit: contain;
filter:
brightness(0) invert(1)
drop-shadow(0 0 14px rgba(232, 130, 28, 0.6))
drop-shadow(0 0 4px rgba(255, 180, 60, 0.4));
drop-shadow(0 0 8px rgba(232, 130, 28, 0.35));
animation: launcherLogoIn 400ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
}
@keyframes launcherLogoIn {
@ -331,7 +330,7 @@
to { opacity: 1; transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.launcher-logo-pulse { animation: none; opacity: 0.6; }
.launcher-logo-pulse { animation: none; opacity: 0.5; }
.launcher-logo { animation: none; }
}
@ -882,290 +881,3 @@ button.btn.primary:active {
margin-bottom: 10px;
font-family: var(--font-mono);
}
/* ============================================================
Recorder menu production redesign.
Recorders are PHYSICAL capture ports grouped under their
node (a hardware "rack"). Lifecycle: DISABLED (dormant)
ENABLED/armed (live standby) RECORDING (on air). Built on
the existing design tokens, badges and .btn classes no new
design language, just elevated rhythm and signal.
============================================================ */
/* ---- Rack (node group) ---- */
.recorder-rack {
background: linear-gradient(180deg, var(--bg-1), var(--bg-0));
border: 1px solid var(--border);
border-radius: var(--r-lg);
padding: 6px 6px 8px;
margin-bottom: 18px;
box-shadow: var(--shadow-card);
transition: opacity 120ms ease, border-color 120ms ease;
}
.recorder-rack.is-offline {
opacity: 0.5;
filter: saturate(0.55);
}
.recorder-rack-head {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px 9px;
margin-bottom: 4px;
border-bottom: 1px solid var(--border);
}
.recorder-rack-icon {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: var(--r-sm);
background: var(--bg-2);
border: 1px solid var(--border);
color: var(--text-3);
flex-shrink: 0;
}
.recorder-rack-id {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
}
.recorder-rack-host {
font-size: 14px;
font-weight: 650;
letter-spacing: 0.01em;
color: var(--text-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.recorder-rack-state {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
font-family: var(--font-mono);
flex-shrink: 0;
}
.recorder-rack-state.online { color: var(--success); }
.recorder-rack-state.offline { color: var(--text-4); }
.recorder-rack-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
.recorder-rack-state.online .recorder-rack-dot {
box-shadow: 0 0 0 3px var(--success-soft);
}
.recorder-rack-ports {
font-size: 11px;
color: var(--text-4);
letter-spacing: 0.02em;
flex-shrink: 0;
}
.recorders-list { gap: 8px; padding: 0 4px; }
/* ---- Row + lifecycle states ---- */
.recorder-row {
position: relative;
padding: 12px 14px 12px 16px;
transition: border-color 120ms ease, background 120ms ease, opacity 120ms ease;
}
/* The lifecycle accent rail on the left edge of every row. */
.recorder-row::before {
content: "";
position: absolute;
left: 0; top: 8px; bottom: 8px;
width: 3px;
border-radius: 0 3px 3px 0;
background: var(--border-strong);
transition: background 120ms ease, box-shadow 120ms ease;
}
/* DISABLED — dormant. Muted, recedes. Enable is the CTA. */
.recorder-row.is-disabled {
background: transparent;
border-color: var(--border);
opacity: 0.82;
}
.recorder-row.is-disabled::before { background: var(--bg-4); }
.recorder-row.is-disabled .recorder-name { color: var(--text-2); }
.recorder-row.is-disabled .recorder-preview { opacity: 0.7; }
/* ENABLED / armed — ready, live standby up. Calm but present. */
.recorder-row.is-armed {
border-color: var(--border-strong);
}
.recorder-row.is-armed::before { background: var(--success); }
/* RECORDING — on air. Hot. */
.recorder-row.recording {
border-color: rgba(255,59,48,0.4);
background:
linear-gradient(90deg, rgba(255,59,48,0.06), transparent 38%);
}
.recorder-row.recording::before {
background: var(--live);
box-shadow: 0 0 10px rgba(255,59,48,0.55);
animation: recRailPulse 1.6s ease-in-out infinite;
}
@keyframes recRailPulse {
0%, 100% { box-shadow: 0 0 8px rgba(255,59,48,0.45); }
50% { box-shadow: 0 0 16px rgba(255,59,48,0.85); }
}
.recorder-row.error::before { background: var(--danger); }
/* ---- Info column ---- */
.recorder-titleline {
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
}
.recorder-name {
font-weight: 600;
font-size: 13.5px;
color: var(--text-1);
letter-spacing: 0.01em;
}
.recorder-hw {
font-size: 10.5px;
color: var(--text-4);
}
.recorder-badges {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
margin-top: 2px;
}
/* Capture port chip the physical input identity. Reads as a
precise hardware tag, not a generic badge. */
.badge.recorder-port-chip {
background: var(--accent-soft);
border: 1px solid var(--accent-soft-2);
color: var(--accent-text);
font-weight: 650;
}
.recorder-sub {
font-size: 11.5px;
color: var(--text-3);
font-family: var(--font-mono);
letter-spacing: 0.01em;
}
.recorder-sub-sep { color: var(--text-4); opacity: 0.7; }
/* ---- Stats ---- */
.recorder-stats { grid-template-columns: 96px 1fr; gap: 16px; }
.recorder-stat .stat-val { color: var(--text-2); font-family: var(--font-mono); }
.recorder-row.recording .recorder-stat .stat-val.mono { color: var(--text-1); }
/* ---- Actions ---- */
.recorder-actions { gap: 8px; }
.recorder-take {
display: flex;
align-items: center;
gap: 6px;
}
.recorder-take-project,
.recorder-take-clip {
height: 26px;
padding: 0 8px;
font-size: 12px;
}
.recorder-take-project { width: 140px; }
.recorder-take-clip { width: 152px; }
.recorder-controls {
display: flex;
align-items: center;
gap: 6px;
}
.recorder-rec-btn { min-width: 84px; justify-content: center; }
/* Enable is the primary lifecycle CTA on dormant ports. */
.recorder-life-btn { min-width: 90px; justify-content: center; }
.recorder-life-btn.is-enable { font-weight: 600; }
.recorder-cfg-btn { color: var(--text-3); }
.recorder-cfg-btn:hover { color: var(--text-1); }
/* ---- Empty state ---- */
.recorder-empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 64px 24px;
text-align: center;
color: var(--text-4);
background:
radial-gradient(ellipse at top, rgba(232,130,28,0.04), transparent 60%);
border: 1px dashed var(--border-strong);
border-radius: var(--r-lg);
}
.recorder-empty-title {
font-size: 14px;
font-weight: 600;
color: var(--text-2);
}
.recorder-empty-sub {
font-size: 12px;
color: var(--text-4);
max-width: 420px;
}
/* ---- Responsive: keep take controls coherent when the row stacks ---- */
@media (max-width: 1280px) {
.recorder-take { flex: 1; }
.recorder-take-project,
.recorder-take-clip { width: auto; flex: 1; min-width: 110px; }
}
/* ── Recorder config modal — recording-mode segmented control + grid ───────── */
.rec-mode-seg {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.rec-mode-opt {
display: flex;
align-items: center;
gap: 9px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 9px;
background: var(--bg-2, rgba(255,255,255,0.02));
color: var(--text-2);
cursor: pointer;
text-align: left;
transition: border-color .12s ease, background .12s ease, color .12s ease;
}
.rec-mode-opt:hover:not(:disabled) { border-color: var(--accent, #4a9eff); }
.rec-mode-opt.active {
border-color: var(--accent, #4a9eff);
background: var(--accent-soft, rgba(74,158,255,0.12));
color: var(--text-1);
}
.rec-mode-opt:disabled { opacity: .55; cursor: default; }
.rec-mode-opt .icon { flex-shrink: 0; opacity: .85; }
.rec-mode-txt { display: flex; flex-direction: column; line-height: 1.25; }
.rec-mode-name { font-size: 13px; font-weight: 600; }
.rec-mode-desc { font-size: 10.5px; color: var(--text-3); }
.rec-mode-hint {
margin-top: 8px;
font-size: 11px;
line-height: 1.5;
color: var(--text-3);
}
.rec-cfg-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.rec-cfg-grid .field:only-child { grid-column: 1 / -1; }

View file

@ -70,7 +70,7 @@
}
.source-type-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.source-type-card {

View file

@ -1437,61 +1437,3 @@
.ctx-menu button.danger:hover:not(:disabled) {
background: var(--danger-soft);
}
/* ── Logs page — cluster-wide log viewer ──────────────────────────────────── */
.logs-layout {
display: grid;
grid-template-columns: 260px 1fr;
gap: 14px;
height: calc(100vh - 160px);
min-height: 420px;
}
.logs-rail {
overflow-y: auto;
padding: 8px;
}
.logs-rail-empty { padding: 24px 12px; color: var(--text-3); font-size: 12.5px; text-align: center; }
.logs-rail-group { margin-bottom: 10px; }
.logs-rail-node {
display: flex; align-items: center; gap: 6px;
font-size: 10.5px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em;
color: var(--text-3); padding: 6px 8px 4px;
}
.logs-rail-item {
display: flex; align-items: center; gap: 8px; width: 100%;
padding: 6px 8px; border-radius: 6px; border: none; background: transparent;
color: var(--text-2); font-size: 12.5px; cursor: pointer; text-align: left;
transition: background .1s ease, color .1s ease;
}
.logs-rail-item:hover { background: var(--bg-2, rgba(255,255,255,0.03)); }
.logs-rail-item.active { background: var(--accent-soft, rgba(74,158,255,0.14)); color: var(--text-1); }
.logs-rail-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono, monospace); }
.logs-rail-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.logs-rail-dot.on { background: var(--success, #2dd4a8); }
.logs-rail-dot.off { background: var(--text-4, #555); }
.logs-view { display: flex; flex-direction: column; overflow: hidden; }
.logs-view-empty {
flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 10px; color: var(--text-3); font-size: 13px;
}
.logs-view-head {
display: flex; align-items: center; gap: 8px;
padding: 10px 12px; border-bottom: 1px solid var(--border);
}
.logs-view-title { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
.logs-view-name { font-size: 13.5px; font-weight: 600; }
.logs-view-node { font-size: 11px; color: var(--text-3); }
.logs-filter { width: 160px; padding: 4px 8px; font-size: 12px; }
.logs-follow { display: flex; align-items: center; gap: 5px; font-size: 12px; color: var(--text-2); cursor: pointer; white-space: nowrap; }
.logs-view-pre {
flex: 1; margin: 0; overflow: auto;
padding: 12px 14px; font-size: 11.5px; line-height: 1.5;
background: var(--bg-1, #0c0e12); color: var(--text-2);
white-space: pre-wrap; word-break: break-word;
}
@media (max-width: 900px) {
.logs-layout { grid-template-columns: 1fr; height: auto; }
.logs-rail { max-height: 220px; }
.logs-view { min-height: 360px; }
}

View file

@ -1066,9 +1066,6 @@
.rail-item .rail-icon { color: var(--text-3); }
.rail-item .rail-count { margin-left: auto; font-family: var(--font-mono); font-size: 10.5px; color: var(--text-3); }
.rail-color-dot { width: 8px; height: 8px; border-radius: 50%; }
/* Show sub-bin create button only on hover of the parent rail-item */
.rail-item:hover .bin-add-child-btn { opacity: 1 !important; }
.bin-add-child-btn { padding: 0 2px; height: 18px; min-width: 18px; }
.library-main {
display: flex; flex-direction: column;

View file

@ -9,7 +9,6 @@ import { trimWorker } from './workers/trimWorker.js';
import { hlsWorker } from './workers/hls.js';
import { playoutStageWorker } from './workers/playout-stage.js';
import { promotionWorker } from './workers/promotion.js';
import { startPromotionScanner } from './workers/promotion-scanner.js';
const parseRedisUrl = (url) => {
const parsed = new URL(url);
@ -99,22 +98,11 @@ const workers = [
// playout-stage = S3 → /media volume + EBU R128 loudnorm. CPU/IO-bound;
// colocate with workers that already have ffmpeg + the media mount.
want('playout-stage') && createWorker('playout-stage', playoutStageWorker, { concurrency: 1 }),
// promotion = growing-files promotion (S3 upload + DB update + queue proxy).
// Triggered manually via POST /assets/:id/promote AND automatically by the
// promotion scanner below once a pending_migration asset has been idle for
// settings.growing_promote_after_seconds.
// promotion = manual growing-files promotion (S3 upload + DB update + queue proxy)
want('promotion') && createWorker('promotion', promotionWorker, { concurrency: 1 }),
].filter(Boolean);
console.log(`WORKER_QUEUES=${_wq || '(all)'}`);
// Auto-promotion scanner — only on promotion-capable workers, and only ONE
// instance is needed cluster-wide, but the scan is idempotent (status guard +
// stable jobId) so running it on every promotion worker is safe.
let _promotionScanner = null;
if (want('promotion')) {
_promotionScanner = startPromotionScanner(redisOptions);
}
// Filmstrip queue singleton — used by thumbnail worker to enqueue filmstrip jobs
export const filmstripQueue = new Queue('filmstrip', { connection: redisOptions });
@ -141,7 +129,6 @@ process.on('SIGTERM', async () => {
proxyThumbnailQueue.close().catch(() => {}),
youtubeProxyQueue.close().catch(() => {}),
filmstripQueue.close().catch(() => {}),
_promotionScanner ? _promotionScanner.promotionQueue.close().catch(() => {}) : Promise.resolve(),
]);
console.log('All workers and queues closed');

View file

@ -1,11 +1,8 @@
import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import { NodeHttpHandler } from '@smithy/node-http-handler';
import { createReadStream, createWriteStream } from 'fs';
import { readdir } from 'fs/promises';
import { join, extname } from 'path';
import { pipeline } from 'stream/promises';
import http from 'node:http';
import https from 'node:https';
const CONTENT_TYPES = {
'.m3u8': 'application/vnd.apple.mpegurl',
@ -13,16 +10,7 @@ const CONTENT_TYPES = {
'.mp4': 'video/mp4',
};
// Keep-alive agents + a long request timeout. The proxy/conform jobs download
// full master files (hundreds of MB) and upload HLS segments; the SDK defaults
// (no keep-alive, 0/short timeouts under contention) caused master downloads to
// stall and abort, leaving assets stuck in 'processing'. Generous timeout +
// pooled sockets make these large transfers reliable.
const _httpAgent = new http.Agent({ keepAlive: true, maxSockets: 128, timeout: 600_000 });
const _httpsAgent = new https.Agent({ keepAlive: true, maxSockets: 128, timeout: 600_000 });
// Build a client. NOTE: callers must NOT destroy() this — see _sharedClient.
const buildS3Client = () => {
const createS3Client = () => {
return new S3Client({
region: process.env.S3_REGION || 'us-east-1',
endpoint: process.env.S3_ENDPOINT,
@ -31,82 +19,37 @@ const buildS3Client = () => {
secretAccessKey: process.env.S3_SECRET_KEY,
},
forcePathStyle: true,
requestHandler: new NodeHttpHandler({
httpAgent: _httpAgent,
httpsAgent: _httpsAgent,
requestTimeout: 600_000,
connectionTimeout: 15_000,
}),
});
};
// ONE shared client reused across all operations. Previously every call did
// createS3Client() then client.destroy() in finally — which tore down the
// keep-alive agent's sockets every time, so pooling never happened and each
// transfer opened brand-new connections. Under burn load (8 masters at once)
// that hammered the connection-limited RustFS backend into aborting streams.
// A single long-lived client lets the keep-alive pool actually be reused.
let _sharedClient = null;
const createS3Client = () => {
if (!_sharedClient) _sharedClient = buildS3Client();
return _sharedClient;
};
export const downloadFromS3 = async (bucket, key, localPath) => {
const client = createS3Client();
try {
const response = await client.send(
new GetObjectCommand({ Bucket: bucket, Key: key })
);
// Transient connection failures that warrant a retry. Under burn load (8 masters
// uploading + downloading at once) the connection-limited RustFS S3 backend
// aborts/hangs up mid-stream — a single failure used to error the whole proxy
// job permanently. These are NOT real "file missing" / auth errors.
const _isTransientS3 = (err) => {
const s = `${err?.name || ''} ${err?.code || ''} ${err?.message || ''}`.toLowerCase();
return /aborted|socket hang up|timeout|econnreset|epipe|econnrefused|enotfound|stream|network|503|500|slowdown|throttl/.test(s);
};
const _sleep = (ms) => new Promise(r => setTimeout(r, ms));
export const downloadFromS3 = async (bucket, key, localPath, maxAttempts = 5) => {
let lastErr = null;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const client = createS3Client();
try {
const response = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
const writeStream = createWriteStream(localPath);
await pipeline(response.Body, writeStream);
return; // success
} catch (err) {
lastErr = err;
// Clean the partial file before retrying so we don't leave a truncated master.
try { await (await import('node:fs/promises')).unlink(localPath); } catch (_) {}
if (attempt < maxAttempts && _isTransientS3(err)) {
const backoff = Math.min(8000, 400 * 2 ** (attempt - 1)); // 400,800,1600,3200ms
console.warn(`[s3] download ${key} attempt ${attempt}/${maxAttempts} failed (${err.name || err.message}); retrying in ${backoff}ms`);
await _sleep(backoff);
continue;
}
throw err;
}
const writeStream = createWriteStream(localPath);
await pipeline(response.Body, writeStream);
} finally {
client.destroy();
}
throw lastErr;
};
export const uploadToS3 = async (bucket, key, localPath, maxAttempts = 5) => {
let lastErr = null;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const client = createS3Client();
try {
const readStream = createReadStream(localPath);
await client.send(new PutObjectCommand({ Bucket: bucket, Key: key, Body: readStream }));
return; // success
} catch (err) {
lastErr = err;
if (attempt < maxAttempts && _isTransientS3(err)) {
const backoff = Math.min(8000, 400 * 2 ** (attempt - 1));
console.warn(`[s3] upload ${key} attempt ${attempt}/${maxAttempts} failed (${err.name || err.message}); retrying in ${backoff}ms`);
await _sleep(backoff);
continue;
}
throw err;
}
export const uploadToS3 = async (bucket, key, localPath) => {
const client = createS3Client();
try {
const readStream = createReadStream(localPath);
await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: readStream,
})
);
} finally {
client.destroy();
}
throw lastErr;
};
// Upload every file in `localDir` to `bucket` under `keyPrefix/`. Used for the
@ -115,19 +58,23 @@ export const uploadToS3 = async (bucket, key, localPath, maxAttempts = 5) => {
// RustFS's broken byte-range path on large objects.
export const uploadDirectoryToS3 = async (bucket, keyPrefix, localDir) => {
const client = createS3Client();
const entries = await readdir(localDir, { withFileTypes: true });
const files = entries.filter(e => e.isFile()).map(e => e.name);
for (const name of files) {
const ext = extname(name).toLowerCase();
const ct = CONTENT_TYPES[ext] || 'application/octet-stream';
await client.send(new PutObjectCommand({
Bucket: bucket,
Key: `${keyPrefix}/${name}`,
Body: createReadStream(join(localDir, name)),
ContentType: ct,
}));
try {
const entries = await readdir(localDir, { withFileTypes: true });
const files = entries.filter(e => e.isFile()).map(e => e.name);
for (const name of files) {
const ext = extname(name).toLowerCase();
const ct = CONTENT_TYPES[ext] || 'application/octet-stream';
await client.send(new PutObjectCommand({
Bucket: bucket,
Key: `${keyPrefix}/${name}`,
Body: createReadStream(join(localDir, name)),
ContentType: ct,
}));
}
return files;
} finally {
client.destroy();
}
return files;
};
// Multipart-aware streaming upload — used by the promotion worker to push
@ -135,11 +82,15 @@ export const uploadDirectoryToS3 = async (bucket, keyPrefix, localDir) => {
export const uploadStreamToS3 = async (bucket, key, readable) => {
const { Upload } = await import('@aws-sdk/lib-storage');
const client = createS3Client();
const upload = new Upload({
client,
params: { Bucket: bucket, Key: key, Body: readable },
queueSize: 4,
partSize: 8 * 1024 * 1024,
});
await upload.done();
try {
const upload = new Upload({
client,
params: { Bucket: bucket, Key: key, Body: readable },
queueSize: 4,
partSize: 8 * 1024 * 1024,
});
await upload.done();
} finally {
client.destroy();
}
};

View file

@ -1,97 +0,0 @@
// Auto-promotion scanner.
//
// Growing-files recordings finish on the SMB share with status='pending_migration'.
// Promotion (SMB → S3 upload + proxy) is otherwise only triggered manually via
// POST /assets/:id/promote. This scanner closes that gap: on a fixed interval it
// finds pending_migration assets that have been idle longer than the operator-
// configured delay (settings.growing_promote_after_seconds) and enqueues a
// promotion job for each — so growing clips land in S3 automatically once the
// editor is done with the live file, without anyone clicking anything.
//
// "Idle" = assets.updated_at older than the delay. Capture stamps updated_at
// when it flips the asset to pending_migration on record stop, so the delay is
// measured from when the file stopped growing.
//
// Safe to run on every worker container: the UPDATE ... WHERE status =
// 'pending_migration' guard + BullMQ jobId dedupe (jobId = 'promote:<assetId>')
// makes double-enqueue from multiple scanners idempotent.
import { Queue } from 'bullmq';
import { query } from '../db/client.js';
const DEFAULT_DELAY_SECONDS = 43200; // 12h fallback if the setting is unset/invalid
const SCAN_INTERVAL_MS = parseInt(process.env.PROMOTION_SCAN_INTERVAL_MS || '60000', 10);
async function getPromoteDelaySeconds() {
try {
const r = await query(
`SELECT value FROM settings WHERE key = 'growing_promote_after_seconds'`
);
if (r.rows.length === 0) return DEFAULT_DELAY_SECONDS;
const n = parseInt(r.rows[0].value, 10);
return Number.isFinite(n) && n >= 0 ? n : DEFAULT_DELAY_SECONDS;
} catch (err) {
console.warn('[promotion-scanner] could not read delay setting:', err.message);
return DEFAULT_DELAY_SECONDS;
}
}
export function startPromotionScanner(redisOptions) {
const promotionQueue = new Queue('promotion', { connection: redisOptions });
const scanOnce = async () => {
try {
const delaySeconds = await getPromoteDelaySeconds();
// Find pending_migration assets idle longer than the delay. EXTRACT(EPOCH …)
// gives the age in seconds; compare against the configured threshold.
const r = await query(
`SELECT id, filename
FROM assets
WHERE status = 'pending_migration'
AND EXTRACT(EPOCH FROM (NOW() - updated_at)) >= $1
ORDER BY updated_at ASC
LIMIT 25`,
[delaySeconds]
);
if (r.rows.length === 0) return;
for (const asset of r.rows) {
// Flip to 'processing' first so a second scan tick won't re-pick it, and
// dedupe the job by a stable jobId so concurrent scanners coalesce.
const upd = await query(
`UPDATE assets SET status = 'processing', updated_at = NOW()
WHERE id = $1 AND status = 'pending_migration'
RETURNING id`,
[asset.id]
);
if (upd.rows.length === 0) continue; // another scanner/operator beat us to it
await promotionQueue.add(
'promote',
{ assetId: asset.id },
{ jobId: `promote:${asset.id}`, removeOnComplete: true, removeOnFail: 50 }
);
console.log(
`[promotion-scanner] auto-promoting ${asset.filename} (${asset.id}) — idle ≥ ${delaySeconds}s`
);
}
} catch (err) {
console.error('[promotion-scanner] scan failed:', err.message);
}
};
// Kick off and then run on an interval. Unref so it never keeps the process
// alive on its own during shutdown.
const timer = setInterval(scanOnce, SCAN_INTERVAL_MS);
timer.unref?.();
// First scan shortly after boot (not instantly — let DB/redis settle).
setTimeout(scanOnce, 5000).unref?.();
console.log(
`[promotion-scanner] started — interval ${SCAN_INTERVAL_MS}ms (delay from settings.growing_promote_after_seconds)`
);
return { promotionQueue, stop: () => clearInterval(timer) };
}