Compare commits

..

7 commits

Author SHA1 Message Date
01a19c0d69 feat(hls): add xhrSetup withCredentials to hls.js instances in visuals.jsx 2026-06-03 04:22:08 +00:00
39e010544c fix(merge): resolve conflicts in playout UI and styles 2026-06-03 04:21:38 +00:00
d58982ad18 feat(hls): return type='hls' in stream endpoint and add audio sync placeholders in editor 2026-06-03 04:18:29 +00:00
a1b8211ea1 feat(editor): add Hls.js support for source and program monitors 2026-06-03 04:17:17 +00:00
ac5a667e65 feat(playout): redesigned MCR CSS — monitor, timeline, SCTE-35, drawer 2026-05-31 19:30:59 -04:00
1ca295d799 fix: upload growing file to S3 on stop so proxy job succeeds
When growing_enabled=true the capture container writes the master to
/growing/{projectId}/{clipName}.{ext} instead of streaming it to S3.
The capture container's graceful-shutdown handler (running during the
Docker stop) calls POST /assets/:id/finalize with the expected S3 key,
which queues a proxy job.  That key never had data in S3 so the proxy
worker downloaded an empty object and failed with 'unable to open file'.

Fix: in the stop endpoint, after the container has exited (meaning
ffmpeg has finished flushing the growing file), upload the growing file
to S3 from the mam-api node (which has /growing mounted).  The upload
completes before the HTTP response is sent, so by the time the client
refreshes and the BullMQ worker dequeues the proxy job the S3 object
exists.

