Compare commits
1 commit
main
...
fix/audit-
| Author | SHA1 | Date | |
|---|---|---|---|
| b766f4cdfb |
56 changed files with 1032 additions and 6368 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:-}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
30
services/capture/build-with-decklink.sh
Executable file
30
services/capture/build-with-decklink.sh
Executable 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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { spawn, execFileSync } from 'child_process';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { mkdirSync, writeFileSync, createReadStream, statSync, unlinkSync } from 'node:fs';
|
||||
import fs from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
|
@ -7,19 +7,13 @@ import { createUploadStream } from './s3/client.js';
|
|||
|
||||
|
||||
const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';
|
||||
// In standby mode the framecache slot has been warm for a long time — reduce
|
||||
// pre-roll to 1s (just enough for fc_pipe to sync its read cursor).
|
||||
// Override with PRE_ROLL_SECONDS env var if needed.
|
||||
const _standbyMode = process.env.STANDBY === '1';
|
||||
const PRE_ROLL_SECONDS = parseInt(process.env.PRE_ROLL_SECONDS || (_standbyMode ? '1' : '5'), 10);
|
||||
|
||||
|
||||
// Growing-files mode: writes the master to a local SMB-backed share that the
|
||||
// editor can mount, instead of streaming to S3 in real time. The promotion
|
||||
// worker uploads the finalized file to S3 after the recording stops.
|
||||
// Toggled per-recorder via `GROWING_ENABLED=true`, delivered per-session on
|
||||
// /capture/start (read fresh from process.env at record time in start(), NOT
|
||||
// cached here — standby sidecars boot with it false).
|
||||
// Toggled per-recorder via `GROWING_ENABLED=true` on the capture container
|
||||
// (see routes/recorders.js where the env is composed).
|
||||
const GROWING_ENABLED = process.env.GROWING_ENABLED === 'true';
|
||||
const GROWING_PATH = process.env.GROWING_PATH || '/growing';
|
||||
|
||||
// Approach A: when a CIFS source is supplied, this (privileged) container mounts
|
||||
|
|
@ -39,18 +33,10 @@ function toUncShare(raw) {
|
|||
if (!s.startsWith('//')) s = '//' + s.replace(/^\/+/, ''); // host/share -> //host/share
|
||||
return s;
|
||||
}
|
||||
// Growing SMB params are read FRESH from process.env at mount time, NOT cached
|
||||
// at module load. Standby capture containers boot with these unset and receive
|
||||
// them per-session over /capture/start (capture.js sets process.env before
|
||||
// captureManager.start()). Caching them in module-level consts at import time
|
||||
// captured the empty boot values, so the mount silently no-op'd and growing
|
||||
// fell back to S3 — producing .mov instead of the XDCAM HD422 .mxf.
|
||||
const growingSmbConfig = () => ({
|
||||
mount: toUncShare(process.env.GROWING_SMB_MOUNT || ''),
|
||||
username: process.env.GROWING_SMB_USERNAME || '',
|
||||
password: process.env.GROWING_SMB_PASSWORD || '',
|
||||
vers: process.env.GROWING_SMB_VERS || '3.0',
|
||||
});
|
||||
const GROWING_SMB_MOUNT = toUncShare(process.env.GROWING_SMB_MOUNT || '');
|
||||
const GROWING_SMB_USERNAME = process.env.GROWING_SMB_USERNAME || '';
|
||||
const GROWING_SMB_PASSWORD = process.env.GROWING_SMB_PASSWORD || '';
|
||||
const GROWING_SMB_VERS = process.env.GROWING_SMB_VERS || '3.0';
|
||||
const SMB_CREDS_FILE = '/run/smb-creds';
|
||||
|
||||
// True when GROWING_PATH is already a mountpoint (e.g. a prior session left it
|
||||
|
|
@ -65,31 +51,26 @@ function isMounted(path) {
|
|||
// Returns true on success (or if already mounted), false on failure — callers
|
||||
// fall back to S3 streaming so a recording is never lost.
|
||||
function mountGrowingShare() {
|
||||
const cfg = growingSmbConfig();
|
||||
if (!cfg.mount) {
|
||||
console.warn('[capture] growing requested but GROWING_SMB_MOUNT is empty — falling back to S3');
|
||||
return false;
|
||||
}
|
||||
if (!GROWING_SMB_MOUNT) return false;
|
||||
try {
|
||||
if (isMounted(GROWING_PATH)) {
|
||||
console.log('[capture] growing share already mounted at', GROWING_PATH);
|
||||
return true;
|
||||
}
|
||||
try { mkdirSync(GROWING_PATH, { recursive: true }); } catch (_) {}
|
||||
// Pass credentials inline rather than via a credentials= file. Some SMB
|
||||
// servers (notably TrueNAS SMB3) reject the credentials-file form with
|
||||
// EACCES (-13) — "cannot mount ... read-only" — even though the very same
|
||||
// username/password mount inline and smbclient lists the share fine. Inline
|
||||
// user=/password= is the reliable form here.
|
||||
writeFileSync(
|
||||
SMB_CREDS_FILE,
|
||||
`username=${GROWING_SMB_USERNAME}\npassword=${GROWING_SMB_PASSWORD}\n`,
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
const opts = [
|
||||
`username=${cfg.username}`,
|
||||
`password=${cfg.password}`,
|
||||
`credentials=${SMB_CREDS_FILE}`,
|
||||
'uid=0', 'gid=0', 'file_mode=0664', 'dir_mode=0775',
|
||||
`vers=${cfg.vers}`,
|
||||
`vers=${GROWING_SMB_VERS}`,
|
||||
].join(',');
|
||||
execFileSync('mount', ['-t', 'cifs', cfg.mount, GROWING_PATH, '-o', opts],
|
||||
execFileSync('mount', ['-t', 'cifs', GROWING_SMB_MOUNT, GROWING_PATH, '-o', opts],
|
||||
{ stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
console.log('[capture] mounted CIFS growing share', cfg.mount, '->', GROWING_PATH);
|
||||
console.log('[capture] mounted CIFS growing share', GROWING_SMB_MOUNT, '->', GROWING_PATH);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const stderr = err.stderr ? err.stderr.toString().trim() : err.message;
|
||||
|
|
@ -100,6 +81,7 @@ function mountGrowingShare() {
|
|||
|
||||
// Best-effort unmount on session stop. Ignores "not mounted".
|
||||
function unmountGrowingShare() {
|
||||
if (!GROWING_SMB_MOUNT) return;
|
||||
try {
|
||||
if (isMounted(GROWING_PATH)) {
|
||||
execFileSync('umount', [GROWING_PATH], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
|
|
@ -146,9 +128,6 @@ const VIDEO_CODECS = {
|
|||
//
|
||||
// -profile:v main10 / p010le: 10-bit 4:2:0 — the closest NVENC HEVC can get
|
||||
// to ProRes 4:2:2 10-bit. If strict 4:2:2 is required, use prores_hq (CPU).
|
||||
// GROWING-file variant: every frame an IDR (all-intra) so a still-growing
|
||||
// file is decodable to its last complete frame. This is HEAVY — only used when
|
||||
// growing-files is on (see hevcNvencArgs()).
|
||||
hevc_nvenc: {
|
||||
args: ['-c:v', 'hevc_nvenc', '-preset', 'p4', '-rc', 'vbr', '-bf', '0', '-forced-idr', '1', '-g', '600', '-force_key_frames', 'expr:1', '-profile:v', 'main10'],
|
||||
bitrateControl: true,
|
||||
|
|
@ -156,27 +135,6 @@ const VIDEO_CODECS = {
|
|||
},
|
||||
};
|
||||
|
||||
// HEVC/NVENC encode args, GOP structure chosen by mode.
|
||||
// growing=false (normal record): efficient long-GOP (2s @ fps) HEVC. NVENC
|
||||
// easily sustains 1080p59.94 10-bit here, so no frame drops → audio/video
|
||||
// lengths stay locked. This is the DEFAULT for recorders.
|
||||
// growing=true (edit-while-record): ALL-INTRA (every frame an IDR) so the
|
||||
// growing file is decodable to its last written frame — the requirement for
|
||||
// Premiere's growing-file refresh. Much heavier, only used when needed.
|
||||
// `force_key_frames expr:1` (all-intra) is the ~4× compute path that was
|
||||
// crippling realtime when applied to every recording; gating it on `growing`
|
||||
// is the fix for the dropped-frame A/V drift.
|
||||
function hevcNvencArgs(framerate, growing) {
|
||||
const base = ['-c:v', 'hevc_nvenc', '-preset', 'p4', '-rc', 'vbr', '-profile:v', 'main10'];
|
||||
if (growing) {
|
||||
return [...base, '-bf', '0', '-forced-idr', '1', '-g', '600', '-force_key_frames', 'expr:1'];
|
||||
}
|
||||
// Normal long-GOP: ~2s keyframe interval, 2 B-frames. Realtime-friendly.
|
||||
const fps = Number.parseFloat(framerate) || 60;
|
||||
const gop = Math.max(2, Math.round(fps * 2));
|
||||
return [...base, '-bf', '2', '-g', String(gop)];
|
||||
}
|
||||
|
||||
// nvenc codecs available in the capture image. Used both to validate the master
|
||||
// codec and (issue #164) as the GPU-availability signal for the HLS preview.
|
||||
const NVENC_CODECS = new Set(['h264_nvenc', 'hevc_nvenc']);
|
||||
|
|
@ -515,14 +473,7 @@ function buildEncodeArgs({
|
|||
const args = [];
|
||||
if (isNetwork) args.push('-map', '0:v:0?', '-map', '0:a:0?');
|
||||
|
||||
// hevc_nvenc GOP structure is mode-dependent: all-intra only for growing
|
||||
// files, efficient long-GOP for normal record (so NVENC stays realtime and
|
||||
// doesn't drop frames). All other codecs use their static arg set.
|
||||
if (codec === 'hevc_nvenc') {
|
||||
args.push(...hevcNvencArgs(framerate, growing));
|
||||
} else {
|
||||
args.push(...v.args);
|
||||
}
|
||||
args.push(...v.args);
|
||||
if (v.pixFmt) args.push('-pix_fmt', v.pixFmt);
|
||||
if (v.bitrateControl && videoBitrate) args.push('-b:v', videoBitrate);
|
||||
if (framerate && framerate !== 'native') args.push('-r', framerate);
|
||||
|
|
@ -531,13 +482,18 @@ function buildEncodeArgs({
|
|||
if (a.bitrateControl && audioBitrate) args.push('-b:a', audioBitrate);
|
||||
if (audioChannels) args.push('-ac', String(audioChannels));
|
||||
|
||||
// Fragmented MOV/MP4 for direct S3 streaming (pipe:1 output — no seekable
|
||||
// file on the worker disk). +frag_keyframe writes a moof/trun fragment per
|
||||
// keyframe; +empty_moov puts a valid moov box at the start so the file is
|
||||
// immediately parseable. Premiere Pro 25.x (2025) handles fragmented MOV
|
||||
// natively. Growing-file masters use the same flags (written to SMB share).
|
||||
// moov-atom placement is the difference between a Premiere-openable master and
|
||||
// a "file cannot be opened" error.
|
||||
//
|
||||
// Finalized masters (the S3-piped recording that stops cleanly) must NOT be
|
||||
// fragmented. Adobe Premiere's QuickTime/MOV importer reads the classic
|
||||
// stco/stsz/stts sample tables in a single top-level moov; a fragmented MOV
|
||||
// (moof/trun, empty sample tables) makes Premiere report "file cannot be
|
||||
// opened." We write a clean, non-fragmented MOV instead. `+faststart` puts the
|
||||
// moov before mdat on the second pass so the file is instantly
|
||||
// seekable/streamable too.
|
||||
if (fmt === 'mov' || fmt === 'mp4') {
|
||||
args.push('-movflags', '+frag_keyframe+empty_moov+default_base_moof');
|
||||
args.push('-movflags', '+faststart');
|
||||
}
|
||||
// ProRes-in-MOV must carry a QuickTime brand or some importers reject the tag.
|
||||
args.push('-f', fmt);
|
||||
|
|
@ -565,55 +521,6 @@ class CaptureManager {
|
|||
* @private
|
||||
*/
|
||||
async _buildInputArgs({ sourceType, sourceBackend = 'blackmagic', device, port, board, sourceUrl, listen, listenPort, streamKey }) {
|
||||
// ── Network sources via framecache (primary when FC_SLOT_ID is set) ──────
|
||||
// node-agent starts net_ingest before the sidecar, which decodes the stream
|
||||
// to raw UYVY422 and registers a framecache slot. We read from that slot via
|
||||
// fc_pipe — same zero-copy path as SDI sources — enabling simultaneous
|
||||
// growing + proxy + HLS from any network source.
|
||||
if ((sourceType === 'srt' || sourceType === 'rtmp') && process.env.FC_SLOT_ID) {
|
||||
const slotId = process.env.FC_SLOT_ID;
|
||||
const fcPipeBin = process.env.FC_PIPE_BIN || 'fc_pipe';
|
||||
const WAIT_MS = 60_000; /* network sources may take longer to connect */
|
||||
|
||||
const fcSize = process.env.DELTACAST_VIDEO_SIZE || '1920x1080';
|
||||
const fcFps = process.env.DELTACAST_FRAMERATE || '30000/1001';
|
||||
|
||||
console.log(`[framecache] net slot=${slotId} size=${fcSize} fps=${fcFps}`);
|
||||
|
||||
const fcPipeProcess = spawn(fcPipeBin, [slotId, String(WAIT_MS)], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
// Pause stdout immediately so frames don't fill the OS pipe buffer (and
|
||||
// block fc_pipe's write()) in the window between spawn here and the
|
||||
// .pipe(ffmpeg.stdin) attach later in start(). .pipe() auto-resumes.
|
||||
fcPipeProcess.stdout.pause();
|
||||
fcPipeProcess.stderr.on('data', chunk => {
|
||||
process.stderr.write(`[fc_pipe:${slotId}] ${chunk}`);
|
||||
});
|
||||
fcPipeProcess.on('error', err =>
|
||||
console.error(`[fc_pipe:${slotId}] spawn error: ${err.message}`));
|
||||
|
||||
return {
|
||||
inputArgs: [
|
||||
// No -use_wallclock_as_timestamps — framecache delivers CFR frames
|
||||
// at the original ingest rate; -framerate produces correct timestamps.
|
||||
'-thread_queue_size', '512',
|
||||
'-f', 'rawvideo',
|
||||
'-pix_fmt', 'uyvy422',
|
||||
'-video_size', fcSize,
|
||||
'-framerate', fcFps,
|
||||
'-i', 'pipe:0',
|
||||
],
|
||||
isNetwork: false, /* treat as raw source — no -map 0:v:0? needed */
|
||||
bridgeProcess: fcPipeProcess,
|
||||
audioFifo: null,
|
||||
interlaced: false,
|
||||
audioInputIndex: 0, /* network fc_pipe is video-only — no audio input */
|
||||
_fcPipeProcess: fcPipeProcess,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Legacy direct network paths (no framecache / net_ingest not running) ──
|
||||
if (sourceType === 'srt') {
|
||||
let url;
|
||||
if (listen) {
|
||||
|
|
@ -640,121 +547,99 @@ class CaptureManager {
|
|||
return { inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-i', sourceUrl], isNetwork: true };
|
||||
}
|
||||
|
||||
// ── Framecache path (primary for deltacast + blackmagic) ────────────────
|
||||
// Deltacast SDI via shared bridge daemon (deltacast-bridge).
|
||||
//
|
||||
// When FC_SLOT_ID is set in the sidecar env (injected by node-agent from
|
||||
// the bridge's format JSON), we use the framecache shm ring buffer as the
|
||||
// video source instead of named FIFOs.
|
||||
// The bridge daemon is started by node-agent (host process, direct /dev access)
|
||||
// and writes each port's streams to named FIFOs in /dev/shm/deltacast/:
|
||||
// /dev/shm/deltacast/video-<port>.fifo
|
||||
// /dev/shm/deltacast/audio-<port>.fifo
|
||||
//
|
||||
// fc_pipe is a small C helper that opens the framecache slot as a consumer
|
||||
// and writes raw UYVY422 frames to stdout. capture-manager spawns it and
|
||||
// pipes its stdout to ffmpeg as a rawvideo input — same pattern as the
|
||||
// existing FIFO path, but with zero-copy shm reads and independent per-
|
||||
// consumer cursors. Multiple fc_pipe instances on the same slot each get
|
||||
// their own cursor, enabling simultaneous growing + proxy + HLS from one
|
||||
// SDI input without any frame splitting.
|
||||
//
|
||||
// Audio stays on the named FIFO path (audio fan-out via shm is a roadmap
|
||||
// item).
|
||||
//
|
||||
// node-agent ALWAYS injects FC_SLOT_ID for SDI sidecars (deterministic
|
||||
// `deltacast-<board>-<port>` / `decklink-<node>-<dev>`), so this is the sole
|
||||
// SDI path. The old FC_SLOT_ID-absent legacy FIFO fallback was removed once
|
||||
// framecache became mandatory on every capture node.
|
||||
if ((sourceType === 'deltacast' || sourceType === 'sdi' || sourceType === 'blackmagic')
|
||||
&& process.env.FC_SLOT_ID) {
|
||||
|
||||
const slotId = process.env.FC_SLOT_ID;
|
||||
const fcPipeBin = process.env.FC_PIPE_BIN || 'fc_pipe';
|
||||
const WAIT_MS = 30_000;
|
||||
|
||||
// Determine audio FIFO path based on source type
|
||||
const idx = (typeof device === 'number' || /^\d+$/.test(String(device)))
|
||||
// This sidecar just reads from those FIFOs. The bridge may still be starting
|
||||
// up or waiting for signal lock, so we wait up to 30s for the FIFOs to appear
|
||||
// before handing them to ffmpeg. The bridge process is managed by node-agent;
|
||||
// bridgeProcess is null here (no per-sidecar bridge spawn).
|
||||
if (sourceType === 'deltacast') {
|
||||
const idx = (typeof device === 'number' || /^\d+$/.test(String(device)))
|
||||
? parseInt(device, 10) : 0;
|
||||
const portIdx = (sourceType === 'deltacast')
|
||||
? ((typeof port === 'number' || /^\d+$/.test(String(port)))
|
||||
? parseInt(port, 10) : idx)
|
||||
: idx;
|
||||
const portIdx = (typeof port === 'number' || /^\d+$/.test(String(port)))
|
||||
? parseInt(port, 10) : idx;
|
||||
|
||||
let audioFifoPath;
|
||||
if (sourceType === 'deltacast') {
|
||||
const DC_PIPE_DIR = process.env.DELTACAST_PIPE_DIR || '/dev/shm/deltacast';
|
||||
audioFifoPath = `${DC_PIPE_DIR}/audio-${portIdx}.fifo`;
|
||||
} else {
|
||||
const DL_AUDIO_DIR = process.env.DECKLINK_AUDIO_DIR || '/dev/shm/decklink';
|
||||
audioFifoPath = `${DL_AUDIO_DIR}/audio-${portIdx}.fifo`;
|
||||
}
|
||||
const DC_PIPE_DIR = process.env.DELTACAST_PIPE_DIR || '/dev/shm/deltacast';
|
||||
const videoFifo = `${DC_PIPE_DIR}/video-${portIdx}.fifo`;
|
||||
const audioFifo = `${DC_PIPE_DIR}/audio-${portIdx}.fifo`;
|
||||
|
||||
// Wait up to 30s for the audio FIFO to exist (bridge starts asynchronously)
|
||||
// Wait up to 30s for both FIFOs to exist (bridge starts asynchronously).
|
||||
const { existsSync: _exists } = await import('node:fs');
|
||||
const deadline = Date.now() + WAIT_MS;
|
||||
const WAIT_MS = 30_000;
|
||||
const POLL_MS = 500;
|
||||
const deadline = Date.now() + WAIT_MS;
|
||||
let videoReady = false;
|
||||
let audioReady = false;
|
||||
while (Date.now() < deadline) {
|
||||
if (_exists(audioFifoPath)) break;
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
videoReady = _exists(videoFifo);
|
||||
audioReady = _exists(audioFifo);
|
||||
if (videoReady && audioReady) break;
|
||||
await new Promise(r => setTimeout(r, POLL_MS));
|
||||
}
|
||||
if (!_exists(audioFifoPath)) {
|
||||
if (!videoReady || !audioReady) {
|
||||
throw new Error(
|
||||
`audio FIFO not ready after ${WAIT_MS / 1000}s: ${audioFifoPath} ` +
|
||||
`— is the bridge running?`
|
||||
`deltacast bridge FIFOs not ready after ${WAIT_MS / 1000}s ` +
|
||||
`(video=${videoReady} audio=${audioReady}) — is deltacast-bridge running?`
|
||||
);
|
||||
}
|
||||
console.log(`[deltacast] port ${portIdx} FIFOs ready: ${videoFifo}, ${audioFifo}`);
|
||||
|
||||
// Video dimensions and fps come from env vars injected by node-agent
|
||||
// (populated from the bridge's format JSON on signal lock).
|
||||
const fcSize = process.env.DELTACAST_VIDEO_SIZE || '1920x1080';
|
||||
const fcFps = process.env.DELTACAST_FRAMERATE || '60000/1001';
|
||||
const fcInterlaced = process.env.DELTACAST_INTERLACED === '1';
|
||||
|
||||
console.log(`[framecache] slot=${slotId} size=${fcSize} fps=${fcFps} audio=${audioFifoPath}`);
|
||||
|
||||
// Spawn fc_pipe: opens the framecache slot with its own read cursor and
|
||||
// streams raw UYVY422 frames to stdout. ffmpeg reads from the pipe as
|
||||
// rawvideo input 0; audio FIFO is input 1 (same as before).
|
||||
const fcPipeProcess = spawn(fcPipeBin, [slotId, String(WAIT_MS)], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
// Pause until piped to ffmpeg (avoids OS pipe-buffer fill stall — see
|
||||
// the network path above for the full rationale).
|
||||
fcPipeProcess.stdout.pause();
|
||||
fcPipeProcess.stderr.on('data', chunk => {
|
||||
process.stderr.write(`[fc_pipe:${slotId}] ${chunk}`);
|
||||
});
|
||||
fcPipeProcess.on('error', err => {
|
||||
console.error(`[fc_pipe:${slotId}] spawn error: ${err.message}`);
|
||||
});
|
||||
// Resolution/fps are not known until the FIFO reader connects and starts
|
||||
// receiving frames. We use sensible defaults here; ffmpeg's rawvideo demuxer
|
||||
// will accept whatever the bridge writes once the pipe opens.
|
||||
// The bridge daemon has already detected the signal and set up streams, so
|
||||
// the FIFO content is ready-to-read as soon as the reader connects.
|
||||
//
|
||||
// NOTE: The format JSON emitted by the bridge on signal lock goes to the
|
||||
// node-agent (which launched the bridge), not to this sidecar. The sidecar
|
||||
// therefore uses fixed rawvideo params here. If per-port format introspection
|
||||
// is needed in future, the node-agent should expose the fmt JSON via an API
|
||||
// and capture-manager can query it before building inputArgs.
|
||||
//
|
||||
// For now, both video dimensions and framerate come from the recorder's
|
||||
// configured values (passed to start() as `framerate` and implicit in the
|
||||
// codec args). The rawvideo input is -video_size / -framerate from env or
|
||||
// recorder config; ffmpeg tolerates a small mismatch in rawvideo (it just
|
||||
// reads N bytes per frame based on the declared size).
|
||||
//
|
||||
// DELTACAST_VIDEO_SIZE / DELTACAST_FRAMERATE: set by node-agent in the
|
||||
// sidecar env based on the bridge's per-port format JSON, if desired.
|
||||
const dcSize = process.env.DELTACAST_VIDEO_SIZE || '1920x1080';
|
||||
const dcFps = process.env.DELTACAST_FRAMERATE || '60000/1001';
|
||||
const dcInterlaced = process.env.DELTACAST_INTERLACED === '1';
|
||||
|
||||
return {
|
||||
inputArgs: [
|
||||
// fc_pipe stdout → ffmpeg rawvideo input 0 (video).
|
||||
// Both raw FIFOs are timestampless. ffmpeg opens input 0 (video) and
|
||||
// input 1 (audio) at slightly different moments, so PTS-zeroing each
|
||||
// stream's first byte would bake in a fixed A/V offset. Stamping each
|
||||
// input by wall-clock ARRIVAL time aligns them by real time regardless
|
||||
// of FIFO open order — the robust fix for the A/V start offset.
|
||||
// Large thread_queue_size avoids "thread message queue blocking" on
|
||||
// the high-bitrate raw video FIFO.
|
||||
'-use_wallclock_as_timestamps', '1',
|
||||
'-thread_queue_size', '512',
|
||||
'-f', 'rawvideo',
|
||||
'-pix_fmt', 'uyvy422',
|
||||
'-video_size', fcSize,
|
||||
'-framerate', fcFps,
|
||||
'-i', 'pipe:0',
|
||||
// Audio FIFO → ffmpeg input 1.
|
||||
//
|
||||
// Do NOT use -use_wallclock_as_timestamps here. The bridge feeds raw
|
||||
// s16le at a steady 48000 samples/s off the SAME SDI clock as video,
|
||||
// so letting ffmpeg derive audio PTS from the sample count keeps audio
|
||||
// and video in one clock domain (no drift). Wallclock stamps audio by
|
||||
// arrival wall-time instead — when the HEVC encoder dips under realtime
|
||||
// the audio ends up 3–18% LONGER than the frame-count video, and the
|
||||
// master aresample=async=1 then pads seconds of LEADING SILENCE to
|
||||
// "align" them → the silent-head + start-stutter + apparent "no audio"
|
||||
// regression (reverts commit d6b0b3a; restores 8e5405c/55a72af).
|
||||
'-video_size', dcSize,
|
||||
'-framerate', dcFps,
|
||||
'-i', videoFifo,
|
||||
'-use_wallclock_as_timestamps', '1',
|
||||
'-thread_queue_size', '512',
|
||||
'-f', 's16le',
|
||||
'-ar', '48000',
|
||||
'-ac', '2',
|
||||
'-i', audioFifoPath,
|
||||
'-i', audioFifo,
|
||||
],
|
||||
isNetwork: false,
|
||||
bridgeProcess: fcPipeProcess, /* capture-manager pipes this to ffmpeg stdin */
|
||||
audioFifo: audioFifoPath, /* flushed just before ffmpeg opens it (A/V align) */
|
||||
interlaced: fcInterlaced,
|
||||
audioInputIndex: 1, /* audio FIFO is ffmpeg input 1 */
|
||||
_fcPipeProcess: fcPipeProcess, /* stored for clean stop */
|
||||
isNetwork: false,
|
||||
bridgeProcess: null, /* bridge is managed by node-agent, not this sidecar */
|
||||
audioFifo: null, /* no per-session FIFO to clean up on stop */
|
||||
interlaced: dcInterlaced,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -901,22 +786,14 @@ OUT=${sh(outPath)}
|
|||
mkfifo "$VF" "$AF"
|
||||
PATCHPID=
|
||||
cleanup() { rm -f "$VF" "$AF"; [ -n "$PATCHPID" ] && kill "$PATCHPID" 2>/dev/null; }
|
||||
trap cleanup EXIT
|
||||
trap cleanup EXIT
|
||||
# Prime both FIFOs read-write (non-blocking) to break the open-order deadlock.
|
||||
exec 7<>"$VF" 8<>"$AF"
|
||||
# raw2bmx: close priming FDs (no stray writer) before exec so it sees real EOF.
|
||||
# CRITICAL: redirect raw2bmx stdin from /dev/null so it does NOT inherit the
|
||||
# parent bash stdin. When the video source is fc_pipe (framecache), bash stdin
|
||||
# carries the raw video stream destined for ffmpeg's pipe:0 — if raw2bmx also
|
||||
# inherited fd 0 it would steal bytes from that stream, corrupting both the
|
||||
# growing master and the ffmpeg input.
|
||||
( exec 7>&- 8>&- 0</dev/null; exec ${bmxLine} ) &
|
||||
( exec 7>&- 8>&-; exec ${bmxLine} ) &
|
||||
BMXPID=$!
|
||||
# ffmpeg: closes priming FDs and EXPLICITLY inherits bash stdin (fd 0) so that
|
||||
# 'pipe:0' reads the fc_pipe video stream Node piped into this orchestrator's
|
||||
# stdin. For non-fc_pipe sources (FIFO/device input) fd 0 is unused and this is
|
||||
# harmless.
|
||||
( exec 7>&- 8>&- 0<&0; exec ${ffLine} ) &
|
||||
# ffmpeg: also closes priming FDs; it opens its own write ends.
|
||||
( exec 7>&- 8>&-; exec ${ffLine} ) &
|
||||
FFPID=$!
|
||||
# Forward a clean stop to ffmpeg; raw2bmx then gets EOF and finalizes the footer.
|
||||
stop() { kill -INT "$FFPID" 2>/dev/null; }
|
||||
|
|
@ -1007,14 +884,8 @@ exit "$BMXRC"
|
|||
// Approach A: if a CIFS source is configured, mount it now. A mount failure
|
||||
// is non-fatal — we fall back to S3 streaming so the recording is never
|
||||
// lost.
|
||||
// Read growing flags FRESH from env at record time — standby sidecars boot
|
||||
// with GROWING_ENABLED=false and receive the real value per-session over
|
||||
// /capture/start (capture.js sets process.env before this runs). The old
|
||||
// module-level `const GROWING_ENABLED` / `GROWING_SMB_MOUNT` captured the
|
||||
// empty boot values, so growing never engaged and every "growing" record
|
||||
// silently produced HEVC/S3 instead of the XDCAM HD422 MXF.
|
||||
let growingActive = process.env.GROWING_ENABLED === 'true';
|
||||
if (growingActive && growingSmbConfig().mount) {
|
||||
let growingActive = GROWING_ENABLED;
|
||||
if (growingActive && GROWING_SMB_MOUNT) {
|
||||
if (!mountGrowingShare()) growingActive = false; // fall back to S3
|
||||
}
|
||||
// Growing master is always MXF OP1a / XDCAM HD422 written by raw2bmx (the
|
||||
|
|
@ -1049,64 +920,17 @@ exit "$BMXRC"
|
|||
// The stop handler sets needsProxy=true so the worker picks it up.
|
||||
const proxyKey = null;
|
||||
|
||||
const startedAt = new Date().toISOString();
|
||||
|
||||
this._sessionIdForBridge = sessionId;
|
||||
const { inputArgs, isNetwork, bridgeProcess = null, audioFifo = null, interlaced = false, audioInputIndex = 0,
|
||||
} = await this._buildInputArgs({
|
||||
const { inputArgs, isNetwork, bridgeProcess = null, audioFifo = null, interlaced = false } = await this._buildInputArgs({
|
||||
sourceType, sourceBackend, device, port, board, sourceUrl, listen, listenPort, streamKey,
|
||||
});
|
||||
|
||||
// ── Pre-roll + A/V alignment ─────────────────────────────────────────────
|
||||
// The pre-roll drains the VIDEO pipe (fc_pipe) to discard unstable startup
|
||||
// frames. In STANDBY the framecache slot is already warm, so there are no
|
||||
// unstable frames — skip the video drain (draining only video while audio
|
||||
// keeps buffering is exactly what offset the streams, giving "silent first
|
||||
// second then clean").
|
||||
if (bridgeProcess && !_standbyMode
|
||||
&& (sourceType === 'deltacast' || sourceType === 'blackmagic' || sourceType === 'sdi')) {
|
||||
console.log(`[capture] pre-rolling: discarding ${PRE_ROLL_SECONDS}s of frames`);
|
||||
bridgeProcess.stdout.on('data', () => {});
|
||||
await new Promise(r => setTimeout(r, PRE_ROLL_SECONDS * 1000));
|
||||
bridgeProcess.stdout.removeAllListeners('data');
|
||||
console.log(`[capture] pre-roll complete.`);
|
||||
}
|
||||
|
||||
// FLUSH STALE AUDIO immediately before ffmpeg opens the FIFO. During standby
|
||||
// the bridge keeps writing audio into the named FIFO while the idle-preview
|
||||
// consumes only video, so the FIFO holds up to a full pipe buffer (~0.5s) of
|
||||
// stale audio. Draining it here (right before the record ffmpeg attaches)
|
||||
// makes audio start at the live edge, time-aligned with the first video
|
||||
// frame — eliminating the leading silence + the ~0.5% audio-length surplus.
|
||||
if (audioFifo) {
|
||||
try {
|
||||
const fsSync = await import('node:fs');
|
||||
const fd = fsSync.openSync(audioFifo, fsSync.constants.O_RDONLY | fsSync.constants.O_NONBLOCK);
|
||||
const tmp = Buffer.allocUnsafe(1 << 20);
|
||||
let drained = 0;
|
||||
for (;;) {
|
||||
let n = 0;
|
||||
try { n = fsSync.readSync(fd, tmp, 0, tmp.length, null); }
|
||||
catch (e) { if (e.code === 'EAGAIN') break; throw e; }
|
||||
if (n <= 0) break;
|
||||
drained += n;
|
||||
}
|
||||
fsSync.closeSync(fd);
|
||||
console.log(`[capture] flushed ${drained} bytes of stale standby audio before record`);
|
||||
} catch (e) {
|
||||
console.warn(`[capture] audio FIFO pre-flush skipped: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const startedAt = new Date().toISOString();
|
||||
const recordingStartedAt = Date.now();
|
||||
|
||||
// Audio input index is returned EXPLICITLY by _buildInputArgs (audioInputIndex)
|
||||
// rather than guessed from sourceType/FC_SLOT_ID — that guess was wrong for
|
||||
// the legacy deltacast FIFO path (which has audio at input 1 but no FC_SLOT_ID),
|
||||
// silently dropping audio. Each return path now declares its own audio input:
|
||||
// - deltacast/blackmagic via framecache: audio FIFO = input 1
|
||||
// - legacy deltacast FIFO: audio FIFO = input 1
|
||||
// - network (framecache or legacy) + DeckLink-backend SDI: audio in input 0
|
||||
const audioMap = `${audioInputIndex}:a:0?`;
|
||||
// Audio input index: the deltacast shared bridge delivers video on input 0
|
||||
// (video FIFO) and audio on input 1 (audio FIFO), so audioMap is '1:a:0?'.
|
||||
// DeckLink SDI and network sources carry audio inside input 0.
|
||||
const audioMap = (sourceType === 'deltacast') ? '1:a:0?' : '0:a:0?';
|
||||
|
||||
// Non-growing master: ffmpeg muxes the finalized MOV directly. Growing
|
||||
// master: raw2bmx muxes the OP1a from elementary FIFOs (handled below via
|
||||
|
|
@ -1121,31 +945,40 @@ exit "$BMXRC"
|
|||
|
||||
if (hiresCodecArgs) console.log('[capture] hires ffmpeg args:', hiresCodecArgs.join(' '));
|
||||
|
||||
const isInterlacedSource = sourceType === 'sdi'
|
||||
|| (sourceType === 'deltacast' && interlaced)
|
||||
|| ((sourceType === 'blackmagic') && interlaced);
|
||||
const isInterlacedSource = sourceType === 'sdi' || (sourceType === 'deltacast' && interlaced);
|
||||
const sdiFilterArgs = isInterlacedSource ? ['-vf', 'yadif=mode=1:deint=1'] : [];
|
||||
|
||||
// Master output destination.
|
||||
// Master output destination (NON-growing path only).
|
||||
//
|
||||
// - Growing-files on → the growing OP1a MXF is written directly to the SMB
|
||||
// share by raw2bmx (see the orchestrator below); ffmpeg only produces the
|
||||
// elementary essence FIFOs + HLS preview.
|
||||
// elementary essence FIFOs + HLS preview. `localMasterPath`/`hiresOutput`
|
||||
// are unused in this case (the master path is `growingPath`).
|
||||
//
|
||||
// - Growing-files off → ffmpeg writes fragmented MOV to pipe:1 (stdout),
|
||||
// which is piped directly into a multipart S3 upload. No local temp file,
|
||||
// no worker disk consumed. Premiere Pro 25.x handles fragmented MOV natively.
|
||||
const hiresOutput = growingPath ? growingPath : 'pipe:1';
|
||||
// pipe:1 = ffmpeg stdout → S3 stream. bridgeProcess (fc_pipe) uses stdin.
|
||||
const hiresStdio = bridgeProcess ? ['pipe', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe'];
|
||||
// - Growing-files off → ffmpeg writes the MOV master to a LOCAL SEEKABLE
|
||||
// temp file, then we upload to S3 on stop. We must NOT pipe the MOV muxer
|
||||
// to S3 directly: the MOV/MP4 muxer cannot write to a non-seekable pipe
|
||||
// without `empty_moov`, and an empty_moov/fragmented MOV is exactly what
|
||||
// makes Adobe Premiere report "file cannot be opened" (no classic
|
||||
// stco/stsz sample tables — samples live in moof/trun). A seekable file
|
||||
// lets ffmpeg write a single contiguous moov with full sample tables and
|
||||
// `+faststart` moves it to the front, producing a Premiere-native master.
|
||||
const localMasterPath = growingPath
|
||||
? null
|
||||
: `/tmp/capture/${sessionId}.${hiresExt}`;
|
||||
if (localMasterPath) {
|
||||
try { mkdirSync(dirname(localMasterPath), { recursive: true }); }
|
||||
catch (err) { console.error('[capture] could not create temp master dir:', err.message); }
|
||||
}
|
||||
const hiresOutput = localMasterPath;
|
||||
// Deltacast reads from FIFOs (no stdin pipe needed). DeckLink pipes stdout.
|
||||
const hiresStdio = ['ignore', 'ignore', 'pipe'];
|
||||
|
||||
// For SDI/framecache sources (including network via framecache) the live
|
||||
// HLS preview is a SECOND OUTPUT of the hires ffmpeg.
|
||||
const _viaFcPipeHls = !!process.env.FC_SLOT_ID;
|
||||
// For SDI we cannot open the DeckLink device a second time for a preview
|
||||
// tee, so the live HLS preview is produced as a SECOND OUTPUT of the hires
|
||||
// ffmpeg: one decklink read -> yadif -> split -> [ProRes/S3] + [H.264/HLS].
|
||||
let sdiHlsDir = null;
|
||||
if ((sourceType === 'sdi' || sourceType === 'deltacast' || sourceType === 'blackmagic'
|
||||
|| (_viaFcPipeHls && (sourceType === 'srt' || sourceType === 'rtmp')))
|
||||
&& this._assetIdForHls) {
|
||||
if ((sourceType === 'sdi' || sourceType === 'deltacast') && this._assetIdForHls) {
|
||||
const fsMod = await import('node:fs');
|
||||
sdiHlsDir = '/live/' + this._assetIdForHls;
|
||||
try { fsMod.mkdirSync(sdiHlsDir, { recursive: true }); } catch (_) {}
|
||||
|
|
@ -1175,88 +1008,57 @@ exit "$BMXRC"
|
|||
interlaced: isInterlacedSource,
|
||||
});
|
||||
console.log('[capture] growing master via raw2bmx; orchestrator script length=' + orchArgs[1].length);
|
||||
hiresProcess = spawn('bash', orchArgs, {
|
||||
stdio: bridgeProcess ? ['pipe', 'ignore', 'pipe'] : ['ignore', 'ignore', 'pipe'],
|
||||
detached: true,
|
||||
});
|
||||
|
||||
// When video comes from fc_pipe, pipe its stdout to the bash orchestrator
|
||||
// stdin (which the orchestrator forwards to the ffmpeg rawvideo input).
|
||||
if (bridgeProcess && bridgeProcess.stdout && hiresProcess.stdin) {
|
||||
bridgeProcess.stdout.pipe(hiresProcess.stdin);
|
||||
bridgeProcess.on('exit', () => {
|
||||
try { if (hiresProcess.stdin) hiresProcess.stdin.end(); } catch (_) {}
|
||||
});
|
||||
}
|
||||
hiresProcess = spawn('bash', orchArgs, { stdio: ['ignore', 'ignore', 'pipe'], detached: true });
|
||||
} else {
|
||||
// ── Finalized (non-growing) master: ffmpeg muxes the MOV directly ──
|
||||
let hiresArgs;
|
||||
const isSdiLike = sourceType === 'sdi' || sourceType === 'deltacast' || sourceType === 'blackmagic';
|
||||
// Network via framecache (fc_pipe) also produces its master + HLS as a
|
||||
// single split ffmpeg, exactly like SDI — it reads pipe:0, not a URL.
|
||||
const isNetFcPipe = !!process.env.FC_SLOT_ID && (sourceType === 'srt' || sourceType === 'rtmp');
|
||||
if ((isSdiLike || isNetFcPipe) && this._assetIdForHls) {
|
||||
if ((sourceType === 'sdi' || sourceType === 'deltacast') && this._assetIdForHls) {
|
||||
const filterStr = isInterlacedSource
|
||||
? '[0:v]yadif=mode=1:deint=1,split=2[vhi][vlo]'
|
||||
: '[0:v]split=2[vhi][vlo]';
|
||||
// Network fc_pipe is video-only (no audio input) — omit audio maps so
|
||||
// ffmpeg doesn't fail trying to map a nonexistent audio stream.
|
||||
const hasAudio = audioInputIndex >= 0 && !isNetFcPipe;
|
||||
const masterAudioMap = hasAudio ? ['-map', audioMap] : [];
|
||||
const masterAudioFilter = hasAudio
|
||||
? ['-af', 'aresample=async=1:min_hard_comp=0.100000:first_pts=0'] : [];
|
||||
const hlsAudioMap = hasAudio ? ['-map', audioMap] : [];
|
||||
const hlsAudioCodec = hasAudio
|
||||
? ['-c:a', 'aac', '-b:a', '128k', '-ar', '44100'] : [];
|
||||
hiresArgs = [
|
||||
...inputArgs,
|
||||
'-filter_complex', filterStr,
|
||||
// Output 0 — master (fragmented MOV streamed to S3 via pipe:1)
|
||||
'-map', '[vhi]', ...masterAudioMap,
|
||||
...masterAudioFilter,
|
||||
// Output 0 — ProRes/MOV master (local temp, uploaded to S3 on stop)
|
||||
'-map', '[vhi]', '-map', audioMap,
|
||||
// Keep raw audio aligned to the video clock. The two raw FIFOs carry
|
||||
// no timestamps; -af aresample=async lets ffmpeg stretch/squeeze audio
|
||||
// to correct any tiny rate mismatch so A/V never drifts over a long
|
||||
// take. Applies to this output's mapped audio stream.
|
||||
'-af', 'aresample=async=1:min_hard_comp=0.100000:first_pts=0',
|
||||
...hiresCodecArgs,
|
||||
hiresOutput,
|
||||
// Output 1 — low-latency H.264 HLS preview for the UI monitor
|
||||
'-map', '[vlo]', ...hlsAudioMap,
|
||||
// Output 1 — low-latency H.264 HLS preview for the UI monitor.
|
||||
// GPU-encoded (h264_nvenc) when the GPU is attached to this sidecar,
|
||||
// otherwise libx264 (issue #164). GOP is pinned to one IDR per HLS
|
||||
// segment so segments start on keyframes (avoids black/flashing).
|
||||
'-map', '[vlo]', '-map', audioMap,
|
||||
...buildHlsVideoArgs(videoCodec, framerate),
|
||||
...hlsAudioCodec,
|
||||
'-c:a', 'aac', '-b:a', '128k', '-ar', '44100',
|
||||
'-f', 'hls', '-hls_time', '2', '-hls_list_size', '15',
|
||||
'-hls_flags', 'delete_segments+append_list+omit_endlist',
|
||||
'-hls_segment_filename', sdiHlsDir + '/seg-%05d.ts',
|
||||
sdiHlsDir + '/index.m3u8',
|
||||
];
|
||||
console.log('[HLS] SDI/framecache preview as 2nd output -> ' + sdiHlsDir);
|
||||
console.log('[HLS] SDI preview as 2nd output -> ' + sdiHlsDir);
|
||||
} else {
|
||||
hiresArgs = [ ...inputArgs, ...sdiFilterArgs, ...hiresCodecArgs, hiresOutput ];
|
||||
}
|
||||
hiresProcess = spawn('ffmpeg', hiresArgs, { stdio: hiresStdio });
|
||||
|
||||
// When video comes from fc_pipe, pipe its stdout to ffmpeg stdin.
|
||||
if (bridgeProcess && bridgeProcess.stdout && hiresProcess.stdin) {
|
||||
bridgeProcess.stdout.pipe(hiresProcess.stdin);
|
||||
bridgeProcess.on('exit', () => {
|
||||
try { if (hiresProcess.stdin) hiresProcess.stdin.end(); } catch (_) {}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Growing: promotion worker handles S3 upload after stop.
|
||||
// Non-growing: start streaming stdout directly to S3 now (multipart upload
|
||||
// completes when ffmpeg exits and closes the pipe).
|
||||
// Growing-files: nothing to upload here (promotion worker handles S3).
|
||||
// Non-growing: the master is uploaded from the finalized local file in
|
||||
// stop(), once ffmpeg has written the moov and exited cleanly — we can't
|
||||
// upload while recording because the file isn't a valid MOV until finalize.
|
||||
// bridgeProcess is null for deltacast (bridge managed by node-agent on the host).
|
||||
const processes = { hires: hiresProcess };
|
||||
const uploads = {
|
||||
hires: growingPath
|
||||
? Promise.resolve({ growingPath })
|
||||
: createUploadStream(S3_BUCKET, hiresKey, hiresProcess.stdout),
|
||||
};
|
||||
const uploads = { hires: growingPath ? Promise.resolve({ growingPath }) : null };
|
||||
|
||||
// ── HLS tee for legacy network sources (live preview in the UI) ──────────
|
||||
// When network sources come via framecache (FC_SLOT_ID set), HLS preview is
|
||||
// handled as a 2nd ffmpeg output in the hires process above (sdiHlsDir path).
|
||||
// This tee is only for the legacy direct-URL network path (no framecache).
|
||||
// ── HLS tee for network sources (live preview in the UI) ──────────
|
||||
let hlsProcess = null;
|
||||
let hlsDir = null;
|
||||
if (isNetwork && !process.env.FC_SLOT_ID && this._assetIdForHls) {
|
||||
if (isNetwork && this._assetIdForHls) {
|
||||
try {
|
||||
const fs = await import('node:fs');
|
||||
hlsDir = '/live/' + this._assetIdForHls;
|
||||
|
|
@ -1264,6 +1066,7 @@ exit "$BMXRC"
|
|||
const hlsArgs = [
|
||||
...inputArgs,
|
||||
'-map', '0:v:0?', '-map', '0:a:0?',
|
||||
// GPU-gated preview encode, same as the SDI 2nd-output path (#164).
|
||||
...buildHlsVideoArgs(videoCodec, framerate),
|
||||
'-c:a', 'aac', '-b:a', '128k', '-ar', '44100',
|
||||
'-f', 'hls', '-hls_time', '2', '-hls_list_size', '15',
|
||||
|
|
@ -1275,7 +1078,7 @@ exit "$BMXRC"
|
|||
hlsProcess.stderr.on('data', (d) => { console.error('[HLS] ' + d); });
|
||||
hlsProcess.on('exit', (c) => console.log('[HLS] exited ' + c));
|
||||
processes.hls = hlsProcess;
|
||||
console.log('[HLS] legacy-net tee started -> ' + hlsDir);
|
||||
console.log('[HLS] tee started -> ' + hlsDir);
|
||||
} catch (err) {
|
||||
console.error('[HLS] tee failed:', err.message);
|
||||
}
|
||||
|
|
@ -1288,13 +1091,12 @@ exit "$BMXRC"
|
|||
if (m) {
|
||||
this.state.framesReceived = parseInt(m[1], 10);
|
||||
this.state.lastFrameAt = new Date().toISOString();
|
||||
// Use ffmpeg's own rolling fps value — it is a short-window average
|
||||
// computed by ffmpeg itself and correctly reflects the true encode rate.
|
||||
// The previous frame/elapsed cumulative calculation dragged low during
|
||||
// startup and was permanently wrong for growing-path (bash orchestrator
|
||||
// stderr doesn't emit frame= lines until ffmpeg flushes them).
|
||||
const ffmpegFps = parseFloat(m[2]);
|
||||
if (ffmpegFps > 0) this.state.currentFps = Math.round(ffmpegFps * 100) / 100;
|
||||
if (this.state.recordingStartedAt) {
|
||||
const elapsedSec = (Date.now() - this.state.recordingStartedAt) / 1000;
|
||||
if (elapsedSec > 0) {
|
||||
this.state.currentFps = Math.round((this.state.framesReceived / elapsedSec) * 100) / 100;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (/Connection refused|No route to host|Connection failed|Input\/output error|Server returned|404 Not Found|Connection timed out/i.test(text)) {
|
||||
this.state.lastError = text.trim().slice(0, 240);
|
||||
|
|
@ -1324,10 +1126,10 @@ exit "$BMXRC"
|
|||
hiresKey,
|
||||
proxyKey,
|
||||
growingPath,
|
||||
localMasterPath,
|
||||
audioFifo,
|
||||
startedAt,
|
||||
duration: 0,
|
||||
_fcPipeProcess: bridgeProcess || null, /* fc_pipe process, if framecache path used */
|
||||
uploads,
|
||||
codecs: {
|
||||
videoCodec, videoBitrate, framerate,
|
||||
|
|
@ -1468,11 +1270,6 @@ exit "$BMXRC"
|
|||
if (processes.hires) processes.hires.kill('SIGINT');
|
||||
if (processes.proxy) processes.proxy.kill('SIGINT');
|
||||
if (processes.hls) { try { processes.hls.kill('SIGINT'); } catch (_) {} }
|
||||
// fc_pipe process (framecache consumer) — stop after ffmpeg so it sees EOF
|
||||
// naturally via EPIPE when ffmpeg stdin closes. SIGTERM as belt-and-suspenders.
|
||||
if (currentSession._fcPipeProcess) {
|
||||
try { currentSession._fcPipeProcess.kill('SIGTERM'); } catch (_) {}
|
||||
}
|
||||
/* processes.bridge: removed — bridge is managed by node-agent, not per-session */
|
||||
|
||||
// Wait for the master writer to finalize before we read/upload the file.
|
||||
|
|
@ -1484,11 +1281,30 @@ exit "$BMXRC"
|
|||
unmountGrowingShare();
|
||||
|
||||
try {
|
||||
// Non-growing: S3 upload was streaming from ffmpeg stdout — it completes
|
||||
// when ffmpeg exits and closes the pipe (waitExit above ensures that).
|
||||
// Growing: promotion worker handles S3.
|
||||
const uploadPromises = [];
|
||||
if (currentSession.uploads.hires) uploadPromises.push(currentSession.uploads.hires);
|
||||
|
||||
// Non-growing: upload the finalized local master file to S3 now that the
|
||||
// moov has been written. Growing: the promotion worker handles S3.
|
||||
if (currentSession.localMasterPath) {
|
||||
let size = 0;
|
||||
try { size = statSync(currentSession.localMasterPath).size; } catch (_) {}
|
||||
if (size > 0) {
|
||||
uploadPromises.push(
|
||||
createUploadStream(
|
||||
S3_BUCKET,
|
||||
currentSession.hiresKey,
|
||||
createReadStream(currentSession.localMasterPath),
|
||||
).then(() => {
|
||||
try { unlinkSync(currentSession.localMasterPath); } catch (_) {}
|
||||
})
|
||||
);
|
||||
} else {
|
||||
console.warn('[capture] local master is 0 bytes — skipping upload:', currentSession.localMasterPath);
|
||||
}
|
||||
} else if (currentSession.uploads.hires) {
|
||||
uploadPromises.push(currentSession.uploads.hires);
|
||||
}
|
||||
|
||||
if (currentSession.uploads.proxy) uploadPromises.push(currentSession.uploads.proxy);
|
||||
await Promise.all(uploadPromises);
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
330
services/capture/src/routes/capture.js.bak
Normal file
330
services/capture/src/routes/capture.js.bak
Normal 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;
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 */
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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 ./
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
@ -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);
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -858,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 {
|
||||
|
|
@ -941,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); }
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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}×tamps=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(
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ const RECORDER_FIELDS = [
|
|||
'proxy_audio_codec', 'proxy_audio_bitrate', 'proxy_audio_channels',
|
||||
'proxy_container',
|
||||
'project_id', 'node_id', 'device_index',
|
||||
'growing_enabled', 'label',
|
||||
'growing_enabled',
|
||||
];
|
||||
|
||||
function pickRecorderFields(body) {
|
||||
|
|
@ -198,7 +198,7 @@ function validateRecorderConfig(cfg, nodeHasGpu = null) {
|
|||
// NVENC requires a GPU on the target node. Only a hard error when we know the
|
||||
// node lacks one; unknown capability is left as a soft pass.
|
||||
if (GPU_CODECS.includes(codec) && nodeHasGpu === false) {
|
||||
return `Invalid combo: codec ${cfg.recording_codec} requires an NVIDIA GPU, but the target node reports no GPU. Assign this recorder to a GPU node.`;
|
||||
return `Invalid combo: codec ${cfg.recording_codec} requires an NVIDIA GPU, but the target node reports no GPU. Choose a software codec (e.g. prores_hq, dnxhr_hq, h264) or assign a GPU node.`;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
@ -227,133 +227,6 @@ async function nodeHasGpuCapability(nodeId) {
|
|||
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
// Build the stable env array for a standby sidecar. Contains everything a
|
||||
// capture container needs EXCEPT session params (CLIP_NAME, ASSET_ID,
|
||||
// PROJECT_ID, BIN_ID) which arrive via HTTP on /capture/start.
|
||||
function buildStandbyEnv(recorder) {
|
||||
const s3Endpoint = process.env.S3_ENDPOINT || '';
|
||||
const s3Bucket = getS3Bucket();
|
||||
const s3AccessKey = process.env.S3_ACCESS_KEY || '';
|
||||
const s3SecretKey = process.env.S3_SECRET_KEY || '';
|
||||
const mamApiUrl = process.env.MAM_API_URL || 'http://mam-api:3000';
|
||||
const externalMamApiUrl = `http://${process.env.NODE_IP || '172.18.91.200'}:${process.env.PORT_MAM_API || 47432}`;
|
||||
const liveDir = process.env.LIVE_DIR || '/mnt/NVME/MAM/wild-dragon-live';
|
||||
const sourceConfig = recorder.source_config || {};
|
||||
const deviceIndex = recorder.device_index ?? sourceConfig.device ?? 0;
|
||||
|
||||
return [
|
||||
`S3_ENDPOINT=${s3Endpoint}`,
|
||||
`S3_BUCKET=${s3Bucket}`,
|
||||
`S3_ACCESS_KEY=${s3AccessKey}`,
|
||||
`S3_SECRET_KEY=${s3SecretKey}`,
|
||||
`S3_REGION=${process.env.S3_REGION || 'us-east-1'}`,
|
||||
// Use external URL — capture container runs on worker host network
|
||||
`MAM_API_URL=${externalMamApiUrl}`,
|
||||
`RECORDER_ID=${recorder.id}`,
|
||||
`SOURCE_TYPE=${recorder.source_type}`,
|
||||
`SOURCE_CONFIG=${JSON.stringify(sourceConfig)}`,
|
||||
`DEVICE_INDEX=${deviceIndex}`,
|
||||
`RECORDING_CODEC=${recorder.recording_codec || 'hevc_nvenc'}`,
|
||||
`RECORDING_RESOLUTION=${recorder.recording_resolution || 'native'}`,
|
||||
`RECORDING_VIDEO_BITRATE=${recorder.recording_video_bitrate || ''}`,
|
||||
`RECORDING_FRAMERATE=${recorder.recording_framerate || ''}`,
|
||||
`RECORDING_AUDIO_CODEC=${recorder.recording_audio_codec || 'pcm_s24le'}`,
|
||||
`RECORDING_AUDIO_BITRATE=${recorder.recording_audio_bitrate || ''}`,
|
||||
`RECORDING_AUDIO_CHANNELS=${recorder.recording_audio_channels ?? 2}`,
|
||||
`RECORDING_CONTAINER=${recorder.recording_container || 'mov'}`,
|
||||
`PROXY_ENABLED=${recorder.proxy_enabled !== false ? 'true' : 'false'}`,
|
||||
`PROXY_CODEC=${recorder.proxy_codec || 'h264_nvenc'}`,
|
||||
`PROXY_RESOLUTION=${recorder.proxy_resolution || '1920x1080'}`,
|
||||
`PROXY_VIDEO_BITRATE=${recorder.proxy_video_bitrate || '2M'}`,
|
||||
`PROXY_FRAMERATE=${recorder.proxy_framerate || ''}`,
|
||||
`PROXY_AUDIO_CODEC=${recorder.proxy_audio_codec || 'aac'}`,
|
||||
`PROXY_AUDIO_BITRATE=${recorder.proxy_audio_bitrate || '128k'}`,
|
||||
`PROXY_AUDIO_CHANNELS=${recorder.proxy_audio_channels ?? 2}`,
|
||||
`PROXY_CONTAINER=${recorder.proxy_container || 'mp4'}`,
|
||||
`GROWING_ENABLED=false`,
|
||||
`GROWING_PATH=/growing`,
|
||||
`GROWING_SMB_MOUNT=`,
|
||||
`LIVE_DIR=${liveDir}`,
|
||||
`MAM_API_TOKEN=${process.env.CAPTURE_TOKEN || ''}`,
|
||||
`STANDBY=1`,
|
||||
`PRE_ROLL_SECONDS=1`,
|
||||
];
|
||||
}
|
||||
|
||||
// Source types that run a long-lived standby sidecar (idle-preview container
|
||||
// kept up 24/7 so `record` is a sub-second HTTP call, not a Docker cold start).
|
||||
const STANDBY_SOURCE_TYPES = ['deltacast', 'sdi', 'blackmagic'];
|
||||
|
||||
// Provision (or re-provision) the single persistent standby sidecar for one
|
||||
// recorder by asking its node's agent to create the idle container. Idempotent
|
||||
// at the node-agent layer (one container per capture port). Updates the
|
||||
// recorder row with the new container_id + status='standby'. Returns:
|
||||
// { ok, containerId?, reason? }
|
||||
// Non-fatal by contract — the caller logs/aggregates; a recorder is still
|
||||
// usable via the on-demand spawn fallback in /start if this fails.
|
||||
async function ensureStandbySidecar(recorder) {
|
||||
if (!recorder.node_id || !STANDBY_SOURCE_TYPES.includes(recorder.source_type)) {
|
||||
return { ok: false, reason: 'not a standby source / no node' };
|
||||
}
|
||||
const { remote: isRemote, apiUrl: targetNodeApiUrl } =
|
||||
await resolveNodeTarget(recorder.node_id).catch(() => ({ remote: false }));
|
||||
if (!isRemote || !targetNodeApiUrl) {
|
||||
return { ok: false, reason: 'node not remote/reachable' };
|
||||
}
|
||||
const capturePort = SIDECAR_PORT_BASE + (recorder.device_index || 0);
|
||||
const useGpu = GPU_CODECS.includes(recorder.recording_codec);
|
||||
const standbyRes = await fetch(`${targetNodeApiUrl}/sidecar/standby`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
image: 'wild-dragon-capture:latest',
|
||||
env: buildStandbyEnv(recorder),
|
||||
capturePort,
|
||||
sourceType: recorder.source_type,
|
||||
useGpu,
|
||||
gpuUuid: recorder.gpu_uuid || null,
|
||||
}),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!standbyRes.ok) {
|
||||
return { ok: false, reason: `node-agent returned ${standbyRes.status}` };
|
||||
}
|
||||
const { containerId } = await standbyRes.json();
|
||||
await pool.query(
|
||||
`UPDATE recorders SET container_id = $1, status = 'standby', updated_at = NOW() WHERE id = $2`,
|
||||
[containerId, recorder.id]
|
||||
);
|
||||
recorder.container_id = containerId;
|
||||
recorder.status = 'standby';
|
||||
console.log(`[recorders] standby sidecar spawned for ${recorder.id}: ${containerId}`);
|
||||
return { ok: true, containerId };
|
||||
}
|
||||
|
||||
// Tear down a recorder's standby sidecar (Disable). Asks the node-agent to
|
||||
// remove the container, then clears container_id and sets status='stopped'.
|
||||
// Best-effort on the node-agent call — even if the delete fails we still clear
|
||||
// the row so the operator isn't stuck; the force-free-port logic on the next
|
||||
// Enable will reclaim a stray container. Returns { ok, reason? }.
|
||||
async function teardownStandbySidecar(recorder) {
|
||||
if (recorder.node_id && recorder.container_id) {
|
||||
const { remote: isRemote, apiUrl: targetNodeApiUrl } =
|
||||
await resolveNodeTarget(recorder.node_id).catch(() => ({ remote: false }));
|
||||
if (isRemote && targetNodeApiUrl) {
|
||||
await fetch(`${targetNodeApiUrl}/sidecar/${recorder.container_id}`, {
|
||||
method: 'DELETE',
|
||||
signal: AbortSignal.timeout(15000),
|
||||
}).catch(e => console.warn(`[recorders] sidecar teardown for ${recorder.id} failed (clearing anyway): ${e.message}`));
|
||||
}
|
||||
}
|
||||
await pool.query(
|
||||
`UPDATE recorders SET container_id = NULL, status = 'stopped', current_session_id = NULL, updated_at = NOW() WHERE id = $1`,
|
||||
[recorder.id]
|
||||
);
|
||||
recorder.container_id = null;
|
||||
recorder.status = 'stopped';
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// Issue #162 — after a local-spawn stop, wait for the capture container to
|
||||
// finalize its master. The asset row was pre-created at start with
|
||||
// status='live' (display_name = current_session_id); the ingest/finalize step
|
||||
|
|
@ -468,9 +341,9 @@ router.post('/', async (req, res, next) => {
|
|||
recording_audio_codec: 'pcm_s24le',
|
||||
recording_audio_channels: 2,
|
||||
recording_container: 'mov',
|
||||
proxy_enabled: true,
|
||||
proxy_codec: 'h264_nvenc',
|
||||
proxy_resolution: '1920x1080',
|
||||
proxy_enabled: true,
|
||||
proxy_codec: 'h264',
|
||||
proxy_resolution: '1920x1080',
|
||||
proxy_video_bitrate: '2M',
|
||||
proxy_audio_codec: 'aac',
|
||||
proxy_audio_bitrate: '128k',
|
||||
|
|
@ -501,111 +374,7 @@ router.post('/', async (req, res, next) => {
|
|||
values
|
||||
);
|
||||
|
||||
const recorder = result.rows[0];
|
||||
|
||||
// Spawn a standby sidecar immediately for SDI/deltacast/blackmagic recorders
|
||||
// that have an assigned node, so the container + bridge are ready before the
|
||||
// user hits record. Non-fatal — recorder is still usable if this fails.
|
||||
await ensureStandbySidecar(recorder).catch(e =>
|
||||
console.warn(`[recorders] standby spawn failed for ${recorder.id} (non-fatal): ${e.message}`));
|
||||
|
||||
res.status(201).json(recorder);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /reconcile-standby - (re)provision the persistent standby sidecar for
|
||||
// every SDI/deltacast recorder that should have one. Standby sidecars are
|
||||
// created on recorder-create and kept up 24/7 (RestartPolicy=unless-stopped),
|
||||
// but if they're externally removed (manual cleanup, node redeploy, a wiped
|
||||
// /dev/shm) nothing recreates them — the recorder then falls back to the slow
|
||||
// on-demand spawn on /start, which can collide on the capture port. This
|
||||
// endpoint re-warms them so all recorders return to the fast standby path.
|
||||
//
|
||||
// Optional body: { force: true } recreates even recorders that currently claim
|
||||
// a container_id (the node-agent is idempotent per capture port, so a stale id
|
||||
// is replaced cleanly). Without force, only recorders with no container_id are
|
||||
// (re)provisioned.
|
||||
router.post('/reconcile-standby', requireRecorderEdit, async (req, res, next) => {
|
||||
try {
|
||||
const force = !!(req.body && req.body.force);
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM recorders
|
||||
WHERE source_type = ANY($1)
|
||||
AND node_id IS NOT NULL
|
||||
ORDER BY name`,
|
||||
[STANDBY_SOURCE_TYPES]
|
||||
);
|
||||
const results = [];
|
||||
for (const recorder of rows) {
|
||||
if (!force && recorder.container_id) {
|
||||
results.push({ id: recorder.id, name: recorder.name, ok: true, skipped: 'already has container_id' });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const r = await ensureStandbySidecar(recorder);
|
||||
results.push({ id: recorder.id, name: recorder.name, ...r });
|
||||
} catch (e) {
|
||||
results.push({ id: recorder.id, name: recorder.name, ok: false, reason: e.message });
|
||||
}
|
||||
}
|
||||
const provisioned = results.filter(r => r.ok && r.containerId).length;
|
||||
res.json({ provisioned, total: rows.length, results });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /:id/enable - Operator opt-in. Brings up the persistent standby sidecar
|
||||
// (idle-preview, kept up 24/7) so the port is ready to record in <1s. Sets
|
||||
// enabled=true. Idempotent: if already enabled with a live container the
|
||||
// node-agent's force-free-port logic replaces any stale container cleanly.
|
||||
router.post('/:id/enable', requireRecorderEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { rows } = await pool.query('SELECT * FROM recorders WHERE id = $1', [id]);
|
||||
if (rows.length === 0) return res.status(404).json({ error: 'Recorder not found' });
|
||||
const recorder = rows[0];
|
||||
|
||||
if (!STANDBY_SOURCE_TYPES.includes(recorder.source_type)) {
|
||||
return res.status(400).json({ error: `Source type "${recorder.source_type}" does not support standby/enable` });
|
||||
}
|
||||
if (!recorder.node_id) {
|
||||
return res.status(409).json({ error: 'Recorder has no assigned node (hardware offline?) — cannot enable' });
|
||||
}
|
||||
|
||||
const r = await ensureStandbySidecar(recorder);
|
||||
if (!r.ok) {
|
||||
return res.status(502).json({ error: `Could not start standby sidecar: ${r.reason || 'unknown'}` });
|
||||
}
|
||||
await pool.query('UPDATE recorders SET enabled = true, updated_at = NOW() WHERE id = $1', [id]);
|
||||
recorder.enabled = true;
|
||||
res.json(recorder);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /:id/disable - Operator opt-out. Stops & removes the standby sidecar,
|
||||
// freeing the capture port, and sets enabled=false. Config (codec, label,
|
||||
// growing) is preserved on the row for the next enable. Refuses while the
|
||||
// recorder is actively recording — stop it first.
|
||||
router.post('/:id/disable', requireRecorderEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { rows } = await pool.query('SELECT * FROM recorders WHERE id = $1', [id]);
|
||||
if (rows.length === 0) return res.status(404).json({ error: 'Recorder not found' });
|
||||
const recorder = rows[0];
|
||||
|
||||
if (recorder.status === 'recording') {
|
||||
return res.status(409).json({ error: 'Recorder is recording — stop it before disabling' });
|
||||
}
|
||||
|
||||
await teardownStandbySidecar(recorder);
|
||||
await pool.query('UPDATE recorders SET enabled = false, updated_at = NOW() WHERE id = $1', [id]);
|
||||
recorder.enabled = false;
|
||||
res.json(recorder);
|
||||
res.status(201).json(result.rows[0]);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
|
|
@ -793,7 +562,7 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
|
|||
`DEVICE_INDEX=${deviceIndex}`,
|
||||
|
||||
// Recording codec controls
|
||||
`RECORDING_CODEC=${recorder.recording_codec || 'hevc_nvenc'}`,
|
||||
`RECORDING_CODEC=${recorder.recording_codec || 'prores_hq'}`,
|
||||
`RECORDING_RESOLUTION=${recorder.recording_resolution || 'native'}`,
|
||||
`RECORDING_VIDEO_BITRATE=${recorder.recording_video_bitrate || ''}`,
|
||||
`RECORDING_FRAMERATE=${recorder.recording_framerate || ''}`,
|
||||
|
|
@ -804,7 +573,7 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
|
|||
|
||||
// Proxy codec controls
|
||||
`PROXY_ENABLED=${recorder.proxy_enabled !== false ? 'true' : 'false'}`,
|
||||
`PROXY_CODEC=${recorder.proxy_codec || 'h264_nvenc'}`,
|
||||
`PROXY_CODEC=${recorder.proxy_codec || 'h264'}`,
|
||||
`PROXY_RESOLUTION=${recorder.proxy_resolution || '1920x1080'}`,
|
||||
`PROXY_VIDEO_BITRATE=${recorder.proxy_video_bitrate || '2M'}`,
|
||||
`PROXY_FRAMERATE=${recorder.proxy_framerate || ''}`,
|
||||
|
|
@ -835,12 +604,6 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
|
|||
if (dcCount) env.push(`DELTACAST_PORT_COUNT=${dcCount}`);
|
||||
}
|
||||
|
||||
// Framecache slot has been warm since the bridge started — 1s pre-roll is
|
||||
// sufficient. Avoids a 5s startup lag on both on-demand and standby spawns.
|
||||
if (['deltacast', 'sdi', 'blackmagic'].includes(sourceType)) {
|
||||
env.push('PRE_ROLL_SECONDS=1');
|
||||
}
|
||||
|
||||
if (sourceType === 'srt' || sourceType === 'rtmp') {
|
||||
env.push(`LISTEN=${isListener ? '1' : '0'}`);
|
||||
if (isListener) {
|
||||
|
|
@ -873,88 +636,10 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
|
|||
}
|
||||
|
||||
let containerId;
|
||||
const capturePort = SIDECAR_PORT_BASE + (deviceIndex || 0);
|
||||
|
||||
// ── Standby fast-path ───────────────────────────────────────────────
|
||||
// If the recorder is already in standby (sidecar running idle), send the
|
||||
// session params to its /capture/start HTTP endpoint instead of spawning
|
||||
// a new container. This eliminates Docker create/start latency and bridge
|
||||
// startup time — the user hits record and ffmpeg starts in <1s.
|
||||
const isStandby = recorder.status === 'standby' && recorder.container_id;
|
||||
if (isStandby) {
|
||||
const captureStartUrl = isRemote
|
||||
? `http://${(await resolveNodeTarget(recorder.node_id)).ip}:${capturePort}/capture/start`
|
||||
: `http://localhost:${capturePort}/capture/start`;
|
||||
try {
|
||||
const startBody = {
|
||||
project_id: takeProjectId,
|
||||
bin_id: null,
|
||||
clip_name: clipName,
|
||||
asset_id: assetIdLive,
|
||||
source_type: sourceType,
|
||||
device: deviceIndex,
|
||||
// Codec params — sidecar already has these in env but we send them
|
||||
// anyway so a config change on the recorder takes effect immediately.
|
||||
recording_codec: recorder.recording_codec,
|
||||
recording_video_bitrate: recorder.recording_video_bitrate,
|
||||
recording_framerate: recorder.recording_framerate,
|
||||
recording_audio_codec: recorder.recording_audio_codec,
|
||||
recording_audio_bitrate: recorder.recording_audio_bitrate,
|
||||
recording_audio_channels: recorder.recording_audio_channels,
|
||||
recording_container: recorder.recording_container,
|
||||
proxy_enabled: recorder.proxy_enabled,
|
||||
proxy_codec: recorder.proxy_codec,
|
||||
proxy_video_bitrate: recorder.proxy_video_bitrate,
|
||||
proxy_framerate: recorder.proxy_framerate,
|
||||
proxy_audio_codec: recorder.proxy_audio_codec,
|
||||
proxy_audio_bitrate: recorder.proxy_audio_bitrate,
|
||||
proxy_audio_channels: recorder.proxy_audio_channels,
|
||||
proxy_container: recorder.proxy_container,
|
||||
growing_enabled: growingEnabled,
|
||||
growing_smb_mount: smbMount,
|
||||
growing_smb_username: growingInfra.growing_smb_username || '',
|
||||
growing_smb_password: growingInfra.growing_smb_password || '',
|
||||
growing_smb_vers: growingInfra.growing_smb_vers || '3.0',
|
||||
};
|
||||
const captureRes = await fetch(captureStartUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(startBody),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (captureRes.ok) {
|
||||
containerId = recorder.container_id;
|
||||
console.log(`[recorders] standby→recording: ${id} via HTTP /capture/start`);
|
||||
} else {
|
||||
const detail = await captureRes.json().catch(() => ({}));
|
||||
console.warn(`[recorders] standby /capture/start returned ${captureRes.status}: ${JSON.stringify(detail)} — falling back to spawn`);
|
||||
// Fall through to on-demand spawn below
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[recorders] standby HTTP start failed: ${e.message} — falling back to spawn`);
|
||||
// Fall through to on-demand spawn below
|
||||
}
|
||||
}
|
||||
|
||||
// If standby HTTP start failed and a stale container_id exists, kill it
|
||||
// before spawning a new one — otherwise the new container gets EADDRINUSE
|
||||
// because the old container is still holding the capture port.
|
||||
if (!containerId && isStandby && recorder.container_id) {
|
||||
console.log(`[recorders] killing stale standby container ${recorder.container_id} before respawn`);
|
||||
try {
|
||||
if (isRemote) {
|
||||
await fetch(`${targetNodeApiUrl}/sidecar/${recorder.container_id}`, {
|
||||
method: 'DELETE',
|
||||
signal: AbortSignal.timeout(10000),
|
||||
}).catch(() => {});
|
||||
} else {
|
||||
await dockerApi('DELETE', `/containers/${recorder.container_id}?force=true`).catch(() => {});
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (!containerId && isRemote) {
|
||||
if (isRemote) {
|
||||
// Remote node: delegate container lifecycle to that node's agent.
|
||||
const capturePort = SIDECAR_PORT_BASE + (deviceIndex || 0);
|
||||
const sidecarRes = await fetch(`${targetNodeApiUrl}/sidecar/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
|
@ -973,7 +658,7 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
|
|||
}
|
||||
const sidecarData = await sidecarRes.json();
|
||||
containerId = sidecarData.containerId;
|
||||
} else if (!containerId) {
|
||||
} else {
|
||||
// Local spawn via Docker socket.
|
||||
const { portBindings, exposedPorts } = buildPortConfig(sourceType, sourceConfig);
|
||||
const alias = `recorder-${id}`;
|
||||
|
|
@ -1093,69 +778,8 @@ router.post('/:id/stop', requireRecorderEdit, async (req, res, next) => {
|
|||
return res.json(result.rows[0]);
|
||||
}
|
||||
|
||||
const { remote: isRemote, apiUrl: targetNodeApiUrl, ip: nodeIp } = await resolveNodeTarget(recorder.node_id);
|
||||
const deviceIndex = recorder.device_index ?? (recorder.source_config?.device ?? 0);
|
||||
const capturePort = SIDECAR_PORT_BASE + deviceIndex;
|
||||
const isStandby = recorder.status === 'standby';
|
||||
const { remote: isRemote, apiUrl: targetNodeApiUrl } = await resolveNodeTarget(recorder.node_id);
|
||||
|
||||
// ── Standby sidecar stop path ─────────────────────────────────────────
|
||||
// If the recorder was in standby (container stays alive between sessions),
|
||||
// stop only the capture session via HTTP — don't kill the container.
|
||||
// The container returns to idle-preview mode and is ready for the next
|
||||
// /start call immediately.
|
||||
//
|
||||
// If NOT in standby (legacy on-demand spawn), use the old docker-stop path.
|
||||
const isStandbySource = STANDBY_SOURCE_TYPES.includes(recorder.source_type);
|
||||
|
||||
if (isStandbySource && recorder.container_id) {
|
||||
// Call /capture/stop on the running sidecar.
|
||||
// Return immediately — S3 upload streams to completion asynchronously.
|
||||
const captureStopUrl = isRemote
|
||||
? `http://${nodeIp}:${capturePort}/capture/stop`
|
||||
: `http://localhost:${capturePort}/capture/stop`;
|
||||
|
||||
// Get session_id from the sidecar's status (it tracks its own sessionId).
|
||||
let sessionId = null;
|
||||
try {
|
||||
const statusRes = await fetch(
|
||||
isRemote ? `http://${nodeIp}:${capturePort}/capture/status` : `http://localhost:${capturePort}/capture/status`,
|
||||
{ signal: AbortSignal.timeout(3000) }
|
||||
);
|
||||
if (statusRes.ok) {
|
||||
const s = await statusRes.json();
|
||||
sessionId = s.sessionId || null;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
if (sessionId) {
|
||||
// Fire-and-forget — the S3 upload completes in the background inside
|
||||
// the sidecar. The /capture/stop route calls /assets/:id/finalize when
|
||||
// done, so the asset transitions from 'live' → 'processing' automatically.
|
||||
fetch(captureStopUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_id: sessionId }),
|
||||
signal: AbortSignal.timeout(185000),
|
||||
}).then(r => {
|
||||
if (!r.ok) console.warn(`[recorders] /capture/stop returned ${r.status} for ${id}`);
|
||||
else console.log(`[recorders] standby stop completed for ${id}`);
|
||||
}).catch(e => {
|
||||
console.error(`[recorders] /capture/stop error for ${id}: ${e.message}`);
|
||||
});
|
||||
} else {
|
||||
console.warn(`[recorders] could not get sessionId for ${id} — sidecar may not be recording`);
|
||||
}
|
||||
|
||||
// Container stays alive in standby — keep container_id, set status='standby'
|
||||
const updateResult = await pool.query(
|
||||
`UPDATE recorders SET status = 'standby', current_session_id = NULL, updated_at = NOW()
|
||||
WHERE id = $1 RETURNING *`,
|
||||
[id]
|
||||
);
|
||||
return res.json(updateResult.rows[0]);
|
||||
}
|
||||
|
||||
// ── Legacy path: on-demand container, kill it on stop ────────────────
|
||||
if (isRemote) {
|
||||
const stopRes = await fetch(`${targetNodeApiUrl}/sidecar/${recorder.container_id}`, {
|
||||
method: 'DELETE',
|
||||
|
|
@ -1166,7 +790,9 @@ router.post('/:id/stop', requireRecorderEdit, async (req, res, next) => {
|
|||
}
|
||||
} else {
|
||||
// Issue #162 — stop local container in the background so the HTTP stop
|
||||
// request returns immediately.
|
||||
// request returns immediately. The container teardown (SIGTERM -> ffmpeg
|
||||
// exit -> S3 upload -> post-stop callback) takes up to 180s for large files,
|
||||
// which would otherwise timeout the browser/API connection.
|
||||
const containerId = recorder.container_id;
|
||||
(async () => {
|
||||
try {
|
||||
|
|
@ -1177,6 +803,7 @@ router.post('/:id/stop', requireRecorderEdit, async (req, res, next) => {
|
|||
}
|
||||
} catch (e) {
|
||||
console.error('[recorders] failed local background stop:', e.message);
|
||||
// Attempt finalize and cleanup even if stop call timed out
|
||||
await waitForFinalize(recorder).catch(() => {});
|
||||
await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {});
|
||||
}
|
||||
|
|
@ -1263,34 +890,18 @@ router.get('/:id/status', async (req, res, next) => {
|
|||
duration = Math.floor((Date.now() - new Date(container.State.StartedAt).getTime()) / 1000);
|
||||
|
||||
try {
|
||||
const _devIdx2 = recorder.device_index ?? (recorder.source_config?.device ?? 0);
|
||||
const _statusPort = SIDECAR_PORT_BASE + _devIdx2;
|
||||
const captureRes = await fetch(`http://localhost:${_statusPort}/capture/status`, { signal: AbortSignal.timeout(2000) });
|
||||
const captureRes = await fetch(`http://recorder-${id}:3001/capture/status`, { signal: AbortSignal.timeout(2000) });
|
||||
if (captureRes.ok) live = await captureRes.json();
|
||||
} catch (_) { /* not ready yet */ }
|
||||
}
|
||||
|
||||
// Recording state and signal come from the capture sidecar's session, NOT
|
||||
// from whether its standby CONTAINER happens to be running. A running
|
||||
// standby container is NOT "recording" and its signal is NOT "stopped" —
|
||||
// it's idle. Only when live.recording is true do we surface the real
|
||||
// session signal/duration; otherwise the row is idle with no elapsed.
|
||||
const isRecording = !!(live && live.recording);
|
||||
if (isRecording) {
|
||||
signal = live.signal || 'connecting';
|
||||
signalKnown = true;
|
||||
} else {
|
||||
signal = 'idle';
|
||||
signalKnown = false;
|
||||
}
|
||||
const sessionDuration = isRecording && live.duration != null ? live.duration : 0;
|
||||
if (isRunning) signal = 'receiving';
|
||||
if (!isRunning) signal = 'stopped';
|
||||
if (live && live.signal) { signal = live.signal; signalKnown = true; }
|
||||
|
||||
res.json({
|
||||
// recording = sidecar is actively capturing a session; standby container
|
||||
// up but idle reports its own status (not 'recording').
|
||||
status: isRecording ? 'recording' : (isRunning ? recorder.status : 'stopped'),
|
||||
recording: isRecording,
|
||||
duration: sessionDuration,
|
||||
status: isRunning ? 'recording' : 'stopped',
|
||||
duration,
|
||||
containerId: recorder.container_id,
|
||||
signal,
|
||||
signalKnown,
|
||||
|
|
@ -1382,11 +993,10 @@ router.post('/probe', async (req, res) => {
|
|||
|
||||
// Validate URL up-front so we don't even let the capture service see junk.
|
||||
let parsed = null;
|
||||
let proto = '';
|
||||
if (url) {
|
||||
try { parsed = new URL(url); }
|
||||
catch { return res.status(400).json({ error: 'Invalid URL' }); }
|
||||
proto = (parsed.protocol || '').replace(':', '').toLowerCase();
|
||||
const proto = (parsed.protocol || '').replace(':', '').toLowerCase();
|
||||
if (!ALLOWED_PROBE_SCHEMES.has(proto)) {
|
||||
return res.status(400).json({ error: `Scheme "${proto}" is not permitted for probe (#104)` });
|
||||
}
|
||||
|
|
@ -1399,7 +1009,6 @@ router.post('/probe', async (req, res) => {
|
|||
if (parsed.hostname === 'mam-api' || parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1') {
|
||||
return res.status(403).json({ error: 'Internal probe target is not permitted' });
|
||||
}
|
||||
}
|
||||
|
||||
// Try the capture service first (5s timeout)
|
||||
try {
|
||||
|
|
@ -1424,6 +1033,7 @@ router.post('/probe', async (req, res) => {
|
|||
}
|
||||
|
||||
const host = parsed.hostname;
|
||||
const proto = (parsed.protocol || '').replace(':', '').toLowerCase();
|
||||
const isUdp = proto === 'srt' || source_type === 'srt';
|
||||
const port = parseInt(parsed.port, 10) || (isUdp ? 9000 : 1935);
|
||||
|
||||
|
|
|
|||
|
|
@ -5,13 +5,12 @@
|
|||
import express from 'express';
|
||||
import fs from 'node:fs';
|
||||
import { promisify } from 'node:util';
|
||||
import { exec as execCb, execFile as execFileCb } from 'node:child_process';
|
||||
import { exec as execCb } from 'node:child_process';
|
||||
import { HeadBucketCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
|
||||
import pool from '../db/pool.js';
|
||||
import { s3Client, getS3Bucket } from '../s3/client.js';
|
||||
|
||||
const exec = promisify(execCb);
|
||||
const execFile = promisify(execFileCb);
|
||||
const router = express.Router();
|
||||
|
||||
// Defaults mirrored from settings.js so the overview never returns nulls.
|
||||
|
|
@ -79,21 +78,14 @@ async function probeS3Bucket() {
|
|||
if (!bucket) { out.error = 'no bucket configured'; return out; }
|
||||
|
||||
const started = Date.now();
|
||||
// Hard cap the whole probe so the admin "Mount health" card never hangs on
|
||||
// "Probing…" when S3 is slow/unreachable. Without this, the SDK's default
|
||||
// retry/backoff can block the request for tens of seconds.
|
||||
const withTimeout = (p, ms) => Promise.race([
|
||||
p,
|
||||
new Promise((_, rej) => setTimeout(() => rej(new Error('probe timed out after ' + ms + 'ms')), ms)),
|
||||
]);
|
||||
try {
|
||||
await withTimeout(s3Client.send(new HeadBucketCommand({ Bucket: bucket })), 5000);
|
||||
await s3Client.send(new HeadBucketCommand({ Bucket: bucket }));
|
||||
out.reachable = true;
|
||||
out.method = 'HeadBucket';
|
||||
} catch (headErr) {
|
||||
// Fall back to a 0-key list for stores that don't expose HeadBucket.
|
||||
try {
|
||||
await withTimeout(s3Client.send(new ListObjectsV2Command({ Bucket: bucket, MaxKeys: 0 })), 5000);
|
||||
await s3Client.send(new ListObjectsV2Command({ Bucket: bucket, MaxKeys: 0 }));
|
||||
out.reachable = true;
|
||||
out.method = 'ListObjectsV2';
|
||||
} catch (listErr) {
|
||||
|
|
@ -104,42 +96,6 @@ async function probeS3Bucket() {
|
|||
return out;
|
||||
}
|
||||
|
||||
// Query the growing-files SMB share's real capacity WITHOUT mounting it.
|
||||
// mam-api never mounts the CIFS share, so df on the container path reports the
|
||||
// local overlay (tens of GB), not the multi-TB NAS volume. `smbclient -c du`
|
||||
// returns "<N> blocks of size 1024. <M> blocks available" for the share, which
|
||||
// is the actual free/total the operator cares about.
|
||||
async function probeSmbShare({ mount, username, password, vers }) {
|
||||
const out = { reachable: false, free_bytes: null, total_bytes: null, error: null };
|
||||
if (!mount) { out.error = 'no smb mount configured'; return out; }
|
||||
// Normalize smb://host/share or \\host\share → //host/share for smbclient.
|
||||
let unc = String(mount).trim().replace(/\\/g, '/').replace(/^smb:\/\//i, '//');
|
||||
if (!unc.startsWith('//')) unc = '//' + unc.replace(/^\/+/, '');
|
||||
const user = `${username || ''}%${password || ''}`;
|
||||
const args = [
|
||||
unc, '-U', user,
|
||||
...(vers ? ['-m', `SMB${String(vers).replace(/\./g, '_').replace(/_0$/, '')}`] : []),
|
||||
'-c', 'du',
|
||||
];
|
||||
try {
|
||||
// execFile avoids shell-quoting the password. 6s cap so a dead NAS can't hang.
|
||||
const { stdout } = await execFile('smbclient', args, { timeout: 6000 });
|
||||
// " 1890828485120 blocks of size 1024. 1890776477696 blocks available"
|
||||
const m = stdout.match(/(\d+)\s+blocks of size\s+(\d+)\.\s+(\d+)\s+blocks available/i);
|
||||
if (m) {
|
||||
const blockSize = parseInt(m[2], 10) || 1024;
|
||||
out.total_bytes = parseInt(m[1], 10) * blockSize;
|
||||
out.free_bytes = parseInt(m[3], 10) * blockSize;
|
||||
out.reachable = true;
|
||||
} else {
|
||||
out.error = 'could not parse smbclient du output';
|
||||
}
|
||||
} catch (err) {
|
||||
out.error = (err.stderr ? String(err.stderr).trim() : err.message).slice(0, 200);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// GET /api/v1/storage/overview
|
||||
// Consolidated read-only view of the storage subsystem for the admin UI.
|
||||
router.get('/overview', async (req, res, next) => {
|
||||
|
|
@ -152,29 +108,6 @@ router.get('/overview', async (req, res, next) => {
|
|||
const containerPath = growingRaw.growing_path || '/growing';
|
||||
const mount = await probeGrowingPath(containerPath);
|
||||
|
||||
// Real capacity comes from the SMB share itself, NOT the local container
|
||||
// path (mam-api never mounts the share, so df reports the tiny overlay).
|
||||
// Query the NAS quota directly via smbclient when a mount is configured.
|
||||
let smbFree = null, smbTotal = null, smbReachable = false, capacityError = mount.error;
|
||||
if (growingEnabled) {
|
||||
const creds = await readSettings(['growing_smb_username', 'growing_smb_password', 'growing_smb_vers']);
|
||||
const smb = await probeSmbShare({
|
||||
mount: growingRaw.growing_smb_mount,
|
||||
username: creds.growing_smb_username,
|
||||
password: creds.growing_smb_password,
|
||||
vers: creds.growing_smb_vers,
|
||||
});
|
||||
if (smb.reachable) {
|
||||
smbFree = smb.free_bytes; smbTotal = smb.total_bytes; smbReachable = true; capacityError = null;
|
||||
} else {
|
||||
// Fall back to the local-path df numbers, but surface why the share
|
||||
// probe failed so the card can show it.
|
||||
smbFree = mount.free_bytes; smbTotal = mount.total_bytes; capacityError = smb.error || mount.error;
|
||||
}
|
||||
} else {
|
||||
smbFree = mount.free_bytes; smbTotal = mount.total_bytes;
|
||||
}
|
||||
|
||||
// S3 — bucket name comes from the live client (env or DB-loaded), not
|
||||
// a fresh DB read, so we report exactly what the running client uses.
|
||||
const s3 = await probeS3Bucket();
|
||||
|
|
@ -192,12 +125,9 @@ router.get('/overview', async (req, res, next) => {
|
|||
promote_after_seconds: parseInt(growingRaw.growing_promote_after_seconds, 10) || 8,
|
||||
exists: mount.exists,
|
||||
writable: mount.writable,
|
||||
// free/total now reflect the actual SMB share (smbclient du) when a
|
||||
// mount is configured; smb_reachable says whether that probe succeeded.
|
||||
free_bytes: smbFree,
|
||||
total_bytes: smbTotal,
|
||||
smb_reachable: smbReachable,
|
||||
error: capacityError,
|
||||
free_bytes: mount.free_bytes,
|
||||
total_bytes: mount.total_bytes,
|
||||
error: mount.error,
|
||||
},
|
||||
s3: {
|
||||
endpoint: s3SettingsRaw.s3_endpoint || process.env.S3_ENDPOINT || '',
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(/\/$/, '');
|
||||
|
|
@ -72,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;
|
||||
}
|
||||
|
|
@ -311,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');
|
||||
|
|
@ -452,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.
|
||||
|
|
@ -467,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 */ }
|
||||
}
|
||||
|
||||
|
|
@ -504,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);
|
||||
|
|
@ -652,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) {
|
||||
|
|
@ -678,212 +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}×tamps=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'];
|
||||
if (useGpu) {
|
||||
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');
|
||||
}
|
||||
sidecarEnv.push(`FC_URL=${FC_URL}`);
|
||||
|
||||
const hostConfig = { NetworkMode: 'host', Privileged: true, Binds: binds };
|
||||
if (useGpu) {
|
||||
hostConfig.Runtime = 'nvidia';
|
||||
hostConfig.DeviceRequests = [{ Driver: 'nvidia', Count: -1, Capabilities: [['gpu']] }];
|
||||
}
|
||||
|
||||
// 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)...`);
|
||||
|
|
@ -899,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);
|
||||
|
|
@ -929,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`);
|
||||
|
|
@ -973,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 ────────────────────────────────────────────────────
|
||||
|
|
@ -1515,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') {
|
||||
|
|
@ -1529,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))
|
||||
|
|
@ -1548,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);
|
||||
|
|
|
|||
BIN
services/premiere-plugin-uxp/dragonflight-mam-2.2.3.ccx
Normal file
BIN
services/premiere-plugin-uxp/dragonflight-mam-2.2.3.ccx
Normal file
Binary file not shown.
|
|
@ -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} />;
|
||||
|
|
|
|||
|
|
@ -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" /></>,
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
<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 }}>
|
||||
|
|
|
|||
|
|
@ -243,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'));
|
||||
|
|
@ -257,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); });
|
||||
};
|
||||
|
||||
|
|
@ -286,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() {
|
||||
|
|
@ -297,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); });
|
||||
};
|
||||
|
||||
|
|
@ -312,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
|
||||
|
|
@ -376,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);
|
||||
});
|
||||
};
|
||||
|
|
@ -399,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; });
|
||||
|
|
|
|||
|
|
@ -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 (disable→enable) 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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,14 +10,6 @@ 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 });
|
||||
|
||||
const createS3Client = () => {
|
||||
return new S3Client({
|
||||
region: process.env.S3_REGION || 'us-east-1',
|
||||
|
|
@ -30,12 +19,6 @@ const createS3Client = () => {
|
|||
secretAccessKey: process.env.S3_SECRET_KEY,
|
||||
},
|
||||
forcePathStyle: true,
|
||||
requestHandler: new NodeHttpHandler({
|
||||
httpAgent: _httpAgent,
|
||||
httpsAgent: _httpsAgent,
|
||||
requestTimeout: 600_000,
|
||||
connectionTimeout: 15_000,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
}
|
||||
Loading…
Reference in a new issue