Compare commits
7 commits
main
...
feat/hls-v
| Author | SHA1 | Date | |
|---|---|---|---|
| 01a19c0d69 | |||
| 39e010544c | |||
| d58982ad18 | |||
| a1b8211ea1 | |||
| ac5a667e65 | |||
| 1ca295d799 | |||
| be819353a7 |
62 changed files with 1590 additions and 6796 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);
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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,4 +1,4 @@
|
|||
import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';
|
||||
import { randomBytes, createHash } from 'node:crypto';
|
||||
|
||||
const PREFIX = 'dfl_';
|
||||
|
||||
|
|
@ -10,14 +10,6 @@ export function hashToken(token) {
|
|||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
export function compareTokens(tokenA, tokenB) {
|
||||
if (!tokenA || !tokenB) return false;
|
||||
const a = Buffer.from(tokenA);
|
||||
const b = Buffer.from(tokenB);
|
||||
if (a.length !== b.length) return false;
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
export function parseBearer(authorizationHeader) {
|
||||
if (!authorizationHeader || typeof authorizationHeader !== 'string') return null;
|
||||
const m = authorizationHeader.match(/^Bearer\s+(\S+)$/i);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -685,33 +685,8 @@ router.get('/:id/filmstrip', async (req, res, next) => {
|
|||
if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
||||
const { filmstrip_s3_key } = r.rows[0];
|
||||
if (!filmstrip_s3_key) return res.json({ url: null, ready: false });
|
||||
|
||||
// Serve the filmstrip JSON THROUGH the API (with retry) instead of handing
|
||||
// the browser a signed URL. The RustFS object store intermittently returns
|
||||
// NoSuchKey on GET for keys that List/Head confirm exist — a single browser
|
||||
// fetch then blanks the strip. Retrying server-side (where the GET succeeds
|
||||
// within a couple attempts) makes filmstrips reliable, and avoids the S3
|
||||
// CORS gap on the signed-URL path.
|
||||
let lastErr = null;
|
||||
for (let attempt = 0; attempt < 4; attempt++) {
|
||||
try {
|
||||
const obj = await s3Client.send(new GetObjectCommand({ Bucket: getS3Bucket(), Key: filmstrip_s3_key }));
|
||||
const body = await obj.Body.transformToString();
|
||||
res.set('Cache-Control', 'public, max-age=86400');
|
||||
res.set('Content-Type', 'application/json');
|
||||
return res.send(body);
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
const code = e?.name || e?.Code || '';
|
||||
// Only retry the known transient store inconsistency; fail fast otherwise.
|
||||
if (!/NoSuchKey|NotFound|404/i.test(code)) break;
|
||||
await new Promise(r => setTimeout(r, 150 * (attempt + 1)));
|
||||
}
|
||||
}
|
||||
// All retries missed — report not-ready so the UI shows its graceful
|
||||
// fallback rather than a hard error.
|
||||
console.warn(`[assets] filmstrip GET failed for ${id} (${filmstrip_s3_key}): ${lastErr?.name || lastErr?.message}`);
|
||||
return res.json({ url: null, ready: false, transient: true });
|
||||
const url = await getSignedUrlForObject(filmstrip_s3_key);
|
||||
res.json({ url, ready: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
|
|
@ -792,7 +767,7 @@ router.get('/:id/stream', async (req, res, next) => {
|
|||
if (a.hls_s3_key) {
|
||||
return res.json({
|
||||
url: `/api/v1/assets/${id}/video`,
|
||||
type: 'mp4',
|
||||
type: 'hls',
|
||||
source: a.proxy_s3_key ? 'proxy' : 'original',
|
||||
hls_url: `/api/v1/assets/${id}/hls/playlist.m3u8`,
|
||||
});
|
||||
|
|
@ -883,9 +858,65 @@ router.get('/:id/live-path', async (req, res, next) => {
|
|||
// - ETag + Last-Modified for conditional requests (304 on repeat visits)
|
||||
// - Cache-Control: private, max-age=3600 so the browser caches segments
|
||||
// and doesn't re-fetch them on every seek within a session
|
||||
// Issue #143 — RustFS returns empty bodies for ranged GETs whose start offset
|
||||
// is past ~5.9 MB on single-file proxy MP4s. Confirmed via direct S3 probe:
|
||||
// HEAD reports correct size, full GET (`bytes=0-`) works perfectly, but
|
||||
// `bytes=8179166-` returns 206 + the right Content-Range header and a zero-
|
||||
// byte body. A streaming GET from 0 reads cleanly *through* the broken zone.
|
||||
//
|
||||
// RustFS issue #143 (empty body on ranged GETs past ~5.9 MB) was fixed in
|
||||
// RustFS 1.0.0-alpha.94 (PR #2493). Standard ranged GETs used throughout.
|
||||
// Workaround until the proxy worker emits HLS (planned v1.2.1): stream the
|
||||
// proxy from offset 0, skip bytes the client didn't ask for, stop after the
|
||||
// requested end. Browser sees a normal 206 + Content-Range. Mem stays flat;
|
||||
// extra RustFS-to-mam-api bandwidth = (end+1 - actual-range) per seek.
|
||||
//
|
||||
// Small head-of-file ranges below RUSTFS_RANGE_SAFE_START are handled by a
|
||||
// direct ranged GET — saves the streaming-from-0 cost on the common case of
|
||||
// initial moov + first-segment fetch.
|
||||
|
||||
async function* stitchedS3Stream(key, startByte, endByte) {
|
||||
// Yields buffers covering exactly [startByte, endByte] inclusive.
|
||||
//
|
||||
// RustFS only mis-serves a ranged GET when the *start* offset of the
|
||||
// request is past ~5.8 MB. So we pull the object in 4 MB windows whose
|
||||
// START offsets always stay below the broken threshold:
|
||||
// - We anchor every chunk's start at a multiple of RUSTFS_SAFE_CHUNK
|
||||
// (0, 4 MB, 8 MB, …).
|
||||
// - Wait — that puts later starts past the threshold.
|
||||
// Instead: skip directly to the chunk containing `startByte`, but request
|
||||
// it as `bytes=anchorStart-end` where anchorStart < threshold. Since the
|
||||
// bug only bites when the *request start* offset is large, we never issue
|
||||
// a single GET whose Range start is past the broken zone — we instead
|
||||
// exploit that a low-offset GET that *continues past* the threshold reads
|
||||
// cleanly (confirmed by the bytes=0- full-GET probe).
|
||||
//
|
||||
// Practically: one GET from 0 that streams up through endByte, dropping
|
||||
// the bytes below startByte as they arrive. Memory stays flat; we pay
|
||||
// (endByte+1) bytes of RustFS-to-mam-api bandwidth per request.
|
||||
const res = await s3Client.send(new GetObjectCommand({
|
||||
Bucket: getS3Bucket(),
|
||||
Key: key,
|
||||
Range: `bytes=0-${endByte}`,
|
||||
}));
|
||||
|
||||
let consumed = 0; // bytes seen so far from S3
|
||||
let totalEmitted = 0;
|
||||
for await (const buf of res.Body) {
|
||||
const bufStart = consumed; // file offset of buf[0]
|
||||
const bufEnd = consumed + buf.length - 1;
|
||||
consumed += buf.length;
|
||||
if (bufEnd < startByte) continue; // entirely before window
|
||||
const sliceFrom = Math.max(0, startByte - bufStart);
|
||||
const sliceTo = Math.min(buf.length, endByte - bufStart + 1);
|
||||
if (sliceTo > sliceFrom) {
|
||||
yield buf.subarray(sliceFrom, sliceTo);
|
||||
totalEmitted += sliceTo - sliceFrom;
|
||||
}
|
||||
if (bufEnd >= endByte) break;
|
||||
}
|
||||
if (totalEmitted === 0) {
|
||||
throw new Error(`RustFS returned empty body for ${key} bytes=0-${endByte}`);
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/:id/video', async (req, res, next) => {
|
||||
try {
|
||||
|
|
@ -966,11 +997,39 @@ router.get('/:id/video', async (req, res, next) => {
|
|||
if (etag) headers['ETag'] = etag;
|
||||
if (lastModified) headers['Last-Modified'] = lastModified.toUTCString();
|
||||
|
||||
const s3Res = await s3Client.send(new GetObjectCommand({
|
||||
Bucket: getS3Bucket(), Key: key, Range: `bytes=${start}-${end}`,
|
||||
}));
|
||||
// For small head-of-file ranges (entirely below the broken threshold)
|
||||
// a direct ranged GET works and saves the streaming-from-0 cost.
|
||||
const RUSTFS_RANGE_SAFE_START = parseInt(process.env.RUSTFS_RANGE_SAFE_START || String(5_500_000), 10);
|
||||
if (start < RUSTFS_RANGE_SAFE_START && end < RUSTFS_RANGE_SAFE_START) {
|
||||
const s3Res = await s3Client.send(new GetObjectCommand({
|
||||
Bucket: getS3Bucket(), Key: key, Range: `bytes=${start}-${end}`,
|
||||
}));
|
||||
res.writeHead(206, headers);
|
||||
s3Res.Body.pipe(res);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise: stream from offset 0, drop bytes below `start`, stop at
|
||||
// `end`. Browser sees a normal 206; mam-api stays memory-flat.
|
||||
res.writeHead(206, headers);
|
||||
s3Res.Body.pipe(res);
|
||||
try {
|
||||
for await (const buf of stitchedS3Stream(key, start, end)) {
|
||||
// res.write returns false when backpressure builds — pause and wait.
|
||||
if (!res.write(buf)) {
|
||||
await new Promise(r => res.once('drain', r));
|
||||
}
|
||||
if (res.destroyed) return;
|
||||
}
|
||||
res.end();
|
||||
} catch (err) {
|
||||
console.error(`[video] stitch failed for ${key}:`, err.message);
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' });
|
||||
res.end('Upstream storage error');
|
||||
} else {
|
||||
res.destroy(err);
|
||||
}
|
||||
}
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -1,16 +1,30 @@
|
|||
import express from 'express';
|
||||
import http from 'http';
|
||||
import fs from 'fs';
|
||||
import { createReadStream, existsSync } from 'fs';
|
||||
import { stat } from 'fs/promises';
|
||||
import net from 'net';
|
||||
import dgram from 'dgram';
|
||||
import pool from '../db/pool.js';
|
||||
import { getS3Bucket } from '../s3/client.js';
|
||||
import { s3Client, getS3Bucket } from '../s3/client.js';
|
||||
import { Upload } from '@aws-sdk/lib-storage';
|
||||
import { validateUuid } from '../middleware/errors.js';
|
||||
import { assertProjectAccess, accessibleProjectIds } from '../auth/authz.js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Queue } from 'bullmq';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// BullMQ proxy queue — used by the growing-file stop handler to queue proxy
|
||||
// jobs when the capture container's finalize call races with the S3 upload.
|
||||
const parseRedisUrl = (url) => {
|
||||
const parsed = new URL(url);
|
||||
return { host: parsed.hostname, port: parseInt(parsed.port, 10) || 6379 };
|
||||
};
|
||||
const proxyQueue = new Queue('proxy', {
|
||||
connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
|
||||
});
|
||||
|
||||
// Every /:id recorder route is scoped to the recorder's project. The param
|
||||
// handler validates the UUID, resolves the owning project_id, and asserts the
|
||||
// 'view' baseline; mutating routes escalate to 'edit' via requireRecorderEdit.
|
||||
|
|
@ -40,7 +54,7 @@ async function requireRecorderEdit(req, res, next) {
|
|||
const SIDECAR_PORT_BASE = 7438;
|
||||
|
||||
// Docker API helper function
|
||||
function dockerApi(method, path, body = null, timeoutMs = 10000) {
|
||||
function dockerApi(method, path, body = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
socketPath: '/var/run/docker.sock',
|
||||
|
|
@ -60,9 +74,9 @@ function dockerApi(method, path, body = null, timeoutMs = 10000) {
|
|||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
// Use parameterizable timeout to prevent indefinite hangs if Docker daemon is unresponsive
|
||||
req.setTimeout(timeoutMs, () => {
|
||||
req.destroy(new Error(`Docker API timeout after ${timeoutMs/1000}s`));
|
||||
// Add 10-second timeout to prevent indefinite hangs if Docker daemon is unresponsive
|
||||
req.setTimeout(10000, () => {
|
||||
req.destroy(new Error('Docker API timeout after 10s'));
|
||||
});
|
||||
if (body) req.write(JSON.stringify(body));
|
||||
req.end();
|
||||
|
|
@ -154,7 +168,7 @@ const RECORDER_FIELDS = [
|
|||
'proxy_audio_codec', 'proxy_audio_bitrate', 'proxy_audio_channels',
|
||||
'proxy_container',
|
||||
'project_id', 'node_id', 'device_index',
|
||||
'growing_enabled', 'label',
|
||||
'growing_enabled',
|
||||
];
|
||||
|
||||
function pickRecorderFields(body) {
|
||||
|
|
@ -198,7 +212,7 @@ function validateRecorderConfig(cfg, nodeHasGpu = null) {
|
|||
// NVENC requires a GPU on the target node. Only a hard error when we know the
|
||||
// node lacks one; unknown capability is left as a soft pass.
|
||||
if (GPU_CODECS.includes(codec) && nodeHasGpu === false) {
|
||||
return `Invalid combo: codec ${cfg.recording_codec} requires an NVIDIA GPU, but the target node reports no GPU. Assign this recorder to a GPU node.`;
|
||||
return `Invalid combo: codec ${cfg.recording_codec} requires an NVIDIA GPU, but the target node reports no GPU. Choose a software codec (e.g. prores_hq, dnxhr_hq, h264) or assign a GPU node.`;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
@ -227,133 +241,6 @@ async function nodeHasGpuCapability(nodeId) {
|
|||
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
// Build the stable env array for a standby sidecar. Contains everything a
|
||||
// capture container needs EXCEPT session params (CLIP_NAME, ASSET_ID,
|
||||
// PROJECT_ID, BIN_ID) which arrive via HTTP on /capture/start.
|
||||
function buildStandbyEnv(recorder) {
|
||||
const s3Endpoint = process.env.S3_ENDPOINT || '';
|
||||
const s3Bucket = getS3Bucket();
|
||||
const s3AccessKey = process.env.S3_ACCESS_KEY || '';
|
||||
const s3SecretKey = process.env.S3_SECRET_KEY || '';
|
||||
const mamApiUrl = process.env.MAM_API_URL || 'http://mam-api:3000';
|
||||
const externalMamApiUrl = `http://${process.env.NODE_IP || '172.18.91.200'}:${process.env.PORT_MAM_API || 47432}`;
|
||||
const liveDir = process.env.LIVE_DIR || '/mnt/NVME/MAM/wild-dragon-live';
|
||||
const sourceConfig = recorder.source_config || {};
|
||||
const deviceIndex = recorder.device_index ?? sourceConfig.device ?? 0;
|
||||
|
||||
return [
|
||||
`S3_ENDPOINT=${s3Endpoint}`,
|
||||
`S3_BUCKET=${s3Bucket}`,
|
||||
`S3_ACCESS_KEY=${s3AccessKey}`,
|
||||
`S3_SECRET_KEY=${s3SecretKey}`,
|
||||
`S3_REGION=${process.env.S3_REGION || 'us-east-1'}`,
|
||||
// Use external URL — capture container runs on worker host network
|
||||
`MAM_API_URL=${externalMamApiUrl}`,
|
||||
`RECORDER_ID=${recorder.id}`,
|
||||
`SOURCE_TYPE=${recorder.source_type}`,
|
||||
`SOURCE_CONFIG=${JSON.stringify(sourceConfig)}`,
|
||||
`DEVICE_INDEX=${deviceIndex}`,
|
||||
`RECORDING_CODEC=${recorder.recording_codec || 'hevc_nvenc'}`,
|
||||
`RECORDING_RESOLUTION=${recorder.recording_resolution || 'native'}`,
|
||||
`RECORDING_VIDEO_BITRATE=${recorder.recording_video_bitrate || ''}`,
|
||||
`RECORDING_FRAMERATE=${recorder.recording_framerate || ''}`,
|
||||
`RECORDING_AUDIO_CODEC=${recorder.recording_audio_codec || 'pcm_s24le'}`,
|
||||
`RECORDING_AUDIO_BITRATE=${recorder.recording_audio_bitrate || ''}`,
|
||||
`RECORDING_AUDIO_CHANNELS=${recorder.recording_audio_channels ?? 2}`,
|
||||
`RECORDING_CONTAINER=${recorder.recording_container || 'mov'}`,
|
||||
`PROXY_ENABLED=${recorder.proxy_enabled !== false ? 'true' : 'false'}`,
|
||||
`PROXY_CODEC=${recorder.proxy_codec || 'h264_nvenc'}`,
|
||||
`PROXY_RESOLUTION=${recorder.proxy_resolution || '1920x1080'}`,
|
||||
`PROXY_VIDEO_BITRATE=${recorder.proxy_video_bitrate || '2M'}`,
|
||||
`PROXY_FRAMERATE=${recorder.proxy_framerate || ''}`,
|
||||
`PROXY_AUDIO_CODEC=${recorder.proxy_audio_codec || 'aac'}`,
|
||||
`PROXY_AUDIO_BITRATE=${recorder.proxy_audio_bitrate || '128k'}`,
|
||||
`PROXY_AUDIO_CHANNELS=${recorder.proxy_audio_channels ?? 2}`,
|
||||
`PROXY_CONTAINER=${recorder.proxy_container || 'mp4'}`,
|
||||
`GROWING_ENABLED=false`,
|
||||
`GROWING_PATH=/growing`,
|
||||
`GROWING_SMB_MOUNT=`,
|
||||
`LIVE_DIR=${liveDir}`,
|
||||
`MAM_API_TOKEN=${process.env.CAPTURE_TOKEN || ''}`,
|
||||
`STANDBY=1`,
|
||||
`PRE_ROLL_SECONDS=1`,
|
||||
];
|
||||
}
|
||||
|
||||
// Source types that run a long-lived standby sidecar (idle-preview container
|
||||
// kept up 24/7 so `record` is a sub-second HTTP call, not a Docker cold start).
|
||||
const STANDBY_SOURCE_TYPES = ['deltacast', 'sdi', 'blackmagic'];
|
||||
|
||||
// Provision (or re-provision) the single persistent standby sidecar for one
|
||||
// recorder by asking its node's agent to create the idle container. Idempotent
|
||||
// at the node-agent layer (one container per capture port). Updates the
|
||||
// recorder row with the new container_id + status='standby'. Returns:
|
||||
// { ok, containerId?, reason? }
|
||||
// Non-fatal by contract — the caller logs/aggregates; a recorder is still
|
||||
// usable via the on-demand spawn fallback in /start if this fails.
|
||||
async function ensureStandbySidecar(recorder) {
|
||||
if (!recorder.node_id || !STANDBY_SOURCE_TYPES.includes(recorder.source_type)) {
|
||||
return { ok: false, reason: 'not a standby source / no node' };
|
||||
}
|
||||
const { remote: isRemote, apiUrl: targetNodeApiUrl } =
|
||||
await resolveNodeTarget(recorder.node_id).catch(() => ({ remote: false }));
|
||||
if (!isRemote || !targetNodeApiUrl) {
|
||||
return { ok: false, reason: 'node not remote/reachable' };
|
||||
}
|
||||
const capturePort = SIDECAR_PORT_BASE + (recorder.device_index || 0);
|
||||
const useGpu = GPU_CODECS.includes(recorder.recording_codec);
|
||||
const standbyRes = await fetch(`${targetNodeApiUrl}/sidecar/standby`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
image: 'wild-dragon-capture:latest',
|
||||
env: buildStandbyEnv(recorder),
|
||||
capturePort,
|
||||
sourceType: recorder.source_type,
|
||||
useGpu,
|
||||
gpuUuid: recorder.gpu_uuid || null,
|
||||
}),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!standbyRes.ok) {
|
||||
return { ok: false, reason: `node-agent returned ${standbyRes.status}` };
|
||||
}
|
||||
const { containerId } = await standbyRes.json();
|
||||
await pool.query(
|
||||
`UPDATE recorders SET container_id = $1, status = 'standby', updated_at = NOW() WHERE id = $2`,
|
||||
[containerId, recorder.id]
|
||||
);
|
||||
recorder.container_id = containerId;
|
||||
recorder.status = 'standby';
|
||||
console.log(`[recorders] standby sidecar spawned for ${recorder.id}: ${containerId}`);
|
||||
return { ok: true, containerId };
|
||||
}
|
||||
|
||||
// Tear down a recorder's standby sidecar (Disable). Asks the node-agent to
|
||||
// remove the container, then clears container_id and sets status='stopped'.
|
||||
// Best-effort on the node-agent call — even if the delete fails we still clear
|
||||
// the row so the operator isn't stuck; the force-free-port logic on the next
|
||||
// Enable will reclaim a stray container. Returns { ok, reason? }.
|
||||
async function teardownStandbySidecar(recorder) {
|
||||
if (recorder.node_id && recorder.container_id) {
|
||||
const { remote: isRemote, apiUrl: targetNodeApiUrl } =
|
||||
await resolveNodeTarget(recorder.node_id).catch(() => ({ remote: false }));
|
||||
if (isRemote && targetNodeApiUrl) {
|
||||
await fetch(`${targetNodeApiUrl}/sidecar/${recorder.container_id}`, {
|
||||
method: 'DELETE',
|
||||
signal: AbortSignal.timeout(15000),
|
||||
}).catch(e => console.warn(`[recorders] sidecar teardown for ${recorder.id} failed (clearing anyway): ${e.message}`));
|
||||
}
|
||||
}
|
||||
await pool.query(
|
||||
`UPDATE recorders SET container_id = NULL, status = 'stopped', current_session_id = NULL, updated_at = NOW() WHERE id = $1`,
|
||||
[recorder.id]
|
||||
);
|
||||
recorder.container_id = null;
|
||||
recorder.status = 'stopped';
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// Issue #162 — after a local-spawn stop, wait for the capture container to
|
||||
// finalize its master. The asset row was pre-created at start with
|
||||
// status='live' (display_name = current_session_id); the ingest/finalize step
|
||||
|
|
@ -468,9 +355,9 @@ router.post('/', async (req, res, next) => {
|
|||
recording_audio_codec: 'pcm_s24le',
|
||||
recording_audio_channels: 2,
|
||||
recording_container: 'mov',
|
||||
proxy_enabled: true,
|
||||
proxy_codec: 'h264_nvenc',
|
||||
proxy_resolution: '1920x1080',
|
||||
proxy_enabled: true,
|
||||
proxy_codec: 'h264',
|
||||
proxy_resolution: '1920x1080',
|
||||
proxy_video_bitrate: '2M',
|
||||
proxy_audio_codec: 'aac',
|
||||
proxy_audio_bitrate: '128k',
|
||||
|
|
@ -501,111 +388,7 @@ router.post('/', async (req, res, next) => {
|
|||
values
|
||||
);
|
||||
|
||||
const recorder = result.rows[0];
|
||||
|
||||
// Spawn a standby sidecar immediately for SDI/deltacast/blackmagic recorders
|
||||
// that have an assigned node, so the container + bridge are ready before the
|
||||
// user hits record. Non-fatal — recorder is still usable if this fails.
|
||||
await ensureStandbySidecar(recorder).catch(e =>
|
||||
console.warn(`[recorders] standby spawn failed for ${recorder.id} (non-fatal): ${e.message}`));
|
||||
|
||||
res.status(201).json(recorder);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /reconcile-standby - (re)provision the persistent standby sidecar for
|
||||
// every SDI/deltacast recorder that should have one. Standby sidecars are
|
||||
// created on recorder-create and kept up 24/7 (RestartPolicy=unless-stopped),
|
||||
// but if they're externally removed (manual cleanup, node redeploy, a wiped
|
||||
// /dev/shm) nothing recreates them — the recorder then falls back to the slow
|
||||
// on-demand spawn on /start, which can collide on the capture port. This
|
||||
// endpoint re-warms them so all recorders return to the fast standby path.
|
||||
//
|
||||
// Optional body: { force: true } recreates even recorders that currently claim
|
||||
// a container_id (the node-agent is idempotent per capture port, so a stale id
|
||||
// is replaced cleanly). Without force, only recorders with no container_id are
|
||||
// (re)provisioned.
|
||||
router.post('/reconcile-standby', requireRecorderEdit, async (req, res, next) => {
|
||||
try {
|
||||
const force = !!(req.body && req.body.force);
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM recorders
|
||||
WHERE source_type = ANY($1)
|
||||
AND node_id IS NOT NULL
|
||||
ORDER BY name`,
|
||||
[STANDBY_SOURCE_TYPES]
|
||||
);
|
||||
const results = [];
|
||||
for (const recorder of rows) {
|
||||
if (!force && recorder.container_id) {
|
||||
results.push({ id: recorder.id, name: recorder.name, ok: true, skipped: 'already has container_id' });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const r = await ensureStandbySidecar(recorder);
|
||||
results.push({ id: recorder.id, name: recorder.name, ...r });
|
||||
} catch (e) {
|
||||
results.push({ id: recorder.id, name: recorder.name, ok: false, reason: e.message });
|
||||
}
|
||||
}
|
||||
const provisioned = results.filter(r => r.ok && r.containerId).length;
|
||||
res.json({ provisioned, total: rows.length, results });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /:id/enable - Operator opt-in. Brings up the persistent standby sidecar
|
||||
// (idle-preview, kept up 24/7) so the port is ready to record in <1s. Sets
|
||||
// enabled=true. Idempotent: if already enabled with a live container the
|
||||
// node-agent's force-free-port logic replaces any stale container cleanly.
|
||||
router.post('/:id/enable', requireRecorderEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { rows } = await pool.query('SELECT * FROM recorders WHERE id = $1', [id]);
|
||||
if (rows.length === 0) return res.status(404).json({ error: 'Recorder not found' });
|
||||
const recorder = rows[0];
|
||||
|
||||
if (!STANDBY_SOURCE_TYPES.includes(recorder.source_type)) {
|
||||
return res.status(400).json({ error: `Source type "${recorder.source_type}" does not support standby/enable` });
|
||||
}
|
||||
if (!recorder.node_id) {
|
||||
return res.status(409).json({ error: 'Recorder has no assigned node (hardware offline?) — cannot enable' });
|
||||
}
|
||||
|
||||
const r = await ensureStandbySidecar(recorder);
|
||||
if (!r.ok) {
|
||||
return res.status(502).json({ error: `Could not start standby sidecar: ${r.reason || 'unknown'}` });
|
||||
}
|
||||
await pool.query('UPDATE recorders SET enabled = true, updated_at = NOW() WHERE id = $1', [id]);
|
||||
recorder.enabled = true;
|
||||
res.json(recorder);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /:id/disable - Operator opt-out. Stops & removes the standby sidecar,
|
||||
// freeing the capture port, and sets enabled=false. Config (codec, label,
|
||||
// growing) is preserved on the row for the next enable. Refuses while the
|
||||
// recorder is actively recording — stop it first.
|
||||
router.post('/:id/disable', requireRecorderEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { rows } = await pool.query('SELECT * FROM recorders WHERE id = $1', [id]);
|
||||
if (rows.length === 0) return res.status(404).json({ error: 'Recorder not found' });
|
||||
const recorder = rows[0];
|
||||
|
||||
if (recorder.status === 'recording') {
|
||||
return res.status(409).json({ error: 'Recorder is recording — stop it before disabling' });
|
||||
}
|
||||
|
||||
await teardownStandbySidecar(recorder);
|
||||
await pool.query('UPDATE recorders SET enabled = false, updated_at = NOW() WHERE id = $1', [id]);
|
||||
recorder.enabled = false;
|
||||
res.json(recorder);
|
||||
res.status(201).json(result.rows[0]);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
|
|
@ -793,7 +576,7 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
|
|||
`DEVICE_INDEX=${deviceIndex}`,
|
||||
|
||||
// Recording codec controls
|
||||
`RECORDING_CODEC=${recorder.recording_codec || 'hevc_nvenc'}`,
|
||||
`RECORDING_CODEC=${recorder.recording_codec || 'prores_hq'}`,
|
||||
`RECORDING_RESOLUTION=${recorder.recording_resolution || 'native'}`,
|
||||
`RECORDING_VIDEO_BITRATE=${recorder.recording_video_bitrate || ''}`,
|
||||
`RECORDING_FRAMERATE=${recorder.recording_framerate || ''}`,
|
||||
|
|
@ -804,7 +587,7 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
|
|||
|
||||
// Proxy codec controls
|
||||
`PROXY_ENABLED=${recorder.proxy_enabled !== false ? 'true' : 'false'}`,
|
||||
`PROXY_CODEC=${recorder.proxy_codec || 'h264_nvenc'}`,
|
||||
`PROXY_CODEC=${recorder.proxy_codec || 'h264'}`,
|
||||
`PROXY_RESOLUTION=${recorder.proxy_resolution || '1920x1080'}`,
|
||||
`PROXY_VIDEO_BITRATE=${recorder.proxy_video_bitrate || '2M'}`,
|
||||
`PROXY_FRAMERATE=${recorder.proxy_framerate || ''}`,
|
||||
|
|
@ -818,9 +601,6 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
|
|||
`ASSET_ID=${assetIdLive}`,
|
||||
`MAM_API_TOKEN=${process.env.CAPTURE_TOKEN || ''}`,
|
||||
`GROWING_ENABLED=${growingEnabled ? 'true' : 'false'}`,
|
||||
// Growing codec: 'avci100' (CPU AVC-Intra 100 MXF, default) or
|
||||
// 'hevc_nvenc' (GPU all-intra HEVC frag-MOV). capture-manager reads this.
|
||||
`GROWING_CODEC=${recorder.growing_codec === 'hevc_nvenc' ? 'hevc_nvenc' : 'avci100'}`,
|
||||
`GROWING_PATH=/growing`,
|
||||
// SMB mount details for the in-container CIFS mount (Approach A). Empty
|
||||
// GROWING_SMB_MOUNT → capture falls back to the host-bound /growing volume
|
||||
|
|
@ -838,12 +618,6 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
|
|||
if (dcCount) env.push(`DELTACAST_PORT_COUNT=${dcCount}`);
|
||||
}
|
||||
|
||||
// Framecache slot has been warm since the bridge started — 1s pre-roll is
|
||||
// sufficient. Avoids a 5s startup lag on both on-demand and standby spawns.
|
||||
if (['deltacast', 'sdi', 'blackmagic'].includes(sourceType)) {
|
||||
env.push('PRE_ROLL_SECONDS=1');
|
||||
}
|
||||
|
||||
if (sourceType === 'srt' || sourceType === 'rtmp') {
|
||||
env.push(`LISTEN=${isListener ? '1' : '0'}`);
|
||||
if (isListener) {
|
||||
|
|
@ -876,88 +650,10 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
|
|||
}
|
||||
|
||||
let containerId;
|
||||
const capturePort = SIDECAR_PORT_BASE + (deviceIndex || 0);
|
||||
|
||||
// ── Standby fast-path ───────────────────────────────────────────────
|
||||
// If the recorder is already in standby (sidecar running idle), send the
|
||||
// session params to its /capture/start HTTP endpoint instead of spawning
|
||||
// a new container. This eliminates Docker create/start latency and bridge
|
||||
// startup time — the user hits record and ffmpeg starts in <1s.
|
||||
const isStandby = recorder.status === 'standby' && recorder.container_id;
|
||||
if (isStandby) {
|
||||
const captureStartUrl = isRemote
|
||||
? `http://${(await resolveNodeTarget(recorder.node_id)).ip}:${capturePort}/capture/start`
|
||||
: `http://localhost:${capturePort}/capture/start`;
|
||||
try {
|
||||
const startBody = {
|
||||
project_id: takeProjectId,
|
||||
bin_id: null,
|
||||
clip_name: clipName,
|
||||
asset_id: assetIdLive,
|
||||
source_type: sourceType,
|
||||
device: deviceIndex,
|
||||
// Codec params — sidecar already has these in env but we send them
|
||||
// anyway so a config change on the recorder takes effect immediately.
|
||||
recording_codec: recorder.recording_codec,
|
||||
recording_video_bitrate: recorder.recording_video_bitrate,
|
||||
recording_framerate: recorder.recording_framerate,
|
||||
recording_audio_codec: recorder.recording_audio_codec,
|
||||
recording_audio_bitrate: recorder.recording_audio_bitrate,
|
||||
recording_audio_channels: recorder.recording_audio_channels,
|
||||
recording_container: recorder.recording_container,
|
||||
proxy_enabled: recorder.proxy_enabled,
|
||||
proxy_codec: recorder.proxy_codec,
|
||||
proxy_video_bitrate: recorder.proxy_video_bitrate,
|
||||
proxy_framerate: recorder.proxy_framerate,
|
||||
proxy_audio_codec: recorder.proxy_audio_codec,
|
||||
proxy_audio_bitrate: recorder.proxy_audio_bitrate,
|
||||
proxy_audio_channels: recorder.proxy_audio_channels,
|
||||
proxy_container: recorder.proxy_container,
|
||||
growing_enabled: growingEnabled,
|
||||
growing_smb_mount: smbMount,
|
||||
growing_smb_username: growingInfra.growing_smb_username || '',
|
||||
growing_smb_password: growingInfra.growing_smb_password || '',
|
||||
growing_smb_vers: growingInfra.growing_smb_vers || '3.0',
|
||||
};
|
||||
const captureRes = await fetch(captureStartUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(startBody),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (captureRes.ok) {
|
||||
containerId = recorder.container_id;
|
||||
console.log(`[recorders] standby→recording: ${id} via HTTP /capture/start`);
|
||||
} else {
|
||||
const detail = await captureRes.json().catch(() => ({}));
|
||||
console.warn(`[recorders] standby /capture/start returned ${captureRes.status}: ${JSON.stringify(detail)} — falling back to spawn`);
|
||||
// Fall through to on-demand spawn below
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[recorders] standby HTTP start failed: ${e.message} — falling back to spawn`);
|
||||
// Fall through to on-demand spawn below
|
||||
}
|
||||
}
|
||||
|
||||
// If standby HTTP start failed and a stale container_id exists, kill it
|
||||
// before spawning a new one — otherwise the new container gets EADDRINUSE
|
||||
// because the old container is still holding the capture port.
|
||||
if (!containerId && isStandby && recorder.container_id) {
|
||||
console.log(`[recorders] killing stale standby container ${recorder.container_id} before respawn`);
|
||||
try {
|
||||
if (isRemote) {
|
||||
await fetch(`${targetNodeApiUrl}/sidecar/${recorder.container_id}`, {
|
||||
method: 'DELETE',
|
||||
signal: AbortSignal.timeout(10000),
|
||||
}).catch(() => {});
|
||||
} else {
|
||||
await dockerApi('DELETE', `/containers/${recorder.container_id}?force=true`).catch(() => {});
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (!containerId && isRemote) {
|
||||
if (isRemote) {
|
||||
// Remote node: delegate container lifecycle to that node's agent.
|
||||
const capturePort = SIDECAR_PORT_BASE + (deviceIndex || 0);
|
||||
const sidecarRes = await fetch(`${targetNodeApiUrl}/sidecar/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
|
@ -976,7 +672,7 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
|
|||
}
|
||||
const sidecarData = await sidecarRes.json();
|
||||
containerId = sidecarData.containerId;
|
||||
} else if (!containerId) {
|
||||
} else {
|
||||
// Local spawn via Docker socket.
|
||||
const { portBindings, exposedPorts } = buildPortConfig(sourceType, sourceConfig);
|
||||
const alias = `recorder-${id}`;
|
||||
|
|
@ -1096,69 +792,8 @@ router.post('/:id/stop', requireRecorderEdit, async (req, res, next) => {
|
|||
return res.json(result.rows[0]);
|
||||
}
|
||||
|
||||
const { remote: isRemote, apiUrl: targetNodeApiUrl, ip: nodeIp } = await resolveNodeTarget(recorder.node_id);
|
||||
const deviceIndex = recorder.device_index ?? (recorder.source_config?.device ?? 0);
|
||||
const capturePort = SIDECAR_PORT_BASE + deviceIndex;
|
||||
const isStandby = recorder.status === 'standby';
|
||||
const { remote: isRemote, apiUrl: targetNodeApiUrl } = await resolveNodeTarget(recorder.node_id);
|
||||
|
||||
// ── Standby sidecar stop path ─────────────────────────────────────────
|
||||
// If the recorder was in standby (container stays alive between sessions),
|
||||
// stop only the capture session via HTTP — don't kill the container.
|
||||
// The container returns to idle-preview mode and is ready for the next
|
||||
// /start call immediately.
|
||||
//
|
||||
// If NOT in standby (legacy on-demand spawn), use the old docker-stop path.
|
||||
const isStandbySource = STANDBY_SOURCE_TYPES.includes(recorder.source_type);
|
||||
|
||||
if (isStandbySource && recorder.container_id) {
|
||||
// Call /capture/stop on the running sidecar.
|
||||
// Return immediately — S3 upload streams to completion asynchronously.
|
||||
const captureStopUrl = isRemote
|
||||
? `http://${nodeIp}:${capturePort}/capture/stop`
|
||||
: `http://localhost:${capturePort}/capture/stop`;
|
||||
|
||||
// Get session_id from the sidecar's status (it tracks its own sessionId).
|
||||
let sessionId = null;
|
||||
try {
|
||||
const statusRes = await fetch(
|
||||
isRemote ? `http://${nodeIp}:${capturePort}/capture/status` : `http://localhost:${capturePort}/capture/status`,
|
||||
{ signal: AbortSignal.timeout(3000) }
|
||||
);
|
||||
if (statusRes.ok) {
|
||||
const s = await statusRes.json();
|
||||
sessionId = s.sessionId || null;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
if (sessionId) {
|
||||
// Fire-and-forget — the S3 upload completes in the background inside
|
||||
// the sidecar. The /capture/stop route calls /assets/:id/finalize when
|
||||
// done, so the asset transitions from 'live' → 'processing' automatically.
|
||||
fetch(captureStopUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_id: sessionId }),
|
||||
signal: AbortSignal.timeout(185000),
|
||||
}).then(r => {
|
||||
if (!r.ok) console.warn(`[recorders] /capture/stop returned ${r.status} for ${id}`);
|
||||
else console.log(`[recorders] standby stop completed for ${id}`);
|
||||
}).catch(e => {
|
||||
console.error(`[recorders] /capture/stop error for ${id}: ${e.message}`);
|
||||
});
|
||||
} else {
|
||||
console.warn(`[recorders] could not get sessionId for ${id} — sidecar may not be recording`);
|
||||
}
|
||||
|
||||
// Container stays alive in standby — keep container_id, set status='standby'
|
||||
const updateResult = await pool.query(
|
||||
`UPDATE recorders SET status = 'standby', current_session_id = NULL, updated_at = NOW()
|
||||
WHERE id = $1 RETURNING *`,
|
||||
[id]
|
||||
);
|
||||
return res.json(updateResult.rows[0]);
|
||||
}
|
||||
|
||||
// ── Legacy path: on-demand container, kill it on stop ────────────────
|
||||
if (isRemote) {
|
||||
const stopRes = await fetch(`${targetNodeApiUrl}/sidecar/${recorder.container_id}`, {
|
||||
method: 'DELETE',
|
||||
|
|
@ -1169,23 +804,45 @@ router.post('/:id/stop', requireRecorderEdit, async (req, res, next) => {
|
|||
}
|
||||
} else {
|
||||
// Issue #162 — stop local container in the background so the HTTP stop
|
||||
// request returns immediately.
|
||||
// request returns immediately. The container teardown (SIGTERM -> ffmpeg
|
||||
// exit -> S3 upload -> post-stop callback) takes up to 180s for large files,
|
||||
// which would otherwise timeout the browser/API connection.
|
||||
const containerId = recorder.container_id;
|
||||
(async () => {
|
||||
try {
|
||||
const stopRes = await dockerApi('POST', `/containers/${containerId}/stop?t=180`, null, 185000);
|
||||
const stopRes = await dockerApi('POST', `/containers/${containerId}/stop?t=180`);
|
||||
if (stopRes.status !== 404) {
|
||||
await waitForFinalize(recorder);
|
||||
await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[recorders] failed local background stop:', e.message);
|
||||
await waitForFinalize(recorder).catch(() => {});
|
||||
await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {});
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// ── Growing-files S3 promotion ────────────────────────────────────────────
|
||||
// When growing_enabled=true the capture container writes the master file to
|
||||
// /growing/{projectId}/{clipName}.{ext} (a host bind-mount that the mam-api
|
||||
// container also has at /growing). The capture container's graceful-shutdown
|
||||
// handler (triggered by the Docker stop above) calls POST /assets/:id/finalize
|
||||
// with the expected S3 key, which queues the proxy job — but the file was
|
||||
// never uploaded to S3, so the proxy worker fails with "unable to open file".
|
||||
//
|
||||
// Fix: after the container has exited (ffmpeg is done flushing), upload the
|
||||
// growing file to the canonical S3 key from here. This is synchronous and
|
||||
// completes before the HTTP response reaches the client, so the already-queued
|
||||
// proxy job will find a valid S3 object when the worker dequeues it.
|
||||
//
|
||||
// Only applies to LOCAL recorders — remote recorders write to a different
|
||||
// node's /growing mount which this process cannot access.
|
||||
if (!isRemote && recorder.growing_enabled === true && recorder.current_session_id) {
|
||||
await promoteGrowingFileToS3(recorder).catch(err => {
|
||||
// Non-fatal — log and continue so the stop always succeeds.
|
||||
console.error('[recorders/stop] growing-file promotion failed (non-fatal):', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
const updateResult = await pool.query(
|
||||
`UPDATE recorders
|
||||
SET container_id = NULL, status = $1, updated_at = NOW()
|
||||
|
|
@ -1200,6 +857,109 @@ router.post('/:id/stop', requireRecorderEdit, async (req, res, next) => {
|
|||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Upload a completed growing-file master from /growing to S3 so the proxy
|
||||
* worker can find it at the expected original_s3_key.
|
||||
*
|
||||
* The capture container writes to:
|
||||
* /growing/{projectId}/{clipName}.{ext}
|
||||
*
|
||||
* The canonical S3 key (set on the asset row at recording start) is:
|
||||
* projects/{projectId}/masters/{clipName}.{ext}
|
||||
*
|
||||
* We look up the live/processing asset to derive both paths, do a multipart
|
||||
* upload, update the asset's original_s3_key and file_size to match what we
|
||||
* actually uploaded, then ensure a proxy job exists for it.
|
||||
*/
|
||||
async function promoteGrowingFileToS3(recorder) {
|
||||
const clipName = recorder.current_session_id;
|
||||
const container = recorder.recording_container || 'mov';
|
||||
|
||||
// Find the asset that was pre-created at recording start. It could be in
|
||||
// 'live' (finalize hasn't fired yet) or 'processing' (finalize already ran
|
||||
// from the container's SIGTERM handler). We need both its id and its
|
||||
// project_id to reconstruct the growing path.
|
||||
const assetRes = await pool.query(
|
||||
`SELECT id, project_id, status, original_s3_key
|
||||
FROM assets
|
||||
WHERE display_name = $1
|
||||
AND status IN ('live', 'processing', 'error')
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1`,
|
||||
[clipName]
|
||||
);
|
||||
|
||||
if (assetRes.rows.length === 0) {
|
||||
console.warn(`[recorders/stop] no asset found for clip "${clipName}" — skipping growing-file promotion`);
|
||||
return;
|
||||
}
|
||||
|
||||
const asset = assetRes.rows[0];
|
||||
const projectId = asset.project_id;
|
||||
const growingDir = process.env.GROWING_DIR || '/growing';
|
||||
const localPath = `${growingDir}/${projectId}/${clipName}.${container}`;
|
||||
const s3Key = `projects/${projectId}/masters/${clipName}.${container}`;
|
||||
|
||||
if (!existsSync(localPath)) {
|
||||
console.warn(`[recorders/stop] growing file not found at ${localPath} — nothing to promote (empty recording?)`);
|
||||
return;
|
||||
}
|
||||
|
||||
const fileStat = await stat(localPath);
|
||||
if (fileStat.size === 0) {
|
||||
console.warn(`[recorders/stop] growing file at ${localPath} is empty — skipping promotion`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[recorders/stop] promoting growing file ${localPath} (${fileStat.size} bytes) → s3://${getS3Bucket()}/${s3Key}`);
|
||||
|
||||
const upload = new Upload({
|
||||
client: s3Client,
|
||||
params: {
|
||||
Bucket: getS3Bucket(),
|
||||
Key: s3Key,
|
||||
Body: createReadStream(localPath),
|
||||
},
|
||||
queueSize: 4,
|
||||
partSize: 8 * 1024 * 1024,
|
||||
});
|
||||
await upload.done();
|
||||
|
||||
console.log(`[recorders/stop] S3 upload complete for ${s3Key}`);
|
||||
|
||||
// Ensure the asset row reflects the correct S3 key and file size. The
|
||||
// capture container's finalize call may have already set original_s3_key to
|
||||
// this same value (it was pre-set at start), but update file_size which
|
||||
// finalize doesn't touch.
|
||||
await pool.query(
|
||||
`UPDATE assets
|
||||
SET original_s3_key = $1,
|
||||
file_size = $2,
|
||||
updated_at = NOW()
|
||||
WHERE id = $3`,
|
||||
[s3Key, fileStat.size, asset.id]
|
||||
);
|
||||
|
||||
// If the asset is still 'live' (capture container's finalize hasn't fired or
|
||||
// failed), flip it to 'processing' and queue the proxy job ourselves so the
|
||||
// clip doesn't get stuck in the library as "Recording…".
|
||||
if (asset.status === 'live') {
|
||||
console.log(`[recorders/stop] finalize not yet called — queueing proxy and flipping asset ${asset.id} to processing`);
|
||||
await pool.query(
|
||||
`UPDATE assets SET status = 'processing', updated_at = NOW() WHERE id = $1`,
|
||||
[asset.id]
|
||||
);
|
||||
await proxyQueue.add('generate', {
|
||||
assetId: asset.id,
|
||||
inputKey: s3Key,
|
||||
outputKey: `proxies/${asset.id}.mp4`,
|
||||
});
|
||||
}
|
||||
// If status is already 'processing', the capture container's finalize already
|
||||
// ran and queued the proxy job. The S3 upload we just did ensures the worker
|
||||
// will find a valid object when it dequeues that job — nothing else to do.
|
||||
}
|
||||
|
||||
// GET /:id/status - Get live status
|
||||
router.get('/:id/status', async (req, res, next) => {
|
||||
try {
|
||||
|
|
@ -1266,34 +1026,18 @@ router.get('/:id/status', async (req, res, next) => {
|
|||
duration = Math.floor((Date.now() - new Date(container.State.StartedAt).getTime()) / 1000);
|
||||
|
||||
try {
|
||||
const _devIdx2 = recorder.device_index ?? (recorder.source_config?.device ?? 0);
|
||||
const _statusPort = SIDECAR_PORT_BASE + _devIdx2;
|
||||
const captureRes = await fetch(`http://localhost:${_statusPort}/capture/status`, { signal: AbortSignal.timeout(2000) });
|
||||
const captureRes = await fetch(`http://recorder-${id}:3001/capture/status`, { signal: AbortSignal.timeout(2000) });
|
||||
if (captureRes.ok) live = await captureRes.json();
|
||||
} catch (_) { /* not ready yet */ }
|
||||
}
|
||||
|
||||
// Recording state and signal come from the capture sidecar's session, NOT
|
||||
// from whether its standby CONTAINER happens to be running. A running
|
||||
// standby container is NOT "recording" and its signal is NOT "stopped" —
|
||||
// it's idle. Only when live.recording is true do we surface the real
|
||||
// session signal/duration; otherwise the row is idle with no elapsed.
|
||||
const isRecording = !!(live && live.recording);
|
||||
if (isRecording) {
|
||||
signal = live.signal || 'connecting';
|
||||
signalKnown = true;
|
||||
} else {
|
||||
signal = 'idle';
|
||||
signalKnown = false;
|
||||
}
|
||||
const sessionDuration = isRecording && live.duration != null ? live.duration : 0;
|
||||
if (isRunning) signal = 'receiving';
|
||||
if (!isRunning) signal = 'stopped';
|
||||
if (live && live.signal) { signal = live.signal; signalKnown = true; }
|
||||
|
||||
res.json({
|
||||
// recording = sidecar is actively capturing a session; standby container
|
||||
// up but idle reports its own status (not 'recording').
|
||||
status: isRecording ? 'recording' : (isRunning ? recorder.status : 'stopped'),
|
||||
recording: isRecording,
|
||||
duration: sessionDuration,
|
||||
status: isRunning ? 'recording' : 'stopped',
|
||||
duration,
|
||||
containerId: recorder.container_id,
|
||||
signal,
|
||||
signalKnown,
|
||||
|
|
@ -1385,23 +1129,17 @@ router.post('/probe', async (req, res) => {
|
|||
|
||||
// Validate URL up-front so we don't even let the capture service see junk.
|
||||
let parsed = null;
|
||||
let proto = '';
|
||||
if (url) {
|
||||
try { parsed = new URL(url); }
|
||||
catch { return res.status(400).json({ error: 'Invalid URL' }); }
|
||||
proto = (parsed.protocol || '').replace(':', '').toLowerCase();
|
||||
const proto = (parsed.protocol || '').replace(':', '').toLowerCase();
|
||||
if (!ALLOWED_PROBE_SCHEMES.has(proto)) {
|
||||
return res.status(400).json({ error: `Scheme "${proto}" is not permitted for probe (#104)` });
|
||||
}
|
||||
// Non-admin users can only probe public hostnames. Admins may probe LAN.
|
||||
if (!isAdmin(req) && isPrivateOrLoopback(parsed.hostname)) {
|
||||
return res.status(403).json({ error: 'Probe target must be a public host (#104)' });
|
||||
}
|
||||
|
||||
// Probe target should not be mam-api itself.
|
||||
if (parsed.hostname === 'mam-api' || parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1') {
|
||||
return res.status(403).json({ error: 'Internal probe target is not permitted' });
|
||||
}
|
||||
// Non-admin users can only probe public hostnames. Admins may probe LAN.
|
||||
if (!isAdmin(req) && isPrivateOrLoopback(parsed.hostname)) {
|
||||
return res.status(403).json({ error: 'Probe target must be a public host (#104)' });
|
||||
}
|
||||
}
|
||||
|
||||
// Try the capture service first (5s timeout)
|
||||
|
|
@ -1427,6 +1165,7 @@ router.post('/probe', async (req, res) => {
|
|||
}
|
||||
|
||||
const host = parsed.hostname;
|
||||
const proto = (parsed.protocol || '').replace(':', '').toLowerCase();
|
||||
const isUdp = proto === 'srt' || source_type === 'srt';
|
||||
const port = parseInt(parsed.port, 10) || (isUdp ? 9000 : 1935);
|
||||
|
||||
|
|
|
|||
|
|
@ -5,13 +5,12 @@
|
|||
import express from 'express';
|
||||
import fs from 'node:fs';
|
||||
import { promisify } from 'node:util';
|
||||
import { exec as execCb, execFile as execFileCb } from 'node:child_process';
|
||||
import { exec as execCb } from 'node:child_process';
|
||||
import { HeadBucketCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
|
||||
import pool from '../db/pool.js';
|
||||
import { s3Client, getS3Bucket } from '../s3/client.js';
|
||||
|
||||
const exec = promisify(execCb);
|
||||
const execFile = promisify(execFileCb);
|
||||
const router = express.Router();
|
||||
|
||||
// Defaults mirrored from settings.js so the overview never returns nulls.
|
||||
|
|
@ -58,7 +57,7 @@ async function probeGrowingPath(path) {
|
|||
|
||||
// df -P returns POSIX-portable output: "Filesystem 1024-blocks Used Available Capacity Mounted-on"
|
||||
try {
|
||||
const { stdout } = await exec(`df -PB1 -- ${JSON.stringify(path)}`, { timeout: 3000 });
|
||||
const { stdout } = await exec(`df -PB1 ${JSON.stringify(path)}`, { timeout: 3000 });
|
||||
const lines = stdout.trim().split('\n');
|
||||
if (lines.length >= 2) {
|
||||
const cols = lines[1].split(/\s+/);
|
||||
|
|
@ -79,21 +78,14 @@ async function probeS3Bucket() {
|
|||
if (!bucket) { out.error = 'no bucket configured'; return out; }
|
||||
|
||||
const started = Date.now();
|
||||
// Hard cap the whole probe so the admin "Mount health" card never hangs on
|
||||
// "Probing…" when S3 is slow/unreachable. Without this, the SDK's default
|
||||
// retry/backoff can block the request for tens of seconds.
|
||||
const withTimeout = (p, ms) => Promise.race([
|
||||
p,
|
||||
new Promise((_, rej) => setTimeout(() => rej(new Error('probe timed out after ' + ms + 'ms')), ms)),
|
||||
]);
|
||||
try {
|
||||
await withTimeout(s3Client.send(new HeadBucketCommand({ Bucket: bucket })), 5000);
|
||||
await s3Client.send(new HeadBucketCommand({ Bucket: bucket }));
|
||||
out.reachable = true;
|
||||
out.method = 'HeadBucket';
|
||||
} catch (headErr) {
|
||||
// Fall back to a 0-key list for stores that don't expose HeadBucket.
|
||||
try {
|
||||
await withTimeout(s3Client.send(new ListObjectsV2Command({ Bucket: bucket, MaxKeys: 0 })), 5000);
|
||||
await s3Client.send(new ListObjectsV2Command({ Bucket: bucket, MaxKeys: 0 }));
|
||||
out.reachable = true;
|
||||
out.method = 'ListObjectsV2';
|
||||
} catch (listErr) {
|
||||
|
|
@ -104,42 +96,6 @@ async function probeS3Bucket() {
|
|||
return out;
|
||||
}
|
||||
|
||||
// Query the growing-files SMB share's real capacity WITHOUT mounting it.
|
||||
// mam-api never mounts the CIFS share, so df on the container path reports the
|
||||
// local overlay (tens of GB), not the multi-TB NAS volume. `smbclient -c du`
|
||||
// returns "<N> blocks of size 1024. <M> blocks available" for the share, which
|
||||
// is the actual free/total the operator cares about.
|
||||
async function probeSmbShare({ mount, username, password, vers }) {
|
||||
const out = { reachable: false, free_bytes: null, total_bytes: null, error: null };
|
||||
if (!mount) { out.error = 'no smb mount configured'; return out; }
|
||||
// Normalize smb://host/share or \\host\share → //host/share for smbclient.
|
||||
let unc = String(mount).trim().replace(/\\/g, '/').replace(/^smb:\/\//i, '//');
|
||||
if (!unc.startsWith('//')) unc = '//' + unc.replace(/^\/+/, '');
|
||||
const user = `${username || ''}%${password || ''}`;
|
||||
const args = [
|
||||
unc, '-U', user,
|
||||
...(vers ? ['-m', `SMB${String(vers).replace(/\./g, '_').replace(/_0$/, '')}`] : []),
|
||||
'-c', 'du',
|
||||
];
|
||||
try {
|
||||
// execFile avoids shell-quoting the password. 6s cap so a dead NAS can't hang.
|
||||
const { stdout } = await execFile('smbclient', args, { timeout: 6000 });
|
||||
// " 1890828485120 blocks of size 1024. 1890776477696 blocks available"
|
||||
const m = stdout.match(/(\d+)\s+blocks of size\s+(\d+)\.\s+(\d+)\s+blocks available/i);
|
||||
if (m) {
|
||||
const blockSize = parseInt(m[2], 10) || 1024;
|
||||
out.total_bytes = parseInt(m[1], 10) * blockSize;
|
||||
out.free_bytes = parseInt(m[3], 10) * blockSize;
|
||||
out.reachable = true;
|
||||
} else {
|
||||
out.error = 'could not parse smbclient du output';
|
||||
}
|
||||
} catch (err) {
|
||||
out.error = (err.stderr ? String(err.stderr).trim() : err.message).slice(0, 200);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// GET /api/v1/storage/overview
|
||||
// Consolidated read-only view of the storage subsystem for the admin UI.
|
||||
router.get('/overview', async (req, res, next) => {
|
||||
|
|
@ -152,29 +108,6 @@ router.get('/overview', async (req, res, next) => {
|
|||
const containerPath = growingRaw.growing_path || '/growing';
|
||||
const mount = await probeGrowingPath(containerPath);
|
||||
|
||||
// Real capacity comes from the SMB share itself, NOT the local container
|
||||
// path (mam-api never mounts the share, so df reports the tiny overlay).
|
||||
// Query the NAS quota directly via smbclient when a mount is configured.
|
||||
let smbFree = null, smbTotal = null, smbReachable = false, capacityError = mount.error;
|
||||
if (growingEnabled) {
|
||||
const creds = await readSettings(['growing_smb_username', 'growing_smb_password', 'growing_smb_vers']);
|
||||
const smb = await probeSmbShare({
|
||||
mount: growingRaw.growing_smb_mount,
|
||||
username: creds.growing_smb_username,
|
||||
password: creds.growing_smb_password,
|
||||
vers: creds.growing_smb_vers,
|
||||
});
|
||||
if (smb.reachable) {
|
||||
smbFree = smb.free_bytes; smbTotal = smb.total_bytes; smbReachable = true; capacityError = null;
|
||||
} else {
|
||||
// Fall back to the local-path df numbers, but surface why the share
|
||||
// probe failed so the card can show it.
|
||||
smbFree = mount.free_bytes; smbTotal = mount.total_bytes; capacityError = smb.error || mount.error;
|
||||
}
|
||||
} else {
|
||||
smbFree = mount.free_bytes; smbTotal = mount.total_bytes;
|
||||
}
|
||||
|
||||
// S3 — bucket name comes from the live client (env or DB-loaded), not
|
||||
// a fresh DB read, so we report exactly what the running client uses.
|
||||
const s3 = await probeS3Bucket();
|
||||
|
|
@ -192,12 +125,9 @@ router.get('/overview', async (req, res, next) => {
|
|||
promote_after_seconds: parseInt(growingRaw.growing_promote_after_seconds, 10) || 8,
|
||||
exists: mount.exists,
|
||||
writable: mount.writable,
|
||||
// free/total now reflect the actual SMB share (smbclient du) when a
|
||||
// mount is configured; smb_reachable says whether that probe succeeded.
|
||||
free_bytes: smbFree,
|
||||
total_bytes: smbTotal,
|
||||
smb_reachable: smbReachable,
|
||||
error: capacityError,
|
||||
free_bytes: mount.free_bytes,
|
||||
total_bytes: mount.total_bytes,
|
||||
error: mount.error,
|
||||
},
|
||||
s3: {
|
||||
endpoint: s3SettingsRaw.s3_endpoint || process.env.S3_ENDPOINT || '',
|
||||
|
|
|
|||
|
|
@ -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(/\/$/, '');
|
||||
|
|
@ -26,53 +25,6 @@ const LIVE_DIR = process.env.LIVE_DIR || '/mnt/NVME/MAM/wild-dragon-live';
|
|||
const REPO_DIR = process.env.REPO_DIR || '/opt/wild-dragon';
|
||||
const VERSION = '1.4.0';
|
||||
|
||||
// Number of GPUs to spread capture encodes across. Each capture sidecar runs
|
||||
// ~2 NVENC sessions (master HEVC + HLS preview); with NVIDIA_VISIBLE_DEVICES=all
|
||||
// and no -gpu selector, ffmpeg's nvenc puts EVERY session on physical GPU 0, so
|
||||
// 8 ports = 16 sessions hammering one card → it falls below realtime → the
|
||||
// framecache ring laps → video freezes/stutters then recovers. Pinning each
|
||||
// sidecar to GPU (port % CAPTURE_GPU_COUNT) spreads the load across all cards.
|
||||
// GPU count for spreading capture encodes. The node-agent image has no
|
||||
// nvidia-smi, and the startup probeGpusViaSmi cache can be empty, so count the
|
||||
// /dev/nvidiaN device nodes directly (same approach the heartbeat uses — these
|
||||
// are visible because the node-agent runs privileged with /dev bound).
|
||||
// CAPTURE_GPU_COUNT / GPU_COUNT env override everything.
|
||||
function detectGpuCount() {
|
||||
const envN = parseInt(process.env.CAPTURE_GPU_COUNT || process.env.GPU_COUNT || '0', 10) || 0;
|
||||
if (envN > 0) return envN;
|
||||
if (Array.isArray(_gpuCache) && _gpuCache.length > 0) return _gpuCache.length;
|
||||
let n = 0;
|
||||
for (let i = 0; i < 16; i++) {
|
||||
try { fs.accessSync(`/dev/nvidia${i}`, fs.constants.F_OK); n++; }
|
||||
catch (_) { break; }
|
||||
}
|
||||
return n > 0 ? n : 1; // no GPU nodes → single-device fallback ('all')
|
||||
}
|
||||
|
||||
// Choose the NVIDIA_VISIBLE_DEVICES value for a capture sidecar. An explicit
|
||||
// per-recorder gpuUuid always wins; otherwise round-robin by capture port so
|
||||
// consecutive Deltacast ports land on different physical GPUs. With a single
|
||||
// device visible, the container's nvenc device 0 == the chosen physical GPU.
|
||||
const SIDECAR_BASE_PORT = 7438;
|
||||
function pickVisibleDevices(gpuUuid, capturePort) {
|
||||
if (gpuUuid != null && String(gpuUuid).trim() !== '') return String(gpuUuid).trim();
|
||||
const count = detectGpuCount();
|
||||
if (count <= 1) return 'all';
|
||||
const idx = Number.isFinite(capturePort) ? ((capturePort - SIDECAR_BASE_PORT) % count + count) % count : 0;
|
||||
return String(idx);
|
||||
}
|
||||
|
||||
// Build the Docker DeviceRequests entry matching a NVIDIA_VISIBLE_DEVICES value.
|
||||
// 'all' → grant every GPU (Count -1). A specific index/UUID → grant ONLY that
|
||||
// device via DeviceIDs, so the container can't see (and nvenc can't fall back
|
||||
// to) any other card. A blanket Count:-1 would silently override the env var.
|
||||
function gpuDeviceRequest(visibleDevices) {
|
||||
if (!visibleDevices || visibleDevices === 'all') {
|
||||
return { Driver: 'nvidia', Count: -1, Capabilities: [['gpu']] };
|
||||
}
|
||||
return { Driver: 'nvidia', DeviceIDs: [String(visibleDevices)], Capabilities: [['gpu']] };
|
||||
}
|
||||
|
||||
// Capture-driver vendor allowlist. NOTHING outside this set is ever passed to
|
||||
// the host installer — the value is only ever used to pick a script arg, never
|
||||
// interpolated into a shell string.
|
||||
|
|
@ -119,201 +71,13 @@ const DC_BRIDGE_BIN = process.env.DELTACAST_BRIDGE_BIN || 'deltacast-bridge';
|
|||
const DC_PORTS_CSV = process.env.DELTACAST_PORTS || '0,1,2,3,4,5,6,7';
|
||||
const DC_BOARD = process.env.DELTACAST_BOARD || '0';
|
||||
|
||||
// Framecache URL — passed to all bridge processes so they can register slots.
|
||||
// Set FC_URL in .env.worker (default: http://framecache:7435 within the
|
||||
// wild-dragon-worker Docker network).
|
||||
const FC_URL = process.env.FC_URL || 'http://framecache:7435';
|
||||
// Node identity for framecache slot IDs (e.g. "decklink-zampp3-0").
|
||||
// Set NODE_NAME in .env.worker so slot IDs are stable across restarts.
|
||||
const FC_NODE_ID = process.env.NODE_NAME || process.env.HOSTNAME || 'local';
|
||||
|
||||
let _dcBridge = null; // ChildProcess | null
|
||||
let _dcSidecarCount = 0; // active deltacast sidecars on this node
|
||||
// Map containerId -> sourceType so stop() can decrement the deltacast counter.
|
||||
const _containerSourceType = new Map();
|
||||
// port -> fmt JSON from bridge stderr (inject into sidecar env + slot_id)
|
||||
// port -> fmt JSON from bridge stderr (inject into sidecar env)
|
||||
const _dcPortFmt = new Map();
|
||||
|
||||
// ── Network ingest ────────────────────────────────────────────────────────
|
||||
// One net_ingest process per active network recorder (SRT/RTMP).
|
||||
// Decodes the stream to raw UYVY422 and writes into a framecache slot so
|
||||
// capture-manager can use fc_pipe — the same consumer path as SDI sources.
|
||||
const NET_INGEST_BIN = process.env.NET_INGEST_BIN || 'net_ingest';
|
||||
// containerId → ChildProcess for cleanup on sidecar stop
|
||||
const _netIngestProcs = new Map();
|
||||
|
||||
function startNetIngest(containerId, { sourceType, sourceUrl, listen, listenPort, streamKey,
|
||||
width = 1920, height = 1080,
|
||||
fpsNum = 30000, fpsDen = 1001 }) {
|
||||
const slotId = `net-${containerId}`;
|
||||
const args = [
|
||||
'--slot-id', slotId,
|
||||
'--fc-url', FC_URL,
|
||||
'--source-type', sourceType,
|
||||
'--width', String(width),
|
||||
'--height', String(height),
|
||||
'--fps-num', String(fpsNum),
|
||||
'--fps-den', String(fpsDen),
|
||||
];
|
||||
if (listen) {
|
||||
args.push('--listen');
|
||||
if (listenPort) args.push('--listen-port', String(listenPort));
|
||||
if (streamKey) args.push('--stream-key', streamKey);
|
||||
} else if (sourceUrl) {
|
||||
args.push('--url', sourceUrl);
|
||||
}
|
||||
|
||||
console.log(`[net-ingest:${slotId}] launching: ${NET_INGEST_BIN} ${args.join(' ')}`);
|
||||
const proc = spawn(NET_INGEST_BIN, args, {
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
env: { ...process.env, FC_URL },
|
||||
});
|
||||
proc.stderr.setEncoding('utf8');
|
||||
proc.stderr.on('data', chunk => {
|
||||
for (const line of chunk.split('\n')) {
|
||||
const t = line.trim();
|
||||
if (t) console.log(`[net-ingest:${slotId}] ${t}`);
|
||||
}
|
||||
});
|
||||
proc.on('error', err => console.error(`[net-ingest:${slotId}] spawn error: ${err.message}`));
|
||||
proc.on('exit', (c, s) => {
|
||||
console.log(`[net-ingest:${slotId}] exited code=${c} signal=${s}`);
|
||||
// The map key may have been remapped from the temp id to the real
|
||||
// containerId after spawn. Delete by PROCESS IDENTITY, not the captured
|
||||
// key, so the entry can't leak after an unexpected crash.
|
||||
for (const [key, entry] of _netIngestProcs) {
|
||||
if (entry.proc === proc) { _netIngestProcs.delete(key); break; }
|
||||
}
|
||||
});
|
||||
_netIngestProcs.set(containerId, { proc, slotId });
|
||||
return slotId;
|
||||
}
|
||||
|
||||
function stopNetIngest(containerId) {
|
||||
const entry = _netIngestProcs.get(containerId);
|
||||
if (!entry) return;
|
||||
console.log(`[net-ingest:${entry.slotId}] stopping`);
|
||||
try { entry.proc.kill('SIGTERM'); } catch (_) {}
|
||||
_netIngestProcs.delete(containerId);
|
||||
}
|
||||
|
||||
// ── DeckLink bridge ───────────────────────────────────────────────────────
|
||||
// One decklink-bridge container per node, managing all DeckLink devices.
|
||||
// Mirrors the deltacast-bridge singleton pattern.
|
||||
const DL_AUDIO_DIR = process.env.DECKLINK_AUDIO_DIR || '/dev/shm/decklink';
|
||||
|
||||
let _dlBridgeId = null; // containerId | null
|
||||
let _dlSidecarCount = 0;
|
||||
// device_idx -> fmt JSON from bridge stderr
|
||||
const _dlDevFmt = new Map();
|
||||
|
||||
async function _dlBridgeRunning() {
|
||||
if (!_dlBridgeId) return false;
|
||||
try {
|
||||
const res = await dockerApi('GET', `/containers/${_dlBridgeId}/json`);
|
||||
return res.status === 200 && res.data.State?.Running;
|
||||
} catch (_) { return false; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to container stderr stream and parse format JSONs.
|
||||
*/
|
||||
function _attachDlBridgeLogs(containerId) {
|
||||
const options = {
|
||||
socketPath: '/var/run/docker.sock',
|
||||
path: `/v1.43/containers/${containerId}/attach?stderr=1&stream=1`,
|
||||
method: 'POST',
|
||||
};
|
||||
const req = http.request(options, (res) => {
|
||||
res.on('data', (chunk) => {
|
||||
// Docker multiplexed stream header: [1/2, 0, 0, 0, size_32be]
|
||||
let offset = 0;
|
||||
while (offset + 8 <= chunk.length) {
|
||||
const size = chunk.readUInt32BE(offset + 4);
|
||||
const end = offset + 8 + size;
|
||||
if (end > chunk.length) break;
|
||||
const text = chunk.toString('utf8', offset + 8, end);
|
||||
for (const line of text.split('\n')) {
|
||||
const t = line.trim();
|
||||
if (!t || !t.startsWith('{')) continue;
|
||||
try {
|
||||
const f = JSON.parse(t);
|
||||
if (typeof f.device === 'number') _dlDevFmt.set(f.device, f);
|
||||
} catch (_) {}
|
||||
}
|
||||
offset = end;
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', (err) => console.error(`[dl-bridge] log attach error: ${err.message}`));
|
||||
req.end();
|
||||
}
|
||||
|
||||
async function startDecklinkBridge(deviceIndices) {
|
||||
if (await _dlBridgeRunning()) return;
|
||||
|
||||
const devCsv = Array.isArray(deviceIndices) ? deviceIndices.join(',') : String(deviceIndices || '0');
|
||||
const DL_IMAGE = 'wild-dragon-capture:latest';
|
||||
const DL_BIN = '/usr/local/bin/decklink-bridge';
|
||||
|
||||
// Pass correct IP to containerized bridge. Default falls back to framecache:7435.
|
||||
const _fcUrl = process.env.FRAMECACHE_IP ? `http://${process.env.FRAMECACHE_IP}:7435` : FC_URL;
|
||||
|
||||
const bridgeArgs = [
|
||||
'--devices', devCsv,
|
||||
'--fc-url', _fcUrl,
|
||||
'--audio-pipe-dir', DL_AUDIO_DIR,
|
||||
];
|
||||
|
||||
console.log(`[dl-bridge] spawning containerized bridge for devices: ${devCsv}`);
|
||||
|
||||
const spec = {
|
||||
Image: DL_IMAGE,
|
||||
Entrypoint: [DL_BIN],
|
||||
Cmd: bridgeArgs,
|
||||
Env: [`NODE_ID=${FC_NODE_ID}`, `FC_URL=${_fcUrl}`],
|
||||
HostConfig: {
|
||||
NetworkMode: 'host',
|
||||
Privileged: true,
|
||||
Binds: ['/dev:/dev', '/dev/shm:/dev/shm'],
|
||||
RestartPolicy: { Name: 'unless-stopped' },
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const createRes = await dockerApi('POST', '/containers/create?name=decklink-bridge', spec);
|
||||
if (createRes.status !== 201 && createRes.status !== 409) {
|
||||
console.error('[dl-bridge] create failed:', createRes.data);
|
||||
return;
|
||||
}
|
||||
|
||||
const containerId = createRes.status === 409 ? 'decklink-bridge' : createRes.data.Id;
|
||||
const startRes = await dockerApi('POST', `/containers/${containerId}/start`);
|
||||
if (startRes.status !== 204 && startRes.status !== 304) {
|
||||
console.error('[dl-bridge] start failed:', startRes.data);
|
||||
return;
|
||||
}
|
||||
|
||||
_dlBridgeId = containerId;
|
||||
_attachDlBridgeLogs(containerId);
|
||||
console.log(`[dl-bridge] running in container ${containerId}`);
|
||||
} catch (err) {
|
||||
console.error(`[dl-bridge] spawn error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopDecklinkBridge() {
|
||||
if (!_dlBridgeId) return;
|
||||
console.log('[dl-bridge] stopping container');
|
||||
try {
|
||||
await dockerApi('POST', `/containers/${_dlBridgeId}/stop?t=5`);
|
||||
await dockerApi('DELETE', `/containers/${_dlBridgeId}?force=true`);
|
||||
} catch (err) {
|
||||
console.error(`[dl-bridge] stop error: ${err.message}`);
|
||||
}
|
||||
_dlBridgeId = null;
|
||||
}
|
||||
|
||||
function _dcBridgeRunning() {
|
||||
return _dcBridge !== null && _dcBridge.exitCode === null && _dcBridge.signalCode === null;
|
||||
}
|
||||
|
|
@ -358,14 +122,12 @@ function startDeltacastBridge() {
|
|||
'--ports', DC_PORTS_CSV,
|
||||
'--video-pipe-dir', DC_PIPE_DIR,
|
||||
'--audio-pipe-dir', DC_PIPE_DIR,
|
||||
'--fc-url', FC_URL,
|
||||
];
|
||||
console.log(`[dc-bridge] launching: ${DC_BRIDGE_BIN} ${args.join(' ')}`);
|
||||
|
||||
const proc = spawn(DC_BRIDGE_BIN, args, {
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
detached: false,
|
||||
env: { ...process.env, FC_URL, NODE_ID: FC_NODE_ID },
|
||||
});
|
||||
|
||||
proc.stderr.setEncoding('utf8');
|
||||
|
|
@ -499,14 +261,7 @@ async function handleSidecarStart(body, res) {
|
|||
gpuUuid = null,
|
||||
} = body;
|
||||
|
||||
// Reclaim the capture port before spawning, so an on-demand start can never
|
||||
// collide (EADDRINUSE) with a stale/standby container already on that port.
|
||||
await freeCapturePort(capturePort);
|
||||
|
||||
const binds = [`${LIVE_DIR}:/live`];
|
||||
// Always mount /dev/shm so the sidecar can access framecache slots.
|
||||
if (fs.existsSync('/dev/shm')) binds.push('/dev/shm:/dev/shm');
|
||||
|
||||
if (sourceType === 'sdi') binds.unshift('/dev/blackmagic:/dev/blackmagic');
|
||||
if (sourceType === 'deltacast') {
|
||||
// Bind each /dev/deltacast* node that exists on the host into the container.
|
||||
|
|
@ -514,6 +269,8 @@ async function handleSidecarStart(body, res) {
|
|||
try {
|
||||
const dcEntries = fs.readdirSync('/dev').filter(n => /^deltacast\d+$/.test(n));
|
||||
for (const d of dcEntries) binds.push(`/dev/${d}:/dev/${d}`);
|
||||
// VideoMaster SDK needs the board IPC shared-memory segment mounted too.
|
||||
if (fs.existsSync('/dev/shm/deltacast')) binds.push('/dev/shm/deltacast:/dev/shm/deltacast');
|
||||
} catch (_) { /* /dev always exists */ }
|
||||
}
|
||||
|
||||
|
|
@ -522,16 +279,13 @@ async function handleSidecarStart(body, res) {
|
|||
if (useGpu) {
|
||||
// Issue #167 — per-recorder GPU affinity. A gpuUuid (UUID string or
|
||||
// numeric index) pins the sidecar to exactly that device; otherwise
|
||||
// Round-robin the encode across all GPUs by capture port (or honor an
|
||||
// explicit per-recorder gpuUuid). Prevents all sidecars piling onto GPU 0.
|
||||
var startVisibleDevices = pickVisibleDevices(gpuUuid, capturePort);
|
||||
sidecarEnv.push(`NVIDIA_VISIBLE_DEVICES=${startVisibleDevices}`);
|
||||
// NVIDIA_VISIBLE_DEVICES=all exposes every GPU on the host (legacy
|
||||
// behavior — for a single-GPU node like zampp2 / L4 this equals GPU 0).
|
||||
const visibleDevices = (gpuUuid != null && String(gpuUuid).trim() !== '')
|
||||
? String(gpuUuid).trim()
|
||||
: 'all';
|
||||
sidecarEnv.push(`NVIDIA_VISIBLE_DEVICES=${visibleDevices}`);
|
||||
sidecarEnv.push('NVIDIA_DRIVER_CAPABILITIES=video,compute,utility');
|
||||
// Privileged sidecars see every /dev/nvidiaN regardless of
|
||||
// NVIDIA_VISIBLE_DEVICES, so also tell ffmpeg explicitly which GPU to
|
||||
// encode on via CAPTURE_GPU_INDEX (capture-manager adds `-gpu N`).
|
||||
if (startVisibleDevices !== 'all') sidecarEnv.push(`CAPTURE_GPU_INDEX=${startVisibleDevices}`);
|
||||
console.log(`[gpu] sidecar port ${capturePort} → NVIDIA_VISIBLE_DEVICES=${startVisibleDevices}`);
|
||||
}
|
||||
|
||||
const hostConfig = {
|
||||
|
|
@ -540,12 +294,12 @@ async function handleSidecarStart(body, res) {
|
|||
Binds: binds,
|
||||
};
|
||||
if (useGpu) {
|
||||
// Tell Docker to use the NVIDIA container runtime for this container.
|
||||
// Equivalent to `docker run --gpus all` / `--runtime=nvidia`.
|
||||
hostConfig.Runtime = 'nvidia';
|
||||
// CRITICAL: scope DeviceRequests to the SAME single GPU as
|
||||
// NVIDIA_VISIBLE_DEVICES. A blanket Count:-1 (all GPUs) OVERRIDES the env
|
||||
// var, so every sidecar got all 3 cards and nvenc piled onto GPU 0. Pass
|
||||
// the specific DeviceIDs so the container truly sees only its one GPU.
|
||||
hostConfig.DeviceRequests = [gpuDeviceRequest(startVisibleDevices)];
|
||||
hostConfig.DeviceRequests = [
|
||||
{ Driver: 'nvidia', Count: -1, Capabilities: [['gpu']] },
|
||||
];
|
||||
}
|
||||
|
||||
const spec = {
|
||||
|
|
@ -554,147 +308,36 @@ async function handleSidecarStart(body, res) {
|
|||
HostConfig: hostConfig,
|
||||
};
|
||||
|
||||
// Always inject FC_URL so capture-manager can find the framecache service.
|
||||
sidecarEnv.push(`FC_URL=${FC_URL}`);
|
||||
|
||||
// Network sources (SRT/RTMP): launch net_ingest to decode stream into
|
||||
// a framecache slot, then inject FC_SLOT_ID so capture-manager reads
|
||||
// from the slot via fc_pipe (same path as SDI sources).
|
||||
if (sourceType === 'srt' || sourceType === 'rtmp') {
|
||||
const _srcCfg = (env.find(e => e.startsWith('SOURCE_CONFIG=')) || '').slice(14);
|
||||
let _netCfg = {};
|
||||
try { _netCfg = JSON.parse(_srcCfg); } catch (_) {}
|
||||
const _listen = !!(body.listen || _netCfg.listen);
|
||||
const _listenPort = body.listenPort || _netCfg.listenPort || 0;
|
||||
const _streamKey = body.streamKey || _netCfg.streamKey || 'stream';
|
||||
const _srcUrl = body.sourceUrl || _netCfg.url || '';
|
||||
// Width/height/fps from recorder config if available; defaults used otherwise.
|
||||
// net_ingest will auto-scale via ffmpeg -vf scale=iw:ih.
|
||||
const _w = _netCfg.width || 1920;
|
||||
const _h = _netCfg.height || 1080;
|
||||
const _fpsNum = _netCfg.fps_num || 30000;
|
||||
const _fpsDen = _netCfg.fps_den || 1001;
|
||||
|
||||
// containerId not known yet — we start net_ingest just before container
|
||||
// start and use a temporary slot ID based on a timestamp.
|
||||
const _tempId = `${sourceType}-${Date.now()}`;
|
||||
const _slotId = startNetIngest(_tempId, {
|
||||
sourceType: sourceType,
|
||||
sourceUrl: _srcUrl,
|
||||
listen: _listen,
|
||||
listenPort: _listenPort,
|
||||
streamKey: _streamKey,
|
||||
width: _w,
|
||||
height: _h,
|
||||
fpsNum: _fpsNum,
|
||||
fpsDen: _fpsDen,
|
||||
});
|
||||
sidecarEnv.push(`FC_SLOT_ID=${_slotId}`);
|
||||
hostConfig.IpcMode = 'host';
|
||||
// Store temp id so we can remap to real containerId on create success
|
||||
body._netIngestTempId = _tempId;
|
||||
}
|
||||
|
||||
// Deltacast: ensure the shared bridge daemon is running on the HOST before
|
||||
// starting the sidecar. The bridge writes frames to the framecache shm ring;
|
||||
// the sidecar reads via the consumer library (fc_client).
|
||||
// starting the sidecar. The sidecar reads FIFOs produced by the bridge;
|
||||
// it does NOT open the board handle itself (no BufMngr.c:781 race).
|
||||
if (sourceType === 'deltacast') {
|
||||
_dcSidecarCount++;
|
||||
startDeltacastBridge();
|
||||
// Inject per-port signal format so capture-manager uses real dimensions/fps
|
||||
const _srcCfg = (env.find(e => e.startsWith('SOURCE_CONFIG=')) || '').slice(14);
|
||||
let _portNum = NaN;
|
||||
try { _portNum = JSON.parse(_srcCfg).port; } catch (_) {}
|
||||
if (!Number.isFinite(_portNum)) _portNum = 0;
|
||||
|
||||
// FC_SLOT_ID is DETERMINISTIC — the deltacast-bridge builds it as
|
||||
// "deltacast-<board>-<port>" (both known here), so we construct it
|
||||
// directly and DO NOT wait for the bridge's async format JSON. This is
|
||||
// the fix for the cold-start race where _dcPortFmt was still empty on
|
||||
// first recorder start. FC_SLOT_ID is now MANDATORY — the legacy
|
||||
// FIFO-video fallback in capture-manager was removed, so a missing slot
|
||||
// id would hard-fail rather than silently degrade.
|
||||
const _slotId = `deltacast-${DC_BOARD}-${_portNum}`;
|
||||
sidecarEnv.push(`FC_SLOT_ID=${_slotId}`);
|
||||
|
||||
// Format (width/height/fps) is best-effort enrichment from the bridge's
|
||||
// stderr JSON if it has already arrived; capture-manager has sane
|
||||
// defaults and waits for the slot to appear regardless.
|
||||
if (_dcPortFmt.has(_portNum)) {
|
||||
if (Number.isFinite(_portNum) && _dcPortFmt.has(_portNum)) {
|
||||
const _fmt = _dcPortFmt.get(_portNum);
|
||||
const _fps = (_fmt.fps_den && _fmt.fps_den !== 1) ? `${_fmt.fps_num}/${_fmt.fps_den}` : String(_fmt.fps_num);
|
||||
sidecarEnv.push(`DELTACAST_VIDEO_SIZE=${_fmt.width}x${_fmt.height}`);
|
||||
sidecarEnv.push(`DELTACAST_FRAMERATE=${_fps}`);
|
||||
sidecarEnv.push(`DELTACAST_INTERLACED=${_fmt.interlaced ? '1' : '0'}`);
|
||||
console.log(`[dc-bridge] port ${_portNum} fmt: ${_fmt.width}x${_fmt.height} ${_fps} slot=${_slotId}`);
|
||||
} else {
|
||||
console.log(`[dc-bridge] port ${_portNum} slot=${_slotId} (fmt not yet available — using defaults)`);
|
||||
console.log(`[dc-bridge] port ${_portNum} fmt: ${_fmt.width}x${_fmt.height} ${_fps} interlaced=${_fmt.interlaced}`);
|
||||
}
|
||||
hostConfig.IpcMode = 'host';
|
||||
}
|
||||
|
||||
// DeckLink: ensure decklink-bridge is running on the HOST.
|
||||
if (sourceType === 'sdi' || sourceType === 'blackmagic') {
|
||||
_dlSidecarCount++;
|
||||
const _bmdDevices = [];
|
||||
try {
|
||||
const _bmdDir = '/dev/blackmagic';
|
||||
const _bmdEntries = fs.readdirSync(_bmdDir).filter(n => /^(dv|io)\d+$/.test(n));
|
||||
_bmdEntries.forEach((_, i) => _bmdDevices.push(i));
|
||||
} catch (_) { _bmdDevices.push(0); }
|
||||
await startDecklinkBridge(_bmdDevices);
|
||||
|
||||
const _srcCfg = (env.find(e => e.startsWith('SOURCE_CONFIG=')) || '').slice(14);
|
||||
let _devIdx = NaN;
|
||||
try { _devIdx = JSON.parse(_srcCfg).device ?? JSON.parse(_srcCfg).index; } catch (_) {}
|
||||
if (!Number.isFinite(_devIdx)) _devIdx = parseInt((env.find(e => e.startsWith('BMD_DEVICE_INDEX=')) || '=0').split('=')[1]) || 0;
|
||||
|
||||
// FC_SLOT_ID is DETERMINISTIC — decklink-bridge builds it as
|
||||
// "decklink-<NODE_ID>-<device_idx>". Construct it directly (no wait on
|
||||
// async fmt JSON). FC_NODE_ID matches what node-agent passes to the
|
||||
// bridge via the NODE_ID env var.
|
||||
const _slotId = `decklink-${FC_NODE_ID}-${_devIdx}`;
|
||||
sidecarEnv.push(`FC_SLOT_ID=${_slotId}`);
|
||||
|
||||
if (_dlDevFmt.has(_devIdx)) {
|
||||
const _fmt = _dlDevFmt.get(_devIdx);
|
||||
const _fps = (_fmt.fps_den && _fmt.fps_den !== 1) ? `${_fmt.fps_num}/${_fmt.fps_den}` : String(_fmt.fps_num);
|
||||
sidecarEnv.push(`DELTACAST_VIDEO_SIZE=${_fmt.width}x${_fmt.height}`);
|
||||
sidecarEnv.push(`DELTACAST_FRAMERATE=${_fps}`);
|
||||
sidecarEnv.push(`DELTACAST_INTERLACED=${_fmt.interlaced ? '1' : '0'}`);
|
||||
console.log(`[dl-bridge] device ${_devIdx} fmt: ${_fmt.width}x${_fmt.height} ${_fps} slot=${_slotId}`);
|
||||
} else {
|
||||
console.log(`[dl-bridge] device ${_devIdx} slot=${_slotId} (fmt not yet available — using defaults)`);
|
||||
}
|
||||
hostConfig.IpcMode = 'host';
|
||||
}
|
||||
|
||||
// Single cleanup for ALL failure paths (create fail, start fail, throw):
|
||||
// decrements the right bridge counter (stopping the bridge when it hits 0)
|
||||
// AND stops any net_ingest started for this request. Previously only the
|
||||
// deltacast counter was decremented — blackmagic count and net_ingest leaked
|
||||
// on every failed start, eventually stranding the bridge / ingest forever.
|
||||
const _cleanupOnFailure = async () => {
|
||||
if (sourceType === 'deltacast') {
|
||||
_dcSidecarCount--;
|
||||
if (_dcSidecarCount <= 0) { _dcSidecarCount = 0; stopDeltacastBridge(); }
|
||||
} else if (sourceType === 'sdi' || sourceType === 'blackmagic') {
|
||||
_dlSidecarCount--;
|
||||
if (_dlSidecarCount <= 0) { _dlSidecarCount = 0; await stopDecklinkBridge(); }
|
||||
} else if (sourceType === 'srt' || sourceType === 'rtmp') {
|
||||
// net_ingest may be keyed by the temp id (create not yet succeeded) or
|
||||
// the real containerId (remapped). Stop whichever exists.
|
||||
if (body._netIngestTempId) stopNetIngest(body._netIngestTempId);
|
||||
if (containerId) stopNetIngest(containerId);
|
||||
}
|
||||
};
|
||||
|
||||
let containerId;
|
||||
try {
|
||||
const createRes = await dockerApi('POST', '/containers/create', spec);
|
||||
if (createRes.status !== 201) {
|
||||
await _cleanupOnFailure();
|
||||
return jsonResponse(res, 502, { error: 'Failed to create container', details: createRes.data });
|
||||
try {
|
||||
const createRes = await dockerApi('POST', '/containers/create', spec);
|
||||
if (createRes.status !== 201) {
|
||||
if (sourceType === 'deltacast') {
|
||||
_dcSidecarCount--;
|
||||
if (_dcSidecarCount <= 0) { _dcSidecarCount = 0; stopDeltacastBridge(); }
|
||||
}
|
||||
return jsonResponse(res, 502, { error: 'Failed to create container', details: createRes.data });
|
||||
}
|
||||
|
||||
containerId = createRes.data.Id;
|
||||
const _u = (env.find(e => e.startsWith('MAM_API_URL=')) || '').slice(12);
|
||||
|
|
@ -702,25 +345,21 @@ async function handleSidecarStart(body, res) {
|
|||
console.log(`[sidecar-start] ${containerId} image=${image} src=${sourceType} MAM_API_URL=${_u} token=${_tok}`);
|
||||
const startRes = await dockerApi('POST', `/containers/${containerId}/start`);
|
||||
if (startRes.status !== 204) {
|
||||
if (sourceType === 'deltacast') {
|
||||
_dcSidecarCount--;
|
||||
if (_dcSidecarCount <= 0) { _dcSidecarCount = 0; stopDeltacastBridge(); }
|
||||
}
|
||||
await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {});
|
||||
await _cleanupOnFailure();
|
||||
return jsonResponse(res, 502, { error: 'Failed to start container', details: startRes.data });
|
||||
}
|
||||
|
||||
if (sourceType === 'deltacast') _containerSourceType.set(containerId, 'deltacast');
|
||||
if (sourceType === 'sdi' || sourceType === 'blackmagic') _containerSourceType.set(containerId, 'blackmagic');
|
||||
if (sourceType === 'srt' || sourceType === 'rtmp') {
|
||||
_containerSourceType.set(containerId, sourceType);
|
||||
// Remap net_ingest from temp id to real containerId
|
||||
if (body._netIngestTempId && _netIngestProcs.has(body._netIngestTempId)) {
|
||||
const entry = _netIngestProcs.get(body._netIngestTempId);
|
||||
_netIngestProcs.delete(body._netIngestTempId);
|
||||
_netIngestProcs.set(containerId, entry);
|
||||
}
|
||||
}
|
||||
jsonResponse(res, 201, { containerId, capturePort });
|
||||
} catch (err) {
|
||||
await _cleanupOnFailure();
|
||||
if (sourceType === 'deltacast') {
|
||||
_dcSidecarCount--;
|
||||
if (_dcSidecarCount <= 0) { _dcSidecarCount = 0; stopDeltacastBridge(); }
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -728,217 +367,23 @@ async function handleSidecarStart(body, res) {
|
|||
}
|
||||
}
|
||||
|
||||
// Strip Docker's stdcopy multiplexing framing (8-byte header per frame for
|
||||
// non-TTY containers: [streamType,0,0,0, uint32be length]) and return clean
|
||||
// UTF-8. The old version just deleted control bytes, which left stray header
|
||||
// remnants (e.g. the length byte) at line starts.
|
||||
function _demuxDocker(buf) {
|
||||
if (!buf || buf.length === 0) return '';
|
||||
const framed = buf.length >= 8 && buf[0] <= 2 && buf[1] === 0 && buf[2] === 0 && buf[3] === 0;
|
||||
if (!framed) return buf.toString('utf8');
|
||||
const out = [];
|
||||
let off = 0;
|
||||
while (off + 8 <= buf.length) {
|
||||
const len = buf.readUInt32BE(off + 4);
|
||||
off += 8;
|
||||
if (len <= 0) continue;
|
||||
out.push(buf.toString('utf8', off, Math.min(off + len, buf.length)));
|
||||
off += len;
|
||||
}
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
async function fetchContainerLogs(containerId, tail = 200) {
|
||||
async function fetchContainerLogs(containerId) {
|
||||
return await new Promise((resolve) => {
|
||||
const options = {
|
||||
socketPath: '/var/run/docker.sock',
|
||||
path: `/v1.43/containers/${containerId}/logs?stdout=1&stderr=1&tail=${tail}×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'];
|
||||
var standbyVisibleDevices = 'all';
|
||||
if (useGpu) {
|
||||
// Same round-robin GPU spread as the start path (see pickVisibleDevices).
|
||||
standbyVisibleDevices = pickVisibleDevices(gpuUuid, capturePort);
|
||||
sidecarEnv.push(`NVIDIA_VISIBLE_DEVICES=${standbyVisibleDevices}`);
|
||||
sidecarEnv.push('NVIDIA_DRIVER_CAPABILITIES=video,compute,utility');
|
||||
if (standbyVisibleDevices !== 'all') sidecarEnv.push(`CAPTURE_GPU_INDEX=${standbyVisibleDevices}`);
|
||||
console.log(`[gpu] standby sidecar port ${capturePort} → NVIDIA_VISIBLE_DEVICES=${standbyVisibleDevices}`);
|
||||
}
|
||||
sidecarEnv.push(`FC_URL=${FC_URL}`);
|
||||
|
||||
const hostConfig = { NetworkMode: 'host', Privileged: true, Binds: binds };
|
||||
if (useGpu) {
|
||||
hostConfig.Runtime = 'nvidia';
|
||||
// Scope to the single chosen GPU (see gpuDeviceRequest) — a blanket
|
||||
// Count:-1 overrides NVIDIA_VISIBLE_DEVICES and re-piles everything on GPU 0.
|
||||
hostConfig.DeviceRequests = [gpuDeviceRequest(standbyVisibleDevices)];
|
||||
}
|
||||
|
||||
// Warm up the bridge and inject FC_SLOT_ID (same as handleSidecarStart).
|
||||
if (sourceType === 'deltacast') {
|
||||
_dcSidecarCount++;
|
||||
startDeltacastBridge();
|
||||
const _srcCfg = (env.find(e => e.startsWith('SOURCE_CONFIG=')) || '').slice(14);
|
||||
let _portNum = NaN;
|
||||
try { _portNum = JSON.parse(_srcCfg).port; } catch (_) {}
|
||||
if (!Number.isFinite(_portNum)) _portNum = 0;
|
||||
const _slotId = `deltacast-${DC_BOARD}-${_portNum}`;
|
||||
sidecarEnv.push(`FC_SLOT_ID=${_slotId}`);
|
||||
if (_dcPortFmt.has(_portNum)) {
|
||||
const _fmt = _dcPortFmt.get(_portNum);
|
||||
const _fps = (_fmt.fps_den && _fmt.fps_den !== 1) ? `${_fmt.fps_num}/${_fmt.fps_den}` : String(_fmt.fps_num);
|
||||
sidecarEnv.push(`DELTACAST_VIDEO_SIZE=${_fmt.width}x${_fmt.height}`);
|
||||
sidecarEnv.push(`DELTACAST_FRAMERATE=${_fps}`);
|
||||
sidecarEnv.push(`DELTACAST_INTERLACED=${_fmt.interlaced ? '1' : '0'}`);
|
||||
}
|
||||
hostConfig.IpcMode = 'host';
|
||||
}
|
||||
if (sourceType === 'sdi' || sourceType === 'blackmagic') {
|
||||
_dlSidecarCount++;
|
||||
const _bmdDevices = [];
|
||||
try {
|
||||
const _bmdEntries = fs.readdirSync('/dev/blackmagic').filter(n => /^(dv|io)\d+$/.test(n));
|
||||
_bmdEntries.forEach((_, i) => _bmdDevices.push(i));
|
||||
} catch (_) { _bmdDevices.push(0); }
|
||||
await startDecklinkBridge(_bmdDevices);
|
||||
|
||||
const _srcCfg = (env.find(e => e.startsWith('SOURCE_CONFIG=')) || '').slice(14);
|
||||
let _devIdx = NaN;
|
||||
try { _devIdx = JSON.parse(_srcCfg).device ?? JSON.parse(_srcCfg).index; } catch (_) {}
|
||||
if (!Number.isFinite(_devIdx)) _devIdx = parseInt((env.find(e => e.startsWith('BMD_DEVICE_INDEX=')) || '=0').split('=')[1]) || 0;
|
||||
const _slotId = `decklink-${FC_NODE_ID}-${_devIdx}`;
|
||||
sidecarEnv.push(`FC_SLOT_ID=${_slotId}`);
|
||||
if (_dlDevFmt.has(_devIdx)) {
|
||||
const _fmt = _dlDevFmt.get(_devIdx);
|
||||
const _fps = (_fmt.fps_den && _fmt.fps_den !== 1) ? `${_fmt.fps_num}/${_fmt.fps_den}` : String(_fmt.fps_num);
|
||||
sidecarEnv.push(`DELTACAST_VIDEO_SIZE=${_fmt.width}x${_fmt.height}`);
|
||||
sidecarEnv.push(`DELTACAST_FRAMERATE=${_fps}`);
|
||||
sidecarEnv.push(`DELTACAST_INTERLACED=${_fmt.interlaced ? '1' : '0'}`);
|
||||
}
|
||||
hostConfig.IpcMode = 'host';
|
||||
}
|
||||
|
||||
const _cleanupOnFailure = async () => {
|
||||
if (sourceType === 'deltacast') {
|
||||
_dcSidecarCount--;
|
||||
if (_dcSidecarCount <= 0) { _dcSidecarCount = 0; stopDeltacastBridge(); }
|
||||
} else if (sourceType === 'sdi' || sourceType === 'blackmagic') {
|
||||
_dlSidecarCount--;
|
||||
if (_dlSidecarCount <= 0) { _dlSidecarCount = 0; await stopDecklinkBridge(); }
|
||||
}
|
||||
};
|
||||
|
||||
let containerId;
|
||||
try {
|
||||
const createRes = await dockerApi('POST', '/containers/create', { Image: image, Env: sidecarEnv, HostConfig: hostConfig });
|
||||
if (createRes.status !== 201) {
|
||||
await _cleanupOnFailure();
|
||||
return jsonResponse(res, 502, { error: 'Failed to create standby container', details: createRes.data });
|
||||
}
|
||||
containerId = createRes.data.Id;
|
||||
console.log(`[sidecar-standby] ${containerId} image=${image} src=${sourceType}`);
|
||||
const startRes = await dockerApi('POST', `/containers/${containerId}/start`);
|
||||
if (startRes.status !== 204) {
|
||||
await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {});
|
||||
await _cleanupOnFailure();
|
||||
return jsonResponse(res, 502, { error: 'Failed to start standby container', details: startRes.data });
|
||||
}
|
||||
if (sourceType === 'deltacast') _containerSourceType.set(containerId, 'deltacast');
|
||||
if (sourceType === 'sdi' || sourceType === 'blackmagic') _containerSourceType.set(containerId, 'blackmagic');
|
||||
jsonResponse(res, 201, { containerId, capturePort });
|
||||
} catch (err) {
|
||||
await _cleanupOnFailure();
|
||||
throw err;
|
||||
}
|
||||
} catch (err) {
|
||||
jsonResponse(res, 500, { error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSidecarStop(containerId, res) {
|
||||
try {
|
||||
console.log(`[sidecar-stop] stopping ${containerId} (grace 180s)...`);
|
||||
|
|
@ -954,23 +399,16 @@ async function handleSidecarStop(containerId, res) {
|
|||
console.log(`[sidecar-stop] ==== capture logs for ${containerId} ====\n${logs}\n[sidecar-stop] ==== end logs ====`);
|
||||
await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {});
|
||||
|
||||
// Bridge lifecycle: decrement sidecar count; stop bridge when last sidecar stops.
|
||||
const _srcType = _containerSourceType.get(containerId);
|
||||
_containerSourceType.delete(containerId);
|
||||
if (_srcType === 'deltacast') {
|
||||
// Deltacast bridge lifecycle: decrement sidecar count; stop bridge when last.
|
||||
if (_containerSourceType.get(containerId) === 'deltacast') {
|
||||
_containerSourceType.delete(containerId);
|
||||
_dcSidecarCount--;
|
||||
if (_dcSidecarCount <= 0) {
|
||||
_dcSidecarCount = 0;
|
||||
stopDeltacastBridge();
|
||||
}
|
||||
} else if (_srcType === 'blackmagic') {
|
||||
_dlSidecarCount--;
|
||||
if (_dlSidecarCount <= 0) {
|
||||
_dlSidecarCount = 0;
|
||||
await stopDecklinkBridge();
|
||||
}
|
||||
} else if (_srcType === 'srt' || _srcType === 'rtmp') {
|
||||
stopNetIngest(containerId);
|
||||
} else {
|
||||
_containerSourceType.delete(containerId);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[sidecar-stop] background cleanup failed for ${containerId}:`, err.message);
|
||||
|
|
@ -984,15 +422,6 @@ async function handleSidecarStop(containerId, res) {
|
|||
}
|
||||
}
|
||||
|
||||
async function handleSidecarLogs(containerId, res) {
|
||||
try {
|
||||
const logs = await fetchContainerLogs(containerId);
|
||||
jsonResponse(res, 200, { logs: logs || '(no logs)' });
|
||||
} catch (err) {
|
||||
jsonResponse(res, 500, { error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSidecarStatus(containerId, res) {
|
||||
try {
|
||||
const inspectRes = await dockerApi('GET', `/containers/${containerId}/json`);
|
||||
|
|
@ -1028,27 +457,11 @@ async function handleSidecarStatus(containerId, res) {
|
|||
// When NODE_TOKEN is configured, privileged control endpoints (driver install)
|
||||
// require a matching `Authorization: Bearer <NODE_TOKEN>`. mam-api forwards the
|
||||
// node's stored token. If NODE_TOKEN is empty (dev), auth is not enforced.
|
||||
//
|
||||
// A SHARED cluster-read token (CLUSTER_READ_TOKEN) is ALSO accepted so the
|
||||
// primary mam-api can fan-out read-only cluster queries (container list, logs)
|
||||
// to every node with ONE token, rather than tracking each node's bound token.
|
||||
// It only grants the same endpoints NODE_TOKEN does; set it identically on
|
||||
// mam-api (NODE_AGENT_TOKEN) and every node-agent.
|
||||
const CLUSTER_READ_TOKEN = process.env.CLUSTER_READ_TOKEN || '';
|
||||
|
||||
function _bearerEq(token, secret) {
|
||||
if (!secret || token.length !== secret.length) return false;
|
||||
try { return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(secret)); }
|
||||
catch (_) { return false; }
|
||||
}
|
||||
|
||||
function checkAgentAuth(req) {
|
||||
if (!NODE_TOKEN && !CLUSTER_READ_TOKEN) return true;
|
||||
if (!NODE_TOKEN) return true;
|
||||
const hdr = req.headers['authorization'] || '';
|
||||
const m = /^Bearer\s+(.+)$/i.exec(hdr);
|
||||
if (!m) return false;
|
||||
const token = m[1];
|
||||
return _bearerEq(token, NODE_TOKEN) || _bearerEq(token, CLUSTER_READ_TOKEN);
|
||||
return !!m && m[1] === NODE_TOKEN;
|
||||
}
|
||||
|
||||
// ── Driver/SDK install ────────────────────────────────────────────────────
|
||||
|
|
@ -1570,7 +983,7 @@ function serveLiveFile(pathname, res) {
|
|||
}
|
||||
|
||||
// ── HTTP server ───────────────────────────────────────────────────────────
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const server = http.createServer((req, res) => {
|
||||
const { pathname } = new URL(req.url, 'http://localhost');
|
||||
|
||||
if (req.method === 'GET' && pathname === '/health') {
|
||||
|
|
@ -1584,11 +997,6 @@ const server = http.createServer(async (req, res) => {
|
|||
ip: getIp(),
|
||||
}));
|
||||
|
||||
} else if (req.method === 'POST' && pathname === '/sidecar/standby') {
|
||||
readBody(req)
|
||||
.then(body => handleSidecarStandby(body, res))
|
||||
.catch(() => jsonResponse(res, 400, { error: 'Invalid request body' }));
|
||||
|
||||
} else if (req.method === 'POST' && pathname === '/sidecar/start') {
|
||||
readBody(req)
|
||||
.then(body => handleSidecarStart(body, res))
|
||||
|
|
@ -1603,15 +1011,6 @@ const server = http.createServer(async (req, res) => {
|
|||
const id = pathname.slice('/sidecar/'.length, -'/status'.length);
|
||||
handleSidecarStatus(id, res);
|
||||
|
||||
} else if (req.method === 'GET' && pathname === '/containers') {
|
||||
if (!checkAgentAuth(req)) return jsonResponse(res, 401, { error: 'Unauthorized' });
|
||||
const cRes = await dockerApi('GET', '/containers/json?all=true');
|
||||
jsonResponse(res, cRes.status, cRes.data);
|
||||
|
||||
} else if (req.method === 'GET' && /^\/sidecar\/[^/]+\/logs$/.test(pathname)) {
|
||||
const id = pathname.slice('/sidecar/'.length, -'/logs'.length);
|
||||
handleSidecarLogs(id, res);
|
||||
|
||||
} else if (req.method === 'GET' && pathname === '/driver/status') {
|
||||
if (!checkAgentAuth(req)) return jsonResponse(res, 401, { error: 'Unauthorized' });
|
||||
handleDriverStatus(res);
|
||||
|
|
|
|||
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.
|
|
@ -73,19 +73,7 @@ server {
|
|||
types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; }
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
add_header Pragma "no-cache" always;
|
||||
# Tighten CORS: no wildcard.
|
||||
add_header Access-Control-Allow-Origin $http_origin always;
|
||||
add_header Access-Control-Allow-Credentials "true" always;
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header Access-Control-Allow-Origin $http_origin;
|
||||
add_header Access-Control-Allow-Credentials "true";
|
||||
add_header Access-Control-Allow-Methods 'GET, OPTIONS';
|
||||
add_header Access-Control-Allow-Headers 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
|
||||
add_header Access-Control-Max-Age 1728000;
|
||||
add_header Content-Type 'text/plain; charset=utf-8';
|
||||
add_header Content-Length 0;
|
||||
return 204;
|
||||
}
|
||||
add_header Access-Control-Allow-Origin * always;
|
||||
}
|
||||
|
||||
# Playout HLS preview — CasparCG sidecar writes to the media volume under
|
||||
|
|
@ -95,19 +83,7 @@ server {
|
|||
types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; }
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
add_header Pragma "no-cache" always;
|
||||
# Tighten CORS: no wildcard.
|
||||
add_header Access-Control-Allow-Origin $http_origin always;
|
||||
add_header Access-Control-Allow-Credentials "true" always;
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header Access-Control-Allow-Origin $http_origin;
|
||||
add_header Access-Control-Allow-Credentials "true";
|
||||
add_header Access-Control-Allow-Methods 'GET, OPTIONS';
|
||||
add_header Access-Control-Allow-Headers 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
|
||||
add_header Access-Control-Max-Age 1728000;
|
||||
add_header Content-Type 'text/plain; charset=utf-8';
|
||||
add_header Content-Length 0;
|
||||
return 204;
|
||||
}
|
||||
add_header Access-Control-Allow-Origin * always;
|
||||
}
|
||||
|
||||
# API proxy - forward to mam-api service
|
||||
|
|
@ -157,11 +133,6 @@ server {
|
|||
try_files $uri $uri/ /index.html;
|
||||
expires -1;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:;" always;
|
||||
}
|
||||
|
||||
# Deny access to dotfiles
|
||||
|
|
|
|||
|
|
@ -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 }}>
|
||||
|
|
|
|||
|
|
@ -102,22 +102,18 @@ function AssetDetail({ asset, onClose }) {
|
|||
setFilmFrames([]);
|
||||
setFilmstripLoading(true);
|
||||
|
||||
// The API now serves the filmstrip frames JSON directly (with server-side
|
||||
// retry around the flaky object store) instead of returning a signed URL.
|
||||
// Response is either the frames array, or { ready:false } when unavailable.
|
||||
window.ZAMPP_API.fetch('/assets/' + assetId + '/filmstrip')
|
||||
.then(function(r) {
|
||||
if (cancelled) return;
|
||||
// New shape: bare array of base64 frames.
|
||||
if (Array.isArray(r) && r.length) { setFilmFrames(r); return; }
|
||||
// Legacy/empty shape: { url } (older API) or { ready:false }.
|
||||
if (r && r.url) {
|
||||
return fetch(r.url)
|
||||
.then(function(res) { return res.json(); })
|
||||
.then(function(frames) {
|
||||
if (!cancelled && Array.isArray(frames) && frames.length) setFilmFrames(frames);
|
||||
});
|
||||
}
|
||||
if (!r || !r.url) { setFilmstripLoading(false); return; }
|
||||
// Fetch the JSON array of base64 frames from the signed S3 URL
|
||||
return fetch(r.url)
|
||||
.then(function(res) { return res.json(); })
|
||||
.then(function(frames) {
|
||||
if (!cancelled && Array.isArray(frames) && frames.length) {
|
||||
setFilmFrames(frames);
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(function() {})
|
||||
.finally(function() { if (!cancelled) setFilmstripLoading(false); });
|
||||
|
|
@ -247,11 +243,7 @@ function AssetDetail({ asset, onClose }) {
|
|||
setDownloading(true);
|
||||
window.ZAMPP_API.fetch('/assets/' + assetId + '/hires')
|
||||
.then(function(r) {
|
||||
if (!r || !r.url) {
|
||||
if (window.toast) window.toast.error('No hi-res source available for this asset.');
|
||||
else window.alert('No hi-res source available for this asset.');
|
||||
return;
|
||||
}
|
||||
if (!r || !r.url) { window.alert('No hi-res source available for this asset.'); return; }
|
||||
const a = document.createElement('a');
|
||||
a.href = r.url;
|
||||
a.download = r.filename || (asset.name + '.' + (r.ext || 'mov'));
|
||||
|
|
@ -261,10 +253,7 @@ function AssetDetail({ asset, onClose }) {
|
|||
a.click();
|
||||
document.body.removeChild(a);
|
||||
})
|
||||
.catch(function(e) {
|
||||
if (window.toast) window.toast.error('Download failed: ' + (e.message || 'unknown error'));
|
||||
else window.alert('Download failed: ' + (e.message || 'unknown error'));
|
||||
})
|
||||
.catch(function(e) { window.alert('Download failed: ' + (e.message || 'unknown error')); })
|
||||
.finally(function() { setDownloading(false); });
|
||||
};
|
||||
|
||||
|
|
@ -290,10 +279,7 @@ function AssetDetail({ asset, onClose }) {
|
|||
}))) return;
|
||||
window.ZAMPP_API.fetch('/assets/' + assetId + '?hard=true', { method: 'DELETE' })
|
||||
.then(function() { onClose && onClose(); })
|
||||
.catch(function(e) {
|
||||
if (window.toast) window.toast.error('Delete failed: ' + e.message);
|
||||
else window.alert('Delete failed: ' + e.message);
|
||||
});
|
||||
.catch(function(e) { window.alert('Delete failed: ' + e.message); });
|
||||
};
|
||||
|
||||
const retryProcessing = function() {
|
||||
|
|
@ -301,13 +287,9 @@ function AssetDetail({ asset, onClose }) {
|
|||
setRetrying(true);
|
||||
window.ZAMPP_API.fetch('/assets/' + assetId + '/retry', { method: 'POST' })
|
||||
.then(function() {
|
||||
if (window.toast) window.toast.success('Re-queued for processing. The proxy worker will pick it up shortly; refresh in a minute to see the player.');
|
||||
else window.alert('Re-queued for processing. The proxy worker will pick it up shortly; refresh in a minute to see the player.');
|
||||
})
|
||||
.catch(function(e) {
|
||||
if (window.toast) window.toast.error('Retry failed: ' + (e.message || 'unknown error'));
|
||||
else window.alert('Retry failed: ' + (e.message || 'unknown error'));
|
||||
window.alert('Re-queued for processing. The proxy worker will pick it up shortly; refresh in a minute to see the player.');
|
||||
})
|
||||
.catch(function(e) { window.alert('Retry failed: ' + (e.message || 'unknown error')); })
|
||||
.finally(function() { setRetrying(false); });
|
||||
};
|
||||
|
||||
|
|
@ -316,26 +298,16 @@ function AssetDetail({ asset, onClose }) {
|
|||
setReprocessing(type);
|
||||
window.ZAMPP_API.fetch('/assets/' + assetId + '/reprocess?type=' + type, { method: 'POST' })
|
||||
.then(function() {
|
||||
if (window.toast) window.toast.success((type === 'proxy' ? 'Proxy' : 'Thumbnail') + ' job queued. Refresh in a moment to see the result.');
|
||||
else window.alert((type === 'proxy' ? 'Proxy' : 'Thumbnail') + ' job queued. Refresh in a moment to see the result.');
|
||||
})
|
||||
.catch(function(e) {
|
||||
if (window.toast) window.toast.error('Reprocess failed: ' + (e.message || 'unknown error'));
|
||||
else window.alert('Reprocess failed: ' + (e.message || 'unknown error'));
|
||||
window.alert((type === 'proxy' ? 'Proxy' : 'Thumbnail') + ' job queued. Refresh in a moment to see the result.');
|
||||
})
|
||||
.catch(function(e) { window.alert('Reprocess failed: ' + (e.message || 'unknown error')); })
|
||||
.finally(function() { setReprocessing(null); });
|
||||
};
|
||||
|
||||
const regenFilmstrip = function() {
|
||||
window.ZAMPP_API.fetch('/assets/' + assetId + '/reprocess?type=filmstrip', { method: 'POST' })
|
||||
.then(function() {
|
||||
if (window.toast) window.toast.success('Filmstrip job queued: it will appear automatically when ready.');
|
||||
else window.alert('Filmstrip job queued: it will appear automatically when ready.');
|
||||
})
|
||||
.catch(function(e) {
|
||||
if (window.toast) window.toast.error('Failed to queue filmstrip: ' + (e.message || 'unknown error'));
|
||||
else window.alert('Failed to queue filmstrip: ' + (e.message || 'unknown error'));
|
||||
});
|
||||
.then(function() { window.alert('Filmstrip job queued: it will appear automatically when ready.'); })
|
||||
.catch(function(e) { window.alert('Failed to queue filmstrip: ' + (e.message || 'unknown error')); });
|
||||
};
|
||||
|
||||
// Map a /assets/:id/comments row into the legacy shape the consumer
|
||||
|
|
@ -380,8 +352,7 @@ function AssetDetail({ asset, onClose }) {
|
|||
setComments(function(c) { return [...c, _normalizeComment(row)]; });
|
||||
})
|
||||
.catch(function(e) {
|
||||
if (window.toast) window.toast.error('Could not post comment: ' + (e.message || 'unknown error'));
|
||||
else window.alert('Could not post comment: ' + (e.message || 'unknown error'));
|
||||
window.alert('Could not post comment: ' + (e.message || 'unknown error'));
|
||||
setNewComment(text);
|
||||
});
|
||||
};
|
||||
|
|
@ -403,10 +374,7 @@ function AssetDetail({ asset, onClose }) {
|
|||
.then(function() {
|
||||
setComments(function(prev) { return prev.filter(x => x.id !== c.id); });
|
||||
})
|
||||
.catch(function(e) {
|
||||
if (window.toast) window.toast.error('Delete failed: ' + e.message);
|
||||
else window.alert('Delete failed: ' + e.message);
|
||||
});
|
||||
.catch(function(e) { window.alert('Delete failed: ' + e.message); });
|
||||
};
|
||||
|
||||
const visibleComments = comments.filter(function(c) { return showResolved || !c.resolved; });
|
||||
|
|
|
|||
|
|
@ -306,7 +306,20 @@ function Editor() {
|
|||
if (r && r.url) { url = r.url; cache[asset.id] = url; }
|
||||
} catch (e) { window.DF_LOG.warn('[editor] stream URL failed', e); }
|
||||
}
|
||||
if (url) { vid.src = url; vid.load(); }
|
||||
if (url) {
|
||||
if (url.endsWith('.m3u8')) {
|
||||
if (window.Hls && window.Hls.isSupported()) {
|
||||
const hls = new window.Hls();
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(vid);
|
||||
} else {
|
||||
vid.src = url;
|
||||
}
|
||||
} else {
|
||||
vid.src = url;
|
||||
}
|
||||
vid.load();
|
||||
}
|
||||
}
|
||||
|
||||
function markSrcIn() {
|
||||
|
|
@ -636,8 +649,14 @@ function ProgramMonitor({ videoRef, currentSeq, playheadFrames, setPlayheadFrame
|
|||
if (vid) vid.pause();
|
||||
}
|
||||
|
||||
// Audio track refs for playback
|
||||
const pgmAudioRefs = React.useRef([]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pgmPlaying || pgmClipIdx < 0 || pgmClipIdx >= pgmClips.length) return;
|
||||
if (!pgmPlaying || pgmClipIdx < 0 || pgmClipIdx >= pgmClips.length) {
|
||||
pgmAudioRefs.current.forEach(a => a.pause());
|
||||
return;
|
||||
}
|
||||
const clip = pgmClips[pgmClipIdx];
|
||||
if (!clip) { stopPgm(); return; }
|
||||
const vid = videoRef.current;
|
||||
|
|
@ -651,7 +670,28 @@ function ProgramMonitor({ videoRef, currentSeq, playheadFrames, setPlayheadFrame
|
|||
if (r && r.url) { url = r.url; cache[clip.asset_id] = url; }
|
||||
} catch (e) { window.DF_LOG.warn('[editor] stream fetch failed', e); return; }
|
||||
}
|
||||
if (vid.src !== url) { vid.src = url; vid.load(); }
|
||||
if (vid.src !== url) {
|
||||
if (url.endsWith('.m3u8')) {
|
||||
if (window.Hls && window.Hls.isSupported()) {
|
||||
const hls = new window.Hls();
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(vid);
|
||||
} else {
|
||||
vid.src = url;
|
||||
}
|
||||
} else {
|
||||
vid.src = url;
|
||||
}
|
||||
vid.load();
|
||||
}
|
||||
|
||||
// Sync audio tracks (A1/A2)
|
||||
const asset = assetsRef.current.find(a => a.id === clip.asset_id);
|
||||
if (asset && asset.media_type === 'video') {
|
||||
// For now, simple video-track audio. Multi-track A1/A2 wiring planned.
|
||||
vid.muted = false;
|
||||
}
|
||||
|
||||
const srcInSecs = clip.source_in_frames / (window.TC ? window.TC.FPS : 59.94);
|
||||
vid.currentTime = srcInSecs;
|
||||
vid.play().catch(() => {});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -358,7 +358,6 @@ function Playlist({ channel, playlistId, items, activeIndex, onReload }) {
|
|||
}
|
||||
|
||||
// ── Audio meter ───────────────────────────────────────────────────────────────
|
||||
// Simulated VU meter — real values would require a WebAudio analyzer on the
|
||||
// HLS stream. For now, animate a plausible signal when on-air. (Named PoAudioMeter
|
||||
// to avoid colliding with the global AudioMeter from visuals.jsx.)
|
||||
function PoAudioMeter({ onAir }) {
|
||||
|
|
@ -447,8 +446,6 @@ function ProgramMonitor({ channel, engine, elapsed }) {
|
|||
React.useEffect(() => {
|
||||
const vid = videoRef.current;
|
||||
if (!vid) return;
|
||||
|
||||
// Tear down any previous HLS instance before re-evaluating.
|
||||
if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; }
|
||||
if (!onAir) { vid.src = ''; return; }
|
||||
|
||||
|
|
@ -526,6 +523,18 @@ function ProgramMonitor({ channel, engine, elapsed }) {
|
|||
const progress = clipDurSecs > 0 ? Math.min(1, elapsed / clipDurSecs) : 0;
|
||||
const timeRemaining = clipDurSecs > 0 ? Math.max(0, clipDurSecs - elapsed) : 0;
|
||||
|
||||
<<<<<<< HEAD
|
||||
return (
|
||||
<div className="po-pgm">
|
||||
{/* Screen */}
|
||||
<div className="po-screen">
|
||||
<video ref={videoRef} className="po-monitor-video" muted playsInline autoPlay />
|
||||
|
||||
{/* ON AIR badge */}
|
||||
{onAir && (
|
||||
<div className="po-onair-badge">ON AIR</div>
|
||||
)}
|
||||
=======
|
||||
// SCTE break countdown (seconds remaining in the active break).
|
||||
const breakRemain = scte && scte.endsAt
|
||||
? Math.max(0, (new Date(scte.endsAt).getTime() - Date.now()) / 1000)
|
||||
|
|
@ -543,6 +552,7 @@ function ProgramMonitor({ channel, engine, elapsed }) {
|
|||
</div>
|
||||
)}
|
||||
{onAir && !scte && <div className="po-onair-badge">ON AIR</div>}
|
||||
>>>>>>> main
|
||||
|
||||
{!onAir && (
|
||||
<div className="po-screen-offline">
|
||||
|
|
@ -551,12 +561,23 @@ function ProgramMonitor({ channel, engine, elapsed }) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<<<<<<< HEAD
|
||||
{/* Timecode overlay */}
|
||||
{onAir && (
|
||||
<div className="po-tc-overlay mono">{fmtTimecode(elapsed)}</div>
|
||||
)}
|
||||
|
||||
{/* Audio meters */}
|
||||
<div className="po-meters-wrap">
|
||||
<AudioMeter onAir={onAir} />
|
||||
=======
|
||||
{onAir && (
|
||||
<div className="po-tc-overlay mono">{playoutFmtTC(elapsed)}</div>
|
||||
)}
|
||||
|
||||
<div className="po-meters-wrap">
|
||||
<PoAudioMeter onAir={onAir} />
|
||||
>>>>>>> main
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -690,12 +711,6 @@ function Scte35Panel({ channel, engine, breaks, onReload, onError }) {
|
|||
<div className="po-card po-scte-card">
|
||||
<div className="po-card-head">
|
||||
<span className="po-section-label">SCTE-35 Break</span>
|
||||
{scte
|
||||
? <span className="po-scte-stub-badge" style={{ color: '#f59e0b' }}>● ON AIR</span>
|
||||
: pending.length > 0
|
||||
? <span className="po-scte-stub-badge">{pending.length} queued</span>
|
||||
: null}
|
||||
</div>
|
||||
<div className="po-scte-body">
|
||||
{scte && (
|
||||
<div className="po-scte-active mono" style={{ color: '#f59e0b', fontWeight: 600, fontSize: 12 }}>
|
||||
|
|
@ -745,6 +760,10 @@ function NowPlayingCard({ engine, elapsed, items }) {
|
|||
const clipDurSecs = currentItem ? itemEffectiveDuration(currentItem) : 0;
|
||||
const progress = clipDurSecs > 0 ? Math.min(1, elapsed / clipDurSecs) : 0;
|
||||
const timeRemaining = clipDurSecs > 0 ? Math.max(0, clipDurSecs - elapsed) : 0;
|
||||
<<<<<<< HEAD
|
||||
|
||||
=======
|
||||
>>>>>>> main
|
||||
const nextItem = items[engine.currentIndex + 1] || null;
|
||||
|
||||
return (
|
||||
|
|
@ -858,6 +877,159 @@ function Timeline({ items, activeIndex, elapsed, breaks }) {
|
|||
);
|
||||
}
|
||||
|
||||
let playheadPct = 0;
|
||||
if (activeIndex >= 0 && totalSecs > 0) {
|
||||
const offsetSecs = items.slice(0, activeIndex).reduce((s, it) => s + itemEffectiveDuration(it), 0);
|
||||
const clipDur = itemEffectiveDuration(items[activeIndex] || {});
|
||||
playheadPct = ((offsetSecs + Math.min(elapsed, clipDur)) / totalSecs) * 100;
|
||||
}
|
||||
|
||||
// Pending position-based breaks → markers at the end of their playlist_pos clip.
|
||||
const breakMarkers = [];
|
||||
if (totalSecs > 0) {
|
||||
for (const b of (breaks || [])) {
|
||||
if (b.status !== 'pending' || b.playlist_pos == null) continue;
|
||||
const pos = Math.min(b.playlist_pos, items.length - 1);
|
||||
const offsetSecs = items.slice(0, pos + 1).reduce((s, it) => s + itemEffectiveDuration(it), 0);
|
||||
breakMarkers.push({ id: b.id, pct: (offsetSecs / totalSecs) * 100, dur: b.duration_s });
|
||||
}
|
||||
}
|
||||
const totalSecs = items.reduce((sum, it) => sum + itemEffectiveDuration(it), 0);
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="po-pgm">
|
||||
<div className="po-screen">
|
||||
<video ref={videoRef} className="po-monitor-video" muted playsInline autoPlay />
|
||||
|
||||
{/* ON AIR / SCTE BREAK badge */}
|
||||
{onAir && scte && (
|
||||
<div className="po-onair-badge" style={{ background: '#f59e0b' }}>
|
||||
SCTE BREAK{breakRemain > 0 ? ' ' + Math.ceil(breakRemain) + 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
{onAir && !scte && <div className="po-onair-badge">ON AIR</div>}
|
||||
|
||||
{!onAir && (
|
||||
<div className="po-screen-offline">
|
||||
<span className="po-screen-offline-dot" />
|
||||
<span>Channel stopped</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onAir && (
|
||||
<div className="po-tc-overlay mono">{playoutFmtTC(elapsed)}</div>
|
||||
)}
|
||||
|
||||
<div className="po-meters-wrap">
|
||||
<PoAudioMeter onAir={onAir} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="po-tl-empty muted">Add clips to the playlist to see the timeline.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
// Compute offset of active clip for the playhead
|
||||
=======
|
||||
>>>>>>> main
|
||||
let playheadPct = 0;
|
||||
if (activeIndex >= 0 && totalSecs > 0) {
|
||||
const offsetSecs = items.slice(0, activeIndex).reduce((s, it) => s + itemEffectiveDuration(it), 0);
|
||||
const clipDur = itemEffectiveDuration(items[activeIndex] || {});
|
||||
playheadPct = ((offsetSecs + Math.min(elapsed, clipDur)) / totalSecs) * 100;
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
// Pending position-based breaks → markers at the end of their playlist_pos clip.
|
||||
const breakMarkers = [];
|
||||
if (totalSecs > 0) {
|
||||
for (const b of (breaks || [])) {
|
||||
if (b.status !== 'pending' || b.playlist_pos == null) continue;
|
||||
const pos = Math.min(b.playlist_pos, items.length - 1);
|
||||
const offsetSecs = items.slice(0, pos + 1).reduce((s, it) => s + itemEffectiveDuration(it), 0);
|
||||
breakMarkers.push({ id: b.id, pct: (offsetSecs / totalSecs) * 100, dur: b.duration_s });
|
||||
}
|
||||
}
|
||||
|
||||
>>>>>>> main
|
||||
const COLORS = ['#3b82f6','#8b5cf6','#06b6d4','#10b981','#f59e0b','#ec4899','#6366f1','#14b8a6'];
|
||||
|
||||
return (
|
||||
<div className="po-tl">
|
||||
<div className="po-tl-head">
|
||||
<span className="po-section-label">Timeline</span>
|
||||
<<<<<<< HEAD
|
||||
<span className="mono muted" style={{ fontSize: 11 }}>{fmtDuration(totalSecs)} total</span>
|
||||
</div>
|
||||
<div className="po-tl-track-wrap">
|
||||
{/* Playhead */}
|
||||
{activeIndex >= 0 && (
|
||||
<div className="po-tl-playhead" style={{ left: playheadPct + '%' }} />
|
||||
)}
|
||||
=======
|
||||
<span className="mono muted" style={{ fontSize: 11 }}>{playoutFmtDur(totalSecs)} total</span>
|
||||
</div>
|
||||
<div className="po-tl-track-wrap">
|
||||
{activeIndex >= 0 && (
|
||||
<div className="po-tl-playhead" style={{ left: playheadPct + '%' }} />
|
||||
)}
|
||||
{breakMarkers.map(m => (
|
||||
<div key={m.id} className="po-tl-scte-marker" style={{ left: m.pct + '%' }}
|
||||
title={'SCTE-35 break · ' + m.dur + 's'} />
|
||||
))}
|
||||
>>>>>>> main
|
||||
<div className="po-tl-track">
|
||||
{items.map((it, i) => {
|
||||
const dur = itemEffectiveDuration(it);
|
||||
const pct = totalSecs > 0 ? (dur / totalSecs) * 100 : 0;
|
||||
const isActive = i === activeIndex;
|
||||
const color = COLORS[i % COLORS.length];
|
||||
return (
|
||||
<div key={it.id}
|
||||
className={'po-tl-clip' + (isActive ? ' po-tl-clip--active' : '')}
|
||||
style={{ width: pct + '%', '--clip-color': color }}
|
||||
<<<<<<< HEAD
|
||||
title={`${it.clip_name || it.asset_id} · ${fmtDuration(dur)}`}>
|
||||
<span className="po-tl-clip-name">{it.clip_name || it.asset_id}</span>
|
||||
<span className="po-tl-clip-dur mono">{fmtDuration(dur)}</span>
|
||||
=======
|
||||
title={`${it.clip_name || it.asset_id} · ${playoutFmtDur(dur)}`}>
|
||||
<span className="po-tl-clip-name">{it.clip_name || it.asset_id}</span>
|
||||
<span className="po-tl-clip-dur mono">{playoutFmtDur(dur)}</span>
|
||||
>>>>>>> main
|
||||
{it.media_status === 'staging' && (
|
||||
<span className="po-tl-staging-dot" title="Staging…" />
|
||||
)}
|
||||
{it.media_status === 'error' && (
|
||||
<span className="po-tl-error-dot" title="Stage error" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<<<<<<< HEAD
|
||||
{/* Time ruler (rough marks) */}
|
||||
<div className="po-tl-ruler">
|
||||
{totalSecs > 0 && Array.from({ length: 5 }).map((_, i) => (
|
||||
<span key={i} className="po-tl-ruler-mark mono"
|
||||
style={{ left: (i * 25) + '%' }}>
|
||||
{fmtDuration((totalSecs * i) / 4)}
|
||||
=======
|
||||
<div className="po-tl-ruler">
|
||||
{totalSecs > 0 && Array.from({ length: 5 }).map((_, i) => (
|
||||
<span key={i} className="po-tl-ruler-mark mono" style={{ left: (i * 25) + '%' }}>
|
||||
{playoutFmtDur((totalSecs * i) / 4)}
|
||||
>>>>>>> main
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── As-run drawer ─────────────────────────────────────────────────────────────
|
||||
function AsRunDrawer({ channel, refreshKey, open, onClose }) {
|
||||
const [rows, setRows] = React.useState([]);
|
||||
|
|
@ -1007,6 +1179,15 @@ function ChannelDetail({ channel, onChannelChange }) {
|
|||
|
||||
const activeIndex = (engine && engine.currentIndex >= 0) ? engine.currentIndex : -1;
|
||||
const elapsed = useElapsed(engine && engine.currentItemStartedAt);
|
||||
<<<<<<< HEAD
|
||||
const onAir = ch.status === 'running';
|
||||
|
||||
return (
|
||||
<div className="po-root">
|
||||
{/* ── Top rail: monitor + right panel ── */}
|
||||
<div className="po-top">
|
||||
{/* PGM monitor + transport */}
|
||||
=======
|
||||
|
||||
return (
|
||||
<div className="po-root">
|
||||
|
|
@ -1014,6 +1195,7 @@ function ChannelDetail({ channel, onChannelChange }) {
|
|||
|
||||
{/* ── Top rail: monitor + right panel ── */}
|
||||
<div className="po-top">
|
||||
>>>>>>> main
|
||||
<div className="po-pgm-col">
|
||||
<ProgramMonitor channel={ch} engine={engine} elapsed={elapsed} />
|
||||
<Transport
|
||||
|
|
@ -1021,10 +1203,17 @@ function ChannelDetail({ channel, onChannelChange }) {
|
|||
playlistId={playlistId}
|
||||
items={items}
|
||||
onStatus={loadItems}
|
||||
<<<<<<< HEAD
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right rail */}
|
||||
=======
|
||||
onError={setActionErr}
|
||||
/>
|
||||
</div>
|
||||
|
||||
>>>>>>> main
|
||||
<div className="po-rail">
|
||||
{/* Channel controls */}
|
||||
<div className="po-card po-channel-card">
|
||||
|
|
@ -1048,6 +1237,20 @@ function ChannelDetail({ channel, onChannelChange }) {
|
|||
)}
|
||||
</div>
|
||||
{ch.error_message && <div className="alert error" style={{ marginTop: 8 }}>{ch.error_message}</div>}
|
||||
<<<<<<< HEAD
|
||||
</div>
|
||||
|
||||
{/* Now playing */}
|
||||
<NowPlayingCard engine={engine} elapsed={elapsed} items={items} />
|
||||
|
||||
{/* SCTE-35 */}
|
||||
<Scte35Panel channel={ch} />
|
||||
|
||||
{/* Quick actions */}
|
||||
<div className="po-rail-actions">
|
||||
<button className="btn ghost sm po-rail-action-btn" onClick={() => setBinOpen(b => !b)}>
|
||||
{binOpen ? '▸ Hide' : '▾ Media Bin'}
|
||||
=======
|
||||
{actionErr && (
|
||||
<div className="alert error" style={{ marginTop: 8 }} onClick={() => setActionErr(null)}>
|
||||
{actionErr}
|
||||
|
|
@ -1063,6 +1266,7 @@ function ChannelDetail({ channel, onChannelChange }) {
|
|||
<div className="po-rail-actions">
|
||||
<button className="btn ghost sm po-rail-action-btn" onClick={() => setBinOpen(b => !b)}>
|
||||
{binOpen ? '▸ Hide bin' : '▾ Media Bin'}
|
||||
>>>>>>> main
|
||||
</button>
|
||||
<button className="btn ghost sm po-rail-action-btn" onClick={() => setAsRunOpen(true)}>
|
||||
As-Run Log
|
||||
|
|
@ -1071,8 +1275,16 @@ function ChannelDetail({ channel, onChannelChange }) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<<<<<<< HEAD
|
||||
{/* Media bin (collapsible, below top rail) */}
|
||||
{binOpen && (
|
||||
<MediaBin projectId={ch.project_id} />
|
||||
)}
|
||||
=======
|
||||
{binOpen && <MediaBin projectId={ch.project_id} />}
|
||||
>>>>>>> main
|
||||
|
||||
{/* Playlist */}
|
||||
{playlistId && (
|
||||
<Playlist
|
||||
channel={ch}
|
||||
|
|
@ -1083,8 +1295,15 @@ function ChannelDetail({ channel, onChannelChange }) {
|
|||
/>
|
||||
)}
|
||||
|
||||
<<<<<<< HEAD
|
||||
{/* Timeline */}
|
||||
<Timeline items={items} activeIndex={activeIndex} elapsed={elapsed} />
|
||||
|
||||
{/* As-run drawer */}
|
||||
=======
|
||||
<Timeline items={items} activeIndex={activeIndex} elapsed={elapsed} breaks={breaks} />
|
||||
|
||||
>>>>>>> main
|
||||
<AsRunDrawer
|
||||
channel={ch}
|
||||
refreshKey={engine && engine.currentItemId}
|
||||
|
|
@ -1147,6 +1366,8 @@ function Playout() {
|
|||
</div>
|
||||
|
||||
<div className="page-body po-page">
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
<div style={{
|
||||
background: '#fef3c7',
|
||||
borderLeft: '4px solid #f59e0b',
|
||||
|
|
@ -1162,6 +1383,7 @@ function Playout() {
|
|||
}}>
|
||||
⚠ Playout is in testing — not for production use.
|
||||
</div>
|
||||
>>>>>>> main
|
||||
{err && <div className="alert error">{err}</div>}
|
||||
|
||||
{channels === null && <div className="muted">Loading channels…</div>}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -539,7 +539,6 @@
|
|||
padding-bottom: 8px; border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* ── SCTE-35 additions (timeline marker + active-break line) ──────────────── */
|
||||
.po-tl-scte-marker {
|
||||
position: absolute; top: 10px; bottom: 28px;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -58,6 +58,9 @@ function AssetThumb({ asset, size = 'md' }) {
|
|||
);
|
||||
}
|
||||
|
||||
// VOD HLS assets: if we have an HLS rendition, we could potentially show a
|
||||
// muted hover-preview here too. For now, just static thumb.
|
||||
|
||||
const altText = asset.name ? `Thumbnail for ${asset.name}` : 'Asset thumbnail';
|
||||
return (
|
||||
<div className="asset-thumb" style={{ background: 'var(--bg-2)', aspectRatio: aspect, overflow: 'hidden', position: 'relative' }}>
|
||||
|
|
@ -112,7 +115,12 @@ function LiveThumb({ assetId, aspect }) {
|
|||
|
||||
const startHls = () => {
|
||||
if (destroyed) return;
|
||||
hls = new window.Hls({ liveSyncDurationCount: 2, lowLatencyMode: true, maxBufferLength: 10 });
|
||||
hls = new window.Hls({
|
||||
liveSyncDurationCount: 2,
|
||||
lowLatencyMode: true,
|
||||
maxBufferLength: 10,
|
||||
xhrSetup: (xhr) => { xhr.withCredentials = true; }
|
||||
});
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(v);
|
||||
hls.on(window.Hls.Events.MANIFEST_PARSED, () => {
|
||||
|
|
|
|||
|
|
@ -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,16 +10,7 @@ const CONTENT_TYPES = {
|
|||
'.mp4': 'video/mp4',
|
||||
};
|
||||
|
||||
// Keep-alive agents + a long request timeout. The proxy/conform jobs download
|
||||
// full master files (hundreds of MB) and upload HLS segments; the SDK defaults
|
||||
// (no keep-alive, 0/short timeouts under contention) caused master downloads to
|
||||
// stall and abort, leaving assets stuck in 'processing'. Generous timeout +
|
||||
// pooled sockets make these large transfers reliable.
|
||||
const _httpAgent = new http.Agent({ keepAlive: true, maxSockets: 128, timeout: 600_000 });
|
||||
const _httpsAgent = new https.Agent({ keepAlive: true, maxSockets: 128, timeout: 600_000 });
|
||||
|
||||
// Build a client. NOTE: callers must NOT destroy() this — see _sharedClient.
|
||||
const buildS3Client = () => {
|
||||
const createS3Client = () => {
|
||||
return new S3Client({
|
||||
region: process.env.S3_REGION || 'us-east-1',
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
|
|
@ -31,82 +19,37 @@ const buildS3Client = () => {
|
|||
secretAccessKey: process.env.S3_SECRET_KEY,
|
||||
},
|
||||
forcePathStyle: true,
|
||||
requestHandler: new NodeHttpHandler({
|
||||
httpAgent: _httpAgent,
|
||||
httpsAgent: _httpsAgent,
|
||||
requestTimeout: 600_000,
|
||||
connectionTimeout: 15_000,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
// ONE shared client reused across all operations. Previously every call did
|
||||
// createS3Client() then client.destroy() in finally — which tore down the
|
||||
// keep-alive agent's sockets every time, so pooling never happened and each
|
||||
// transfer opened brand-new connections. Under burn load (8 masters at once)
|
||||
// that hammered the connection-limited RustFS backend into aborting streams.
|
||||
// A single long-lived client lets the keep-alive pool actually be reused.
|
||||
let _sharedClient = null;
|
||||
const createS3Client = () => {
|
||||
if (!_sharedClient) _sharedClient = buildS3Client();
|
||||
return _sharedClient;
|
||||
};
|
||||
export const downloadFromS3 = async (bucket, key, localPath) => {
|
||||
const client = createS3Client();
|
||||
try {
|
||||
const response = await client.send(
|
||||
new GetObjectCommand({ Bucket: bucket, Key: key })
|
||||
);
|
||||
|
||||
// Transient connection failures that warrant a retry. Under burn load (8 masters
|
||||
// uploading + downloading at once) the connection-limited RustFS S3 backend
|
||||
// aborts/hangs up mid-stream — a single failure used to error the whole proxy
|
||||
// job permanently. These are NOT real "file missing" / auth errors.
|
||||
const _isTransientS3 = (err) => {
|
||||
const s = `${err?.name || ''} ${err?.code || ''} ${err?.message || ''}`.toLowerCase();
|
||||
return /aborted|socket hang up|timeout|econnreset|epipe|econnrefused|enotfound|stream|network|503|500|slowdown|throttl/.test(s);
|
||||
};
|
||||
const _sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
export const downloadFromS3 = async (bucket, key, localPath, maxAttempts = 5) => {
|
||||
let lastErr = null;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
const client = createS3Client();
|
||||
try {
|
||||
const response = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
|
||||
const writeStream = createWriteStream(localPath);
|
||||
await pipeline(response.Body, writeStream);
|
||||
return; // success
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
// Clean the partial file before retrying so we don't leave a truncated master.
|
||||
try { await (await import('node:fs/promises')).unlink(localPath); } catch (_) {}
|
||||
if (attempt < maxAttempts && _isTransientS3(err)) {
|
||||
const backoff = Math.min(8000, 400 * 2 ** (attempt - 1)); // 400,800,1600,3200ms
|
||||
console.warn(`[s3] download ${key} attempt ${attempt}/${maxAttempts} failed (${err.name || err.message}); retrying in ${backoff}ms`);
|
||||
await _sleep(backoff);
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const writeStream = createWriteStream(localPath);
|
||||
await pipeline(response.Body, writeStream);
|
||||
} finally {
|
||||
client.destroy();
|
||||
}
|
||||
throw lastErr;
|
||||
};
|
||||
|
||||
export const uploadToS3 = async (bucket, key, localPath, maxAttempts = 5) => {
|
||||
let lastErr = null;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
const client = createS3Client();
|
||||
try {
|
||||
const readStream = createReadStream(localPath);
|
||||
await client.send(new PutObjectCommand({ Bucket: bucket, Key: key, Body: readStream }));
|
||||
return; // success
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
if (attempt < maxAttempts && _isTransientS3(err)) {
|
||||
const backoff = Math.min(8000, 400 * 2 ** (attempt - 1));
|
||||
console.warn(`[s3] upload ${key} attempt ${attempt}/${maxAttempts} failed (${err.name || err.message}); retrying in ${backoff}ms`);
|
||||
await _sleep(backoff);
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
export const uploadToS3 = async (bucket, key, localPath) => {
|
||||
const client = createS3Client();
|
||||
try {
|
||||
const readStream = createReadStream(localPath);
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: readStream,
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
client.destroy();
|
||||
}
|
||||
throw lastErr;
|
||||
};
|
||||
|
||||
// Upload every file in `localDir` to `bucket` under `keyPrefix/`. Used for the
|
||||
|
|
@ -115,19 +58,23 @@ export const uploadToS3 = async (bucket, key, localPath, maxAttempts = 5) => {
|
|||
// RustFS's broken byte-range path on large objects.
|
||||
export const uploadDirectoryToS3 = async (bucket, keyPrefix, localDir) => {
|
||||
const client = createS3Client();
|
||||
const entries = await readdir(localDir, { withFileTypes: true });
|
||||
const files = entries.filter(e => e.isFile()).map(e => e.name);
|
||||
for (const name of files) {
|
||||
const ext = extname(name).toLowerCase();
|
||||
const ct = CONTENT_TYPES[ext] || 'application/octet-stream';
|
||||
await client.send(new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: `${keyPrefix}/${name}`,
|
||||
Body: createReadStream(join(localDir, name)),
|
||||
ContentType: ct,
|
||||
}));
|
||||
try {
|
||||
const entries = await readdir(localDir, { withFileTypes: true });
|
||||
const files = entries.filter(e => e.isFile()).map(e => e.name);
|
||||
for (const name of files) {
|
||||
const ext = extname(name).toLowerCase();
|
||||
const ct = CONTENT_TYPES[ext] || 'application/octet-stream';
|
||||
await client.send(new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: `${keyPrefix}/${name}`,
|
||||
Body: createReadStream(join(localDir, name)),
|
||||
ContentType: ct,
|
||||
}));
|
||||
}
|
||||
return files;
|
||||
} finally {
|
||||
client.destroy();
|
||||
}
|
||||
return files;
|
||||
};
|
||||
|
||||
// Multipart-aware streaming upload — used by the promotion worker to push
|
||||
|
|
@ -135,11 +82,15 @@ export const uploadDirectoryToS3 = async (bucket, keyPrefix, localDir) => {
|
|||
export const uploadStreamToS3 = async (bucket, key, readable) => {
|
||||
const { Upload } = await import('@aws-sdk/lib-storage');
|
||||
const client = createS3Client();
|
||||
const upload = new Upload({
|
||||
client,
|
||||
params: { Bucket: bucket, Key: key, Body: readable },
|
||||
queueSize: 4,
|
||||
partSize: 8 * 1024 * 1024,
|
||||
});
|
||||
await upload.done();
|
||||
try {
|
||||
const upload = new Upload({
|
||||
client,
|
||||
params: { Bucket: bucket, Key: key, Body: readable },
|
||||
queueSize: 4,
|
||||
partSize: 8 * 1024 * 1024,
|
||||
});
|
||||
await upload.done();
|
||||
} finally {
|
||||
client.destroy();
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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