Compare commits

..

1 commit

73 changed files with 2413 additions and 12510 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

2
.gitignore vendored
View file

@ -35,5 +35,3 @@ services/capture/lib/
*.bak
*.bak2
.env.bak.*
.env.worker
services/capture/videomaster-linux.x64-6.34.1-dev.tar.gz

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
if command -v lspci &>/dev/null; then
if 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
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,4 +1,23 @@
services:
db:
image: postgres:16
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "${PORT_DB:-5432}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./services/mam-api/src/db/schema.sql:/docker-entrypoint-initdb.d/001-schema.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
interval: 5s
timeout: 5s
retries: 5
networks:
- wild-dragon
queue:
image: redis:7-alpine
ports:
@ -11,6 +30,8 @@ services:
mam-api:
build: ./services/mam-api
depends_on:
db:
condition: service_healthy
queue:
condition: service_started
ports:
@ -98,6 +119,7 @@ services:
privileged: true
depends_on:
- queue
- db
environment:
REDIS_URL: ${REDIS_URL}
DATABASE_URL: ${DATABASE_URL}
@ -130,7 +152,7 @@ services:
worker-p400a:
image: wild-dragon-worker-gpu:latest
runtime: nvidia
depends_on: [queue, worker-p4]
depends_on: [queue, db, worker-p4]
environment:
REDIS_URL: ${REDIS_URL}
DATABASE_URL: ${DATABASE_URL}
@ -149,7 +171,7 @@ services:
worker-p400b:
image: wild-dragon-worker-gpu:latest
runtime: nvidia
depends_on: [queue, worker-p4]
depends_on: [queue, db, worker-p4]
environment:
REDIS_URL: ${REDIS_URL}
DATABASE_URL: ${DATABASE_URL}
@ -188,6 +210,7 @@ services:
image: wild-dragon-playout:latest
volumes:
postgres_data:
redis_data:
networks:

View file