Also handles the edge case where finalize already ran and flipped the
asset to 'processing' — we still do the upload so the already-queued
proxy job can succeed.  Best-effort: a missing growing file (empty
recording or SMB-path scenario) is logged but does not fail the stop."
2026-05-31 19:30:13 -04:00
be819353a7 feat(playout): redesigned MCR screen — design polish + real API wiring 2026-05-31 19:28:08 -04:00
62 changed files with 1590 additions and 6796 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';
import { randomBytes, createHash } from 'node:crypto';
const PREFIX = 'dfl_';
@ -10,14 +10,6 @@ export function hashToken(token) {
return createHash('sha256').update(token).digest('hex');
}
export function compareTokens(tokenA, tokenB) {
if (!tokenA || !tokenB) return false;
const a = Buffer.from(tokenA);
const b = Buffer.from(tokenB);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
export function parseBearer(authorizationHeader) {
if (!authorizationHeader || typeof authorizationHeader !== 'string') return null;
const m = authorizationHeader.match(/^Bearer\s+(\S+)$/i);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,16 +1,30 @@
import express from 'express';
import http from 'http';
import fs from 'fs';
import { createReadStream, existsSync } from 'fs';
import { stat } from 'fs/promises';
import net from 'net';
import dgram from 'dgram';
import pool from '../db/pool.js';
import { getS3Bucket } from '../s3/client.js';
import { s3Client, getS3Bucket } from '../s3/client.js';
import { Upload } from '@aws-sdk/lib-storage';
import { validateUuid } from '../middleware/errors.js';
import { assertProjectAccess, accessibleProjectIds } from '../auth/authz.js';
import { v4 as uuidv4 } from 'uuid';
import { Queue } from 'bullmq';
const router = express.Router();
// BullMQ proxy queue — used by the growing-file stop handler to queue proxy
// jobs when the capture container's finalize call races with the S3 upload.
const parseRedisUrl = (url) => {
const parsed = new URL(url);
return { host: parsed.hostname, port: parseInt(parsed.port, 10) || 6379 };
};
const proxyQueue = new Queue('proxy', {
connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
});
// Every /:id recorder route is scoped to the recorder's project. The param
// handler validates the UUID, resolves the owning project_id, and asserts the
// 'view' baseline; mutating routes escalate to 'edit' via requireRecorderEdit.
@ -40,7 +54,7 @@ async function requireRecorderEdit(req, res, next) {
const SIDECAR_PORT_BASE = 7438;
// Docker API helper function
function dockerApi(method, path, body = null, timeoutMs = 10000) {
function dockerApi(method, path, body = null) {
return new Promise((resolve, reject) => {
const options = {
socketPath: '/var/run/docker.sock',
@ -60,9 +74,9 @@ function dockerApi(method, path, body = null, timeoutMs = 10000) {
});
});
req.on('error', reject);
// Use parameterizable timeout to prevent indefinite hangs if Docker daemon is unresponsive
req.setTimeout(timeoutMs, () => {
req.destroy(new Error(`Docker API timeout after ${timeoutMs/1000}s`));
// Add 10-second timeout to prevent indefinite hangs if Docker daemon is unresponsive
req.setTimeout(10000, () => {
req.destroy(new Error('Docker API timeout after 10s'));
});
if (body) req.write(JSON.stringify(body));
req.end();
@ -154,7 +168,7 @@ const RECORDER_FIELDS = [
'proxy_audio_codec', 'proxy_audio_bitrate', 'proxy_audio_channels',
'proxy_container',
'project_id', 'node_id', 'device_index',
'growing_enabled', 'label',
'growing_enabled',
];
function pickRecorderFields(body) {
@ -198,7 +212,7 @@ function validateRecorderConfig(cfg, nodeHasGpu = null) {
// NVENC requires a GPU on the target node. Only a hard error when we know the
// node lacks one; unknown capability is left as a soft pass.
if (GPU_CODECS.includes(codec) && nodeHasGpu === false) {
return `Invalid combo: codec ${cfg.recording_codec} requires an NVIDIA GPU, but the target node reports no GPU. Assign this recorder to a GPU node.`;
return `Invalid combo: codec ${cfg.recording_codec} requires an NVIDIA GPU, but the target node reports no GPU. Choose a software codec (e.g. prores_hq, dnxhr_hq, h264) or assign a GPU node.`;
}
return null;
@ -227,133 +241,6 @@ async function nodeHasGpuCapability(nodeId) {
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
// Build the stable env array for a standby sidecar. Contains everything a
// capture container needs EXCEPT session params (CLIP_NAME, ASSET_ID,
// PROJECT_ID, BIN_ID) which arrive via HTTP on /capture/start.
function buildStandbyEnv(recorder) {
const s3Endpoint = process.env.S3_ENDPOINT || '';
const s3Bucket = getS3Bucket();
const s3AccessKey = process.env.S3_ACCESS_KEY || '';
const s3SecretKey = process.env.S3_SECRET_KEY || '';
const mamApiUrl = process.env.MAM_API_URL || 'http://mam-api:3000';
const externalMamApiUrl = `http://${process.env.NODE_IP || '172.18.91.200'}:${process.env.PORT_MAM_API || 47432}`;
const liveDir = process.env.LIVE_DIR || '/mnt/NVME/MAM/wild-dragon-live';
const sourceConfig = recorder.source_config || {};
const deviceIndex = recorder.device_index ?? sourceConfig.device ?? 0;
return [
`S3_ENDPOINT=${s3Endpoint}`,
`S3_BUCKET=${s3Bucket}`,
`S3_ACCESS_KEY=${s3AccessKey}`,
`S3_SECRET_KEY=${s3SecretKey}`,
`S3_REGION=${process.env.S3_REGION || 'us-east-1'}`,
// Use external URL — capture container runs on worker host network
`MAM_API_URL=${externalMamApiUrl}`,
`RECORDER_ID=${recorder.id}`,
`SOURCE_TYPE=${recorder.source_type}`,
`SOURCE_CONFIG=${JSON.stringify(sourceConfig)}`,
`DEVICE_INDEX=${deviceIndex}`,
`RECORDING_CODEC=${recorder.recording_codec || 'hevc_nvenc'}`,
`RECORDING_RESOLUTION=${recorder.recording_resolution || 'native'}`,
`RECORDING_VIDEO_BITRATE=${recorder.recording_video_bitrate || ''}`,
`RECORDING_FRAMERATE=${recorder.recording_framerate || ''}`,
`RECORDING_AUDIO_CODEC=${recorder.recording_audio_codec || 'pcm_s24le'}`,
`RECORDING_AUDIO_BITRATE=${recorder.recording_audio_bitrate || ''}`,
`RECORDING_AUDIO_CHANNELS=${recorder.recording_audio_channels ?? 2}`,
`RECORDING_CONTAINER=${recorder.recording_container || 'mov'}`,
`PROXY_ENABLED=${recorder.proxy_enabled !== false ? 'true' : 'false'}`,
`PROXY_CODEC=${recorder.proxy_codec || 'h264_nvenc'}`,
`PROXY_RESOLUTION=${recorder.proxy_resolution || '1920x1080'}`,
`PROXY_VIDEO_BITRATE=${recorder.proxy_video_bitrate || '2M'}`,
`PROXY_FRAMERATE=${recorder.proxy_framerate || ''}`,
`PROXY_AUDIO_CODEC=${recorder.proxy_audio_codec || 'aac'}`,
`PROXY_AUDIO_BITRATE=${recorder.proxy_audio_bitrate || '128k'}`,
`PROXY_AUDIO_CHANNELS=${recorder.proxy_audio_channels ?? 2}`,
`PROXY_CONTAINER=${recorder.proxy_container || 'mp4'}`,
`GROWING_ENABLED=false`,
`GROWING_PATH=/growing`,
`GROWING_SMB_MOUNT=`,
`LIVE_DIR=${liveDir}`,
`MAM_API_TOKEN=${process.env.CAPTURE_TOKEN || ''}`,
`STANDBY=1`,
`PRE_ROLL_SECONDS=1`,
];
}
// Source types that run a long-lived standby sidecar (idle-preview container
// kept up 24/7 so `record` is a sub-second HTTP call, not a Docker cold start).
const STANDBY_SOURCE_TYPES = ['deltacast', 'sdi', 'blackmagic'];
// Provision (or re-provision) the single persistent standby sidecar for one
// recorder by asking its node's agent to create the idle container. Idempotent
// at the node-agent layer (one container per capture port). Updates the
// recorder row with the new container_id + status='standby'. Returns:
// { ok, containerId?, reason? }
// Non-fatal by contract — the caller logs/aggregates; a recorder is still
// usable via the on-demand spawn fallback in /start if this fails.
async function ensureStandbySidecar(recorder) {
if (!recorder.node_id || !STANDBY_SOURCE_TYPES.includes(recorder.source_type)) {
return { ok: false, reason: 'not a standby source / no node' };
}
const { remote: isRemote, apiUrl: targetNodeApiUrl } =
await resolveNodeTarget(recorder.node_id).catch(() => ({ remote: false }));
if (!isRemote || !targetNodeApiUrl) {
return { ok: false, reason: 'node not remote/reachable' };
}
const capturePort = SIDECAR_PORT_BASE + (recorder.device_index || 0);
const useGpu = GPU_CODECS.includes(recorder.recording_codec);
const standbyRes = await fetch(`${targetNodeApiUrl}/sidecar/standby`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
image: 'wild-dragon-capture:latest',
env: buildStandbyEnv(recorder),
capturePort,
sourceType: recorder.source_type,
useGpu,
gpuUuid: recorder.gpu_uuid || null,
}),
signal: AbortSignal.timeout(15000),
});
if (!standbyRes.ok) {
return { ok: false, reason: `node-agent returned ${standbyRes.status}` };
}
const { containerId } = await standbyRes.json();
await pool.query(
`UPDATE recorders SET container_id = $1, status = 'standby', updated_at = NOW() WHERE id = $2`,
[containerId, recorder.id]
);
recorder.container_id = containerId;
recorder.status = 'standby';
console.log(`[recorders] standby sidecar spawned for ${recorder.id}: ${containerId}`);
return { ok: true, containerId };
}
// Tear down a recorder's standby sidecar (Disable). Asks the node-agent to
// remove the container, then clears container_id and sets status='stopped'.
// Best-effort on the node-agent call — even if the delete fails we still clear
// the row so the operator isn't stuck; the force-free-port logic on the next
// Enable will reclaim a stray container. Returns { ok, reason? }.
async function teardownStandbySidecar(recorder) {
if (recorder.node_id && recorder.container_id) {
const { remote: isRemote, apiUrl: targetNodeApiUrl } =
await resolveNodeTarget(recorder.node_id).catch(() => ({ remote: false }));
if (isRemote && targetNodeApiUrl) {
await fetch(`${targetNodeApiUrl}/sidecar/${recorder.container_id}`, {
method: 'DELETE',
signal: AbortSignal.timeout(15000),
}).catch(e => console.warn(`[recorders] sidecar teardown for ${recorder.id} failed (clearing anyway): ${e.message}`));
}
}
await pool.query(
`UPDATE recorders SET container_id = NULL, status = 'stopped', current_session_id = NULL, updated_at = NOW() WHERE id = $1`,
[recorder.id]
);
recorder.container_id = null;
recorder.status = 'stopped';
return { ok: true };
}
// Issue #162 — after a local-spawn stop, wait for the capture container to
// finalize its master. The asset row was pre-created at start with
// status='live' (display_name = current_session_id); the ingest/finalize step
@ -468,9 +355,9 @@ router.post('/', async (req, res, next) => {
recording_audio_codec: 'pcm_s24le',
recording_audio_channels: 2,
recording_container: 'mov',
proxy_enabled: true,
proxy_codec: 'h264_nvenc',
proxy_resolution: '1920x1080',
proxy_enabled: true,
proxy_codec: 'h264',
proxy_resolution: '1920x1080',
proxy_video_bitrate: '2M',
proxy_audio_codec: 'aac',
proxy_audio_bitrate: '128k',
@ -501,111 +388,7 @@ router.post('/', async (req, res, next) => {
values
);
const recorder = result.rows[0];
// Spawn a standby sidecar immediately for SDI/deltacast/blackmagic recorders
// that have an assigned node, so the container + bridge are ready before the
// user hits record. Non-fatal — recorder is still usable if this fails.
await ensureStandbySidecar(recorder).catch(e =>
console.warn(`[recorders] standby spawn failed for ${recorder.id} (non-fatal): ${e.message}`));
res.status(201).json(recorder);
} catch (err) {
next(err);
}
});
// POST /reconcile-standby - (re)provision the persistent standby sidecar for
// every SDI/deltacast recorder that should have one. Standby sidecars are
// created on recorder-create and kept up 24/7 (RestartPolicy=unless-stopped),
// but if they're externally removed (manual cleanup, node redeploy, a wiped
// /dev/shm) nothing recreates them — the recorder then falls back to the slow
// on-demand spawn on /start, which can collide on the capture port. This
// endpoint re-warms them so all recorders return to the fast standby path.
//
// Optional body: { force: true } recreates even recorders that currently claim
// a container_id (the node-agent is idempotent per capture port, so a stale id
// is replaced cleanly). Without force, only recorders with no container_id are
// (re)provisioned.
router.post('/reconcile-standby', requireRecorderEdit, async (req, res, next) => {
try {
const force = !!(req.body && req.body.force);
const { rows } = await pool.query(
`SELECT * FROM recorders
WHERE source_type = ANY($1)
AND node_id IS NOT NULL
ORDER BY name`,
[STANDBY_SOURCE_TYPES]
);
const results = [];
for (const recorder of rows) {
if (!force && recorder.container_id) {
results.push({ id: recorder.id, name: recorder.name, ok: true, skipped: 'already has container_id' });
continue;
}
try {
const r = await ensureStandbySidecar(recorder);
results.push({ id: recorder.id, name: recorder.name, ...r });
} catch (e) {
results.push({ id: recorder.id, name: recorder.name, ok: false, reason: e.message });
}
}
const provisioned = results.filter(r => r.ok && r.containerId).length;
res.json({ provisioned, total: rows.length, results });
} catch (err) {
next(err);
}
});
// POST /:id/enable - Operator opt-in. Brings up the persistent standby sidecar
// (idle-preview, kept up 24/7) so the port is ready to record in <1s. Sets
// enabled=true. Idempotent: if already enabled with a live container the
// node-agent's force-free-port logic replaces any stale container cleanly.
router.post('/:id/enable', requireRecorderEdit, async (req, res, next) => {
try {
const { id } = req.params;
const { rows } = await pool.query('SELECT * FROM recorders WHERE id = $1', [id]);
if (rows.length === 0) return res.status(404).json({ error: 'Recorder not found' });
const recorder = rows[0];
if (!STANDBY_SOURCE_TYPES.includes(recorder.source_type)) {
return res.status(400).json({ error: `Source type "${recorder.source_type}" does not support standby/enable` });
}
if (!recorder.node_id) {
return res.status(409).json({ error: 'Recorder has no assigned node (hardware offline?) — cannot enable' });
}
const r = await ensureStandbySidecar(recorder);
if (!r.ok) {
return res.status(502).json({ error: `Could not start standby sidecar: ${r.reason || 'unknown'}` });
}
await pool.query('UPDATE recorders SET enabled = true, updated_at = NOW() WHERE id = $1', [id]);
recorder.enabled = true;
res.json(recorder);
} catch (err) {
next(err);
}
});
// POST /:id/disable - Operator opt-out. Stops & removes the standby sidecar,
// freeing the capture port, and sets enabled=false. Config (codec, label,
// growing) is preserved on the row for the next enable. Refuses while the
// recorder is actively recording — stop it first.
router.post('/:id/disable', requireRecorderEdit, async (req, res, next) => {
try {
const { id } = req.params;
const { rows } = await pool.query('SELECT * FROM recorders WHERE id = $1', [id]);
if (rows.length === 0) return res.status(404).json({ error: 'Recorder not found' });
const recorder = rows[0];
if (recorder.status === 'recording') {
return res.status(409).json({ error: 'Recorder is recording — stop it before disabling' });
}
await teardownStandbySidecar(recorder);
await pool.query('UPDATE recorders SET enabled = false, updated_at = NOW() WHERE id = $1', [id]);
recorder.enabled = false;
res.json(recorder);
res.status(201).json(result.rows[0]);
} catch (err) {
next(err);
}
@ -793,7 +576,7 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
`DEVICE_INDEX=${deviceIndex}`,
// Recording codec controls
`RECORDING_CODEC=${recorder.recording_codec || 'hevc_nvenc'}`,
`RECORDING_CODEC=${recorder.recording_codec || 'prores_hq'}`,
`RECORDING_RESOLUTION=${recorder.recording_resolution || 'native'}`,
`RECORDING_VIDEO_BITRATE=${recorder.recording_video_bitrate || ''}`,
`RECORDING_FRAMERATE=${recorder.recording_framerate || ''}`,
@ -804,7 +587,7 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
// Proxy codec controls
`PROXY_ENABLED=${recorder.proxy_enabled !== false ? 'true' : 'false'}`,
`PROXY_CODEC=${recorder.proxy_codec || 'h264_nvenc'}`,
`PROXY_CODEC=${recorder.proxy_codec || 'h264'}`,
`PROXY_RESOLUTION=${recorder.proxy_resolution || '1920x1080'}`,
`PROXY_VIDEO_BITRATE=${recorder.proxy_video_bitrate || '2M'}`,
`PROXY_FRAMERATE=${recorder.proxy_framerate || ''}`,
@ -818,9 +601,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: 'avci100' (CPU AVC-Intra 100 MXF, default) or
// 'hevc_nvenc' (GPU all-intra HEVC frag-MOV). capture-manager reads this.
`GROWING_CODEC=${recorder.growing_codec === 'hevc_nvenc' ? 'hevc_nvenc' : 'avci100'}`,
`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
@ -838,12 +618,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) {
@ -876,88 +650,10 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
}
let containerId;
const capturePort = SIDECAR_PORT_BASE + (deviceIndex || 0);
// ── Standby fast-path ───────────────────────────────────────────────
// If the recorder is already in standby (sidecar running idle), send the
// session params to its /capture/start HTTP endpoint instead of spawning
// a new container. This eliminates Docker create/start latency and bridge
// startup time — the user hits record and ffmpeg starts in <1s.
const isStandby = recorder.status === 'standby' && recorder.container_id;
if (isStandby) {
const captureStartUrl = isRemote
? `http://${(await resolveNodeTarget(recorder.node_id)).ip}:${capturePort}/capture/start`
: `http://localhost:${capturePort}/capture/start`;
try {
const startBody = {
project_id: takeProjectId,
bin_id: null,
clip_name: clipName,
asset_id: assetIdLive,
source_type: sourceType,
device: deviceIndex,
// Codec params — sidecar already has these in env but we send them
// anyway so a config change on the recorder takes effect immediately.
recording_codec: recorder.recording_codec,
recording_video_bitrate: recorder.recording_video_bitrate,
recording_framerate: recorder.recording_framerate,
recording_audio_codec: recorder.recording_audio_codec,
recording_audio_bitrate: recorder.recording_audio_bitrate,
recording_audio_channels: recorder.recording_audio_channels,
recording_container: recorder.recording_container,
proxy_enabled: recorder.proxy_enabled,
proxy_codec: recorder.proxy_codec,
proxy_video_bitrate: recorder.proxy_video_bitrate,
proxy_framerate: recorder.proxy_framerate,
proxy_audio_codec: recorder.proxy_audio_codec,
proxy_audio_bitrate: recorder.proxy_audio_bitrate,
proxy_audio_channels: recorder.proxy_audio_channels,
proxy_container: recorder.proxy_container,
growing_enabled: growingEnabled,
growing_smb_mount: smbMount,
growing_smb_username: growingInfra.growing_smb_username || '',
growing_smb_password: growingInfra.growing_smb_password || '',
growing_smb_vers: growingInfra.growing_smb_vers || '3.0',
};
const captureRes = await fetch(captureStartUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(startBody),
signal: AbortSignal.timeout(15000),
});
if (captureRes.ok) {
containerId = recorder.container_id;
console.log(`[recorders] standby→recording: ${id} via HTTP /capture/start`);
} else {
const detail = await captureRes.json().catch(() => ({}));
console.warn(`[recorders] standby /capture/start returned ${captureRes.status}: ${JSON.stringify(detail)} — falling back to spawn`);
// Fall through to on-demand spawn below
}
} catch (e) {
console.warn(`[recorders] standby HTTP start failed: ${e.message} — falling back to spawn`);
// Fall through to on-demand spawn below
}
}
// If standby HTTP start failed and a stale container_id exists, kill it
// before spawning a new one — otherwise the new container gets EADDRINUSE
// because the old container is still holding the capture port.
if (!containerId && isStandby && recorder.container_id) {
console.log(`[recorders] killing stale standby container ${recorder.container_id} before respawn`);
try {
if (isRemote) {
await fetch(`${targetNodeApiUrl}/sidecar/${recorder.container_id}`, {
method: 'DELETE',
signal: AbortSignal.timeout(10000),
}).catch(() => {});
} else {
await dockerApi('DELETE', `/containers/${recorder.container_id}?force=true`).catch(() => {});
}
} catch (_) {}
}
if (!containerId && isRemote) {
if (isRemote) {
// Remote node: delegate container lifecycle to that node's agent.
const capturePort = SIDECAR_PORT_BASE + (deviceIndex || 0);
const sidecarRes = await fetch(`${targetNodeApiUrl}/sidecar/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@ -976,7 +672,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}`;
@ -1096,69 +792,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',
@ -1169,23 +804,45 @@ 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 {
const stopRes = await dockerApi('POST', `/containers/${containerId}/stop?t=180`, null, 185000);
const stopRes = await dockerApi('POST', `/containers/${containerId}/stop?t=180`);
if (stopRes.status !== 404) {
await waitForFinalize(recorder);
await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {});
}
} catch (e) {
console.error('[recorders] failed local background stop:', e.message);
await waitForFinalize(recorder).catch(() => {});
await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {});
}
})();
}
// ── Growing-files S3 promotion ────────────────────────────────────────────
// When growing_enabled=true the capture container writes the master file to
// /growing/{projectId}/{clipName}.{ext} (a host bind-mount that the mam-api
// container also has at /growing). The capture container's graceful-shutdown
// handler (triggered by the Docker stop above) calls POST /assets/:id/finalize
// with the expected S3 key, which queues the proxy job — but the file was
// never uploaded to S3, so the proxy worker fails with "unable to open file".
//
// Fix: after the container has exited (ffmpeg is done flushing), upload the
// growing file to the canonical S3 key from here. This is synchronous and
// completes before the HTTP response reaches the client, so the already-queued
// proxy job will find a valid S3 object when the worker dequeues it.
//
// Only applies to LOCAL recorders — remote recorders write to a different
// node's /growing mount which this process cannot access.
if (!isRemote && recorder.growing_enabled === true && recorder.current_session_id) {
await promoteGrowingFileToS3(recorder).catch(err => {
// Non-fatal — log and continue so the stop always succeeds.
console.error('[recorders/stop] growing-file promotion failed (non-fatal):', err.message);
});
}
const updateResult = await pool.query(
`UPDATE recorders
SET container_id = NULL, status = $1, updated_at = NOW()
@ -1200,6 +857,109 @@ router.post('/:id/stop', requireRecorderEdit, async (req, res, next) => {
}
});
/**
* Upload a completed growing-file master from /growing to S3 so the proxy
* worker can find it at the expected original_s3_key.
*
* The capture container writes to:
* /growing/{projectId}/{clipName}.{ext}
*
* The canonical S3 key (set on the asset row at recording start) is:
* projects/{projectId}/masters/{clipName}.{ext}
*
* We look up the live/processing asset to derive both paths, do a multipart
* upload, update the asset's original_s3_key and file_size to match what we
* actually uploaded, then ensure a proxy job exists for it.
*/
async function promoteGrowingFileToS3(recorder) {
const clipName = recorder.current_session_id;
const container = recorder.recording_container || 'mov';
// Find the asset that was pre-created at recording start. It could be in
// 'live' (finalize hasn't fired yet) or 'processing' (finalize already ran
// from the container's SIGTERM handler). We need both its id and its
// project_id to reconstruct the growing path.
const assetRes = await pool.query(
`SELECT id, project_id, status, original_s3_key
FROM assets
WHERE display_name = $1
AND status IN ('live', 'processing', 'error')
ORDER BY created_at DESC
LIMIT 1`,
[clipName]
);
if (assetRes.rows.length === 0) {
console.warn(`[recorders/stop] no asset found for clip "${clipName}" — skipping growing-file promotion`);
return;
}
const asset = assetRes.rows[0];
const projectId = asset.project_id;
const growingDir = process.env.GROWING_DIR || '/growing';
const localPath = `${growingDir}/${projectId}/${clipName}.${container}`;
const s3Key = `projects/${projectId}/masters/${clipName}.${container}`;
if (!existsSync(localPath)) {
console.warn(`[recorders/stop] growing file not found at ${localPath} — nothing to promote (empty recording?)`);
return;
}
const fileStat = await stat(localPath);
if (fileStat.size === 0) {
console.warn(`[recorders/stop] growing file at ${localPath} is empty — skipping promotion`);
return;
}
console.log(`[recorders/stop] promoting growing file ${localPath} (${fileStat.size} bytes) → s3://${getS3Bucket()}/${s3Key}`);
const upload = new Upload({
client: s3Client,
params: {
Bucket: getS3Bucket(),
Key: s3Key,
Body: createReadStream(localPath),
},
queueSize: 4,
partSize: 8 * 1024 * 1024,
});
await upload.done();
console.log(`[recorders/stop] S3 upload complete for ${s3Key}`);
// Ensure the asset row reflects the correct S3 key and file size. The
// capture container's finalize call may have already set original_s3_key to
// this same value (it was pre-set at start), but update file_size which
// finalize doesn't touch.
await pool.query(
`UPDATE assets
SET original_s3_key = $1,
file_size = $2,
updated_at = NOW()
WHERE id = $3`,
[s3Key, fileStat.size, asset.id]
);
// If the asset is still 'live' (capture container's finalize hasn't fired or
// failed), flip it to 'processing' and queue the proxy job ourselves so the
// clip doesn't get stuck in the library as "Recording…".
if (asset.status === 'live') {
console.log(`[recorders/stop] finalize not yet called — queueing proxy and flipping asset ${asset.id} to processing`);
await pool.query(
`UPDATE assets SET status = 'processing', updated_at = NOW() WHERE id = $1`,
[asset.id]
);
await proxyQueue.add('generate', {
assetId: asset.id,
inputKey: s3Key,
outputKey: `proxies/${asset.id}.mp4`,
});
}
// If status is already 'processing', the capture container's finalize already
// ran and queued the proxy job. The S3 upload we just did ensures the worker
// will find a valid object when it dequeues that job — nothing else to do.
}
// GET /:id/status - Get live status
router.get('/:id/status', async (req, res, next) => {
try {
@ -1266,34 +1026,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,
@ -1385,23 +1129,17 @@ 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)` });
}
// Non-admin users can only probe public hostnames. Admins may probe LAN.
if (!isAdmin(req) && isPrivateOrLoopback(parsed.hostname)) {
return res.status(403).json({ error: 'Probe target must be a public host (#104)' });
}
// Probe target should not be mam-api itself.
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' });
}
// Non-admin users can only probe public hostnames. Admins may probe LAN.
if (!isAdmin(req) && isPrivateOrLoopback(parsed.hostname)) {
return res.status(403).json({ error: 'Probe target must be a public host (#104)' });
}
}
// Try the capture service first (5s timeout)
@ -1427,6 +1165,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.
@ -58,7 +57,7 @@ async function probeGrowingPath(path) {
// df -P returns POSIX-portable output: "Filesystem 1024-blocks Used Available Capacity Mounted-on"
try {
const { stdout } = await exec(`df -PB1 -- ${JSON.stringify(path)}`, { timeout: 3000 });
const { stdout } = await exec(`df -PB1 ${JSON.stringify(path)}`, { timeout: 3000 });
const lines = stdout.trim().split('\n');
if (lines.length >= 2) {
const cols = lines[1].split(/\s+/);
@ -79,21 +78,14 @@ async function probeS3Bucket() {
if (!bucket) { out.error = 'no bucket configured'; return out; }
const started = Date.now();
// Hard cap the whole probe so the admin "Mount health" card never hangs on
// "Probing…" when S3 is slow/unreachable. Without this, the SDK's default
// retry/backoff can block the request for tens of seconds.
const withTimeout = (p, ms) => Promise.race([
p,
new Promise((_, rej) => setTimeout(() => rej(new Error('probe timed out after ' + ms + 'ms')), ms)),
]);
try {
await withTimeout(s3Client.send(new HeadBucketCommand({ Bucket: bucket })), 5000);
await s3Client.send(new HeadBucketCommand({ Bucket: bucket }));
out.reachable = true;
out.method = 'HeadBucket';
} catch (headErr) {
// Fall back to a 0-key list for stores that don't expose HeadBucket.
try {
await withTimeout(s3Client.send(new ListObjectsV2Command({ Bucket: bucket, MaxKeys: 0 })), 5000);
await s3Client.send(new ListObjectsV2Command({ Bucket: bucket, MaxKeys: 0 }));
out.reachable = true;
out.method = 'ListObjectsV2';
} catch (listErr) {
@ -104,42 +96,6 @@ async function probeS3Bucket() {
return out;
}
// Query the growing-files SMB share's real capacity WITHOUT mounting it.
// mam-api never mounts the CIFS share, so df on the container path reports the
// local overlay (tens of GB), not the multi-TB NAS volume. `smbclient -c du`
// returns "<N> blocks of size 1024. <M> blocks available" for the share, which
// is the actual free/total the operator cares about.
async function probeSmbShare({ mount, username, password, vers }) {
const out = { reachable: false, free_bytes: null, total_bytes: null, error: null };
if (!mount) { out.error = 'no smb mount configured'; return out; }
// Normalize smb://host/share or \\host\share → //host/share for smbclient.
let unc = String(mount).trim().replace(/\\/g, '/').replace(/^smb:\/\//i, '//');
if (!unc.startsWith('//')) unc = '//' + unc.replace(/^\/+/, '');
const user = `${username || ''}%${password || ''}`;
const args = [
unc, '-U', user,
...(vers ? ['-m', `SMB${String(vers).replace(/\./g, '_').replace(/_0$/, '')}`] : []),
'-c', 'du',
];
try {
// execFile avoids shell-quoting the password. 6s cap so a dead NAS can't hang.
const { stdout } = await execFile('smbclient', args, { timeout: 6000 });
// " 1890828485120 blocks of size 1024. 1890776477696 blocks available"
const m = stdout.match(/(\d+)\s+blocks of size\s+(\d+)\.\s+(\d+)\s+blocks available/i);
if (m) {
const blockSize = parseInt(m[2], 10) || 1024;
out.total_bytes = parseInt(m[1], 10) * blockSize;
out.free_bytes = parseInt(m[3], 10) * blockSize;
out.reachable = true;
} else {
out.error = 'could not parse smbclient du output';
}
} catch (err) {
out.error = (err.stderr ? String(err.stderr).trim() : err.message).slice(0, 200);
}
return out;
}
// GET /api/v1/storage/overview
// Consolidated read-only view of the storage subsystem for the admin UI.
router.get('/overview', async (req, res, next) => {
@ -152,29 +108,6 @@ router.get('/overview', async (req, res, next) => {
const containerPath = growingRaw.growing_path || '/growing';
const mount = await probeGrowingPath(containerPath);
// Real capacity comes from the SMB share itself, NOT the local container
// path (mam-api never mounts the share, so df reports the tiny overlay).
// Query the NAS quota directly via smbclient when a mount is configured.
let smbFree = null, smbTotal = null, smbReachable = false, capacityError = mount.error;
if (growingEnabled) {
const creds = await readSettings(['growing_smb_username', 'growing_smb_password', 'growing_smb_vers']);
const smb = await probeSmbShare({
mount: growingRaw.growing_smb_mount,
username: creds.growing_smb_username,
password: creds.growing_smb_password,
vers: creds.growing_smb_vers,
});
if (smb.reachable) {
smbFree = smb.free_bytes; smbTotal = smb.total_bytes; smbReachable = true; capacityError = null;
} else {
// Fall back to the local-path df numbers, but surface why the share
// probe failed so the card can show it.
smbFree = mount.free_bytes; smbTotal = mount.total_bytes; capacityError = smb.error || mount.error;
}
} else {
smbFree = mount.free_bytes; smbTotal = mount.total_bytes;
}
// S3 — bucket name comes from the live client (env or DB-loaded), not
// a fresh DB read, so we report exactly what the running client uses.
const s3 = await probeS3Bucket();
@ -192,12 +125,9 @@ router.get('/overview', async (req, res, next) => {
promote_after_seconds: parseInt(growingRaw.growing_promote_after_seconds, 10) || 8,
exists: mount.exists,
writable: mount.writable,
// free/total now reflect the actual SMB share (smbclient du) when a
// mount is configured; smb_reachable says whether that probe succeeded.
free_bytes: smbFree,
total_bytes: smbTotal,
smb_reachable: smbReachable,
error: capacityError,
free_bytes: mount.free_bytes,
total_bytes: mount.total_bytes,
error: mount.error,
},
s3: {
endpoint: s3SettingsRaw.s3_endpoint || process.env.S3_ENDPOINT || '',

View file

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

View file

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

View file

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

View file

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

View file

@ -73,19 +73,7 @@ server {
types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; }
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
add_header Pragma "no-cache" always;
# Tighten CORS: no wildcard.
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Credentials "true" always;
if ($request_method = 'OPTIONS') {
add_header Access-Control-Allow-Origin $http_origin;
add_header Access-Control-Allow-Credentials "true";
add_header Access-Control-Allow-Methods 'GET, OPTIONS';
add_header Access-Control-Allow-Headers 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
add_header Access-Control-Max-Age 1728000;
add_header Content-Type 'text/plain; charset=utf-8';
add_header Content-Length 0;
return 204;
}
add_header Access-Control-Allow-Origin * always;
}
# Playout HLS preview CasparCG sidecar writes to the media volume under
@ -95,19 +83,7 @@ server {
types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; }
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
add_header Pragma "no-cache" always;
# Tighten CORS: no wildcard.
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Credentials "true" always;
if ($request_method = 'OPTIONS') {
add_header Access-Control-Allow-Origin $http_origin;
add_header Access-Control-Allow-Credentials "true";
add_header Access-Control-Allow-Methods 'GET, OPTIONS';
add_header Access-Control-Allow-Headers 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
add_header Access-Control-Max-Age 1728000;
add_header Content-Type 'text/plain; charset=utf-8';
add_header Content-Length 0;
return 204;
}
add_header Access-Control-Allow-Origin * always;
}
# API proxy - forward to mam-api service
@ -157,11 +133,6 @@ server {
try_files $uri $uri/ /index.html;
expires -1;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:;" always;
}
# Deny access to dotfiles

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -306,7 +306,20 @@ function Editor() {
if (r && r.url) { url = r.url; cache[asset.id] = url; }
} catch (e) { window.DF_LOG.warn('[editor] stream URL failed', e); }
}
if (url) { vid.src = url; vid.load(); }
if (url) {
if (url.endsWith('.m3u8')) {
if (window.Hls && window.Hls.isSupported()) {
const hls = new window.Hls();
hls.loadSource(url);
hls.attachMedia(vid);
} else {
vid.src = url;
}
} else {
vid.src = url;
}
vid.load();
}
}
function markSrcIn() {
@ -636,8 +649,14 @@ function ProgramMonitor({ videoRef, currentSeq, playheadFrames, setPlayheadFrame
if (vid) vid.pause();
}
// Audio track refs for playback
const pgmAudioRefs = React.useRef([]);
React.useEffect(() => {
if (!pgmPlaying || pgmClipIdx < 0 || pgmClipIdx >= pgmClips.length) return;
if (!pgmPlaying || pgmClipIdx < 0 || pgmClipIdx >= pgmClips.length) {
pgmAudioRefs.current.forEach(a => a.pause());
return;
}
const clip = pgmClips[pgmClipIdx];
if (!clip) { stopPgm(); return; }
const vid = videoRef.current;
@ -651,7 +670,28 @@ function ProgramMonitor({ videoRef, currentSeq, playheadFrames, setPlayheadFrame
if (r && r.url) { url = r.url; cache[clip.asset_id] = url; }
} catch (e) { window.DF_LOG.warn('[editor] stream fetch failed', e); return; }
}
if (vid.src !== url) { vid.src = url; vid.load(); }
if (vid.src !== url) {
if (url.endsWith('.m3u8')) {
if (window.Hls && window.Hls.isSupported()) {
const hls = new window.Hls();
hls.loadSource(url);
hls.attachMedia(vid);
} else {
vid.src = url;
}
} else {
vid.src = url;
}
vid.load();
}
// Sync audio tracks (A1/A2)
const asset = assetsRef.current.find(a => a.id === clip.asset_id);
if (asset && asset.media_type === 'video') {
// For now, simple video-track audio. Multi-track A1/A2 wiring planned.
vid.muted = false;
}
const srcInSecs = clip.source_in_frames / (window.TC ? window.TC.FPS : 59.94);
vid.currentTime = srcInSecs;
vid.play().catch(() => {});

View file

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

View file

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

View file

@ -358,7 +358,6 @@ function Playlist({ channel, playlistId, items, activeIndex, onReload }) {
}
// Audio meter
// Simulated VU meter real values would require a WebAudio analyzer on the
// HLS stream. For now, animate a plausible signal when on-air. (Named PoAudioMeter
// to avoid colliding with the global AudioMeter from visuals.jsx.)
function PoAudioMeter({ onAir }) {
@ -447,8 +446,6 @@ function ProgramMonitor({ channel, engine, elapsed }) {
React.useEffect(() => {
const vid = videoRef.current;
if (!vid) return;
// Tear down any previous HLS instance before re-evaluating.
if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; }
if (!onAir) { vid.src = ''; return; }
@ -526,6 +523,18 @@ function ProgramMonitor({ channel, engine, elapsed }) {
const progress = clipDurSecs > 0 ? Math.min(1, elapsed / clipDurSecs) : 0;
const timeRemaining = clipDurSecs > 0 ? Math.max(0, clipDurSecs - elapsed) : 0;
<<<<<<< HEAD
return (
<div className="po-pgm">
{/* Screen */}
<div className="po-screen">
<video ref={videoRef} className="po-monitor-video" muted playsInline autoPlay />
{/* ON AIR badge */}
{onAir && (
<div className="po-onair-badge">ON AIR</div>
)}
=======
// SCTE break countdown (seconds remaining in the active break).
const breakRemain = scte && scte.endsAt
? Math.max(0, (new Date(scte.endsAt).getTime() - Date.now()) / 1000)
@ -543,6 +552,7 @@ function ProgramMonitor({ channel, engine, elapsed }) {
</div>
)}
{onAir && !scte && <div className="po-onair-badge">ON AIR</div>}
>>>>>>> main
{!onAir && (
<div className="po-screen-offline">
@ -551,12 +561,23 @@ function ProgramMonitor({ channel, engine, elapsed }) {
</div>
)}
<<<<<<< HEAD
{/* Timecode overlay */}
{onAir && (
<div className="po-tc-overlay mono">{fmtTimecode(elapsed)}</div>
)}
{/* Audio meters */}
<div className="po-meters-wrap">
<AudioMeter onAir={onAir} />
=======
{onAir && (
<div className="po-tc-overlay mono">{playoutFmtTC(elapsed)}</div>
)}
<div className="po-meters-wrap">
<PoAudioMeter onAir={onAir} />
>>>>>>> main
</div>
</div>
@ -690,12 +711,6 @@ function Scte35Panel({ channel, engine, breaks, onReload, onError }) {
<div className="po-card po-scte-card">
<div className="po-card-head">
<span className="po-section-label">SCTE-35 Break</span>
{scte
? <span className="po-scte-stub-badge" style={{ color: '#f59e0b' }}> ON AIR</span>
: pending.length > 0
? <span className="po-scte-stub-badge">{pending.length} queued</span>
: null}
</div>
<div className="po-scte-body">
{scte && (
<div className="po-scte-active mono" style={{ color: '#f59e0b', fontWeight: 600, fontSize: 12 }}>
@ -745,6 +760,10 @@ function NowPlayingCard({ engine, elapsed, items }) {
const clipDurSecs = currentItem ? itemEffectiveDuration(currentItem) : 0;
const progress = clipDurSecs > 0 ? Math.min(1, elapsed / clipDurSecs) : 0;
const timeRemaining = clipDurSecs > 0 ? Math.max(0, clipDurSecs - elapsed) : 0;
<<<<<<< HEAD
=======
>>>>>>> main
const nextItem = items[engine.currentIndex + 1] || null;
return (
@ -858,6 +877,159 @@ function Timeline({ items, activeIndex, elapsed, breaks }) {
);
}
let playheadPct = 0;
if (activeIndex >= 0 && totalSecs > 0) {
const offsetSecs = items.slice(0, activeIndex).reduce((s, it) => s + itemEffectiveDuration(it), 0);
const clipDur = itemEffectiveDuration(items[activeIndex] || {});
playheadPct = ((offsetSecs + Math.min(elapsed, clipDur)) / totalSecs) * 100;
}
// Pending position-based breaks markers at the end of their playlist_pos clip.
const breakMarkers = [];
if (totalSecs > 0) {
for (const b of (breaks || [])) {
if (b.status !== 'pending' || b.playlist_pos == null) continue;
const pos = Math.min(b.playlist_pos, items.length - 1);
const offsetSecs = items.slice(0, pos + 1).reduce((s, it) => s + itemEffectiveDuration(it), 0);
breakMarkers.push({ id: b.id, pct: (offsetSecs / totalSecs) * 100, dur: b.duration_s });
}
}
const totalSecs = items.reduce((sum, it) => sum + itemEffectiveDuration(it), 0);
if (items.length === 0) {
return (
<div className="po-pgm">
<div className="po-screen">
<video ref={videoRef} className="po-monitor-video" muted playsInline autoPlay />
{/* ON AIR / SCTE BREAK badge */}
{onAir && scte && (
<div className="po-onair-badge" style={{ background: '#f59e0b' }}>
SCTE BREAK{breakRemain > 0 ? ' ' + Math.ceil(breakRemain) + 's' : ''}
</div>
)}
{onAir && !scte && <div className="po-onair-badge">ON AIR</div>}
{!onAir && (
<div className="po-screen-offline">
<span className="po-screen-offline-dot" />
<span>Channel stopped</span>
</div>
)}
{onAir && (
<div className="po-tc-overlay mono">{playoutFmtTC(elapsed)}</div>
)}
<div className="po-meters-wrap">
<PoAudioMeter onAir={onAir} />
</div>
</div>
<div className="po-tl-empty muted">Add clips to the playlist to see the timeline.</div>
</div>
);
}
<<<<<<< HEAD
// Compute offset of active clip for the playhead
=======
>>>>>>> main
let playheadPct = 0;
if (activeIndex >= 0 && totalSecs > 0) {
const offsetSecs = items.slice(0, activeIndex).reduce((s, it) => s + itemEffectiveDuration(it), 0);
const clipDur = itemEffectiveDuration(items[activeIndex] || {});
playheadPct = ((offsetSecs + Math.min(elapsed, clipDur)) / totalSecs) * 100;
}
<<<<<<< HEAD
=======
// Pending position-based breaks markers at the end of their playlist_pos clip.
const breakMarkers = [];
if (totalSecs > 0) {
for (const b of (breaks || [])) {
if (b.status !== 'pending' || b.playlist_pos == null) continue;
const pos = Math.min(b.playlist_pos, items.length - 1);
const offsetSecs = items.slice(0, pos + 1).reduce((s, it) => s + itemEffectiveDuration(it), 0);
breakMarkers.push({ id: b.id, pct: (offsetSecs / totalSecs) * 100, dur: b.duration_s });
}
}
>>>>>>> main
const COLORS = ['#3b82f6','#8b5cf6','#06b6d4','#10b981','#f59e0b','#ec4899','#6366f1','#14b8a6'];
return (
<div className="po-tl">
<div className="po-tl-head">
<span className="po-section-label">Timeline</span>
<<<<<<< HEAD
<span className="mono muted" style={{ fontSize: 11 }}>{fmtDuration(totalSecs)} total</span>
</div>
<div className="po-tl-track-wrap">
{/* Playhead */}
{activeIndex >= 0 && (
<div className="po-tl-playhead" style={{ left: playheadPct + '%' }} />
)}
=======
<span className="mono muted" style={{ fontSize: 11 }}>{playoutFmtDur(totalSecs)} total</span>
</div>
<div className="po-tl-track-wrap">
{activeIndex >= 0 && (
<div className="po-tl-playhead" style={{ left: playheadPct + '%' }} />
)}
{breakMarkers.map(m => (
<div key={m.id} className="po-tl-scte-marker" style={{ left: m.pct + '%' }}
title={'SCTE-35 break · ' + m.dur + 's'} />
))}
>>>>>>> main
<div className="po-tl-track">
{items.map((it, i) => {
const dur = itemEffectiveDuration(it);
const pct = totalSecs > 0 ? (dur / totalSecs) * 100 : 0;
const isActive = i === activeIndex;
const color = COLORS[i % COLORS.length];
return (
<div key={it.id}
className={'po-tl-clip' + (isActive ? ' po-tl-clip--active' : '')}
style={{ width: pct + '%', '--clip-color': color }}
<<<<<<< HEAD
title={`${it.clip_name || it.asset_id} · ${fmtDuration(dur)}`}>
<span className="po-tl-clip-name">{it.clip_name || it.asset_id}</span>
<span className="po-tl-clip-dur mono">{fmtDuration(dur)}</span>
=======
title={`${it.clip_name || it.asset_id} · ${playoutFmtDur(dur)}`}>
<span className="po-tl-clip-name">{it.clip_name || it.asset_id}</span>
<span className="po-tl-clip-dur mono">{playoutFmtDur(dur)}</span>
>>>>>>> main
{it.media_status === 'staging' && (
<span className="po-tl-staging-dot" title="Staging…" />
)}
{it.media_status === 'error' && (
<span className="po-tl-error-dot" title="Stage error" />
)}
</div>
);
})}
</div>
<<<<<<< HEAD
{/* Time ruler (rough marks) */}
<div className="po-tl-ruler">
{totalSecs > 0 && Array.from({ length: 5 }).map((_, i) => (
<span key={i} className="po-tl-ruler-mark mono"
style={{ left: (i * 25) + '%' }}>
{fmtDuration((totalSecs * i) / 4)}
=======
<div className="po-tl-ruler">
{totalSecs > 0 && Array.from({ length: 5 }).map((_, i) => (
<span key={i} className="po-tl-ruler-mark mono" style={{ left: (i * 25) + '%' }}>
{playoutFmtDur((totalSecs * i) / 4)}
>>>>>>> main
</span>
))}
</div>
</div>
</div>
);
}
// As-run drawer
function AsRunDrawer({ channel, refreshKey, open, onClose }) {
const [rows, setRows] = React.useState([]);
@ -1007,6 +1179,15 @@ function ChannelDetail({ channel, onChannelChange }) {
const activeIndex = (engine && engine.currentIndex >= 0) ? engine.currentIndex : -1;
const elapsed = useElapsed(engine && engine.currentItemStartedAt);
<<<<<<< HEAD
const onAir = ch.status === 'running';
return (
<div className="po-root">
{/* ── Top rail: monitor + right panel ── */}
<div className="po-top">
{/* PGM monitor + transport */}
=======
return (
<div className="po-root">
@ -1014,6 +1195,7 @@ function ChannelDetail({ channel, onChannelChange }) {
{/* ── Top rail: monitor + right panel ── */}
<div className="po-top">
>>>>>>> main
<div className="po-pgm-col">
<ProgramMonitor channel={ch} engine={engine} elapsed={elapsed} />
<Transport
@ -1021,10 +1203,17 @@ function ChannelDetail({ channel, onChannelChange }) {
playlistId={playlistId}
items={items}
onStatus={loadItems}
<<<<<<< HEAD
/>
</div>
{/* Right rail */}
=======
onError={setActionErr}
/>
</div>
>>>>>>> main
<div className="po-rail">
{/* Channel controls */}
<div className="po-card po-channel-card">
@ -1048,6 +1237,20 @@ function ChannelDetail({ channel, onChannelChange }) {
)}
</div>
{ch.error_message && <div className="alert error" style={{ marginTop: 8 }}>{ch.error_message}</div>}
<<<<<<< HEAD
</div>
{/* Now playing */}
<NowPlayingCard engine={engine} elapsed={elapsed} items={items} />
{/* SCTE-35 */}
<Scte35Panel channel={ch} />
{/* Quick actions */}
<div className="po-rail-actions">
<button className="btn ghost sm po-rail-action-btn" onClick={() => setBinOpen(b => !b)}>
{binOpen ? '▸ Hide' : '▾ Media Bin'}
=======
{actionErr && (
<div className="alert error" style={{ marginTop: 8 }} onClick={() => setActionErr(null)}>
{actionErr}
@ -1063,6 +1266,7 @@ function ChannelDetail({ channel, onChannelChange }) {
<div className="po-rail-actions">
<button className="btn ghost sm po-rail-action-btn" onClick={() => setBinOpen(b => !b)}>
{binOpen ? '▸ Hide bin' : '▾ Media Bin'}
>>>>>>> main
</button>
<button className="btn ghost sm po-rail-action-btn" onClick={() => setAsRunOpen(true)}>
As-Run Log
@ -1071,8 +1275,16 @@ function ChannelDetail({ channel, onChannelChange }) {
</div>
</div>
<<<<<<< HEAD
{/* Media bin (collapsible, below top rail) */}
{binOpen && (
<MediaBin projectId={ch.project_id} />
)}
=======
{binOpen && <MediaBin projectId={ch.project_id} />}
>>>>>>> main
{/* Playlist */}
{playlistId && (
<Playlist
channel={ch}
@ -1083,8 +1295,15 @@ function ChannelDetail({ channel, onChannelChange }) {
/>
)}
<<<<<<< HEAD
{/* Timeline */}
<Timeline items={items} activeIndex={activeIndex} elapsed={elapsed} />
{/* As-run drawer */}
=======
<Timeline items={items} activeIndex={activeIndex} elapsed={elapsed} breaks={breaks} />
>>>>>>> main
<AsRunDrawer
channel={ch}
refreshKey={engine && engine.currentItemId}
@ -1147,6 +1366,8 @@ function Playout() {
</div>
<div className="page-body po-page">
<<<<<<< HEAD
=======
<div style={{
background: '#fef3c7',
borderLeft: '4px solid #f59e0b',
@ -1162,6 +1383,7 @@ function Playout() {
}}>
Playout is in testing not for production use.
</div>
>>>>>>> main
{err && <div className="alert error">{err}</div>}
{channels === null && <div className="muted">Loading channels</div>}

View file

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

View file

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

View file

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

View file

@ -539,7 +539,6 @@
padding-bottom: 8px; border-bottom: 1px solid var(--border);
margin-bottom: 10px;
}
/* ── SCTE-35 additions (timeline marker + active-break line) ──────────────── */
.po-tl-scte-marker {
position: absolute; top: 10px; bottom: 28px;

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

@ -58,6 +58,9 @@ function AssetThumb({ asset, size = 'md' }) {
);
}
// VOD HLS assets: if we have an HLS rendition, we could potentially show a
// muted hover-preview here too. For now, just static thumb.
const altText = asset.name ? `Thumbnail for ${asset.name}` : 'Asset thumbnail';
return (
<div className="asset-thumb" style={{ background: 'var(--bg-2)', aspectRatio: aspect, overflow: 'hidden', position: 'relative' }}>
@ -112,7 +115,12 @@ function LiveThumb({ assetId, aspect }) {
const startHls = () => {
if (destroyed) return;
hls = new window.Hls({ liveSyncDurationCount: 2, lowLatencyMode: true, maxBufferLength: 10 });
hls = new window.Hls({
liveSyncDurationCount: 2,
lowLatencyMode: true,
maxBufferLength: 10,
xhrSetup: (xhr) => { xhr.withCredentials = true; }
});
hls.loadSource(url);
hls.attachMedia(v);
hls.on(window.Hls.Events.MANIFEST_PARSED, () => {

View file

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

View file

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

View file

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