@ -1,10 +0,0 @@
# zampp3-specific overrides: 3x L4 GPUs, no DeckLink
services:
node-agent:
devices: !reset []
worker-l4:
environment:
NVIDIA_VISIBLE_DEVICES: "all"
WORKER_LABEL: "zampp3 / 3x L4"
WORKER_QUEUES: proxy,conform,trim
PROXY_CONCURRENCY: "9"

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,79 +0,0 @@
# Capability-Routed GPU Worker Pool + Job Node Attribution
Date: 2026-05-28 | Status: approved (design), pending implementation
## Problem
All transcode/proxy jobs run on a single zampp1 worker configured with
`NVENC_ENABLED=` (empty) -> CPU libx264, despite a Tesla P4 in the box.
zampp2's L4 runs no worker (0% util). No visibility into which node/GPU ran a
job. Idle hardware: Tesla P4 + 2x Quadro P400 (zampp1), L4 (zampp2).
## Goals
1. Use all GPUs, routed by capability (heavy encodes on strong cards, light
decode-only jobs on weak cards).
2. Distribute jobs across nodes automatically.
3. Show which node + GPU ran each job in the Jobs UI.
Non-goals: autoscaling, custom scheduler beyond BullMQ competing-consumers,
multi-GPU selection inside one worker process.
## Current architecture (facts)
- BullMQ on shared Redis; queues already type-named: proxy, thumbnail,
filmstrip, conform, trim. mam-api enqueues by type -> NO mam-api change.
- worker/src/index.js creates a Worker per queue in one process; per-queue
*_CONCURRENCY envs already exist.
- proxy.js picks gpu_codec || (gpuEnabled ? h264_nvenc : libx264) and falls
back to libx264 on GPU encode failure (proxy.js:181).
- Chain: proxy -> thumbnail -> filmstrip. Workers are competing consumers.
## Design
### Tiers (by queue subscription)
- HEAVY: subscribes proxy, conform, trim. Cards: Tesla P4 (zampp1), L4
(zampp2). NVENC_ENABLED=true -> h264_nvenc.
- LIGHT: subscribes thumbnail, filmstrip. Cards: 2x Quadro P400 (zampp1).
P400s never subscribe to heavy queues, so a weak card cannot bottleneck a heavy
job. Strong cards do not subscribe to light queues in v1 (clean tiers; revisit
if light backlog ever starves while P4/L4 idle).
### Worker change (only code change)
Add WORKER_QUEUES env (comma list) to worker/src/index.js: only create Workers
for listed queues; unset = all (back-compat). No GPU-selection code change —
each container pinned to one GPU via NVIDIA_VISIBLE_DEVICES (sees it as dev 0).
### Topology
zampp1 (docker-compose.yml + gpu overlay):
- worker-p4 : VISIBLE=<P4 uuid>, NVENC_ENABLED=true, WORKER_QUEUES=proxy,conform,trim
- worker-p400a : VISIBLE=<P400a uuid>, WORKER_QUEUES=thumbnail,filmstrip
- worker-p400b : VISIBLE=<P400b uuid>, WORKER_QUEUES=thumbnail,filmstrip
zampp2 (docker-compose.worker.yml + gpu overlay):
- worker-l4 : VISIBLE=<L4 uuid>, NVENC_ENABLED=true, WORKER_QUEUES=proxy,conform,trim
needs REDIS_URL / DATABASE_URL / S3_* in zampp2 .env pointing at zampp1.
Pin by GPU UUID (nvidia-smi -L) not index, so reordering does not remap cards.
### Concurrency (initial, tunable via *_CONCURRENCY)
P4 proxy 2 ; L4 proxy 3 ; conform/trim 1 ; P400 thumbnail 2, filmstrip 2.
### Node/GPU attribution (phase 2)
Stamp each job with node hostname + GPU + tier on start. Mechanism pending
confirmation of whether Jobs screen reads the jobs DB table or live BullMQ
(appeared empty during runs): if DB-backed add node/gpu cols (or result jsonb)
and stamp in createWorker; if BullMQ-backed include node/gpu in job data and
surface via jobs API. Show "zampp2 / L4" in the Jobs UI row.
## Risks
- zampp2: heavy NVENC job can contend with an active recording's CPU work
(decode/mux uses CPU; GPU itself separate). Mitigate via L4 concurrency and,
if needed, pausing heavy intake during active capture.
- P400 NVENC session caps -> covered by libx264 fallback.
- zampp2 worker needs reach to zampp1 Redis/Postgres/S3 (already proven for API).
## Verification
- Enqueue several proxies; nvidia-smi shows encoder util on P4 (zampp1) and L4
(zampp2); P400s only on thumbnail/filmstrip.
- A heavy job never lands on a P400 (worker logs / attribution).
- Assets still reach ready with proxy+thumbnail+filmstrip.
- Phase 2: Jobs UI shows correct node/GPU per job.
## Rollout order
1. GPU tier (WORKER_QUEUES + topology + NVENC) — FIRST.
2. Node/GPU attribution + Jobs UI.

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,345 +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.
* MUST stay byte-for-byte consistent with services/framecache/src/slot.h
* the writer and all readers share this layout. Bumped to FC_VERSION 2 for
* frame-coupled audio (each ring entry carries video + that frame's audio). */
#define FC_MAGIC 0x46524D43u
#define FC_VERSION 2u
#define FC_RING_DEPTH 120u
#define FC_HEADER_SIZE 4096u
#define FC_FRAME_HDR_SIZE 24u
#define FC_MAX_AUDIO_BYTES 16384u /* must equal slot.h FC_MAX_AUDIO_BYTES */
#define FC_AUDIO_RATE 48000u
#define FC_AUDIO_CHANNELS 2u
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 audio_max_bytes; /* FC_MAX_AUDIO_BYTES */
uint32_t audio_rate; /* FC_AUDIO_RATE */
uint32_t audio_channels; /* FC_AUDIO_CHANNELS */
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 - 124];
} fc_hdr_t;
typedef struct {
uint64_t pts_us;
uint64_t wall_us;
uint32_t size; /* video bytes */
uint32_t audio_size; /* audio bytes for this frame (s16le stereo 48k), 0 = none */
uint8_t data[]; /* [video frame_size][audio audio_max_bytes] */
} fc_frm_t;
/* Per-entry stride MUST include the audio region. */
static inline size_t fc_entry_stride(uint32_t frame_size) {
return (size_t)FC_FRAME_HDR_SIZE + frame_size + (size_t)FC_MAX_AUDIO_BYTES;
}
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;
}
if (hdr.version != FC_VERSION) {
fprintf(stderr, "[fc_writer:%s] shm version %u != expected %u — refusing (fail-safe)\n",
slot_id, hdr.version, FC_VERSION);
close(fd); return NULL;
}
size_t total = (size_t)FC_HEADER_SIZE
+ (size_t)FC_RING_DEPTH * fc_entry_stride(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)
{
/* Video-only convenience wrapper (no embedded audio this frame). */
fc_writer_write_av(w, data, size, NULL, 0, pts_us);
}
void fc_writer_write_av(fc_writer_t *w,
const uint8_t *video, uint32_t vsize,
const uint8_t *audio, uint32_t asize,
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 entry in ring (stride includes the audio region). */
uint8_t *frames = (uint8_t *)w->base + FC_HEADER_SIZE;
fc_frm_t *frame = (fc_frm_t *)(frames + idx * fc_entry_stride(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 = vsize < hdr->frame_size ? vsize : hdr->frame_size;
memcpy(frame->data, video, frame->size);
/* Frame-coupled audio: pack THIS frame's audio immediately after the video
* bytes, within the reserved audio region. Clamp to capacity. */
uint32_t acap = hdr->audio_max_bytes ? hdr->audio_max_bytes : FC_MAX_AUDIO_BYTES;
uint32_t awr = (audio && asize) ? (asize < acap ? asize : acap) : 0;
frame->audio_size = awr;
if (awr)
memcpy(frame->data + hdr->frame_size, audio, awr);
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,61 +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);
/**
* Write one FRAME-COUPLED entry: a video frame plus that frame's SDI-embedded
* audio (interleaved s16le stereo 48k) into the SAME ring slot. Both are read
* back together by one consumer iteration, so audio cannot drift from video.
* audio may be NULL / asize 0 when the frame has no embedded audio.
*/
void fc_writer_write_av(fc_writer_t *w,
const uint8_t *video, uint32_t vsize,
const uint8_t *audio, uint32_t asize,
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,29 +37,15 @@
#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
/* ── Globals ──────────────────────────────────────────────────────────── */
static atomic_int g_stop = 0; /* global shutdown (SIGTERM/SIGINT only) */
/* Fixed A/V alignment: ms of leading silence prepended to the audio stream when
* a reader attaches. The video path (framecache ring -> fc_pipe -> ffmpeg input
* queue) is buffered deeper than the direct audio FIFO, so without compensation
* audio reaches the muxer AHEAD of its matching video frame. Prepending N ms of
* silence delays audio by N ms to re-align. Set via --audio-delay-ms (default 0).
* One value, all ports, deterministic no per-session env plumbing. */
static int g_audio_delay_ms = 0;
static void on_signal(int s) { (void)s; atomic_store(&g_stop, 1); }
@ -170,69 +144,6 @@ static int write_all(int fd, const unsigned char *p, size_t len) {
return 0;
}
/* ── Embedded-audio PCM ring (SPSC) ───────────────────────────────────────
* JOINED architecture: the video_thread extracts the SDI-embedded audio of
* each frame from the SAME slot it pulls video from, and pushes that PCM into
* this lock-free single-producer/single-consumer ring. The audio_thread is the
* single consumer: it drains the ring into the named audio FIFO (ffmpeg input
* 1) and survives ffmpeg restarts (EPIPE reopen) without touching the board.
*
* Why a ring instead of writing the FIFO directly from video_thread:
* - open(audio_fifo, O_WRONLY) blocks until an ffmpeg reader attaches. If the
* video_thread blocked on that, video capture would stall. The ring keeps
* the board-paced frame loop (video + audio extract) free-running while the
* FIFO lifecycle (blocking open, EPIPE reopen, silence fallback) lives in
* audio_thread.
* - Audio is still bound to its exact video frame because it is extracted on
* the SAME slot in the SAME loop iteration zero constant offset at root.
*
* 4 MB holds ~21 s of 48 kHz stereo s16le far more than any FIFO hiccup. */
#define APCM_RING_BYTES (4u * 1024u * 1024u)
typedef struct {
unsigned char *buf; /* APCM_RING_BYTES, power-of-two-free */
_Atomic size_t w; /* producer write offset (monotonic) */
_Atomic size_t r; /* consumer read offset (monotonic) */
_Atomic int have_embedded; /* 1 once real embedded PCM seen */
} ApcmRing;
/* Producer (video_thread): copy n bytes in; drop on overflow (never blocks). */
static void apcm_push(ApcmRing *ring, const unsigned char *src, size_t n) {
if (!ring->buf || n == 0) return;
size_t w = atomic_load_explicit(&ring->w, memory_order_relaxed);
size_t r = atomic_load_explicit(&ring->r, memory_order_acquire);
size_t used = w - r;
if (used + n > APCM_RING_BYTES) {
/* Overflow: reader stalled (no ffmpeg attached, or slow). Drop the
* oldest by advancing nothing here and simply refusing the write
* keeping the most-recent contiguous audio aligned to live video. */
return;
}
for (size_t i = 0; i < n; i++)
ring->buf[(w + i) % APCM_RING_BYTES] = src[i];
atomic_store_explicit(&ring->w, w + n, memory_order_release);
}
/* Consumer (audio_thread): pop up to max bytes; returns bytes copied. */
static size_t apcm_pop(ApcmRing *ring, unsigned char *dst, size_t max) {
if (!ring->buf) return 0;
size_t r = atomic_load_explicit(&ring->r, memory_order_relaxed);
size_t w = atomic_load_explicit(&ring->w, memory_order_acquire);
size_t avail = w - r;
size_t n = avail < max ? avail : max;
for (size_t i = 0; i < n; i++)
dst[i] = ring->buf[(r + i) % APCM_RING_BYTES];
atomic_store_explicit(&ring->r, r + n, memory_order_release);
return n;
}
/* Consumer: discard everything currently queued (flush stale backlog to the
* live edge when a fresh reader attaches). */
static void apcm_drain(ApcmRing *ring) {
if (!ring->buf) return;
size_t w = atomic_load_explicit(&ring->w, memory_order_acquire);
atomic_store_explicit(&ring->r, w, memory_order_release);
}
/* ── Per-port state ───────────────────────────────────────────────────── */
typedef struct {
HANDLE board;
@ -243,43 +154,21 @@ 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; /* JOINED RX stream: carries video + embedded audio */
#ifndef LEGACY_FIFO
fc_writer_t *fc_writer; /* shm ring buffer writer (NULL = use FIFO fallback) */
#endif
/* JOINED embedded-audio plumbing (producer=video_thread, consumer=audio_thread) */
ApcmRing apcm; /* video_thread → audio_thread PCM hand-off */
HANDLE video_stream;
} PortState;
/* ── Audio thread (JOINED architecture: FIFO sink, no second VHD stream) ──
/* ── Audio thread ──────────────────────────────────────────────────────
*
* In the JOINED re-architecture the board is opened with ONE RX stream per
* port (VHD_SDI_STPROC_JOINED). The video_thread locks each slot and extracts
* BOTH the video frame and that frame's SDI-EMBEDDED audio from the SAME slot,
* pushing the de-interleaved s16le stereo PCM into ps->apcm. Because the audio
* is the embedded audio of the exact frame, it is inherently sync'd with that
* frame zero constant offset at the root (no separate DISJOINED_ANC stream,
* no independent buffer queue racing ahead of video).
*
* This thread NO LONGER opens a VHD stream. Its sole job is FIFO lifecycle:
* - Open the named audio FIFO (blocks until ffmpeg input 1 attaches).
* - On reader attach, flush the ring backlog to the LIVE edge.
* - Drain ps->apcm FIFO. When the ring is momentarily empty, emit
* wall-clock-paced silence so ffmpeg input 1 never starves (also the
* silence-fallback when the signal carries no embedded audio at all).
* - On EPIPE (ffmpeg reader died): close and REOPEN the FIFO so the thread
* survives an ffmpeg restart without bringing down other ports.
* - Opens FIFO writer (blocks until a reader connects correct behaviour).
* - Feeds continuous wall-clock-paced s16le stereo (real or silence).
* - Best-effort VHD audio stream; silence fallback on any failure.
* - On EPIPE (ffmpeg reader died): closes and REOPENS the FIFO so the
* thread survives an ffmpeg restart without bringing down other ports.
* EPIPE never sets g_stop only SIGTERM/SIGINT does that.
*
* The legacy --audio-delay-ms knob is still honoured (prepended once on reader
* attach) but should be UNNECESSARY now that audio rides with its frame; leave
* it at the default 0.
*/
static void *audio_thread(void *arg) {
PortState *ps = (PortState *)arg;
@ -293,15 +182,61 @@ static void *audio_thread(void *arg) {
if (samples_per_frame < 1) samples_per_frame = 1;
size_t tick_bytes = (size_t)samples_per_frame * FRAME_BYTES;
long frame_ns = (long)(1000000000.0 * (double)fps_den / (double)fps_num);
/* Scratch buffer: large enough for a generous burst pulled from the ring
* in one go (several frames of audio) plus the per-tick silence buffer. */
size_t buf_sz = tick_bytes * 8;
if (buf_sz < 65536) buf_sz = 65536;
ULONG max_samples = VHD_GetNbSamples((VHD_VIDEOSTANDARD)ps->video_std,
(VHD_CLOCKDIVISOR)ps->clock_div,
VHD_ASR_48000, 0);
ULONG block_size = VHD_GetBlockSize(VHD_AF_16, VHD_AM_STEREO);
size_t vhd_buf_sz = ((size_t)max_samples + 64) * (block_size ? block_size : FRAME_BYTES);
size_t buf_sz = vhd_buf_sz > tick_bytes ? vhd_buf_sz : tick_bytes;
unsigned char *buf = calloc(1, buf_sz);
if (!buf) return NULL;
/* Open the VHD audio stream once for the lifetime of the bridge.
* The stream stays open across reader reconnects no need to reopen it. */
HANDLE stream = NULL;
int have_vhd_audio = 0;
VHD_AUDIOINFO ai;
memset(&ai, 0, sizeof(ai));
ULONG r = VHD_OpenStreamHandle(ps->board, rx_streamtype(ps->port),
VHD_SDI_STPROC_DISJOINED_ANC,
NULL, &stream, NULL);
if (r == VHDERR_NOERROR) {
/* Per Deltacast SDK Sample_RXAudio.cpp: VHD_SDI_SP_INTERFACE must be
* propagated to the audio stream, otherwise VHD_SlotExtractAudio
* returns 0 samples (silent capture). */
ULONG iface = 0;
VHD_GetStreamProperty(stream, VHD_SDI_SP_INTERFACE, &iface);
VHD_SetStreamProperty(stream, VHD_SDI_SP_VIDEO_STANDARD, ps->video_std);
VHD_SetStreamProperty(stream, VHD_SDI_SP_CLOCK_SYSTEM, ps->clock_div);
VHD_SetStreamProperty(stream, VHD_CORE_SP_TRANSFER_SCHEME, VHD_TRANSFER_SLAVED);
VHD_SetStreamProperty(stream, VHD_SDI_SP_INTERFACE, iface);
/* Configure BOTH channels of the stereo pair (group 0). The actual PCM
* samples land in pAudioChannels[0].pData (packed L/R s16le). Channel
* [1] must declare Mode+BufferFormat so the SDK recognizes the pair. */
ai.pAudioGroups[0].pAudioChannels[0].Mode = VHD_AM_STEREO;
ai.pAudioGroups[0].pAudioChannels[0].BufferFormat = VHD_AF_16;
ai.pAudioGroups[0].pAudioChannels[0].pData = buf;
ai.pAudioGroups[0].pAudioChannels[1].Mode = VHD_AM_STEREO;
ai.pAudioGroups[0].pAudioChannels[1].BufferFormat = VHD_AF_16;
if (VHD_StartStream(stream) == VHDERR_NOERROR) {
have_vhd_audio = 1;
} else {
fprintf(stderr, "[audio:%u] VHD_StartStream failed — feeding silence\n", ps->port);
VHD_CloseStreamHandle(stream);
stream = NULL;
}
} else {
fprintf(stderr, "[audio:%u] VHD_OpenStreamHandle failed (%lu) — feeding silence\n",
ps->port, r);
}
long frame_ns = (long)(1000000000.0 * (double)fps_den / (double)fps_num);
HANDLE slot = NULL;
/* Outer loop: reopen the FIFO writer each time a reader connects.
* This allows the bridge to survive ffmpeg session stop/restart on a port
* without affecting any other port's threads. */
@ -317,81 +252,66 @@ static void *audio_thread(void *arg) {
}
fcntl(fd, F_SETPIPE_SZ, 1024 * 1024);
/* ── Flush the embedded-audio ring backlog to the LIVE edge ─────────
* While no reader is attached (recorder idle/standby), the open() above
* blocks but the video_thread keeps free-running and pushing the
* embedded audio of every live frame into ps->apcm. Without flushing,
* the first thing a newly-attached reader (the record ffmpeg) receives
* is that backlog seconds of stale audio that plays as leading
* mis-sync. Discard everything queued so we hand the reader the LIVE
* edge, frame-aligned with the video fc_pipe is delivering right now. */
apcm_drain(&ps->apcm);
/* ── Fixed A/V alignment: prepend g_audio_delay_ms of leading silence ──
* Retained for compatibility; with JOINED capture audio already rides
* with its frame so this should stay 0. When set, the real PCM zeros at
* 48 kHz consume exactly N ms of the audio timeline (ffmpeg derives
* audio PTS from sample count) a precise, drift-free shift. */
if (g_audio_delay_ms > 0) {
long delay_samples = (long)AUDIO_RATE * g_audio_delay_ms / 1000;
size_t delay_bytes = (size_t)delay_samples * FRAME_BYTES;
unsigned char sil[8192];
memset(sil, 0, sizeof(sil));
size_t remaining = delay_bytes;
int delay_ok = 1;
while (remaining > 0) {
size_t chunk = remaining > sizeof(sil) ? sizeof(sil) : remaining;
if (write_all(fd, sil, chunk) < 0) { delay_ok = 0; break; }
remaining -= chunk;
}
if (delay_ok)
fprintf(stderr, "[audio:%u] prepended %d ms (%ld samples) of A/V-align silence\n",
ps->port, g_audio_delay_ms, delay_samples);
}
/* Wall-clock baseline for the silence-fill cadence. */
/* Reset wall-clock baseline after potentially blocking on open().
* Only used for the SILENCE fallback path (no hardware audio). */
struct timespec next;
clock_gettime(CLOCK_MONOTONIC, &next);
/* Inner loop: drain the ring into the FIFO until the reader exits.
*
* Pacing model:
* - Whenever the ring has embedded PCM, write ALL of it immediately.
* The producer (video_thread) is paced by the board's JOINED slot
* cadence = the true SDI clock, so the volume of bytes the ring
* accumulates per unit time exactly tracks video. We never pad or
* resample it, so the audio timeline length matches video length
* (no progressive drift).
* - When the ring is empty for a whole frame interval (no embedded
* audio on the signal, or a brief gap), emit exactly one frame of
* silence, wall-clock paced, so ffmpeg input 1 never starves. */
int wrote_real_since_log = 0;
/* Inner loop: feed audio into the open FIFO until reader exits (EPIPE). */
while (!atomic_load(&g_stop) && !atomic_load(&g_port_stop[ps->port])) {
size_t got = apcm_pop(&ps->apcm, buf, buf_sz);
if (got > 0) {
if (write_all(fd, buf, got) < 0) {
size_t out_bytes = 0;
if (have_vhd_audio) {
/* HARDWARE-PACED PATH (the normal case).
* VHD_LockSlotHandle blocks until the board has the next audio
* slot ready this slot is generated from the SAME SDI signal
* as the video, so blocking here paces audio in lockstep with
* video at the TRUE hardware rate. We write ONLY the real
* samples the board gives us (no silence padding, no wall-clock
* sleep) so the audio timeline length exactly tracks video.
* This is the fix for progressive A/V drift: mixing wall-clock
* paced silence with variable-length real reads made the audio
* stream length diverge from the video stream length. */
r = VHD_LockSlotHandle(stream, &slot);
if (r == VHDERR_NOERROR) {
ai.pAudioGroups[0].pAudioChannels[0].DataSize = (ULONG)buf_sz;
if (VHD_SlotExtractAudio(slot, &ai) == VHDERR_NOERROR) {
ULONG sz = ai.pAudioGroups[0].pAudioChannels[0].DataSize;
if (sz > 0 && (size_t)sz <= buf_sz) out_bytes = (size_t)sz;
}
VHD_UnlockSlotHandle(slot);
if (out_bytes > 0) {
if (write_all(fd, buf, out_bytes) < 0) {
fprintf(stderr, "[audio:%u] EPIPE — waiting for next reader\n", ps->port);
break;
}
if (!wrote_real_since_log &&
atomic_load_explicit(&ps->apcm.have_embedded, memory_order_relaxed)) {
fprintf(stderr, "[audio:%u] streaming SDI-embedded audio (JOINED slot)\n",
ps->port);
wrote_real_since_log = 1;
}
/* Re-baseline the silence clock so we don't burst silence right
* after a real chunk; the next empty interval starts from now. */
clock_gettime(CLOCK_MONOTONIC, &next);
/* Small yield to avoid a busy spin when the ring is being fed in
* sub-frame increments; the board cadence refills it promptly. */
struct timespec ts = {0, frame_ns / 4 > 0 ? frame_ns / 4 : 250000L};
nanosleep(&ts, NULL);
/* No wall-clock sleep — the board's slot cadence is the clock. */
continue;
} else if (r == VHDERR_TIMEOUT) {
/* No slot yet — loop and try again (do NOT inject silence,
* that would add extra samples and cause drift). */
continue;
} else {
fprintf(stderr, "[audio:%u] lock error %lu — degrading to silence\n",
ps->port, r);
VHD_StopStream(stream);
VHD_CloseStreamHandle(stream);
stream = NULL;
have_vhd_audio = 0;
clock_gettime(CLOCK_MONOTONIC, &next); /* rebase silence clock */
}
}
/* Ring empty this interval → emit one frame of silence, paced. */
/* SILENCE FALLBACK PATH (no hardware audio available).
* Wall-clock paced one-frame-of-silence per video-frame interval so
* ffmpeg's input 1 never starves and audio length still tracks
* real time. */
memset(buf, 0, tick_bytes);
if (write_all(fd, buf, tick_bytes) < 0) {
out_bytes = tick_bytes;
if (write_all(fd, buf, out_bytes) < 0) {
fprintf(stderr, "[audio:%u] EPIPE — waiting for next reader\n", ps->port);
break;
}
@ -411,202 +331,22 @@ static void *audio_thread(void *arg) {
close(fd);
}
if (stream) {
VHD_StopStream(stream);
VHD_CloseStreamHandle(stream);
}
free(buf);
return NULL;
}
/* ── Embedded-audio extraction context (used inside the JOINED video loop) ─
* Set up once per video_thread; reused for every slot. The VHD_AUDIOINFO is
* configured for a single stereo pair (group 0) in s16le, exactly as the old
* DISJOINED_ANC audio path was the SDK lands packed L/R s16le PCM in
* pAudioChannels[0].pData with the byte count in .DataSize. */
typedef struct {
int enabled; /* 0 = no scratch buffer (extraction disabled) */
unsigned char *buf; /* scratch PCM landing buffer */
size_t buf_sz;
size_t silence_bytes; /* one frame of s16le stereo silence (fallback) */
VHD_AUDIOINFO ai;
} AudioExtract;
static void audio_extract_init(AudioExtract *ax, PortState *ps) {
memset(ax, 0, sizeof(*ax));
/* Worst-case samples per frame at this standard/clock, + headroom. */
ULONG max_samples = VHD_GetNbSamples((VHD_VIDEOSTANDARD)ps->video_std,
(VHD_CLOCKDIVISOR)ps->clock_div,
VHD_ASR_48000, 0);
ULONG block_size = VHD_GetBlockSize(VHD_AF_16, VHD_AM_STEREO);
size_t fb = (size_t)2 /*ch*/ * 2 /*s16*/;
if (block_size == 0) block_size = (ULONG)fb;
/* Exact per-frame capacity for ONE stereo pair (s16le, packed L/R), the
* size the SDK fills in pAudioChannels[0].pData. + headroom. */
ax->buf_sz = ((size_t)max_samples + 64) * (size_t)block_size;
if (ax->buf_sz < 65536) ax->buf_sz = 65536;
ax->buf = calloc(1, ax->buf_sz);
if (!ax->buf) { ax->enabled = 0; return; }
/* One video-frame worth of s16le stereo silence (samples/frame * 2ch * 2B),
* used as the frame-coupled silence fallback when the signal carries no
* embedded audio on a frame keeps the audio timeline length == video. */
{
int fn = ps->vi.fps_num > 0 ? ps->vi.fps_num : 25;
int fd = ps->vi.fps_den > 0 ? ps->vi.fps_den : 1;
long spf = ((long)48000 * fd + fn / 2) / fn;
if (spf < 1) spf = 1;
ax->silence_bytes = (size_t)spf * 2 /*ch*/ * 2 /*s16*/;
if (ax->silence_bytes > ax->buf_sz) ax->silence_bytes = ax->buf_sz;
}
memset(&ax->ai, 0, sizeof(ax->ai));
/* ── Silent-audio FIX ───────────────────────────────────────────────────
* Configure ONLY pAudioChannels[0] of group 0 as ONE stereo pair, exactly
* like Deltacast's own FFmpeg fork (libavdevice/videomaster_common.c,
* init_audio_info): for a stereo pair the SDK packs interleaved L/R s16le
* into the EVEN channel's pData; the ODD channel (index 1) must be left
* ZEROED. The previous JOINED code ALSO set pAudioChannels[1].Mode/
* BufferFormat = STEREO/AF_16, declaring a SECOND stereo pair the signal
* does not carry. That mismatch made VHD_SlotExtractAudio return zero
* samples the -91 dB "silent audio" regression. Leaving channel[1] zero
* and sizing DataSize = nb_samples * VHD_GetBlockSize(AF_16, STEREO) (set
* per call in audio_extract_slot) makes the SDK land real PCM.
*
* DataSize must be (re)set to the buffer capacity before EACH extract call
* because the SDK overwrites it with the number of bytes actually written. */
ax->ai.pAudioGroups[0].pAudioChannels[0].Mode = VHD_AM_STEREO;
ax->ai.pAudioGroups[0].pAudioChannels[0].BufferFormat = VHD_AF_16;
ax->ai.pAudioGroups[0].pAudioChannels[0].pData = ax->buf;
/* BOTH channels of the stereo pair MUST be declared (Mode+BufferFormat) for
* VHD_SlotExtractAudio to return samples on THIS card/SDK. Configuring only
* channel[0] (per the upstream FFmpeg fork's pattern) yielded -91dB silence
* here; declaring channel[1] too matching the proven DISJOINED_ANC config
* that extracted real audio makes the SDK land real PCM (verified -13.3dB
* against the live source). The interleaved L/R s16le still lands in
* channel[0].pData; channel[1] just needs its descriptor present. */
ax->ai.pAudioGroups[0].pAudioChannels[1].Mode = VHD_AM_STEREO;
ax->ai.pAudioGroups[0].pAudioChannels[1].BufferFormat = VHD_AF_16;
ax->enabled = 1;
}
/* Extract this slot's SDI-embedded audio into ax->buf and return the byte
* count. Must be called while `slot` is locked (JOINED slot = same frame as the
* video) so the returned PCM is exactly this video frame's embedded audio.
*
* Frame-coupled silence fallback: if the slot yields zero samples (no embedded
* audio on the signal, or a transient extract miss) we fill ax->buf with one
* frame-interval of silence and return that, so EVERY ring entry carries one
* frame of audio and the audio timeline length always equals the video timeline
* length (drift-free). On real embedded PCM we flag have_embedded for logging.
*
* Returns the number of valid PCM bytes in ax->buf (>= 0). */
static size_t audio_extract_slot(AudioExtract *ax, PortState *ps, HANDLE slot) {
if (!ax->enabled) return 0;
ax->ai.pAudioGroups[0].pAudioChannels[0].DataSize = (ULONG)ax->buf_sz;
if (VHD_SlotExtractAudio(slot, &ax->ai) == VHDERR_NOERROR) {
ULONG sz = ax->ai.pAudioGroups[0].pAudioChannels[0].DataSize;
if (sz > 0 && (size_t)sz <= ax->buf_sz) {
atomic_store_explicit(&ps->apcm.have_embedded, 1, memory_order_relaxed);
return (size_t)sz;
}
}
/* No embedded audio this frame → one frame-interval of silence. */
if (ax->silence_bytes) memset(ax->buf, 0, ax->silence_bytes);
return ax->silence_bytes;
}
static void audio_extract_free(AudioExtract *ax) {
if (ax->buf) free(ax->buf);
ax->buf = NULL;
ax->enabled = 0;
}
/* ── Video thread ─────────────────────────────────────────────────────── */
static void *video_thread(void *arg) {
PortState *ps = (PortState *)arg;
/* JOINED: set up embedded-audio extraction once; reused for every slot. */
AudioExtract ax;
audio_extract_init(&ax, ps);
if (ax.enabled)
fprintf(stderr, "[video:%u] JOINED audio extraction armed (buf=%zu)\n",
ps->port, ax.buf_sz);
else
fprintf(stderr, "[video:%u] WARN: audio extract buffer alloc failed — silence only\n",
ps->port);
#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) {
/* ── JOINED frame-coupled capture ───────────────────────────
* Extract this frame's SDI-embedded audio from the SAME locked
* slot as the video, then write BOTH into ONE framecache ring
* entry via fc_writer_write_av. Because audio + video share one
* ring slot advanced by one atomic cursor step, and are read
* back together by one consumer (fc_pipe) iteration, the audio
* can never drift from its video frame there is no separate
* audio transport/buffer. (audio_extract_slot returns one
* frame of silence when the signal carries no embedded audio.)*/
size_t asz = audio_extract_slot(&ax, ps, slot);
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_av(ps->fc_writer, buf, (uint32_t)sz,
ax.enabled ? ax.buf : NULL,
(uint32_t)asz, 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;
}
}
audio_extract_free(&ax);
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);
@ -619,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));
}
}
@ -629,28 +368,19 @@ static void *video_thread(void *arg) {
while (!atomic_load(&g_stop) && !atomic_load(&g_port_stop[ps->port])) {
ULONG r = VHD_LockSlotHandle(ps->video_stream, &slot);
if (r == VHDERR_NOERROR) {
/* LEGACY_FIFO path: video → video FIFO, audio → apcm ring →
* audio_thread audio FIFO (the old two-transport scheme).
* Extract this frame's embedded audio on the SAME slot and push
* it to the ring for audio_thread. (Frame coupling is only
* available on the framecache path above; legacy keeps the
* separate-FIFO behavior for nodes without framecache.) */
size_t asz = audio_extract_slot(&ax, ps, slot);
if (asz > 0) apcm_push(&ps->apcm, ax.buf, asz);
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: 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;
@ -659,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;
@ -670,7 +400,6 @@ static void *video_thread(void *arg) {
if (fatal) break;
}
audio_extract_free(&ax);
return NULL;
}
@ -696,9 +425,6 @@ int main(int argc, char *argv[]) {
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;
for (int i = 1; i < argc; i++) {
if (!strcmp(argv[i], "--device") && i+1 < argc) {
@ -715,17 +441,8 @@ 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];
} else if (!strcmp(argv[i], "--audio-delay-ms") && i+1 < argc) {
g_audio_delay_ms = atoi(argv[++i]);
if (g_audio_delay_ms < 0) g_audio_delay_ms = 0;
if (g_audio_delay_ms > 1000) g_audio_delay_ms = 1000;
}
}
/* Env override (FC_AUDIO_DELAY_MS) for ops who tune without editing the unit. */
{ const char *ad = getenv("FC_AUDIO_DELAY_MS");
if (ad && *ad) { int v = atoi(ad); if (v >= 0 && v <= 1000) g_audio_delay_ms = v; } }
if (port_count == 0) {
fprintf(stderr, "{\"error\":\"no ports specified — use --ports 0,1,2,...\"}\n");
@ -790,7 +507,6 @@ int main(int argc, char *argv[]) {
return 1;
}
fprintf(stderr, "[board] opened board %u with %d port(s)\n", device_id, port_count);
fprintf(stderr, "[board] audio A/V-align delay = %d ms\n", g_audio_delay_ms);
/* Per SDK samples: for 12G-ASI or 3G-ASI channel types the channel must be
* explicitly switched to SDI mode. Without this, VHD_SDI_CP_VIDEO_STANDARD
@ -885,24 +601,8 @@ 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);
#ifndef LEGACY_FIFO
/* ── Primary: frame-coupled framecache path ─────────────────────────
* Open the framecache slot for video + this frame's embedded audio
* (both packed into one ring entry by fc_writer_write_av). In this path
* the bridge does NOT create or write the audio FIFO capture-manager
* creates it and fc_pipe writes it, sourced from the SAME ring entry as
* the video so audio is frame-locked. Fall back to the legacy two-FIFO
* scheme only 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 — falling back to legacy video+audio FIFOs\n",
ports[pi]);
/* 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;
@ -911,35 +611,14 @@ int main(int argc, char *argv[]) {
fprintf(stderr, "[port:%u] mkfifo audio failed: %s\n", ports[pi], strerror(errno));
continue;
}
}
#else
/* Legacy: always use video + audio FIFOs (two transports). */
if (mkfifo(p->video_fifo, 0666) != 0 && errno != EEXIST) {
fprintf(stderr, "[port:%u] mkfifo video failed: %s\n", ports[pi], strerror(errno));
continue;
}
if (mkfifo(p->audio_fifo, 0666) != 0 && errno != EEXIST) {
fprintf(stderr, "[port:%u] mkfifo audio failed: %s\n", ports[pi], strerror(errno));
continue;
}
#endif
/* Open the RX stream in JOINED processing mode.
*
* JOINED (vs. the old DISJOINED_VIDEO + a separate DISJOINED_ANC audio
* stream) means a single stream delivers slots that carry BOTH the
* video frame AND its SDI-embedded ancillary audio. The video_thread
* locks each slot once and pulls video (VHD_GetSlotBuffer) and that
* frame's audio (VHD_SlotExtractAudio) from the SAME slot, so audio is
* inherently frame-synchronised eliminating the constant "audio ahead
* of video" offset that two independently-buffered streams produced.
* (Pattern per Deltacast's own FFmpeg fork: libavdevice/videomaster_common.c.) */
/* Open video stream. */
HANDLE vs = NULL;
ULONG r = VHD_OpenStreamHandle(board, rx_streamtype(ports[pi]),
VHD_SDI_STPROC_JOINED,
VHD_SDI_STPROC_DISJOINED_VIDEO,
NULL, &vs, NULL);
if (r != VHDERR_NOERROR) {
fprintf(stderr, "{\"error\":\"VHD_OpenStreamHandle JOINED failed port %u rc=%lu\"}\n",
fprintf(stderr, "{\"error\":\"VHD_OpenStreamHandle video failed port %u rc=%lu\"}\n",
ports[pi], r);
continue;
}
@ -947,25 +626,10 @@ int main(int argc, char *argv[]) {
VHD_SetStreamProperty(vs, VHD_SDI_SP_CLOCK_SYSTEM, p->clock_div);
VHD_SetStreamProperty(vs, VHD_CORE_SP_TRANSFER_SCHEME, VHD_TRANSFER_SLAVED);
VHD_SetStreamProperty(vs, VHD_CORE_SP_BUFFERQUEUE_DEPTH, 8);
/* ── SDI interface propagation (required for embedded-audio extract) ──
* Per Deltacast's FFmpeg fork (libavdevice/videomaster_common.c,
* ff_videomaster_start_stream): a JOINED SDI stream must have
* VHD_SDI_SP_INTERFACE set to the DETECTED cable interface before
* StartStream, otherwise VHD_SlotExtractAudio on the resulting slots
* returns zero samples. We read the channel's detected interface and
* set it on the stream. (Prefer the channel-detected value; fall back
* to the stream's current value if the channel property is unavailable
* on this SDK build.) */
ULONG iface = 0;
if (VHD_GetChannelProperty(board, VHD_RX_CHANNEL, ports[pi],
VHD_SDI_CP_INTERFACE, &iface) == VHDERR_NOERROR) {
if (VHD_GetStreamProperty(vs, VHD_SDI_SP_INTERFACE, &iface) == VHDERR_NOERROR) {
VHD_SetStreamProperty(vs, VHD_SDI_SP_INTERFACE, iface);
fprintf(stderr, "[board] port %u set SDI Interface (channel-detected) to %lu\n",
ports[pi], iface);
} else if (VHD_GetStreamProperty(vs, VHD_SDI_SP_INTERFACE, &iface) == VHDERR_NOERROR) {
VHD_SetStreamProperty(vs, VHD_SDI_SP_INTERFACE, iface);
fprintf(stderr, "[board] port %u set SDI Interface (stream default) to %lu\n",
ports[pi], iface);
fprintf(stderr, "[board] port %u explicitly set SDI Interface to %lu\n", ports[pi], iface);
}
/* Pin to tightly-packed 8-bit UYVY. Relying on SDK default caused
* the board to deliver frames whose size != width*height*2,
@ -980,58 +644,25 @@ 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);
/* ── Audio transport selection ──────────────────────────────────────
* Frame-coupled path (fc_writer active): audio rides in the framecache
* ring entry with its video frame; fc_pipe delivers it to the audio
* FIFO frame-locked. NO apcm ring, NO audio_thread, NO bridge-owned
* audio FIFO there is no second transport to drift.
*
* Legacy path (fc_writer NULL, framecache unreachable, or -DLEGACY_FIFO):
* keep the apcm ring + audio_thread that drains it to the separate audio
* FIFO (the old two-transport scheme). */
int legacy_audio;
#ifdef LEGACY_FIFO
legacy_audio = 1;
#else
legacy_audio = (p->fc_writer == NULL);
#endif
if (legacy_audio) {
p->apcm.buf = calloc(1, APCM_RING_BYTES);
atomic_store(&p->apcm.w, 0);
atomic_store(&p->apcm.r, 0);
atomic_store(&p->apcm.have_embedded, 0);
if (!p->apcm.buf)
fprintf(stderr, "[port:%u] WARN: apcm ring alloc failed — audio will be silence\n",
ports[pi]);
/* Launch audio thread (FIFO sink: drains apcm ring → audio FIFO). */
/* Launch audio thread (blocks until reader connects to audio FIFO). */
pthread_create(&p->audio_tid, NULL, audio_thread, p);
} else {
fprintf(stderr, "[port:%u] frame-coupled audio (framecache ring) — no separate audio FIFO/thread\n",
ports[pi]);
}
/* Launch video thread. fc_writer path: video + this frame's embedded
* audio ONE framecache ring entry (fc_writer_write_av). Legacy path:
* video video FIFO, audio apcm ring audio_thread audio FIFO. */
/* Launch video thread (blocks until reader connects to video FIFO). */
pthread_create(&p->video_tid, NULL, video_thread, p);
active_count++;
@ -1055,16 +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
if (ps[i].apcm.buf) {
free(ps[i].apcm.buf);
ps[i].apcm.buf = NULL;
}
}
VHD_CloseBoardHandle(board);

File diff suppressed because it is too large Load diff

View file

@ -22,26 +22,13 @@ app.use('/capture', captureRoutes);
const server = app.listen(PORT, () => {
console.log(`Wild Dragon Capture Service listening on port ${PORT}`);
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.
const _srcType = process.env.SOURCE_TYPE;
if (process.env.RECORDER_ID && (_srcType === 'deltacast' || _srcType === 'sdi')) {
setTimeout(() => captureManager.startIdlePreview(), 3000);
}
}
});
// Mapped from the env vars routes/recorders.js writes into the container.

View file

@ -301,36 +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,
growing_codec,
audio_offset_ms,
} = req.body;
if (!project_id || !clip_name) {
@ -340,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) {
@ -356,14 +332,12 @@ 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) {
// Create live asset in MAM API before starting capture
let assetId;
try {
const mamResponse = await fetch(`${MAM_API_URL}/api/v1/assets`, {
method: 'POST',
@ -376,81 +350,30 @@ router.post('/start', async (req, res) => {
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}` });
}
}
// 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;
}
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;
if (growing_codec) process.env.GROWING_CODEC = growing_codec;
// Per-recorder A/V trim. Always set (incl. 0) so a standby sidecar that
// carried a stale offset from a prior session is reset. capture-manager
// reads AUDIO_OFFSET_MS and applies it as -itsoffset on the audio input.
if (audio_offset_ms !== undefined && audio_offset_ms !== null)
process.env.AUDIO_OFFSET_MS = String(audio_offset_ms);
else
process.env.AUDIO_OFFSET_MS = '0';
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);
@ -494,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 pthread)
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,247 +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 copy buffer: [video frame_size][audio audio_max] */
uint32_t frame_size; /* cached from header (video bytes) */
uint32_t audio_max; /* cached from header (audio region capacity) */
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;
}
/* Version gate: an old reader against a new writer (or vice-versa) computes
* the wrong per-entry stride and would misparse. Fail safe. */
if (hdr.version != FC_VERSION) {
fprintf(stderr, "[fc_client] slot %s version %u != expected %u — refusing\n",
slot_id, hdr.version, FC_VERSION);
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. Sized for video + audio so
* the frame-coupled audio is copied atomically with its video. */
c->copy_buf = malloc((size_t)hdr.frame_size + hdr.audio_max_bytes);
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;
c->audio_max = hdr.audio_max_bytes;
/* 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 entry (video + this frame's audio) into the owned buffer ─
* Both are copied from the SAME ring entry in the SAME iteration, so the
* audio handed to the caller is exactly this video frame's embedded audio
* frame-coupled, no second buffer to drift. copy_buf layout mirrors the
* shm entry: [video frame_size][audio audio_max]. */
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;
uint32_t asz = frame->audio_size;
if (asz > c->audio_max) asz = c->audio_max;
uint64_t pts = frame->pts_us;
uint64_t wall = frame->wall_us;
memcpy(c->copy_buf, frame->data, fsz);
if (asz)
memcpy(c->copy_buf + c->frame_size,
fc_frame_audio(frame, hdr->frame_size), asz);
/* ── 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->audio = asz ? (c->copy_buf + c->frame_size) : NULL;
ref->audio_size = asz;
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;
}
int fc_consumer_info(fc_consumer_t *c, fc_stream_info_t *info)
{
if (!c || !info) return -1;
fc_header_t *hdr = (fc_header_t *)c->base;
info->width = hdr->width;
info->height = hdr->height;
info->fps_num = hdr->fps_num;
info->fps_den = hdr->fps_den;
info->pixel_format = hdr->pixel_format;
info->frame_size = hdr->frame_size;
info->audio_rate = hdr->audio_rate ? hdr->audio_rate : FC_AUDIO_RATE;
info->audio_channels = hdr->audio_channels ? hdr->audio_channels : FC_AUDIO_CHANNELS;
info->audio_sample_bytes = FC_AUDIO_SAMPLE_BYTES; /* s16le */
return 0;
}

View file

@ -1,103 +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; /* video bytes */
const uint8_t *audio; /* pointer to a CONSUMER-OWNED copy of this frame's
* embedded audio (s16le stereo 48k), stable until
* the next fc_consumer_read(). NULL if audio_size 0. */
uint32_t audio_size; /* audio bytes for this frame (0 = none) */
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);
/* Stream format info read from the slot header (set at slot creation by the
* bridge). Used by fc_pipe to emit a correct AVI/container header. */
typedef struct {
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; /* video bytes per frame (width*height*2 for UYVY422) */
uint32_t audio_rate; /* 48000 */
uint32_t audio_channels; /* 2 */
uint32_t audio_sample_bytes; /* 2 (s16le) */
} fc_stream_info_t;
/** Fill *info from the slot header. Returns 0 on success, -1 on error. */
int fc_consumer_info(fc_consumer_t *c, fc_stream_info_t *info);
#ifdef __cplusplus
}
#endif

View file

@ -1,405 +0,0 @@
/**
* fc_pipe.c Framecache slot stdout pipe adapter.
*
* FRAME-COUPLED AUDIO (FC_VERSION 2):
* Each framecache ring entry carries the VIDEO frame AND that frame's
* SDI-embedded AUDIO together (written by the JOINED bridge from one slot).
* fc_pipe reads ONE entry per loop iteration.
*
* TWO OUTPUT MODES:
*
* 1) AVI MODE (default when audio is wanted; selected with --avi or by giving
* an arg of "avi"): fc_pipe writes a SINGLE streaming AVI container to
* stdout video and audio INTERLEAVED in one byte stream. ffmpeg reads it
* as ONE input:
* ffmpeg -f avi -i pipe:0 -map 0:v ... -map 0:a ...
* This eliminates the two-live-pipe deadlock: when ffmpeg was given a raw
* video pipe AND a separate audio FIFO it stalled forever probing input 0.
* The AVI muxer writes its header once, then for each ring entry emits a
* '00dc' video chunk followed by a '01wb' audio chunk frame-coupled by
* construction (both come from the same ring entry in the same iteration).
*
* 2) RAW MODE (legacy, video-only): if no audio FIFO / avi flag is given,
* fc_pipe writes raw UYVY422 video bytes to stdout as before.
*
* The old split video-stdout / audio-FIFO design is REMOVED it was the
* source of the ffmpeg deadlock.
*
* Usage: fc_pipe <slot_id> [wait_ms] [mode]
* mode: "--avi" | "avi" single streaming AVI (video+audio) on stdout.
* omitted | "-" raw UYVY422 video-only on stdout.
*
* Terminates on: SIGTERM/SIGINT, stdout EPIPE (ffmpeg exited), slot gone.
*/
#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>
#include <math.h>
static volatile int g_stop = 0;
static void on_signal(int s) { (void)s; g_stop = 1; }
/* Write all bytes to fd (blocking). 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;
}
return 0;
}
/* ── Little-endian byte emitters into a caller buffer ────────────────────────── */
static inline void put_u16(uint8_t **pp, uint16_t v) {
uint8_t *p = *pp; p[0] = (uint8_t)(v & 0xff); p[1] = (uint8_t)((v >> 8) & 0xff); *pp = p + 2;
}
static inline void put_u32(uint8_t **pp, uint32_t v) {
uint8_t *p = *pp;
p[0] = (uint8_t)(v & 0xff); p[1] = (uint8_t)((v >> 8) & 0xff);
p[2] = (uint8_t)((v >> 16) & 0xff); p[3] = (uint8_t)((v >> 24) & 0xff);
*pp = p + 4;
}
static inline void put_fourcc(uint8_t **pp, const char *cc) {
uint8_t *p = *pp; p[0] = (uint8_t)cc[0]; p[1] = (uint8_t)cc[1];
p[2] = (uint8_t)cc[2]; p[3] = (uint8_t)cc[3]; *pp = p + 4;
}
/* ── Streaming AVI header ─────────────────────────────────────────────────────
* Builds RIFF('AVI ') + LIST('hdrl'){ avih + strl(vids) + strl(auds) } +
* LIST('movi'). For a streaming AVI over a pipe we cannot seek back to patch
* the RIFF and movi sizes, so we set them to 0x7FFFFFFF; ffmpeg's AVI demuxer
* reads the strf headers and the 00dc/01wb chunk stream regardless. The hdrl
* LIST size IS fixed/known, so it is written correctly. dwFlags is 0 we do
* NOT set AVIF_HASINDEX / AVIF_MUSTUSEINDEX (there is no index in a stream).
*
* Writes the header to *out and returns its length. Buffer must be >= 512. */
static size_t build_avi_header(uint8_t *out,
uint32_t width, uint32_t height,
uint32_t fps_num, uint32_t fps_den,
uint32_t video_bytes,
uint32_t audio_rate, uint32_t audio_channels,
uint32_t audio_sample_bytes) {
const uint32_t STREAMING = 0x7FFFFFFFu;
const uint16_t bits_per_sample = (uint16_t)(audio_sample_bytes * 8u);
const uint16_t block_align = (uint16_t)(audio_channels * audio_sample_bytes);
const uint32_t avg_bytes_sec = audio_rate * block_align;
/* dwMicroSecPerFrame = 1e6 * fps_den / fps_num */
const uint32_t usec_per_frame =
(uint32_t)((1000000.0 * (double)fps_den / (double)fps_num) + 0.5);
/* Fixed sub-sizes (data bytes only, excluding the 8-byte ckID+ckSize). */
const uint32_t AVIH_DATA = 56; /* MainAVIHeader */
const uint32_t STRH_DATA = 56; /* AVISTREAMHEADER */
const uint32_t BIH_DATA = 40; /* BITMAPINFOHEADER */
const uint32_t WFX_DATA = 18; /* WAVEFORMATEX (cbSize=0) */
/* LIST('strl') sizes = 4 (the 'strl' fourcc) + contained chunks. */
const uint32_t vstrl_size = 4 + (8 + STRH_DATA) + (8 + BIH_DATA); /* 4+64+48 = 116 */
const uint32_t astrl_size = 4 + (8 + STRH_DATA) + (8 + WFX_DATA); /* 4+64+26 = 94 */
/* LIST('hdrl') size = 4 (the 'hdrl' fourcc) + avih chunk + both strl LISTs. */
const uint32_t hdrl_size = 4 + (8 + AVIH_DATA) + (8 + vstrl_size) + (8 + astrl_size);
uint8_t *p = out;
/* RIFF 'AVI ' (size unseekable → streaming sentinel) */
put_fourcc(&p, "RIFF");
put_u32(&p, STREAMING);
put_fourcc(&p, "AVI ");
/* LIST 'hdrl' */
put_fourcc(&p, "LIST");
put_u32(&p, hdrl_size);
put_fourcc(&p, "hdrl");
/* avih — MainAVIHeader (56 bytes) */
put_fourcc(&p, "avih");
put_u32(&p, AVIH_DATA);
put_u32(&p, usec_per_frame); /* dwMicroSecPerFrame */
put_u32(&p, 0); /* dwMaxBytesPerSec */
put_u32(&p, 0); /* dwPaddingGranularity */
put_u32(&p, 0); /* dwFlags — NO index flags */
put_u32(&p, 0); /* dwTotalFrames (unknown in stream) */
put_u32(&p, 0); /* dwInitialFrames */
put_u32(&p, 2); /* dwStreams (video + audio) */
put_u32(&p, 0); /* dwSuggestedBufferSize */
put_u32(&p, width); /* dwWidth */
put_u32(&p, height); /* dwHeight */
put_u32(&p, 0); put_u32(&p, 0); put_u32(&p, 0); put_u32(&p, 0); /* dwReserved[4] */
/* LIST 'strl' — VIDEO */
put_fourcc(&p, "LIST");
put_u32(&p, vstrl_size);
put_fourcc(&p, "strl");
/* strh — AVISTREAMHEADER 'vids' (56 bytes) */
put_fourcc(&p, "strh");
put_u32(&p, STRH_DATA);
put_fourcc(&p, "vids"); /* fccType */
put_fourcc(&p, "UYVY"); /* fccHandler */
put_u32(&p, 0); /* dwFlags */
put_u16(&p, 0); /* wPriority */
put_u16(&p, 0); /* wLanguage */
put_u32(&p, 0); /* dwInitialFrames */
put_u32(&p, fps_den); /* dwScale = 1001 */
put_u32(&p, fps_num); /* dwRate = 60000 */
put_u32(&p, 0); /* dwStart */
put_u32(&p, 0); /* dwLength (unknown) */
put_u32(&p, video_bytes); /* dwSuggestedBufferSize */
put_u32(&p, 0xFFFFFFFFu); /* dwQuality (-1 default) */
put_u32(&p, video_bytes); /* dwSampleSize (fixed for uncompressed) */
put_u16(&p, 0); put_u16(&p, 0); /* rcFrame.left, top */
put_u16(&p, (uint16_t)width); /* rcFrame.right */
put_u16(&p, (uint16_t)height); /* rcFrame.bottom */
/* strf — BITMAPINFOHEADER (40 bytes) */
put_fourcc(&p, "strf");
put_u32(&p, BIH_DATA);
put_u32(&p, 40); /* biSize */
put_u32(&p, width); /* biWidth */
put_u32(&p, height); /* biHeight */
put_u16(&p, 1); /* biPlanes */
put_u16(&p, 16); /* biBitCount (UYVY422 = 16bpp) */
put_fourcc(&p, "UYVY"); /* biCompression fourcc */
put_u32(&p, video_bytes); /* biSizeImage = W*H*2 */
put_u32(&p, 0); /* biXPelsPerMeter */
put_u32(&p, 0); /* biYPelsPerMeter */
put_u32(&p, 0); /* biClrUsed */
put_u32(&p, 0); /* biClrImportant */
/* LIST 'strl' — AUDIO */
put_fourcc(&p, "LIST");
put_u32(&p, astrl_size);
put_fourcc(&p, "strl");
/* strh — AVISTREAMHEADER 'auds' (56 bytes) */
put_fourcc(&p, "strh");
put_u32(&p, STRH_DATA);
put_fourcc(&p, "auds"); /* fccType */
put_u32(&p, 0); /* fccHandler (none for PCM) */
put_u32(&p, 0); /* dwFlags */
put_u16(&p, 0); /* wPriority */
put_u16(&p, 0); /* wLanguage */
put_u32(&p, 0); /* dwInitialFrames */
put_u32(&p, block_align); /* dwScale = nBlockAlign */
put_u32(&p, avg_bytes_sec); /* dwRate = nAvgBytesPerSec */
put_u32(&p, 0); /* dwStart */
put_u32(&p, 0); /* dwLength (unknown) */
put_u32(&p, avg_bytes_sec); /* dwSuggestedBufferSize (~1s) */
put_u32(&p, 0xFFFFFFFFu); /* dwQuality */
put_u32(&p, block_align); /* dwSampleSize = nBlockAlign */
put_u16(&p, 0); put_u16(&p, 0); put_u16(&p, 0); put_u16(&p, 0); /* rcFrame */
/* strf — WAVEFORMATEX (18 bytes) */
put_fourcc(&p, "strf");
put_u32(&p, WFX_DATA);
put_u16(&p, 1); /* wFormatTag = WAVE_FORMAT_PCM */
put_u16(&p, (uint16_t)audio_channels); /* nChannels */
put_u32(&p, audio_rate); /* nSamplesPerSec */
put_u32(&p, avg_bytes_sec); /* nAvgBytesPerSec */
put_u16(&p, block_align); /* nBlockAlign */
put_u16(&p, bits_per_sample); /* wBitsPerSample */
put_u16(&p, 0); /* cbSize */
/* LIST 'movi' — frames follow. Size unseekable → streaming sentinel. */
put_fourcc(&p, "LIST");
put_u32(&p, STREAMING);
put_fourcc(&p, "movi");
return (size_t)(p - out);
}
/* Write a single AVI chunk: 4-byte fourcc + u32 LE size + data (+ pad byte if
* the size is odd, per the RIFF even-alignment rule). Returns 0 / -1. */
static int write_avi_chunk(int fd, const char *cc,
const uint8_t *data, uint32_t size) {
uint8_t hdr[8];
uint8_t *p = hdr;
put_fourcc(&p, cc);
put_u32(&p, size);
if (write_all_fd(fd, hdr, 8) < 0) return -1;
if (size && write_all_fd(fd, data, size) < 0) return -1;
if (size & 1u) {
uint8_t pad = 0;
if (write_all_fd(fd, &pad, 1) < 0) return -1;
}
return 0;
}
int main(int argc, char *argv[]) {
if (argc < 2) {
fprintf(stderr, "Usage: %s <slot_id> [wait_ms] [--avi|-]\n", argv[0]);
return 1;
}
const char *slot_id = argv[1];
uint64_t wait_ms = argc >= 3 ? (uint64_t)atoll(argv[2]) : 30000;
/* AVI mode is selected by an explicit flag in argv[3]. Anything that is not
* "--avi"/"avi" (including "-" or omitted) legacy raw video-only mode. */
int avi_mode = 0;
if (argc >= 4) {
const char *m = argv[3];
if (strcmp(m, "--avi") == 0 || strcmp(m, "avi") == 0) avi_mode = 1;
}
signal(SIGTERM, on_signal);
signal(SIGINT, on_signal);
signal(SIGPIPE, SIG_IGN);
fcntl(STDOUT_FILENO, F_SETFL,
fcntl(STDOUT_FILENO, F_GETFL, 0) & ~O_NONBLOCK);
fprintf(stderr, "[fc_pipe] opening slot '%s' (wait %llums) mode=%s\n",
slot_id, (unsigned long long)wait_ms, avi_mode ? "avi" : "rawvideo");
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;
}
/* Pull stream format from the slot header for the AVI header. */
fc_stream_info_t si;
if (fc_consumer_info(c, &si) != 0 || si.width == 0 || si.height == 0) {
fprintf(stderr, "[fc_pipe] failed to read slot stream info\n");
fc_consumer_close(c);
return 1;
}
if (si.fps_num == 0) { si.fps_num = 60000; si.fps_den = 1001; }
if (si.fps_den == 0) si.fps_den = 1;
if (si.audio_rate == 0) si.audio_rate = 48000;
if (si.audio_channels == 0) si.audio_channels = 2;
if (si.audio_sample_bytes == 0) si.audio_sample_bytes = 2;
const uint32_t video_bytes = si.frame_size ? si.frame_size
: si.width * si.height * 2u;
const uint32_t a_blockalign = si.audio_channels * si.audio_sample_bytes;
/* Samples per video frame for synthesized silence when a frame has no audio:
* round(audio_rate * fps_den / fps_num). Bytes = samples * blockalign. */
uint32_t silence_bytes = 0;
{
double spf = (double)si.audio_rate * (double)si.fps_den / (double)si.fps_num;
uint32_t samples = (uint32_t)(spf + 0.5);
silence_bytes = samples * a_blockalign;
}
uint8_t *silence = NULL;
if (avi_mode && silence_bytes) {
silence = (uint8_t *)calloc(1, silence_bytes);
if (!silence) silence_bytes = 0;
}
if (avi_mode) {
uint8_t hdr[512];
size_t hlen = build_avi_header(hdr, si.width, si.height,
si.fps_num, si.fps_den, video_bytes,
si.audio_rate, si.audio_channels,
si.audio_sample_bytes);
if (write_all_fd(STDOUT_FILENO, hdr, hlen) < 0) {
fprintf(stderr, "[fc_pipe] stdout EPIPE writing AVI header\n");
fc_consumer_close(c); free(silence);
return 1;
}
fprintf(stderr,
"[fc_pipe] slot open, streaming AVI(video+audio) → stdout "
"(%ux%u %u/%u, %ub/frame, audio %uHz %uch s%ule, silence=%uB/frame)\n",
si.width, si.height, si.fps_num, si.fps_den, video_bytes,
si.audio_rate, si.audio_channels, si.audio_sample_bytes * 8u,
silence_bytes);
} else {
fprintf(stderr, "[fc_pipe] slot open, streaming raw video → stdout (%ux%u)\n",
si.width, si.height);
}
uint64_t frames_out = 0;
uint64_t total_dropped = 0;
uint64_t audio_bytes = 0;
uint64_t audio_gaps = 0;
while (!g_stop) {
fc_frame_ref_t ref;
int rc = fc_consumer_read(c, &ref, 2000);
if (rc == FC_TIMEOUT) continue;
if (rc == FC_ERROR) break;
if (rc == FC_LAPPED) {
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) {
total_dropped = fc_consumer_dropped(c);
fprintf(stderr, "[fc_pipe] WARNING: consumer fell behind — total dropped: %llu\n",
(unsigned long long)total_dropped);
}
if (avi_mode) {
/* Interleave THIS frame's video + audio in one stream. Both are
* sourced from the SAME ring entry frame-coupled by construction.
* Video first (00dc), then audio (01wb). */
if (write_avi_chunk(STDOUT_FILENO, "00dc", ref.data, ref.size) < 0) {
if (!g_stop)
fprintf(stderr, "[fc_pipe] stdout EPIPE (video) — ffmpeg exited\n");
break;
}
if (ref.audio_size > 0 && ref.audio) {
if (write_avi_chunk(STDOUT_FILENO, "01wb", ref.audio, ref.audio_size) < 0) {
if (!g_stop)
fprintf(stderr, "[fc_pipe] stdout EPIPE (audio) — ffmpeg exited\n");
break;
}
audio_bytes += ref.audio_size;
} else {
/* No embedded audio this frame: emit one frame-interval of
* silence so the audio stream length tracks the video and
* ffmpeg never starves on the audio demuxer. */
if (silence_bytes &&
write_avi_chunk(STDOUT_FILENO, "01wb", silence, silence_bytes) < 0) {
if (!g_stop)
fprintf(stderr, "[fc_pipe] stdout EPIPE (silence) — ffmpeg exited\n");
break;
}
audio_bytes += silence_bytes;
audio_gaps++;
}
} else {
/* Legacy raw video-only: write the UYVY422 bytes straight to stdout. */
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++;
if (frames_out % 300 == 0) {
fprintf(stderr, "[fc_pipe] frames=%llu dropped=%llu audio_bytes=%llu gaps=%llu\n",
(unsigned long long)frames_out,
(unsigned long long)total_dropped,
(unsigned long long)audio_bytes,
(unsigned long long)audio_gaps);
}
}
free(silence);
fc_consumer_close(c);
fprintf(stderr, "[fc_pipe] done frames=%llu dropped=%llu audio_bytes=%llu gaps=%llu\n",
(unsigned long long)frames_out,
(unsigned long long)total_dropped,
(unsigned long long)audio_bytes,
(unsigned long long)audio_gaps);
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,254 +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;
hdr->audio_max_bytes = FC_MAX_AUDIO_BYTES;
hdr->audio_rate = FC_AUDIO_RATE;
hdr->audio_channels = FC_AUDIO_CHANNELS;
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;
frame->audio_size = 0; /* video-only source (e.g. net_ingest): no embedded audio */
memcpy(frame->data, data, frame->size);
atomic_store_explicit(&hdr->write_cursor, cur + 1, memory_order_release);
sem_post(s->sem);
}
/**
* Write one frame-coupled entry: video bytes + this frame's audio bytes.
* audio may be NULL / asize 0 (no embedded audio this frame). Never blocks.
*/
void fc_slot_write_av(struct fc_slot *s,
const uint8_t *video, uint32_t vsize,
const uint8_t *audio, uint32_t asize,
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 = vsize < hdr->frame_size ? vsize : hdr->frame_size;
memcpy(frame->data, video, frame->size);
uint32_t acap = hdr->audio_max_bytes;
frame->audio_size = (audio && asize) ? (asize < acap ? asize : acap) : 0;
if (frame->audio_size)
memcpy(fc_frame_audio(frame, hdr->frame_size), audio, frame->audio_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;
}
if (tmp_hdr.version != FC_VERSION) {
fprintf(stderr, "[framecache] slot %s version %u != expected %u — refusing (fail-safe)\n",
slot_id, tmp_hdr.version, FC_VERSION);
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,171 +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 + FC_MAX_AUDIO_BYTES bytes]
*
* Writer advances write_cursor atomically and posts the named semaphore.
* Each consumer tracks its own read_cursor independently writer never blocks.
*
* FRAME-COUPLED AUDIO (FC_VERSION 2)
* Each ring entry now carries the VIDEO frame AND that frame's SDI-embedded
* AUDIO together in ONE transport. The per-entry data region is:
*
* [ video bytes : frame_size ][ audio bytes : up to FC_MAX_AUDIO_BYTES ]
*
* fc_frame_t.size = video byte count (== frame_size for UYVY422)
* fc_frame_t.audio_size = audio byte count for THIS frame (s16le stereo 48k),
* 0 if the signal carries no embedded audio on this frame.
*
* Because both streams live in the same ring slot written in one atomic cursor
* advance and read in one consumer iteration, audio can never drift ahead of
* (or behind) its video frame the "audio ahead of video" offset is eliminated
* at the root: there is no second independent buffer/transport to race.
*
* Versioning: FC_VERSION bumped 1 2. The frame header grew from 24 to 24
* bytes (the former _pad uint32 is REUSED as audio_size no size change), but
* the per-entry stride grew by FC_MAX_AUDIO_BYTES and the header gained audio
* descriptor fields. An old (v1) reader mmapping a v2 slot would compute the
* wrong stride and misparse, so readers MUST check version == FC_VERSION and
* fail safe. The writer and all readers share this header; mismatched binaries
* refuse to attach.
*/
#pragma once
#include <stdint.h>
#include <stdatomic.h>
#include <semaphore.h>
#define FC_MAGIC 0x46524D43u /* "FRMC" */
#define FC_VERSION 2u /* bumped for frame-coupled audio */
#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) + audio_size(4) */
#define FC_MAX_SLOT_ID 64u
/* ── Frame-coupled audio constants ──────────────────────────────────────────
* Audio is always delivered as interleaved s16le stereo at 48 kHz (the SDI
* embedded-audio mapping ffmpeg/raw2bmx expect). Worst-case samples per video
* frame at the lowest broadcast frame rate (~23.976 fps) is ~2002 samples;
* 2048 samples * 2 ch * 2 bytes = 8192 bytes. We reserve 16 KB per entry for
* generous headroom (covers transient over-delivery and any future 4-byte
* sample widths) ~1.9 MB extra across the 120-deep ring, negligible. */
#define FC_AUDIO_RATE 48000u
#define FC_AUDIO_CHANNELS 2u
#define FC_AUDIO_SAMPLE_BYTES 2u /* s16le */
#define FC_MAX_AUDIO_BYTES 16384u
/* 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 (video bytes per entry) */
uint32_t ring_depth; /* FC_RING_DEPTH */
uint32_t audio_max_bytes; /* FC_MAX_AUDIO_BYTES — audio region per entry */
uint32_t audio_rate; /* FC_AUDIO_RATE (48000) */
uint32_t audio_channels; /* FC_AUDIO_CHANNELS (2) */
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 - 156];
} fc_header_t;
/* Per-entry metadata + data (variable length — use fc_frame_at() accessor).
*
* data layout (frame-coupled): [video : size bytes][audio : audio_size bytes]
* The audio region is reserved at audio_max_bytes; audio_size <= audio_max_bytes
* tells the reader how many audio bytes are valid for THIS frame.
*/
typedef struct {
uint64_t pts_us;
uint64_t wall_us;
uint32_t size; /* video bytes */
uint32_t audio_size; /* audio bytes for this frame (s16le stereo 48k), 0 = none */
uint8_t data[]; /* [video frame_size][audio audio_max_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);
/* Frame-coupled write: video + this frame's audio in one ring entry. */
void fc_slot_write_av(struct fc_slot *s,
const uint8_t *video, uint32_t vsize,
const uint8_t *audio, uint32_t asize,
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; }
/**
* Per-entry stride: header + video bytes + reserved audio region.
* Shared by writer and all readers frame-coupled audio rides in the same
* entry, so the stride MUST include FC_MAX_AUDIO_BYTES on every component.
*/
static inline size_t fc_entry_stride(uint32_t frame_size) {
return (size_t)FC_FRAME_HDR_SIZE + frame_size + (size_t)FC_MAX_AUDIO_BYTES;
}
/**
* Compute total shm size for a slot given frame_size.
* = FC_HEADER_SIZE + ring_depth * fc_entry_stride(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 * fc_entry_stride(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)
* fc_entry_stride(frame_size));
}
/**
* Return pointer to the audio region of an entry (immediately after the
* frame_size video bytes within data[]).
*/
static inline uint8_t *fc_frame_audio(fc_frame_t *f, uint32_t frame_size) {
return f->data + 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,7 +0,0 @@
-- 037-recorder-audio-offset.sql
-- Per-recorder A/V alignment trim. The capture pipeline applies this as an
-- ffmpeg -itsoffset on the audio input: a POSITIVE value delays audio (use when
-- audio is ahead of video), NEGATIVE advances it. Clamped to +/-1000 ms in the
-- API/capture layers. Default 0 = no shift.
ALTER TABLE recorders
ADD COLUMN IF NOT EXISTS audio_offset_ms integer NOT NULL DEFAULT 0;

File diff suppressed because it is too large Load diff

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

@ -203,18 +203,13 @@ router.post('/', async (req, res, next) => {
id = uuidv4();
const mediaType = (sourceType === 'audio') ? 'audio' : 'video';
const assetStatus = status || 'processing';
// Growing-file masters land on the SMB share as .mxf — tag them 'smb' from
// the moment they're created (while still 'live') so the library shows the
// SMB origin and can always offer the S3 migrate action.
const isGrowingSmb = !!(hiresKey && /\.mxf$/i.test(hiresKey));
const initialTags = isGrowingSmb ? ['smb'] : [];
const ins = await pool.query(
`INSERT INTO assets (
id, project_id, bin_id,
filename, display_name,
status, media_type,
original_s3_key, proxy_s3_key,
duration_ms, tags,
duration_ms,
created_at, updated_at
)
VALUES (
@ -222,7 +217,7 @@ router.post('/', async (req, res, next) => {
$4, $4,
$10, $9,
$5, $6,
$7, $11,
$7,
COALESCE($8::timestamptz, NOW()), NOW()
)
RETURNING *`,
@ -234,7 +229,6 @@ router.post('/', async (req, res, next) => {
capturedAt || null,
mediaType,
assetStatus,
initialTags,
]
);
asset = ins.rows[0];
@ -539,57 +533,41 @@ router.post('/:id/pending-migration', requireAssetEdit, async (req, res, next) =
`UPDATE assets
SET status = 'pending_migration',
duration_ms = COALESCE($2, duration_ms),
-- Tag the growing master as living on SMB (in addition to its live
-- origin) so the library can show it + offer the S3 migrate action.
tags = (
SELECT ARRAY(SELECT DISTINCT unnest(COALESCE(tags, '{}'::text[]) || ARRAY['smb']))
),
updated_at = NOW()
WHERE id = $1
RETURNING *`,
[id, durationMs]
);
console.log(`[assets] set pending-migration status (+smb tag) for asset ${id}`);
console.log(`[assets] set pending-migration status for asset ${id}`);
res.json(upd.rows[0]);
} catch (err) { next(err); }
});
// POST /:id/promote
// Promotes a growing-file / SMB master to S3.
// Normally an asset is 'pending_migration' (flipped by the sidecar on a clean
// growing-file stop). But a growing recording can get STUCK in 'live' if the
// sidecar's post-stop /pending-migration call never lands (crash, network).
// Operators must always be able to migrate those too, so we accept BOTH
// 'pending_migration' and 'live' here. Enqueues a 'promotion' BullMQ job (which
// shows in the Jobs tab) to handle the SMB→S3 upload + metadata updates.
// Promotes an asset from 'pending_migration' (SMB) to S3.
// Enqueues a 'promotion' job in BullMQ to handle the S3 upload and metadata updates.
router.post('/:id/promote', requireAssetEdit, async (req, res, next) => {
try {
const { id } = req.params;
const check = await pool.query(`SELECT status, original_s3_key FROM assets WHERE id = $1`, [id]);
const check = await pool.query(`SELECT status FROM assets WHERE id = $1`, [id]);
if (check.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
const { status, original_s3_key } = check.rows[0];
const { status } = check.rows[0];
const MIGRATABLE = new Set(['pending_migration', 'live']);
if (!MIGRATABLE.has(status)) {
return res.status(400).json({ error: `Asset status is "${status}" — only growing-file (SMB) assets in "pending_migration" or "live" can be migrated to S3` });
}
// Guard: only growing-file masters live on SMB. A non-growing 'live' asset
// (still recording, or a normal upload) has no SMB master to migrate.
if (status === 'live' && !(original_s3_key && /\.mxf$/i.test(original_s3_key))) {
return res.status(400).json({ error: 'This live asset is not a finished growing-file master on SMB.' });
if (status !== 'pending_migration') {
return res.status(400).json({ error: `Asset status is "${status}" — only "pending_migration" assets can be promoted` });
}
// Lock it: 'processing' while the promotion job runs.
// Update status to 'processing' so it is locked
await pool.query(
`UPDATE assets SET status = 'processing', updated_at = NOW() WHERE id = $1`,
[id]
);
// Queue the promotion job in BullMQ — listed in the Jobs tab (type 'promotion').
// Queue the promotion job in BullMQ
await promotionQueue.add('promote', { assetId: id });
console.log(`[assets] queued promotion (SMB→S3) for asset ${id} (was ${status})`);
console.log(`[assets] queued promotion for asset ${id}`);
res.json({ ok: true, status: 'processing' });
} catch (err) { next(err); }
@ -707,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); }
});
@ -905,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 {
@ -988,11 +997,39 @@ router.get('/:id/video', async (req, res, next) => {
if (etag) headers['ETag'] = etag;
if (lastModified) headers['Last-Modified'] = lastModified.toUTCString();
// 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);
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

@ -43,10 +43,9 @@ function pickIp(reportedIp, reqIp) {
const clean = (s) => (s || '').replace(/^::ffff:/, '');
const isDockerBridge = (ip) => /^172\.17\./.test(ip || '');
const r = clean(reqIp);
// Trust payload ip_address when present and not docker0 bridge
if (reportedIp && !isDockerBridge(reportedIp)) return reportedIp;
// Fall back to req.ip when payload missing or docker0
return r || null;
if (!reportedIp) return r || null;
if (isDockerBridge(reportedIp) && r && !isDockerBridge(r)) return r;
return reportedIp;
}
function dockerRequest(path, method = 'GET', body = null) {
@ -73,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(
@ -145,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 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; }
}
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,
ports,
cpu: 0,
memBytes,
};
});
}));
res.json(out);
} catch (err) {
console.warn(`[cluster] failed to fetch containers from ${node.hostname}:`, err.message);
return [];
if (err.code === 'ENOENT' || err.code === 'EACCES') return res.json([]);
next(err);
}
});
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); }
});
router.post('/containers/:nameOrId/restart', async (req, res, next) => {
@ -292,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

@ -29,7 +29,6 @@ const conformQueue = new Queue('conform', { connection: redisConn })
const importQueue = new Queue('import', { connection: redisConn });
const trimQueue = new Queue('trim', { connection: redisConn });
const playoutStageQueue = new Queue('playout-stage', { connection: redisConn });
const promotionQueue = new Queue('promotion', { connection: redisConn });
const QUEUES = [
{ queue: proxyQueue, type: 'proxy' },
@ -39,8 +38,6 @@ const QUEUES = [
{ queue: importQueue, type: 'import' },
{ queue: trimQueue, type: 'trim' },
{ queue: playoutStageQueue, type: 'playout-stage' },
// SMB→S3 migration of growing-file masters. Shows the migrate action in Jobs.
{ queue: promotionQueue, type: 'promotion' },
];
// BullMQ state → API status mapping

View file

@ -154,8 +154,7 @@ const RECORDER_FIELDS = [
'proxy_audio_codec', 'proxy_audio_bitrate', 'proxy_audio_channels',
'proxy_container',
'project_id', 'node_id', 'device_index',
'growing_enabled', 'growing_codec', 'label',
'audio_offset_ms',
'growing_enabled',
];
function pickRecorderFields(body) {
@ -199,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;
@ -228,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
@ -470,7 +342,7 @@ router.post('/', async (req, res, next) => {
recording_audio_channels: 2,
recording_container: 'mov',
proxy_enabled: true,
proxy_codec: 'h264_nvenc',
proxy_codec: 'h264',
proxy_resolution: '1920x1080',
proxy_video_bitrate: '2M',
proxy_audio_codec: 'aac',
@ -502,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);
}
@ -794,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 || ''}`,
@ -802,14 +570,10 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
`RECORDING_AUDIO_BITRATE=${recorder.recording_audio_bitrate || ''}`,
`RECORDING_AUDIO_CHANNELS=${recorder.recording_audio_channels ?? 2}`,
`RECORDING_CONTAINER=${recorder.recording_container || 'mov'}`,
// Per-recorder A/V alignment trim. capture-manager applies this as an
// -itsoffset on the audio input (positive delays audio = fixes audio-ahead).
// Clamped to +/-1000ms. Default 0.
`AUDIO_OFFSET_MS=${Math.max(-1000, Math.min(1000, parseInt(recorder.audio_offset_ms, 10) || 0))}`,
// 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 || ''}`,
@ -823,9 +587,6 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
`ASSET_ID=${assetIdLive}`,
`MAM_API_TOKEN=${process.env.CAPTURE_TOKEN || ''}`,
`GROWING_ENABLED=${growingEnabled ? 'true' : 'false'}`,
// Growing codec: VC-3/DNxHD MXF (ffmpeg-direct, Premiere-native edit-while-
// record — matches vMix). 'vc3_90' (90 Mbps, default) or 'vc3_220' (220 Mbps).
`GROWING_CODEC=${['vc3_220','vc3_90'].includes(recorder.growing_codec) ? recorder.growing_codec : 'vc3_90'}`,
`GROWING_PATH=/growing`,
// SMB mount details for the in-container CIFS mount (Approach A). Empty
// GROWING_SMB_MOUNT → capture falls back to the host-bound /growing volume
@ -843,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) {
@ -881,90 +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',
growing_codec: ['vc3_220','vc3_90'].includes(recorder.growing_codec) ? recorder.growing_codec : 'vc3_90',
audio_offset_ms: Math.max(-1000, Math.min(1000, parseInt(recorder.audio_offset_ms, 10) || 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) {
// 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' },
@ -983,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}`;
@ -1103,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',
@ -1176,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 {
@ -1187,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(() => {});
}
@ -1273,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,
@ -1392,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)` });
}
@ -1409,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 {
@ -1434,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,145 +308,34 @@ 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();
if (sourceType === 'deltacast') {
_dcSidecarCount--;
if (_dcSidecarCount <= 0) { _dcSidecarCount = 0; stopDeltacastBridge(); }
}
return jsonResponse(res, 502, { error: 'Failed to create container', details: createRes.data });
}
@ -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);
// Deltacast bridge lifecycle: decrement sidecar count; stop bridge when last.
if (_containerSourceType.get(containerId) === 'deltacast') {
_containerSourceType.delete(containerId);
if (_srcType === 'deltacast') {
_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

@ -2,7 +2,7 @@
"manifestVersion": 5,
"id": "net.wilddragon.dragonflight.uxp",
"name": "Dragonflight MAM",
"version": "2.2.4",
"version": "2.2.3",
"main": "index.html",
"host": {
"app": "premierepro",

View file

@ -1,4 +1,4 @@
// Dragonflight API client — v2.1.5
// Dragonflight API client — v2.1.4
// UXP fetch() notes:
// • redirect:'manual' is NOT a supported option — UXP auto-follows redirects
// • Authorization is stripped by UXP on cross-origin redirects (security fix)
@ -31,21 +31,6 @@
function trimUrl(u) { return String(u || '').replace(/\/+$/, ''); }
// Pull the human-readable error string out of an API error response.
// mam-api returns { error: "..." } (sometimes with a code); fall back to
// the raw text, then to the HTTP status line.
async function _errText(r) {
const raw = await r.text().catch(() => '');
if (raw) {
try {
const j = JSON.parse(raw);
if (j && (j.error || j.message)) return j.error || j.message;
} catch (_) { /* not JSON — use raw */ }
return raw.slice(0, 240);
}
return 'HTTP ' + r.status;
}
// Core request — adds Bearer only for same-server URLs.
// No redirect option — UXP handles redirects automatically.
API.request = async function (urlOrPath, opts) {
@ -158,11 +143,7 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(clips),
});
if (!r.ok) {
// Surface the server's real reason (e.g. cross-project asset, bad frame
// fields) instead of a bare status code — conform failures were opaque.
throw new Error('Clip push failed (HTTP ' + r.status + '): ' + (await _errText(r)));
}
if (!r.ok) throw new Error('Clip push HTTP ' + r.status);
return r.json();
};

View file

@ -1,20 +1,29 @@
// import-flow.js — v2.2.4
// import-flow.js — v2.2.3
// premierepro API: docs say sync, runtime returns Promises. Await everything.
//
// NOTE: UXP's require('fs') exposes promise-returning writeFile/stat/readFile
// directly — await them as-is. Do NOT wrap them in a node-style
// `fs.promises || callback` shim: UXP's methods are not node-callback-style,
// so the callback fallback never settles and import hangs (regressed in
// v2.2.3, reverted here).
(function () {
const Import = {};
const fs = require('fs');
const fsPromises = fs.promises || {};
// window.path is a UXP global (v6.4+) — no require('path')
let os; try { os = require('os'); } catch (_) { os = {}; }
let uxpFs; try { uxpFs = require('uxp').storage.localFileSystem; } catch (_) { uxpFs = null; }
// UXP fs is callback-based — wrap in Promise where promisify unavailable.
function _writeFile(path, data) {
if (fsPromises.writeFile) return fsPromises.writeFile(path, data);
return new Promise((resolve, reject) => { fs.writeFile(path, data, (err) => { if (err) reject(err); else resolve(); }); });
}
function _readFile(path) {
if (fsPromises.readFile) return fsPromises.readFile(path);
return new Promise((resolve, reject) => { fs.readFile(path, (err, data) => { if (err) reject(err); else resolve(data); }); });
}
function _stat(path) {
if (fsPromises.stat) return fsPromises.stat(path);
return new Promise((resolve, reject) => { fs.stat(path, (err, stats) => { if (err) reject(err); else resolve(stats); }); });
}
// ── Temp folder ──────────────────────────────────────────────────
async function _getTempBase() {
if (uxpFs && uxpFs.getTemporaryFolder) {
@ -45,7 +54,7 @@
// Returns true if the path already exists on disk.
Import._fileExists = async function (filePath) {
try { await fs.stat(filePath); return true; } catch (_) { return false; }
try { await _stat(filePath); return true; } catch (_) { return false; }
};
// Write ArrayBuffer to disk via fs.writeFile.
@ -53,7 +62,7 @@
// previous import) we treat that as success: the bytes are already there.
Import._writeBuffer = async function (destPath, arrayBuffer) {
try {
await fs.writeFile(destPath, arrayBuffer);
await _writeFile(destPath, arrayBuffer);
} catch (e) {
const busy = e.code === 'EBUSY' || /resource busy/i.test(String(e.message));
if (!busy) throw e;
@ -185,7 +194,7 @@
const filename = meta.filename || path.basename(nativePath);
const contentType = _contentType(filename);
const buf = await fs.readFile(nativePath);
const buf = await _readFile(nativePath);
const size = buf.byteLength != null ? buf.byteLength : buf.length;
if (size <= SIMPLE_MAX) {

View file

@ -1,4 +1,4 @@
// timeline.js — v2.2.0
// timeline.js — v2.1.6
// premierepro API: docs say sync, runtime returns Promises. Await everything.
(function () {
@ -195,66 +195,19 @@
return out.join('\n');
};
// Resolve the MAM project that actually owns the matched clip assets.
// mam-api requires every clip in PUT /sequences/:id/clips to belong to the
// sequence's project; pushing to the "wrong" project 400s. Rather than trust
// the operator's picked project, ask the server which project each asset
// lives in and reconcile:
// • all assets in one project → return that project (authoritative).
// • assets span >1 project → throw a clear, per-project breakdown.
// requestedProjectId is only used as a tie-break hint / for the error text.
Timeline._resolveClipProject = async function (matched, requestedProjectId) {
const ids = [...new Set(matched.map(c => c.asset_id).filter(Boolean))];
// Map asset_id → project_id (+ display name for error messages).
const byProject = {}; // projectId → [{id, name}]
for (const id of ids) {
let asset;
try { asset = await API.getAsset(id); }
catch (e) { throw new Error('Could not look up asset ' + id + ': ' + e.message); }
const pid = asset && (asset.project_id || asset.projectId);
if (!pid) throw new Error('Asset "' + ((asset && (asset.display_name || asset.filename)) || id) + '" has no project — re-import it before conforming.');
(byProject[pid] = byProject[pid] || []).push({
id, name: (asset.display_name || asset.filename || id),
});
}
const projectIds = Object.keys(byProject);
if (projectIds.length === 0) throw new Error('No resolvable clip assets to conform.');
if (projectIds.length === 1) return projectIds[0];
// Genuinely mixed timeline — build an actionable message.
const lines = projectIds.map(pid => {
const names = byProject[pid].map(a => a.name).slice(0, 4);
const more = byProject[pid].length > 4 ? ' +' + (byProject[pid].length - 4) + ' more' : '';
return '• project ' + pid + ': ' + names.join(', ') + more;
});
throw new Error(
'This timeline mixes clips from ' + projectIds.length + ' different MAM projects, ' +
'so it cannot conform into one sequence:\n' + lines.join('\n') +
'\nKeep each conform to clips from a single project (or re-import the sources into one project).'
);
};
// ── Push Timeline to MAM ─────────────────────────────────────────
Timeline.pushToMAM = async function (seqName, projectId, td) {
const resolved = Library.resolveClipsToAssets(td.clips || []);
const matched = resolved.filter(c => c.asset_id);
if (!matched.length) throw new Error('No clips matched MAM assets — import proxies first so the plugin can map file paths to asset IDs');
// Conform into the project that actually owns the clip assets. This makes
// the common "operator picked the wrong project" case just work, and turns
// a genuinely mixed timeline into a clear error instead of an opaque 400.
const effProjectId = await Timeline._resolveClipProject(matched, projectId);
const seqs = await API.listSequences(effProjectId);
const seqs = await API.listSequences(projectId);
let seqId;
const existing = seqs.find(s => s.name === seqName);
if (existing) {
await API.updateSequence(existing.id, { frame_rate: td.frameRate, width: td.width, height: td.height });
seqId = existing.id;
} else {
const created = await API.createSequence(effProjectId, seqName, td.frameRate, td.width, td.height);
const created = await API.createSequence(projectId, seqName, td.frameRate, td.width, td.height);
seqId = created.id;
}
await API.pushClips(seqId, matched.map(c => ({
@ -262,7 +215,7 @@
timeline_in_frames: c.timelineInFrames, timeline_out_frames: c.timelineOutFrames,
source_in_frames: c.sourceInFrames, source_out_frames: c.sourceOutFrames,
})));
return { seqId, projectId: effProjectId, matched: matched.length, skipped: resolved.length - matched.length };
return { seqId, matched: matched.length, skipped: resolved.length - matched.length };
};
// ── Conform ──────────────────────────────────────────────────────

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,7 @@ function App() {
const [route, setRoute] = React.useState('home');
const [openAsset, setOpenAsset] = React.useState(null);
const [openProject, setOpenProject] = React.useState(null);
const [showNewRecorder, setShowNewRecorder] = React.useState(false);
const [dataReady, setDataReady] = React.useState(false);
const [loadError, setLoadError] = React.useState(null);
const [sidebarCollapsed, setSidebarCollapsed] = React.useState(() => {
@ -80,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 }));
@ -111,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;
@ -122,10 +123,10 @@ 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} />; break;
case 'recorders': content = <Recorders navigate={navigate} onNew={() => setShowNewRecorder(true)} />; break;
case 'schedule': content = <Schedule navigate={navigate} />; break;
case 'youtube': content = <YouTubeImport navigate={navigate} />; break;
case 'capture': content = <Capture navigate={navigate} />; break;
@ -136,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} />;
@ -161,6 +161,7 @@ function App() {
)}
{content}
</div>
{showNewRecorder && <NewRecorderModal open={showNewRecorder} onClose={() => setShowNewRecorder(false)} />}
</div>
);
}

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

@ -49,6 +49,7 @@
<script src="dist/screens-editor.js"></script>
<script src="dist/screens-admin.js"></script>
<script src="dist/screens-playout.js"></script>
<script src="dist/modal-new-recorder.js"></script>
<script src="dist/app.js"></script>
</body>
</html>

View file

@ -0,0 +1,589 @@
// modal-new-recorder.jsx - New Recorder dialog (SRT / RTMP / SDI / Deltacast)
/**
* DevicePortPicker - groups a flat per-port API response by node_id and
* renders one button per actual port. Replaces the old code that iterated
* over entries and synthesised port counts, which caused duplicate groups.
*
* props:
* ports - flat array from /cluster/devices/blackmagic or /deltacast
* each entry: { node_id, hostname, model, index, device, present? }
* selectedIdx - currently selected device_index
* selectedNode - currently selected node_id
* onSelect(idx, nodeId)
* portLabel - e.g. "SDI" or "Port"
* showTestBadge - show TEST CARD badge when present===false
*/
function DevicePortPicker({ ports, selectedIdx, selectedNode, onSelect, portLabel = 'Port', showTestBadge = false }) {
// Group by node_id (stable - one group per physical node)
const groups = React.useMemo(() => {
const map = new Map();
for (const p of ports) {
const key = p.node_id || p.hostname || 'unknown';
if (!map.has(key)) map.set(key, { nodeId: p.node_id || p.hostname || '', hostname: p.hostname || key, model: p.model || '', ports: [] });
map.get(key).ports.push(p);
}
// Sort ports within each group by index
for (const g of map.values()) g.ports.sort((a, b) => a.index - b.index);
return Array.from(map.values());
}, [ports]);
return (
<div className="sdi-port-mini">
{groups.map(group => (
<div key={group.nodeId} style={{ marginBottom: groups.length > 1 ? 12 : 4 }}>
{/* Node header: only show when multiple groups, or always for clarity */}
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.08em', color: 'var(--text-3)', textTransform: 'uppercase', padding: '0 0 6px' }}>
{group.model ? group.model.toUpperCase() + ' · ' : ''}{group.hostname}
</div>
{group.ports.map(port => {
const active = selectedIdx === port.index && selectedNode === group.nodeId;
return (
<button key={port.index}
className={`sdi-mini-port${active ? ' active' : ''}`}
onClick={() => onSelect(port.index, group.nodeId)}>
<span className="sdi-mini-radio"><span className="sdi-mini-radio-dot" /></span>
<span style={{ fontSize: 12, fontWeight: 600 }}>
{portLabel} {port.index + 1}
{showTestBadge && port.present === false && (
<span style={{ fontSize: 9, fontWeight: 700, letterSpacing: '0.06em', color: 'var(--accent)', background: 'var(--accent-soft)', borderRadius: 3, padding: '1px 5px', marginLeft: 7 }}>
TEST CARD
</span>
)}
</span>
<span style={{ fontSize: 11, color: 'var(--text-3)', marginLeft: 'auto', fontFamily: 'var(--font-mono)' }}>
{port.device ? port.device.split('/').pop() : `index ${port.index}`}
</span>
</button>
);
})}
</div>
))}
</div>
);
}
/**
* ManualDevicePicker - fallback when no devices detected. Lets the operator
* pick node + index from dropdowns.
*/
function ManualDevicePicker({ nodes, nodeId, deviceIdx, portLabel, portCount, onNodeChange, onIdxChange, emptyNote }) {
return (
<div style={{ padding: '4px 0' }}>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginBottom: 10 }}>
{emptyNote || `No ${portLabel} devices auto-detected. Configure manually:`}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<div className="field">
<label className="field-label">Capture node</label>
<select className="field-input" value={nodeId}
onChange={e => onNodeChange(e.target.value)} style={{ appearance: 'auto' }}>
{nodes.length === 0
? <option value="">No cluster nodes</option>
: nodes.map(n => {
const id = n.id || n.hostname || n.name || '';
return <option key={id} value={id}>{n.hostname || n.name || id}</option>;
})}
</select>
</div>
<div className="field">
<label className="field-label">{portLabel} index</label>
<select className="field-input" value={deviceIdx}
onChange={e => onIdxChange(Number(e.target.value))} style={{ appearance: 'auto' }}>
{Array.from({ length: portCount }, (_, i) =>
<option key={i} value={i}>{portLabel} {i + 1} (index {i})</option>)}
</select>
</div>
</div>
</div>
);
}
function ProbeResult({ result }) {
if (!result.ok) {
return (
<div style={{ marginTop: 6, padding: '8px 10px', background: 'var(--danger-soft)', border: '1px solid var(--danger)', borderRadius: 5, fontSize: 11.5, color: 'var(--danger)' }}>
Probe failed: {result.error}
</div>
);
}
const d = result.data || {};
const entries = Object.entries(d).filter(([, v]) => v !== null && typeof v !== 'object');
if (entries.length === 0) {
return (
<div style={{ marginTop: 6, padding: '8px 10px', background: 'var(--success-soft)', border: '1px solid var(--success)', borderRadius: 5, fontSize: 11.5, color: 'var(--success)' }}>
Source reachable
</div>
);
}
return (
<div style={{ marginTop: 6, padding: '10px 12px', background: 'var(--bg-2)', border: '1px solid var(--success)', borderRadius: 5, fontSize: 11.5 }}>
{entries.map(([k, v]) => (
<div key={k} style={{ display: 'flex', gap: 8, padding: '2px 0' }}>
<span style={{ color: 'var(--text-3)', minWidth: 100, flexShrink: 0 }}>{k}</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11 }}>{String(v)}</span>
</div>
))}
</div>
);
}
function NewRecorderModal({ open, onClose }) {
const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
const NODES = window.ZAMPP_DATA?.NODES || [];
const [name, setName] = React.useState('');
const [sourceType, setSourceType] = React.useState('SRT');
const [srtUrl, setSrtUrl] = React.useState('srt://10.0.4.18:4200');
const [rtmpUrl, setRtmpUrl] = React.useState('rtmp://stream.local/live/cam_a');
const [sdiDeviceIdx, setSdiDeviceIdx] = React.useState(0);
const [sdiNodeId, setSdiNodeId] = React.useState(() => {
const n = NODES[0];
return n ? (n.id || n.hostname || '') : '';
});
const [sdiDevices, setSdiDevices] = React.useState(null);
const [dcDeviceIdx, setDcDeviceIdx] = React.useState(0);
const [dcNodeId, setDcNodeId] = React.useState(() => {
const n = NODES[0];
return n ? (n.id || n.hostname || '') : '';
});
const [dcDevices, setDcDevices] = React.useState(null);
const [recTab, setRecTab] = React.useState('video');
// All-Intra HEVC (NVENC) is the default master GPU-encoded, growing-file
// capable in fragmented MOV. ProRes stays selectable for 4:2:2 mezzanine.
const [recCodec, setRecCodec] = React.useState('hevc_nvenc');
// Custom target bitrate in Mbps for bitrate-controlled codecs (NVENC / x264 /
// x265 / DNxHD). ProRes ignores bitrate (quality is profile-driven).
const [recBitrate, setRecBitrate] = React.useState('25');
// Container is derived from the codec, not manually chosen: HEVC/ProRes/DNxHR
// MOV (fragmented, growing-capable); H.264 MP4.
const recContainer = (recCodec === 'h264' || recCodec === 'h264_nvenc' || recCodec === 'libx264') ? 'mp4' : 'mov';
// Codecs whose bitrate is operator-controlled (everything except ProRes).
const BITRATE_CODECS = new Set(['hevc_nvenc', 'h264_nvenc', 'libx264', 'libx265', 'dnxhd', 'dnxhr_hq']);
const codecUsesBitrate = BITRATE_CODECS.has(recCodec);
const [proxyOn, setProxyOn] = React.useState(true);
const [growingOn, setGrowingOn] = React.useState(false);
// Growing-files mode forces the master to XDCAM HD422 / MXF OP1a in the capture
// backend (the only growing format Premiere can import live), but the target
// bitrate is still operator-controlled and applied via -b:v. Keep the bitrate
// input visible/editable whenever growing is on, even if the selected (and
// soon-to-be-overridden) codec would normally be quality-driven (ProRes).
const showBitrate = codecUsesBitrate || growingOn;
const [projectId, setProjectId] = React.useState(PROJECTS[0]?.id || '');
const [submitting, setSubmitting] = React.useState(false);
const [submitErr, setSubmitErr] = React.useState(null);
const [probing, setProbing] = React.useState(false);
const [probeResult, setProbeResult] = React.useState(null);
React.useEffect(() => {
if (sourceType !== 'SDI' || sdiDevices !== null) return;
window.ZAMPP_API.fetch('/cluster/devices/blackmagic')
.then(d => setSdiDevices(Array.isArray(d) ? d : []))
.catch(() => setSdiDevices([]));
}, [sourceType]);
React.useEffect(() => {
if (sourceType !== 'DELTACAST' || dcDevices !== null) return;
window.ZAMPP_API.fetch('/cluster/devices/deltacast')
.then(d => setDcDevices(Array.isArray(d) ? d : []))
.catch(() => setDcDevices([]));
}, [sourceType]);
React.useEffect(() => { setProbeResult(null); }, [sourceType, srtUrl, rtmpUrl]);
const handleProbe = () => {
setProbing(true);
setProbeResult(null);
const body = sourceType === 'SRT'
? { source_type: 'srt', url: srtUrl }
: { source_type: 'rtmp', url: rtmpUrl };
window.ZAMPP_API.fetch('/recorders/probe', { method: 'POST', body: JSON.stringify(body) })
.then(r => { setProbing(false); setProbeResult({ ok: true, data: r }); })
.catch(e => { setProbing(false); setProbeResult({ ok: false, error: e.message }); });
};
const handleCreate = () => {
if (!name.trim()) { setSubmitErr('Recorder name is required.'); return; }
if (sourceType === 'SDI' && !sdiNodeId) { setSubmitErr('Select a capture node for SDI.'); return; }
if (sourceType === 'DELTACAST' && !dcNodeId) { setSubmitErr('Select a capture node for Deltacast.'); return; }
setSubmitting(true);
setSubmitErr(null);
const body = {
name: name.trim(),
source_type: sourceType.toLowerCase(),
project_id: projectId || undefined,
generate_proxy: proxyOn,
growing_enabled: growingOn,
recording_codec: recCodec,
recording_container: recContainer,
// Framerate + resolution are auto-detected from the source signal/stream.
recording_framerate: '', // empty = match source
recording_resolution: 'native',
};
// Custom bitrate applies to bitrate-controlled codecs AND to growing-files
// mode (which forces H.264/TS in capture but still honors -b:v). ProRes
// without growing ignores bitrate, so we omit it there.
if ((codecUsesBitrate || growingOn) && recBitrate) {
body.recording_video_bitrate = `${recBitrate}M`;
}
if (sourceType === 'SRT') {
body.source_config = { url: srtUrl };
} else if (sourceType === 'RTMP') {
body.source_config = { url: rtmpUrl };
} else if (sourceType === 'DELTACAST') {
// One Deltacast board (index 0) exposes 8 channels. The picker's selected
// index IS the capture channel, so persist it as source_config.port; the
// capture sidecar maps that to the bridge's --port. device_index is kept
// for backward-compatible display/fallback.
body.source_config = { port: dcDeviceIdx };
body.device_index = dcDeviceIdx;
body.node_id = dcNodeId || undefined;
} else {
// SDI (DeckLink): device_index and node_id are top-level fields
body.source_config = {};
body.device_index = sdiDeviceIdx;
body.node_id = sdiNodeId || undefined;
}
window.ZAMPP_API.fetch('/recorders', { method: 'POST', body: JSON.stringify(body) })
.then(() => {
setSubmitting(false);
// Recorders list listens for this and re-fetches; otherwise the
// operator has to wait for the next 10s poll tick to see the new row.
window.dispatchEvent(new CustomEvent('df:recorders-changed'));
onClose();
})
.catch(e => { setSubmitting(false); setSubmitErr(e.message || 'Failed to create recorder'); });
};
if (!open) return null;
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div>
<div style={{ fontSize: 15, fontWeight: 600 }}>New recorder</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 2 }}>Configure source, codec, and destination</div>
</div>
<button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
</div>
<div className="modal-body">
<div className="field">
<label className="field-label">Recorder name</label>
<input className="field-input" placeholder="e.g. Studio A Stage Cam"
value={name} onChange={e => setName(e.target.value)} />
</div>
<div className="field">
<label className="field-label">Source type</label>
<div className="source-type-grid">
{[
{ id: 'SRT', label: 'SRT', desc: 'Secure Reliable Transport · pull caller', icon: 'signal' },
{ id: 'RTMP', label: 'RTMP', desc: 'Real-Time Messaging Protocol', icon: 'globe' },
{ id: 'SDI', label: 'SDI', desc: 'Blackmagic DeckLink hardware', icon: 'video' },
{ id: 'DELTACAST', label: 'Deltacast', desc: 'Deltacast VideoMaster SDI', icon: 'video' },
].map(t => (
<button key={t.id}
className={`source-type-card ${sourceType === t.id ? 'active' : ''}`}
onClick={() => setSourceType(t.id)}>
<div className="source-type-icon"><Icon name={t.icon} size={16} /></div>
<div>
<div style={{ fontWeight: 600, fontSize: 13 }}>{t.label}</div>
<div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 2 }}>{t.desc}</div>
</div>
</button>
))}
</div>
</div>
{sourceType === 'SRT' && (
<div className="field">
<label className="field-label">Source URL</label>
<div style={{ display: 'flex', gap: 6 }}>
<input className="field-input mono" placeholder="srt://192.168.1.100:4200"
value={srtUrl} onChange={e => setSrtUrl(e.target.value)} style={{ flex: 1 }} />
<button className="btn ghost sm" onClick={handleProbe} disabled={probing}
style={{ flexShrink: 0, minWidth: 64 }}>
{probing ? '…' : 'Probe'}
</button>
</div>
<div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 4 }}>
Recorder connects out to this URL (caller mode).
</div>
{probeResult && <ProbeResult result={probeResult} />}
</div>
)}
{sourceType === 'RTMP' && (
<div className="field">
<label className="field-label">Source URL</label>
<div style={{ display: 'flex', gap: 6 }}>
<input className="field-input mono" placeholder="rtmp://server/live/streamkey"
value={rtmpUrl} onChange={e => setRtmpUrl(e.target.value)} style={{ flex: 1 }} />
<button className="btn ghost sm" onClick={handleProbe} disabled={probing}
style={{ flexShrink: 0, minWidth: 64 }}>
{probing ? '…' : 'Probe'}
</button>
</div>
<div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 4 }}>
Recorder pulls this RTMP stream.
</div>
{probeResult && <ProbeResult result={probeResult} />}
</div>
)}
{sourceType === 'SDI' && (
<div className="field">
<label className="field-label">Capture device</label>
{sdiDevices === null && (
<div style={{ padding: '10px 0', color: 'var(--text-3)', fontSize: 12 }}>Detecting DeckLink devices</div>
)}
{sdiDevices !== null && sdiDevices.length > 0 && (
<DevicePortPicker
ports={sdiDevices}
selectedIdx={sdiDeviceIdx}
selectedNode={sdiNodeId}
onSelect={(idx, nodeId) => { setSdiDeviceIdx(idx); setSdiNodeId(nodeId); }}
portLabel="SDI"
/>
)}
{sdiDevices !== null && sdiDevices.length === 0 && (
<ManualDevicePicker
nodes={NODES}
nodeId={sdiNodeId}
deviceIdx={sdiDeviceIdx}
portLabel="SDI"
portCount={4}
onNodeChange={setSdiNodeId}
onIdxChange={setSdiDeviceIdx}
/>
)}
</div>
)}
{sourceType === 'DELTACAST' && (
<div className="field">
<label className="field-label">Capture device</label>
{dcDevices === null && (
<div style={{ padding: '10px 0', color: 'var(--text-3)', fontSize: 12 }}>Detecting Deltacast devices</div>
)}
{dcDevices !== null && dcDevices.length > 0 && (
<DevicePortPicker
ports={dcDevices}
selectedIdx={dcDeviceIdx}
selectedNode={dcNodeId}
onSelect={(idx, nodeId) => { setDcDeviceIdx(idx); setDcNodeId(nodeId); }}
portLabel="Port"
showTestBadge
/>
)}
{dcDevices !== null && dcDevices.length === 0 && (
<ManualDevicePicker
nodes={NODES}
nodeId={dcNodeId}
deviceIdx={dcDeviceIdx}
portLabel="Port"
portCount={8}
onNodeChange={setDcNodeId}
onIdxChange={setDcDeviceIdx}
emptyNote="No Deltacast devices detected. Configure manually (test-card mode):"
/>
)}
</div>
)}
<div className="modal-section">
<div className="modal-section-head">
<span>Master recording</span>
<span style={{ flex: 1 }} />
<div className="tab-group">
{['video', 'audio', 'container'].map(t => (
<button key={t} className={recTab === t ? 'active' : ''} onClick={() => setRecTab(t)}>
{t[0].toUpperCase() + t.slice(1)}
</button>
))}
</div>
</div>
<div className="modal-section-body">
{recTab === 'video' && (
<>
{/* Codec presets one click fills codec + bitrate with a known-good
combo that passes the server-side validateRecorderConfig guard.
Container is derived from the codec (HEVC/ProRes/DNxHR MOV,
H.264 MP4), and master audio is always PCM (valid in MOV). */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
{[
{ 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' : ''}`}
onClick={() => { setRecCodec(p.codec); setRecBitrate(p.bitrate); }}
style={{ flexShrink: 0 }}>
{p.label}
</button>
))}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<div className="field">
<label className="field-label">
Video codec{growingOn && <span style={{ marginLeft: 6, fontSize: 10, fontWeight: 700, letterSpacing: '0.06em', color: 'var(--warn, #d9a441)' }}>· LOCKED BY GROWING</span>}
</label>
<select className="field-input" value={growingOn ? 'h264_growing' : recCodec}
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">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 ? (
<div className="field">
<label className="field-label">Target bitrate (Mbps)</label>
<input
className="field-input"
type="number" min="1" max="400" step="1"
value={recBitrate}
onChange={e => setRecBitrate(e.target.value)}
/>
</div>
) : (
<Field label="Bitrate" value="Quality-based (profile)" select />
)}
<Field label="Resolution" value="Auto (from source)" select />
<Field label="Framerate" value="Auto (from source)" select />
{/* #3: warn when the configured bitrate exceeds the probed source
bitrate re-encoding above source adds storage, not quality. */}
{codecUsesBitrate && (() => {
const d = probeResult && probeResult.ok ? (probeResult.data || {}) : null;
const raw = d && (d.bitrate ?? d.bit_rate ?? d.video_bitrate ?? (d.video && d.video.bit_rate));
const srcMbps = raw ? (Number(raw) > 100000 ? Number(raw) / 1e6 : Number(raw)) : null;
const cfg = parseFloat(recBitrate);
if (srcMbps && cfg && cfg > srcMbps * 1.05) {
return (
<div style={{ gridColumn: '1 / -1', fontSize: 11.5, color: 'var(--warn, #d9a441)', border: '1px solid var(--warn, #d9a441)', borderRadius: 6, padding: '8px 10px', background: 'rgba(217,164,65,0.08)' }}>
Target {cfg} Mbps exceeds the source stream (~{srcMbps.toFixed(1)} Mbps). Encoding above the source bitrate increases file size without adding quality.
</div>
);
}
return null;
})()}
</div>
</>
)}
{recTab === 'audio' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<Field label="Audio codec" value="PCM" select />
<Field label="Sample rate" value="48 kHz" select />
<Field label="Channels" value="2.0 stereo" select />
<Field label="Bit depth" value="24-bit" select />
</div>
)}
{recTab === 'container' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<Field label="Container"
value={growingOn ? 'MXF OP1a (growing, auto)' : (recContainer === 'mp4' ? 'MP4 (auto)' : 'Fragmented MOV (auto)')} select />
<Field label="Growing-file"
value={growingOn ? 'On (edit-while-record, locked)' : (recContainer === 'mov' ? 'Supported (edit-while-record)' : 'No')} select />
</div>
)}
</div>
</div>
<div className="modal-toggle-row">
<label className="switch">
<input type="checkbox" checked={proxyOn} onChange={e => setProxyOn(e.target.checked)} />
<span className="switch-track"><span className="switch-knob" /></span>
</label>
<div>
<div style={{ fontWeight: 500, fontSize: 13 }}>Generate proxy</div>
<div style={{ fontSize: 11, color: 'var(--text-3)' }}>
SDI sources record proxy in parallel. Network sources generate proxy after stop.
</div>
</div>
</div>
<div className="modal-toggle-row">
<label className="switch">
<input type="checkbox" checked={growingOn} onChange={e => setGrowingOn(e.target.checked)} />
<span className="switch-track"><span className="switch-knob" /></span>
</label>
<div>
<div style={{ fontWeight: 500, fontSize: 13 }}>Growing-files mode</div>
<div style={{ fontSize: 11, color: 'var(--text-3)' }}>
Write the live master to the SMB share so editors can cut while it's still recording.
Requires the SMB share to be configured in Settings Storage.
</div>
{growingOn && (
<div style={{ fontSize: 11, color: 'var(--warn, #d9a441)', marginTop: 6 }}>
Growing-files mode records XDCAM HD422 (MPEG-2 4:2:2 CBR) in MXF OP1a the format Premiere supports for edit-while-record growing files. Bitrate below still applies.
Premiere can import while it's still being written. The codec and container above
are overridden for this recorder (the target bitrate still applies). Turn growing
off to record your selected master codec/container.
</div>
)}
</div>
</div>
{proxyOn && (
<div className="modal-section">
<div className="modal-section-head"><span>Proxy</span></div>
<div className="modal-section-body">
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
{['H.264', '2 Mbps', 'MP4', '1920×1080', 'AAC 128 kbps'].map(tag => (
<span key={tag} className="mono" style={{ background: 'var(--bg-3)', borderRadius: 4, padding: '2px 8px', fontSize: 12 }}>{tag}</span>
))}
</div>
<div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 6 }}>Fixed proxy profile. Not configurable.</div>
</div>
</div>
)}
<div className="modal-section">
<div className="modal-section-head"><span>Destination</span></div>
<div className="modal-section-body">
<div className="field">
<label className="field-label">Project</label>
<select className="field-input" value={projectId}
onChange={e => setProjectId(e.target.value)} style={{ appearance: 'auto' }}>
{PROJECTS.length === 0
? <option value="">No projects</option>
: PROJECTS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
</div>
</div>
{submitErr && (
<div style={{
padding: '10px 14px', background: 'var(--danger-soft)',
border: '1px solid var(--danger)', borderRadius: 6,
fontSize: 12.5, color: 'var(--danger)', marginTop: 4,
}}>{submitErr}</div>
)}
</div>
<div className="modal-foot">
<button className="btn ghost" onClick={onClose}>Cancel</button>
<span style={{ flex: 1 }} />
<button className="btn primary" onClick={handleCreate} disabled={submitting}>
{submitting ? 'Creating…' : 'Create recorder'}
</button>
</div>
</div>
</div>
);
}
window.NewRecorderModal = NewRecorderModal;

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>
@ -1200,144 +1192,6 @@ function Containers() {
</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

@ -89,8 +89,8 @@ function AssetDetail({ asset, onClose }) {
if (!streamUrl || streamType !== 'hls' || !videoRef.current) return;
if (!window.Hls) return;
const hls = new window.Hls();
hls.loadSource(streamUrl);
hls.attachMedia(videoRef.current);
hls.on(window.Hls.Events.MEDIA_ATTACHED, function() { hls.loadSource(streamUrl); });
return function() { hls.destroy(); };
}, [streamUrl, streamType]);
@ -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) {
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);
});
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; });
@ -500,7 +468,7 @@ function AssetDetail({ asset, onClose }) {
const msg = err ? `MediaError code=${err.code} message=${err.message || '(none)'}` : 'unknown error';
setPlayerState('error');
setPlayerError(msg);
window.DF_LOG?.debug('[player]', msg, e);
console.error('[player]', msg, e);
}}
onEnded={function() { setPlaying(false); setPlayerState('paused'); }}
/>

View file

@ -601,291 +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 || [];
// Underlying GPU master codec stored on the row when growing is on (the growing
// essence itself is VC-3, set separately via growing_codec). Keeps the row
// coherent if growing is later turned off.
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 (non-growing only; growing uses a
// codec-fixed VC-3 bitrate). Default 25 Mbps for a GPU master.
const _seedBitrate = (recorder.recording_video_bitrate || '25').replace(/M$/i, '');
const [bitrate, setBitrate] = React.useState(_seedBitrate);
const [growing, setGrowing] = React.useState(recorder.growing_enabled === true);
// Growing master is VC-3 / DNxHD MXF (ffmpeg-direct, Premiere-native). 90 or 220 Mbps.
const [growingCodec, setGrowingCodec] = React.useState(
['vc3_220','vc3_90'].includes(recorder.growing_codec) ? recorder.growing_codec : 'vc3_90'
);
const [projectId, setProjectId] = React.useState(recorder.project_id || PROJECTS[0]?.id || '');
// Per-recorder A/V alignment trim (ms). Positive delays audio (fixes audio-ahead).
const [audioOffsetMs, setAudioOffsetMs] = React.useState(
Number.isFinite(parseInt(recorder.audio_offset_ms, 10)) ? parseInt(recorder.audio_offset_ms, 10) : 0
);
const [saving, setSaving] = React.useState(false);
const [err, setErr] = React.useState(null);
const isRec = recorder.status === 'recording';
// Growing uses VC-3 with a codec-fixed bitrate (vc3_90 / vc3_220) never the
// freeform bitrate field. Only show/send bitrate for non-growing tunable codecs.
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,
growing_codec: growing ? growingCodec : undefined,
project_id: projectId || null,
audio_offset_ms: Math.max(-1000, Math.min(1000, parseInt(audioOffsetMs, 10) || 0)),
};
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 master to the SMB share so editors can cut the clip live (codec selectable below).'
: 'Encodes a GPU master (HEVC/H.264) streamed straight to the library on stop.'}
</div>
</div>
{/* Standard mode: GPU codec + bitrate. Growing mode: VC-3 codec select
only (90 or 220 Mbps) bitrate is fixed by the chosen VC-3 profile. */}
{!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="rec-cfg-grid">
<div className="field">
<label className="field-label">Growing quality · VC-3 / DNxHD</label>
{/* Clean two-way segmented toggle for the only growing choice that
matters: 90 vs 220 Mbps. Both are VC-3/DNxHD MXF OP1a, the rest
is fixed, so a binary toggle reads faster than a dropdown. */}
<div className="seg-toggle" role="group" aria-label="Growing bitrate"
style={{ display: 'flex', gap: 6 }}>
{[
{ v: 'vc3_90', big: '90', sub: 'Mbps', note: 'Lighter · default' },
{ v: 'vc3_220', big: '220', sub: 'Mbps', note: 'Highest quality' },
].map(opt => {
const active = growingCodec === opt.v;
return (
<button key={opt.v} type="button"
onClick={() => !isRec && setGrowingCodec(opt.v)}
disabled={isRec}
className={`btn ${active ? 'active' : 'ghost'}`}
style={{
flex: 1, flexDirection: 'column', alignItems: 'flex-start',
padding: '8px 12px', borderRadius: 8,
border: active ? '1px solid var(--accent, #4a9eff)' : '1px solid var(--border, #333)',
background: active ? 'var(--accent-soft, rgba(74,158,255,0.12))' : 'transparent',
cursor: isRec ? 'not-allowed' : 'pointer', opacity: isRec ? 0.5 : 1,
}}>
<span style={{ fontSize: 16, fontWeight: 700, lineHeight: 1 }}>
{opt.big}<span style={{ fontSize: 10, fontWeight: 500, marginLeft: 3, color: 'var(--text-3)' }}>{opt.sub}</span>
</span>
<span style={{ fontSize: 10.5, color: active ? 'var(--accent, #4a9eff)' : 'var(--text-3)', marginTop: 3 }}>{opt.note}</span>
</button>
);
})}
</div>
<div className="mono" style={{ fontSize: 10.5, color: 'var(--text-3)', marginTop: 6 }}>
8-bit 4:2:2 · MXF OP1a · Premiere-native edit-while-record (imports + grows live).
</div>
</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>
<div className="field">
<label className="field-label">Audio offset (ms)</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input className="field-input" type="number" min="-1000" max="1000" step="1"
value={audioOffsetMs} disabled={isRec}
onChange={e => setAudioOffsetMs(e.target.value)}
style={{ width: 110 }} />
<span className="mono" style={{ fontSize: 10.5, color: 'var(--text-3)' }}>
+ delays audio (use if audio is ahead) · advances · 0 = none
</span>
</div>
</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,
growingCodec: (['vc3_220','vc3_90'].includes(r.growing_codec) ? r.growing_codec : 'vc3_90'),
audio_offset_ms: Number.isFinite(parseInt(r.audio_offset_ms, 10)) ? parseInt(r.audio_offset_ms, 10) : 0,
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 }) {
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')
@ -900,24 +648,12 @@ function Recorders({ navigate }) {
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);
@ -929,35 +665,12 @@ function Recorders({ navigate }) {
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">
@ -965,55 +678,26 @@ function Recorders({ navigate }) {
<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)} />
))}
{recorders.map(r => <RecorderRow key={r.id} recorder={r} onRefresh={refresh} />)}
</div>
</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);
@ -1023,10 +707,7 @@ function RecorderRow({ recorder: initialRecorder, onRefresh, onConfigure, nodeOn
// Project override for this take. Defaults to the recorder's configured project.
const [takeProjectId, setTakeProjectId] = React.useState(initialRecorder.project_id || PROJECTS[0]?.id || '');
const [confirm, confirmModal] = window.useConfirm();
// Override status immediately on toggle (prevents stale badge until next poll)
const [statusOverride, setStatusOverride] = React.useState(null);
const displayStatus = statusOverride || recorder.status;
const isRec = displayStatus === 'recording';
const isRec = recorder.status === 'recording';
// Keep takeProjectId in sync if the recorder row changes (e.g. after a refresh).
React.useEffect(() => {
@ -1048,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));
@ -1068,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 '·';
@ -1078,22 +759,25 @@ 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 = () => {
if (pending) return;
const action = isRec ? 'stop' : 'start';
setStatusOverride(action === 'stop' ? 'standby' : 'recording'); // optimistic
setPending(true);
setErr(null);
setRecorder(r => ({ ...r, status: isRec ? 'idle' : 'recording' }));
@ -1109,7 +793,6 @@ function RecorderRow({ recorder: initialRecorder, onRefresh, onConfigure, nodeOn
setPending(false);
// Clear the clip name on a successful stop so the next take starts fresh.
// Leave takeProjectId as-is (operator likely wants the same project for the next take).
setStatusOverride(null); // clear override, let real status take over
if (action === 'stop') setClipName('');
onRefresh();
window.dispatchEvent(new CustomEvent('df:recorders-changed'));
@ -1119,77 +802,47 @@ function RecorderRow({ recorder: initialRecorder, onRefresh, onConfigure, nodeOn
window.dispatchEvent(new CustomEvent('df:assets-changed'));
}
})
.catch(e => { setStatusOverride(null); setPending(false); setErr(e.message || 'Failed'); setRecorder(initialRecorder); });
.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 · VC-3/DNxHD ${recorder.growingCodec === 'vc3_220' ? '220' : '90'} (MXF OP1a)`}>
GROWING · VC-3/{recorder.growingCodec === 'vc3_220' ? '220' : '90'}
</span>
)}
</div>
<div className="recorder-sub mono">{recorder.url}</div>
<div className="recorder-sub">
{recorder.growing ? (
<>
<span>VC-3/DNxHD</span><span className="recorder-sub-sep">·</span>
<span>MXF OP1a</span><span className="recorder-sub-sep">·</span>
<span>4:2:2</span>
</>
) : (
<>
<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 && (
@ -1208,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>}
<button className="icon-btn" onClick={handleDelete} title="Delete recorder" aria-label="Delete recorder" style={{ color: 'var(--text-3)' }}>
<Icon name="x" />
</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>
</div>
</div>
);
}
function badgeForStatus(s) {
return { recording: 'live', armed: 'accent', idle: 'neutral', error: 'danger', offline: 'neutral', stopped: 'neutral' }[s] || 'neutral';
}
/* ===== Capture ===== */
function _captureSignalChip(sig) {
@ -1315,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'
@ -191,7 +163,7 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject, onOpenPro
// to 'ready' (with thumbnail) without a manual reload. Also pull once on
// mount so uploads/imports created on other screens appear immediately.
const hasLive = React.useMemo(
() => allAssets.some(a => ['live','processing','ingesting','recording'].includes(a.status)),
() => allAssets.some(a => a.status === 'live' || a.status === 'processing' || a.status === 'ingesting'),
[allAssets]
);
React.useEffect(() => {
@ -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 }} />
) : 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); });
};
@ -643,18 +628,9 @@ function AssetContextMenu({ asset, x, y, bins, onClose, onChanged, onOpen, onRen
{asset.original_s3_key && onDownload && (
<button onClick={function() { onDownload(asset); }}><Icon name="download" size={11} />Download original</button>
)}
{(function() {
// A growing-file master lives on the SMB share. Offer "Move to S3" for
// any such asset both the normal 'pending_migration' state AND a
// recording that got stuck in 'live' (its post-stop migrate never fired).
const onSmb = (asset.tags || []).indexOf('smb') !== -1
|| /\.mxf$/i.test(asset.original_s3_key || '');
const migratable = asset.status === 'pending_migration'
|| (asset.status === 'live' && onSmb);
return migratable
? <button onClick={promoteToS3}><Icon name="upload" size={11} />Move to S3</button>
: null;
})()}
{asset.status === 'pending_migration' && (
<button onClick={promoteToS3}><Icon name="upload" size={11} />Move to S3</button>
)}
<div className="ctx-divider" />
{(bins && bins.length > 0) ? (
<>
@ -710,25 +686,16 @@ function AssetCard({ asset, onOpen, onContextMenu, onDownload, onDragStart, drag
setHovered(false);
};
// HLS wiring - safe: attachMedia before loadSource
// HLS wiring
React.useEffect(function() {
if (!hovered || !hoverStream || !videoRef.current) return;
var vid = videoRef.current;
if (hoverStream.type !== 'hls') {
vid.src = hoverStream.url; vid.play().catch(function() {});
return function() { vid.pause(); vid.src = ''; };
}
if (!window.Hls || !window.Hls.isSupported()) {
vid.src = hoverStream.url; vid.play().catch(function() {});
return function() { vid.pause(); vid.src = ''; };
}
var hls = new window.Hls({ maxBufferLength: 8, startLevel: 0, autoStartLoad: true });
hls.attachMedia(vid);
hls.on(window.Hls.Events.MEDIA_ATTACHED, function() { hls.loadSource(hoverStream.url); });
hls.on(window.Hls.Events.MANIFEST_PARSED, function() { vid.play().catch(function() {}); });
hls.on(window.Hls.Events.ERROR, function(_e, data) { if (data.fatal) hls.destroy(); });
hlsRef.current = hls;
return function() { hls.destroy(); hlsRef.current=null; vid.pause(); vid.removeAttribute('src'); };
if (!hovered || !hoverStream || hoverStream.type !== 'hls' || !videoRef.current) return;
if (!window.Hls) return;
hlsRef.current = new window.Hls({ maxBufferLength: 10 });
hlsRef.current.loadSource(hoverStream.url);
hlsRef.current.attachMedia(videoRef.current);
return function() {
if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; }
};
}, [hovered, hoverStream]);
const showVideo = hovered && hoverStream;
@ -747,6 +714,7 @@ function AssetCard({ asset, onOpen, onContextMenu, onDownload, onDragStart, drag
<video
key={hoverStream.url}
ref={videoRef}
src={hoverStream.type !== 'hls' ? hoverStream.url : undefined}
autoPlay
muted
loop
@ -767,11 +735,7 @@ function AssetCard({ asset, onOpen, onContextMenu, onDownload, onDragStart, drag
{asset.status === 'live' && <span className="badge live">LIVE</span>}
{asset.status === 'processing' && <span className="badge warning">Processing</span>}
{asset.status === 'error' && <span className="badge danger">Error</span>}
{/* SMB badge for any growing-file master on the share shown ALONGSIDE
LIVE while it records, and on its own while pending migration. */}
{(((asset.tags || []).indexOf('smb') !== -1 || /\.mxf$/i.test(asset.original_s3_key || ''))
&& (asset.status === 'live' || asset.status === 'pending_migration')) &&
<span className="badge warning" style={{ background: '#e8821c', color: '#fff' }}>SMB</span>}
{asset.status === 'pending_migration' && <span className="badge warning" style={{ background: '#e8821c', color: '#fff' }}>SMB</span>}
</div>
{/* Hi-res download trigger: only shown when the asset has an
original_s3_key (everything queued through ingest / conform).
@ -817,9 +781,8 @@ function ProjectContextMenu({ project, x, y, onClose, onRename }) {
onClick={function(e) { e.stopPropagation(); }}
onContextMenu={function(e) { e.preventDefault(); e.stopPropagation(); }}>
<div className="ctx-header">{project.name}</div>
<button onClick={function() { onClose(); if (window._dfOpenProject) window._dfOpenProject(project); }}><Icon name="folder" size={11} />Open project</button>
<button onClick={function() { onClose(); onRename(project); }}><Icon name="edit" size={11} />Rename project</button>
<button onClick={function() { window.ZAMPP_API.fetch('/projects/' + project.id, { method: 'DELETE' }).then(function() { onClose(); window.dispatchEvent(new CustomEvent('df:projects-changed')); }).catch(function(e) { alert('Delete failed: ' + e.message); }); }} className="danger"><Icon name="trash" size={11} />Delete project</button>
<button onClick={function() { onClose(); window.ZAMPP_API.fetch('/projects/' + project.id, { method: 'DELETE' }).then(function() { window.location.reload(); }).catch(function(e) { alert('Delete failed: ' + e.message); }); }} className="danger"><Icon name="trash" size={11} />Delete project</button>
</div>
);
}
@ -910,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" },
],
@ -213,7 +212,6 @@ function Sidebar({ active, onNavigate, me, collapsed, onToggle }) {
</button>
)}
</div>
<div className="app-version">β 0.56</div>
</aside>
);
}

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

@ -469,19 +469,3 @@ a { color: inherit; text-decoration: none; }
pointer-events: none;
border: 1px solid var(--border-strong);
}
/* ========== App version badge ========== */
.app-version {
position: fixed;
bottom: 8px;
left: 0;
width: var(--sidebar-w, 232px);
text-align: center;
font-size: 10px;
font-family: var(--font-mono);
color: var(--text-3);
opacity: 0.5;
pointer-events: none;
user-select: none;
z-index: 10;
}

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;
};
// 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++) {
export const downloadFromS3 = async (bucket, key, localPath) => {
const client = createS3Client();
try {
const response = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
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;
} finally {
client.destroy();
}
throw err;
}
}
throw lastErr;
};
export const uploadToS3 = async (bucket, key, localPath, maxAttempts = 5) => {
let lastErr = null;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
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 }));
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;
await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: readStream,
})
);
} finally {
client.destroy();
}
throw err;
}
}
throw lastErr;
};
// Upload every file in `localDir` to `bucket` under `keyPrefix/`. Used for the
@ -115,6 +58,7 @@ 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();
try {
const entries = await readdir(localDir, { withFileTypes: true });
const files = entries.filter(e => e.isFile()).map(e => e.name);
for (const name of files) {
@ -128,6 +72,9 @@ export const uploadDirectoryToS3 = async (bucket, keyPrefix, localDir) => {
}));
}
return files;
} finally {
client.destroy();
}
};
// Multipart-aware streaming upload — used by the promotion worker to push
@ -135,6 +82,7 @@ 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();
try {
const upload = new Upload({
client,
params: { Bucket: bucket, Key: key, Body: readable },
@ -142,4 +90,7 @@ export const uploadStreamToS3 = async (bucket, key, readable) => {
partSize: 8 * 1024 * 1024,
});
await upload.done();
} finally {
client.destroy();
}
};

View file

@ -295,39 +295,28 @@ export const conformWorker = async (job) => {
// Codec map. The panel sends 'prores_hq' / 'prores_4444' / 'h264' / 'h265'
// / 'dnxhr_hq'; old EDL callers send 'prores' / 'h265' / 'h264'. Match
// both. prores_ks profiles: 0=proxy 1=lt 2=std 3=hq 4=4444.
// pixFmtFlag pins a codec-correct pixel format on the final encode so the
// broadcast master is deterministic regardless of the (yuv420p) normalised
// segments. prores_ks defaults are profile-driven but we make HQ explicitly
// 10-bit 4:2:2 (broadcast spec) and 4444 explicitly 4:4:4+alpha. dnxhd
// *requires* a pixel format it supports (yuv422p for DNxHR HQ) or it can
// error on certain inputs.
let videoCodec, profileFlag = [], pixFmtFlag = [];
let videoCodec, profileFlag = [];
if (codec === 'prores_hq' || codec === 'prores') {
videoCodec = 'prores_ks'; profileFlag = ['-profile:v', '3'];
pixFmtFlag = ['-pix_fmt', 'yuv422p10le'];
} else if (codec === 'prores_4444') {
videoCodec = 'prores_ks'; profileFlag = ['-profile:v', '4'];
pixFmtFlag = ['-pix_fmt', 'yuva444p10le'];
} else if (codec === 'h265' || codec === 'hevc') {
videoCodec = 'libx265';
} else if (codec === 'dnxhr_hq') {
videoCodec = 'dnxhd'; profileFlag = ['-profile:v', 'dnxhr_hq'];
pixFmtFlag = ['-pix_fmt', 'yuv422p'];
} else {
videoCodec = 'libx264';
}
// Quality args are libx264/libx265-specific (-preset/-crf). ProRes encodes
// quality via its profile; dnxhd (DNxHR) drives quality via the profile too
// and rejects/ignores x264's -preset/-crf. Only emit these for the x26x
// encoders so we never feed an encoder flags it doesn't understand.
const isCrfCodec = videoCodec === 'libx264' || videoCodec === 'libx265';
const qualityArgs = isCrfCodec
? [
// prores_ks ignores -crf and uses -preset differently; libx264/x265 use
// crf-based quality. Branch the encode args.
const isProRes = videoCodec === 'prores_ks';
const qualityArgs = isProRes
? [] // ProRes profile already encodes the quality target
: [
'-preset', quality === 'high' ? 'slow' : quality === 'broadcast' ? 'veryslow' : 'fast',
'-crf', quality === 'broadcast' ? '18' : quality === 'high' ? '23' : '28',
]
: []; // ProRes / DNxHR: quality target is encoded by the profile
];
// Concat: every segment was normalised at trim time (uniform fps,
// resolution, pixel format, sample rate, stereo). The demuxer can
@ -346,7 +335,6 @@ export const conformWorker = async (job) => {
'-i', segmentListPath,
'-c:v', videoCodec,
...profileFlag,
...pixFmtFlag,
...qualityArgs,
...encodeAudio,
'-y', outputPath,
@ -409,19 +397,11 @@ export const conformWorker = async (job) => {
// BUG FIX #1: Mark the output asset (if any) as 'error' so the UI doesn't
// show a perpetually-spinning 'processing' state when the conform fails.
// We don't have an assetId until the INSERT succeeds, so target by job key.
//
// BUG FIX #2: the output key extension is codec-dependent (ProRes / DNxHR
// land in .mov, everything else in .mp4 — see `outputExt` above). The error
// mark previously hard-coded `.mp4`, so a *failed* ProRes/DNxHR conform was
// never matched and the asset spun in 'processing' forever — which is the
// exact asymmetry behind "Broadcast (ProRes) fails but H.264/web works":
// the H.264 row flips to 'error' and surfaces cleanly, the ProRes row does
// not. Use the same outputExt the success path used.
await query(
`UPDATE assets
SET status = 'error', updated_at = NOW()
WHERE original_s3_key = $1`,
[`jobs/${jobId}/conformed.${outputExt}`]
[`jobs/${jobId}/conformed.mp4`]
).catch(e => console.error('[conform] Failed to mark asset error:', e.message));
throw error;
} finally {

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) };
}

View file

@ -1,7 +1,7 @@
import { readdir, stat, unlink, mkdir, writeFile } from 'node:fs/promises';
import { execFileSync } from 'node:child_process';
import { join, relative, basename } from 'node:path';
import { createReadStream, existsSync, readdirSync } from 'node:fs';
import { createReadStream, existsSync } from 'node:fs';
import { Queue } from 'bullmq';
import { query } from '../db/client.js';
import { uploadStreamToS3 } from '../s3/client.js';
@ -38,20 +38,9 @@ async function ensureGrowingShareMounted() {
}
try {
if (isMounted(GROWING_PATH)) {
// A CIFS soft-mount can stay "mounted" yet be DEAD (server dropped the
// connection): the mountpoint exists but every access returns ENOENT/EIO.
// This is the recurring "Growing file not found" / stuck-in-processing bug.
// Probe the mount with a real readdir; if it fails, force-unmount so we
// fall through and remount fresh below.
let healthy = false;
try { readdirSync(GROWING_PATH); healthy = true; } catch (_) { healthy = false; }
if (healthy) {
console.log('[promotion] growing share healthy at', GROWING_PATH);
console.log('[promotion] growing share already mounted at', GROWING_PATH);
return;
}
console.warn('[promotion] growing share mounted but DEAD — remounting', GROWING_PATH);
try { execFileSync('umount', ['-l', GROWING_PATH], { stdio: ['ignore', 'ignore', 'pipe'] }); } catch (_) {}
}
await mkdir(GROWING_PATH, { recursive: true }).catch(() => {});
await writeFile(
SMB_CREDS_FILE,
@ -84,33 +73,6 @@ const proxyQueue = new Queue('proxy', {
// BullMQ Worker handler for manual S3 promotion
export const promotionWorker = async (job) => {
const { assetId } = job.data;
try {
return await runPromotion(job);
} catch (err) {
// RECOVERY: the /promote endpoint set the asset to 'processing' BEFORE
// queuing this job. If the job fails (e.g. the growing .mxf isn't on the
// share yet because the recorder is still writing/flushing, or a transient
// SMB/S3 error), the asset would otherwise be ORPHANED in 'processing'
// forever. Reset it to a retryable state so it isn't stuck: 'pending_migration'
// if the master still exists on SMB, else 'error' so it surfaces in the UI.
try {
const cur = await query('SELECT project_id, filename FROM assets WHERE id = $1', [assetId]);
const row = cur.rows[0];
const onSmb = row && existsSync(`${GROWING_PATH}/${row.project_id}/${row.filename}.mxf`);
await query(
`UPDATE assets SET status = $2, updated_at = NOW() WHERE id = $1 AND status = 'processing'`,
[assetId, onSmb ? 'pending_migration' : 'error']
);
console.error(`[promotion] asset ${assetId} promotion failed (${err.message}); reset to ${onSmb ? 'pending_migration (retryable)' : 'error'}`);
} catch (e2) {
console.error(`[promotion] asset ${assetId} failed AND status-reset failed: ${e2.message}`);
}
throw err;
}
};
async function runPromotion(job) {
const { assetId } = job.data;
// 1. Ensure growing share is mounted
await ensureGrowingShareMounted();
@ -125,26 +87,10 @@ async function runPromotion(job) {
}
const asset = r.rows[0];
// 3. Resolve local path. RETRY: the master is written to the SMB share by the
// capture sidecar's mount; the promotion worker sees it through its OWN CIFS
// mount, which has an attribute cache (actimeo). A just-finalized file (or a
// file written via a different mount) can show a transient negative-lookup
// here for a second or two even though it exists. Without a retry the job
// fails "Growing file not found" and the asset gets stranded — the recurring
// stuck-in-processing bug. Re-check for up to ~20s before giving up.
// 3. Resolve local path
const localPath = `${GROWING_PATH}/${asset.project_id}/${asset.filename}.mxf`;
{
const deadline = Date.now() + 20000;
let seen = existsSync(localPath);
while (!seen && Date.now() < deadline) {
await new Promise(r => setTimeout(r, 2000));
// Re-mount best-effort in case the share dropped, then re-check.
await ensureGrowingShareMounted().catch(() => {});
seen = existsSync(localPath);
}
if (!seen) {
throw new Error(`Growing file not found at ${localPath} after 20s`);
}
if (!existsSync(localPath)) {
throw new Error(`Growing file not found at ${localPath}`);
}
const s3Key = `projects/${asset.project_id}/masters/${asset.filename}.mxf`;
@ -153,20 +99,12 @@ async function runPromotion(job) {
console.log(`[promotion] promoting asset ${assetId}: uploading ${localPath} (${st.size} bytes) -> s3://${S3_BUCKET}/${s3Key}`);
await uploadStreamToS3(S3_BUCKET, s3Key, createReadStream(localPath));
// 4. Update asset status to ready (with correct S3 key and size).
// Swap the 'smb' origin tag for 's3' now the master lives in S3.
// 4. Update asset status to ready (with correct S3 key and size)
await query(
`UPDATE assets
SET original_s3_key = $1,
file_size = $2,
status = 'ready',
tags = (
SELECT ARRAY(
SELECT DISTINCT unnest(
array_remove(COALESCE(tags, '{}'::text[]), 'smb') || ARRAY['s3']
)
)
),
updated_at = NOW()
WHERE id = $3`,
[s3Key, st.size, assetId]

View file

@ -164,15 +164,7 @@ export const proxyWorker = async (job) => {
// Empty/truncated capture: probe returned a video stream but ffmpeg can't
// read any frames. Bail with a clear message instead of dumping ~3KB of
// ffmpeg stderr into the failed-jobs list.
//
// NOTE: a null container duration alone is NOT proof of emptiness. Growing
// VC-3/DNxHD MXF (OP1a) masters carry a valid, decodable video stream but
// report format.duration = N/A, so durationMs comes back null even though
// ffmpeg transcodes them fine. Only bail when there is ALSO no decodable
// video stream (no resolution) — that is the true aborted-capture signature
// (ftyp-only / 0-frame objects). This preserves the empty-capture guard for
// SRT/RTMP drops while letting MXF masters through to the transcoder.
if (mediaInfo.durationMs === null && mediaInfo.codec && !mediaInfo.resolution) {
if (mediaInfo.durationMs === null && mediaInfo.codec) {
throw new Error(
`Empty or truncated source: codec=${mediaInfo.codec}, ` +
`resolution=${mediaInfo.resolution || 'unknown'}, no readable frames.`