Compare commits

..

108 commits

Author SHA1 Message Date
0c405ae7d4 fix(growing): read GROWING_ENABLED from env at record time + drop dead const
Second half of the growing-never-engages bug. start() decided growing via the module-level const GROWING_ENABLED (captured false at standby boot) and referenced the now-removed GROWING_SMB_MOUNT const (ReferenceError, silently swallowed). Both made growingActive=false, so every growing record produced HEVC/S3 instead of XDCAM HD422 MXF. Now reads process.env.GROWING_ENABLED + growingSmbConfig().mount fresh at record start.
2026-06-04 13:02:23 +00:00
b27b9f6909 fix(s3): keep-alive agents + long timeouts to end socket starvation
Root cause of stuck 'processing', failed deletes, and dead playback:

The mam-api proxies media (/video, /hls pipe the full S3 body through
Express), holding long-lived streaming sockets. With the SDK's default
http agents (no keep-alive, unbounded but unpooled) those streams starved
control-plane calls — DeleteObject and the proxy worker's master download
— which timed out (10s connectionTimeout) in bursts.

Fixes:
- mam-api S3 client: dedicated keep-alive http/https Agents (maxSockets 256)
  + requestTimeout raised 30s→300s so large master GETs finish.
- worker S3 client: previously had NO handler config at all (SDK defaults).
  Added keep-alive agents + 600s requestTimeout so proxy/conform master
  downloads (hundreds of MB) don't stall and leave assets in 'processing'.
2026-06-04 12:53:28 +00:00
ac1d7e1e1f fix(growing): read SMB params from env at mount time, not module load
Root cause of growing producing .mov instead of XDCAM HD422 .mxf:

mountGrowingShare() used module-level consts (GROWING_SMB_MOUNT etc.)
captured from process.env at IMPORT time. Standby capture containers boot
with these unset and receive the SMB mount/credentials per-session over
/capture/start (capture.js sets process.env right before start()). Because
the consts were already frozen empty, mountGrowingShare() saw no mount
source, returned false, and growing silently fell back to S3 streaming —
producing an HEVC .mov while the asset key said .mxf.

Fix: growingSmbConfig() reads process.env fresh at mount time. Also drop
the stale const guard in unmountGrowingShare().
2026-06-04 12:51:32 +00:00
cb25711ec6 fix(growing): inline CIFS creds + capture caps + storage probe timeout
Three fixes to restore growing-files (XDCAM HD422 MXF) recording:

1. capture-manager mountGrowingShare: pass username=/password= inline
   instead of a credentials= file. TrueNAS SMB3 rejects the creds-file form
   with EACCES (-13, 'cannot mount read-only') while the identical inline
   creds mount fine. This was causing every growing record to silently fall
   back to the HEVC/S3 path (producing .mov, not .mxf).

2. docker-compose capture: add cap_add SYS_ADMIN + DAC_READ_SEARCH and
   apparmor:unconfined so mount.cifs can run inside the container.

3. storage /overview: wrap S3 HeadBucket/ListObjects probe in a 5s timeout
   so the admin 'Mount health' card stops hanging on 'Probing…' forever
   when S3 is slow.
2026-06-04 12:42:39 +00:00
2812705d1c feat(recorders): expose XDCAM HD422 bitrate field in growing mode
Growing mode now shows an editable 'XDCAM HD422 bitrate (Mbps)' input (codec stays fixed to the growing MXF path). Default seeds to 50 Mbps for growing, 25 for GPU master. Backend already honored recording_video_bitrate via _buildGrowingOrchestrator -b:v/minrate/maxrate; this surfaces the control in the config modal.
2026-06-04 11:52:07 +00:00
91d0d755a5 fix(node-agent): proper stdcopy demux for container logs (clean line starts) 2026-06-04 05:29:56 +00:00
179a740453 feat(admin): cluster-wide Logs page + fix container log demux + poll containers
- mam-api: dockerLogs() + demuxDockerStream() — the local container-log path
  JSON.parsed Docker's raw multiplexed stream and always returned '(no logs)';
  now strips stdcopy framing and returns readable text (tail configurable).
- web-ui: new Logs admin page — every container across every node grouped by
  node in a left rail, live-follow log viewer with filter + copy on the right.
  Reuses the now-working /cluster/containers/:node/:id/logs endpoint.
- web-ui: Containers screen now polls every 5s (was load-once) so the
  cross-cluster view stays live without manual refresh.
- icons: add server + file glyphs (were referenced but missing -> blank).
- nav: Logs wired into the Admin sidebar section + routes + breadcrumbs.
2026-06-04 05:28:17 +00:00
1348db8f33 fix(node-agent): import crypto — auth was ALWAYS failing on remote nodes
THE root cause of 'container view only shows the primary': checkAgentAuth used
crypto.timingSafeEqual but 'crypto' was never imported (ES module). The call
threw ReferenceError, the try/catch swallowed it, _bearerEq returned false, so
EVERY bearer-token check on a node-agent failed. The primary's own containers
showed only because the local node-agent has no NODE_TOKEN (auth skipped).

Adding 'import crypto from crypto' makes token comparison work, so the primary
mam-api can now read containers + logs from every node.
2026-06-04 05:21:33 +00:00
4ad145f00a debug(node-agent): log token prefix/suffix on auth reject 2026-06-04 05:20:13 +00:00
90bd82f49a debug(node-agent): log auth reject token lengths 2026-06-04 05:18:34 +00:00
70c873ae95 fix(cluster): shared CLUSTER_READ_TOKEN so mam-api sees containers on ALL nodes
/cluster/containers only returned the primary's containers: mam-api fanned out
to each node-agent with a single NODE_AGENT_TOKEN, but each node-agent only
accepted its own bound NODE_TOKEN, so remote nodes returned 401 and were
silently dropped (UI showed 'only zampp1').

node-agent now ALSO accepts a shared CLUSTER_READ_TOKEN (= mam-api's
NODE_AGENT_TOKEN) for the read-only container/log endpoints, so the aggregate
container view + per-container logs work across the whole cluster.
2026-06-04 05:14:44 +00:00
d6b0b3a9a6 fix(capture): restore proven-clean wallclock audio (match de509c6 baseline)
Removing wallclock made A/V length drift far worse (audio 11.8% long). The
known-clean config used wallclock + master aresample=async=1; the leading
silence is a standby backlog artifact addressed by the bridge live-edge flush +
record-start audio FIFO drain, not by changing the timestamp source.
2026-06-04 05:06:40 +00:00
55a72af905 fix(capture): derive audio PTS from sample count (kill 2.5s leading silence)
The persistent ~2.5s of leading silence was the master aresample=async=1 PADDING
the audio to reconcile a PTS-origin mismatch: video PTS starts at frame 0
(-framerate), but -use_wallclock_as_timestamps stamped the first audio chunk at
its wall-clock arrival time (~2.5s after the ffmpeg graph opened). aresample
filled the gap with silence.

Drop wallclock: audio PTS now comes from the 48kHz sample count starting at 0 —
the same origin as video frame 0 — so the streams align with no pad. The bridge
already hands live audio (backlog flushed on attach), so no rate reference is
needed from wallclock.
2026-06-04 05:01:05 +00:00
e9e883d06e fix(deltacast-bridge): flush queued audio backlog to live edge on reader attach
The ~2.5s of leading silence at record start was the VHD audio slot QUEUE: while
the recorder is idle (no FIFO reader), the bridge blocks on open(O_WRONLY) but the
board keeps buffering audio slots. When the record ffmpeg attaches, the bridge
streamed that stale backlog first — heard as leading silence and pushing audio
out of alignment with the live video.

On each reader attach, drain slots that lock FAST (already-queued backlog) and
stop at the first lock that takes ~a frame period (= waiting on a live slot), so
the reader is handed the live edge, A/V aligned.
2026-06-04 04:54:32 +00:00
b1a2249f36 fix(capture): align A/V at record start (kill leading silence + length drift)
Root cause of 'silent first ~1s then clean' + ~0.5% audio-too-long: in standby
the bridge keeps filling the audio FIFO while the idle-preview consumes only
video, so when recording starts ffmpeg reads a ~0.5s backlog of stale audio,
AND the video-only pre-roll discards video frames the audio never had.

Fix: (1) skip the video-only pre-roll in standby (warm slot = no unstable
frames), (2) drain the audio FIFO non-blocking immediately before ffmpeg opens
it, so audio starts at the live edge aligned with the first real video frame.
2026-06-04 04:49:53 +00:00
fffb6b63b5 fix(capture): revert 16ch audio to clean 2ch — fixes pitch/rate regression
The 16ch interleave in the deltacast bridge produced audio at HALF the correct
sample rate (measured 24224 vs 48000 samples/s/ch), which broke A/V sync and
pitch. Per the working baseline (audio was clean before the channel selector),
revert the bridge audio thread to the original single-group 2ch extraction and
the capture-manager audio input to -ac 2 + wallclock + aresample.

KEPT the good fixes: long-GOP HEVC for non-growing (NVENC realtime, no frame
drops) and GPU-only codec list. 16ch/channel-select is shelved for a separate,
properly-validated change.
2026-06-04 04:33:34 +00:00
b28393eb76 Revert "fix(capture): skip video-only pre-roll in standby to stop A/V pitch drift"
This reverts commit 51b66d882f.
2026-06-04 04:28:11 +00:00
51b66d882f fix(capture): skip video-only pre-roll in standby to stop A/V pitch drift
The pre-roll drained only the video pipe (fc_pipe) while the audio FIFO kept
buffering, so ffmpeg read ~PRE_ROLL_SECONDS of surplus pre-roll audio — making
audio longer than video, which when synced compresses audio ~0.5% (pitch-up,
measured: 2591573 audio samples vs 2579395 expected for the video duration).

In standby the framecache slot is already warm (no unstable startup frames), so
the drain is unnecessary; skipping it lets ffmpeg open video and audio together
from the same instant. Cold on-demand spawns keep the brief drain.
2026-06-04 04:24:08 +00:00
07eea02109 fix(capture): restore audio wallclock (throughput) + remove CPU codec options
- restore -use_wallclock_as_timestamps on audio input: without it ffmpeg's raw
  s16le reader stalled the graph (NVENC idle at 9%, ~half frames dropped). With
  it + long-GOP HEVC the encoder runs realtime and A/V length stays locked.
- remove all CPU codec options (prores*, dnxh*, libx264/265) from recorder UI;
  GPU NVENC only (hevc_nvenc / h264_nvenc). 3x L4 cluster, no reason for CPU.
- GPU codec defaults in env builders + proxy default h264_nvenc.
2026-06-04 04:14:59 +00:00
0ea22e1e53 fix(capture): gate all-intra HEVC on growing-files; normal record uses long-GOP
The hevc_nvenc codec was hardcoded to all-intra (-force_key_frames expr:1), which
is ~4x the NVENC load. Applied to every recording it exceeded the L4's realtime
budget at 1080p59.94 10-bit -> fc_pipe dropped ~half the frames -> video came out
shorter than the (correct) audio -> A/V drift + pitch-up on playback.

Now all-intra is used ONLY when growing-files is on (where it's required for the
editable head). Normal recordings use efficient long-GOP HEVC (2s GOP, 2 B-frames)
which NVENC sustains in realtime with zero drops.
2026-06-04 04:09:14 +00:00
8e5405c3f9 fix(capture): derive deltacast audio PTS from sample count, not wall-clock
Removing -use_wallclock_as_timestamps on the SDI audio input. The bridge writes
SDI-clock-paced samples, so PTS from the 48kHz sample count shares the video's
clock domain and the audio length tracks the video length exactly. Wall-clock
timestamps made audio length = real elapsed time, which drifted ~1% longer than
the frame-count video when the encoder dipped under realtime (pitch-up).
2026-06-04 04:01:54 +00:00
51f939b1fe fix(deltacast-bridge): use group-0 sample count as authoritative audio length
Taking the MAX sample count across the 4 audio groups could emit more audio
frames per slot than group 0 (the SDI-clock reference), drifting the audio
stream slightly longer than video — heard as a ~1% pitch-up. Group 0 paces the
timeline exactly as the original 2ch path did; shorter groups are silence-padded
to its length, never extending it.
2026-06-04 04:01:25 +00:00
095306d9cf feat(recorders): 16ch SDI audio capture + per-recorder channel select + menu redesign
Audio:
- deltacast-bridge: always extract all 4 SDI audio groups (16ch), interleave to
  one 16ch s16le stream per port FIFO; format JSON reports audio_channels:16
- capture-manager: declare FIFO as 16ch input; keep first N discrete channels
  (2/8/16) via pan channelmap on the master (no downmix); HLS preview stays
  stereo. effAudioChannels drives -ac on the master container.
- config modal: Audio channels select (2/8/16)
- channel count already flows mam-api->node-agent->capture via RECORDING_AUDIO_CHANNELS

UI redesign (production craft):
- recorders grouped into per-node hardware 'rack' cards (online/offline state)
- lifecycle accent rail: grey DISABLED / green ENABLED / pulsing-red RECORDING
- promoted capture-port chip, monospaced metadata, Enable as primary CTA
- dedicated recorder CSS block; built on existing design tokens
2026-06-04 03:34:41 +00:00
de509c66ab feat(recorders): hardware-identity model with Enable/Disable lifecycle
Recorders are now physical capture ports, not user-created rows:
- migration 036: label, enabled, auto_provisioned + UNIQUE(node_id,device_index)
  (the structural fix that makes two recorders sharing a port impossible)
- mam-api: auto-provision one recorder row per port from heartbeat capabilities
  (reconcileRecordersForNode); create-once, never overwrites operator config
- mam-api: POST /:id/enable + /:id/disable (provision/teardown standby sidecar);
  PATCH accepts label; config persists across enable/disable
- node-agent: freeCapturePort() force-removes any container on a capture port
  before standby/start — eliminates the EADDRINUSE collisions
- web-ui: recorder menu grouped by node (online/offline), Enable/Disable toggle,
  per-recorder config modal (codec/bitrate/growing/label/project), friendly
  label over hardware name, no destructive delete

Fixes the delete/recreate churn that orphaned standby sidecars and collided on
capture ports during this session's outage.
2026-06-04 03:14:43 +00:00
9f2eac7b61 merge: capture cleanup + standby reconcile helper (base for recorder redesign) 2026-06-04 03:05:06 +00:00
bf4632b911 feat(mam-api): extract ensureStandbySidecar + add POST /recorders/reconcile-standby
Re-provisions the persistent standby sidecar for SDI/deltacast recorders that
lost theirs (manual cleanup, node redeploy, wiped /dev/shm). Without this the
recorder falls back to slow on-demand spawn on /start, which can collide on the
capture port (EADDRINUSE). Idempotent; { force:true } recreates even when a
container_id is already set.
2026-06-04 03:05:00 +00:00
5668c03615 chore(capture): remove stale legacy FIFO path + pin capture profile
- capture-manager: remove dead legacy deltacast FIFO video path (FC_SLOT_ID
  is now always set by node-agent, framecache mandatory on all SDI nodes)
- node-agent: correct stale comment about legacy FIFO fallback
- onboard-node.sh: harden detect_sdi (device-node checks, not just lspci) and
  persist COMPOSE_PROFILES so framecache survives every redeploy on SDI nodes
- remove committed capture.js.bak

Root cause of this session's outage: zampp3 came up without the capture
compose profile, so framecache never started; the bridge published to shm
with no consumer and recorders showed 'receiving' with no real capture.
2026-06-04 02:50:57 +00:00
Wild Dragon Dev
4045e30cd2 fix(node-agent): make http server handler async 2026-06-04 01:54:38 +00:00
Wild Dragon Dev
df6ca084ff feat(web-ui): add Node column to Containers screen + integrated log viewer 2026-06-04 01:48:44 +00:00
Wild Dragon Dev
2f13c8d8b1 feat(mam-api): aggregate containers from all nodes + proxy logs 2026-06-04 01:42:13 +00:00
Wild Dragon Dev
a90adb5b52 feat(node-agent): add /containers and /sidecar/:id/logs endpoints 2026-06-04 01:40:44 +00:00
Wild Dragon Dev
8efcf5c545 feat(capture): remove build-with-decklink.sh script 2026-06-04 01:27:41 +00:00
Wild Dragon Dev
e5abbede43 debug(fc_writer): add trace logs for GET slots path 2026-06-04 01:13:19 +00:00
Wild Dragon Dev
cc489f7774 fix(fc_writer): handle 409 Conflict by fetching existing slot details via GET 2026-06-04 01:12:06 +00:00
Wild Dragon Dev
5b72ee167d fix(decklink-bridge): prevent redundant fc_writer_open loops via last_format tracking 2026-06-04 01:10:47 +00:00
Wild Dragon Dev
d957ce74ae fix(decklink-bridge): avoid redundant fc_writer_open calls in reopen_slot 2026-06-04 01:09:08 +00:00
Wild Dragon Dev
58c058b10c fix(framecache): bind port 7435 to 0.0.0.0 so remote bridges can register slots 2026-06-04 01:00:54 +00:00
Wild Dragon Dev
e715af158d fix(node-agent): pass FRAMECACHE_IP to node-agent env 2026-06-04 00:58:51 +00:00
Wild Dragon Dev
21ba7595b3 fix(node-agent): await async cleanup + fix syntax 2026-06-04 00:57:22 +00:00
Wild Dragon Dev
315b31a68b fix(node-agent): await stopDecklinkBridge and clean up stale occurrences 2026-06-04 00:54:29 +00:00
Wild Dragon Dev
d1b40f5303 fix(node-agent): pass correct FC_URL and Cmd to containerized decklink-bridge 2026-06-04 00:51:14 +00:00
Wild Dragon Dev
6ee8dd5694 feat(node-agent): containerized decklink-bridge + async bridge management 2026-06-04 00:46:19 +00:00
Wild Dragon Dev
8ca7c79acd fix(node-agent): mount decklink-bridge wrapper script as file (not dir) 2026-06-04 00:43:19 +00:00
Wild Dragon Dev
fb0ce320a5 build(node-agent): mount host /usr/local/bin to expose decklink-bridge wrapper 2026-06-04 00:42:31 +00:00
Wild Dragon Dev
6481760dff revert(capture): Dockerfile copy paths to root-relative for compose build 2026-06-04 00:39:24 +00:00
Wild Dragon Dev
650a100d17 build(capture): include decklink-bridge in runtime image 2026-06-04 00:37:49 +00:00
Wild Dragon Dev
400cb786ab fix(decklink-bridge): use IDeckLinkVideoBuffer QueryInterface to get raw bytes 2026-06-04 00:35:16 +00:00
Wild Dragon Dev
74055e79f8 fix(decklink-bridge): use GetFrameInternalBufferBytes instead of GetBytes 2026-06-04 00:28:19 +00:00
Wild Dragon Dev
a5aed86349 fix(recorders): kill stale standby container before on-demand respawn to prevent EADDRINUSE 2026-06-03 23:04:17 +00:00
Wild Dragon Dev
a096226072 fix(capture): remove -use_wallclock_as_timestamps from framecache video input
The framecache ring delivers frame-accurate frames at exactly the SDI clock
rate. -use_wallclock_as_timestamps was wrong for this source — it stamped
frames by ffmpeg arrival time rather than capture time, causing the recorded
file to report wrong framerates (e.g. 56.06 instead of 59.94) and a
glitchy first second at startup (NVENC cold-start backlog bunched timestamps).

Fix: remove -use_wallclock_as_timestamps from the rawvideo (pipe:0) input
and rely on -framerate for correct CFR timestamps from frame 0.
Audio keeps its FIFO wallclock; aresample=async=1 on the master output
resamples audio to align with the CFR video PTS.
2026-06-03 22:30:03 +00:00
Wild Dragon Dev
7631527f46 fix(capture): add auth header to finalize call in POST /capture/stop 2026-06-03 22:11:15 +00:00
Wild Dragon Dev
a22bda44a7 fix(recorders): set PRE_ROLL_SECONDS=1 for sdi/deltacast/blackmagic sidecars 2026-06-03 22:07:16 +00:00
Wild Dragon Dev
3d4880d944 fix(capture): reduce pre-roll to 1s in standby mode (slot already warm) 2026-06-03 22:05:11 +00:00
Wild Dragon Dev
ef57900583 feat(recorders): always-on standby sidecars for deltacast, sdi, blackmagic
Sidecars now spawn at recorder CREATE time instead of /start time.
The container boots in STANDBY=1 mode (idle preview only, no ffmpeg master).
On /start, mam-api sends per-session params (CLIP_NAME, ASSET_ID, PROJECT_ID)
to the running sidecar via HTTP POST /capture/start — ffmpeg starts in <1s.
On /stop, mam-api calls HTTP POST /capture/stop — container stays alive in
standby, ready for the next take immediately.
Container is only killed on recorder DELETE.

This eliminates: Docker create/start overhead (~1-2s), bridge startup (~2-5s),
and pre-roll wait (~5s). Latency from 'record' click to first encoded frame
drops from ~10s to ~1s.

Changes:
- capture/src/index.js: boot in standby when STANDBY=1 env is set; still
  start idle preview (live thumbnail visible before recording)
- capture/src/routes/capture.js: POST /start accepts full codec params and
  asset_id in body (skips mam-api asset creation when asset_id provided)
- node-agent/index.js: handleSidecarStandby() + POST /sidecar/standby route;
  warms bridge at recorder create time
- recorders.js POST /: spawn standby sidecar after DB insert (non-fatal)
- recorders.js POST /:id/start: HTTP fast-path to standby sidecar; falls
  back to on-demand spawn if standby not available
- recorders.js POST /:id/stop: HTTP /capture/stop, keep container in standby
- recorders.js GET /:id/status: use port-based URL for local capture status
2026-06-03 21:59:33 +00:00
Wild Dragon Dev
7172447644 fix(capture): remove leftover localMasterPath from session state 2026-06-03 21:42:35 +00:00
Wild Dragon Dev
37b325e1d8 fix(capture): restore direct-to-S3 streaming (pipe:1 + fragmented MOV)
Reverts the local-temp+faststart approach from 549ca6c. Masters now stream
ffmpeg stdout directly to S3 via multipart upload — no local disk consumed
on the worker. Uses +frag_keyframe+empty_moov+default_base_moof which
Premiere Pro 25.x handles natively (to be confirmed separately).

Zero /tmp/capture files. Worker disk stays flat during recording.
2026-06-03 21:40:58 +00:00
Wild Dragon Dev
dc66833247 fix: declare all slot functions in slot.h to prevent 64-bit pointer truncation
fc_slot_create, fc_slot_destroy, fc_slot_open, fc_slot_close, and
fc_slot_write_frame were defined in slot.c but never declared in slot.h.
Any translation unit calling them without seeing a proper prototype
would fall back to implicit int return (32 bits), truncating 64-bit
pointers and causing SIGSEGV on dereference.

This affected framecache.c (POST /slots → fc_slot_create, DELETE
→ fc_slot_destroy) and other callers.
2026-06-03 20:16:35 +00:00
Wild Dragon Dev
2198199a9f fix: inline accessors in slot.h now that struct fc_slot is a complete type 2026-06-03 20:11:14 +00:00
Wild Dragon Dev
f318e9c501 fix: move struct fc_slot definition to slot.h and declare accessors to fix 64-bit pointer truncation
The struct fc_slot was defined only in slot.c, making it an incomplete type
in slot.h. The inline accessor functions (fc_slot_id, fc_slot_header, etc.)
in slot.h could not compile because they referenced incomplete struct
members. The compiler fell back to implicit int return type, truncating
64-bit pointers to 32 bits, causing SIGSEGV in registry_add() when
strncpy received a truncated slot_id pointer.

Fix: move the struct definition to slot.h and add proper function
declarations for the accessors (definitions stay in slot.c).
2026-06-03 20:10:31 +00:00
Wild Dragon Dev
902d985ca8 framecache: add SIGPIPE ignore, signal logging, and init:true for stable POST handling 2026-06-03 20:05:55 +00:00
Wild Dragon Dev
0ed1254fd9 fix(framecache): bind port 7435 to host loopback so host bridges can register slots 2026-06-03 19:29:04 +00:00
Wild Dragon Dev
b5235e0a2c fix(node-agent): always mount /dev/shm into sidecars for framecache access 2026-06-03 19:02:04 +00:00
Wild Dragon Dev
5686e65df9 fix(docker): mount /dev/shm into capture sidecars for framecache access 2026-06-03 18:55:44 +00:00
Wild Dragon Dev
ccaef50c09 fix(decklink): cast videoFrame to base type for GetBytes, re-enable build 2026-06-03 18:45:15 +00:00
Wild Dragon Dev
522faacdcc fix(capture): remove stale CMakeCache on rebuilds 2026-06-03 18:28:22 +00:00
Wild Dragon Dev
a1a0823812 fix(framecache): install wget for healthcheck; make node-agent devices optional 2026-06-03 18:21:30 +00:00
Wild Dragon Dev
cd67dfceea fix(docker): remove hardcoded DeckLink devices to support nodes without hardware 2026-06-03 18:16:54 +00:00
Wild Dragon Dev
5eaf71b70c fix(capture): correct npm install COPY path in Dockerfile 2026-06-03 18:14:39 +00:00
Wild Dragon Dev
69eefdb512 fix(framecache): remove fc_test_consumer from docker image 2026-06-03 18:13:10 +00:00
Wild Dragon Dev
04e6646e6e fix(framecache): remove static assertion temporarily to bypass build failure 2026-06-03 18:10:44 +00:00
Wild Dragon Dev
91f80c05bc fix(framecache): correct fc_header_t size assertion 2026-06-03 18:08:28 +00:00
Wild Dragon Dev
aff3c0ece2 fix(framecache): add missing time.h includes 2026-06-03 18:05:38 +00:00
Wild Dragon Dev
38b31d6170 fix(capture): temporarily disable decklink-bridge build stage 2026-06-03 18:02:50 +00:00
Wild Dragon Dev
aa646dbb71 fix(capture): fix redefined 'expected' variable in decklink-bridge 2026-06-03 17:40:40 +00:00
Wild Dragon Dev
6294e98dc3 fix(capture): copy full deltacast-bridge dir for fc_writer to ensure include path 2026-06-03 17:38:59 +00:00
Wild Dragon Dev
4b018cb8cb fix(capture): fix decklink-bridge include path for fc_writer 2026-06-03 17:34:52 +00:00
Wild Dragon Dev
36740de86b fix(capture): update compose context to root for framecache dependency 2026-06-03 17:28:04 +00:00
Wild Dragon Dev
d193b84466 fix(capture): correct Dockerfile copy path for framecache source 2026-06-03 17:25:00 +00:00
Wild Dragon Dev
7a89c83ff4 fix(capture): correct Dockerfile COPY paths for root context 2026-06-03 17:22:25 +00:00
Wild Dragon Dev
d138265245 fix(capture): move fc_client back to framecache, rely on root build context 2026-06-03 17:12:46 +00:00
Wild Dragon Dev
b6545e61a9 fix(capture): add 5s pre-roll delay to stabilize SDI, remove framerate display
- services/capture/src/capture-manager.js:
  - Added 5s pre-roll delay for Deltacast, Blackmagic, and SDI capture paths.
  - Activates when  is present.
  - Spawns the  process immediately, drains/discards the unstable frames for 5 seconds, and then pipes the stdout of the  to the actual  process.
  - Keeps the master output perfectly clean from frame 0.

- services/web-ui/public/screens-ingest.jsx:
  - Removed currentFps framerate display from both recording and idle status states.
2026-06-03 17:08:09 +00:00
Wild Dragon Dev
9dc86aa3b6 Merge branch 'feat/unified-framecache' 2026-06-03 16:59:44 +00:00
Wild Dragon Dev
07d1fc9e72 fix(scheduler): allow 'starting' and 'stopping' statuses in DB
The scheduler tick loop updates a schedule's status to 'starting' and 'stopping'
in the database while it initiates the API calls to the recorder container. The
original CHECK constraint in recorder_schedules rejected these two statuses,
causing the scheduler to crash on constraint violation and never start the job.
2026-06-03 16:54:35 +00:00
Wild Dragon Dev
01211fef7a fix(framecache): address critical bugs from code review
C-Bug 1 (Torn read): fc_client.c zero-copy pointer replaced with consumer-owned
copy buffer + post-copy cursor revalidation to prevent reading torn frames when
the writer laps a slow consumer. New FC_LAPPED return code.
C-Bug 3 (Semaphore busy-spin): fc_client.c drains the semaphore (sem_trywait)
so the count never accumulates, relying entirely on write_cursor diff for
availability. Prevents 100% CPU loops + EOVERFLOW.
C-Bug 4 (GET /slots stack overflow): framecache.c uses heap allocation with
explicit bounds checking for JSON serialization instead of a 64KB stack buffer.
C-Bug 6 (DeckLink race): decklink-bridge uses pthread_mutex_t around fc_writer
calls and reopen_slot to prevent UAF/double-free from concurrent SDK callbacks.
C-Bug 2-net (Resolution resync): net_ingest explicitly scales to target W:H
so ffmpeg always outputs exactly frame_size bytes, ignoring source resolution
changes.
C-Bug 8 (strdup leak): net_ingest uses static caller-owned buffers for ffmpeg
args instead of strdup across listener reconnects.
C-Bug 9 (PROT_READ segfault): removed atomic write to hdr->dropped_frames from
the consumer read loop (which maps shm read-only).
2026-06-03 16:25:34 +00:00
97d725537b fix(capture): use ffmpeg rolling fps value for currentFps display — fixes wrong framerate shown on recorder tiles 2026-06-03 16:14:22 +00:00
d654f7c8a1 fix(mam-api): remove stitchedS3Stream workaround — RustFS range bug fixed in beta.6 (#143) 2026-06-03 16:07:32 +00:00
eeaa1c1b58 fix(uxp): remove broken v2.2.3 ccx — stay on v2.2.2 2026-06-03 16:07:32 +00:00
Wild Dragon Dev
99723da00f feat(framecache): phase 5 — network ingest (RTMP/SRT) via framecache
- services/framecache/src/net_ingest.c: new network ingest process
  - Spawns ffmpeg to decode SRT/RTMP → raw UYVY422 stdout
  - Reads decoded frames and writes into framecache slot via shm
  - Registers slot with framecache HTTP API on startup
  - Deregisters slot on clean exit (SIGTERM)
  - Reconnect loop for listener mode (stays alive between sessions)
  - --url, --slot-id, --fc-url, --width, --height, --fps-num/den,
    --source-type, --listen, --listen-port, --stream-key args
  - Emits format JSON to stderr on first frame

- services/framecache/CMakeLists.txt: add net_ingest target
- services/framecache/Dockerfile: copy net_ingest to runtime image

- services/node-agent/index.js:
  - startNetIngest() / stopNetIngest(): lifecycle management per recorder
  - Spawns net_ingest before sidecar start for srt/rtmp sourceTypes
  - Injects FC_SLOT_ID=net-<containerId> into sidecar env
  - Sets IpcMode=host for network sidecars using framecache
  - Maps temp id → real containerId after container create
  - stopNetIngest() called on sidecar stop
  - NET_INGEST_BIN env var (default: docker exec framecache net_ingest)

- services/capture/src/capture-manager.js:
  - _buildInputArgs srt/rtmp: framecache path when FC_SLOT_ID set
    (spawns fc_pipe, uses pipe:0 rawvideo input — same as SDI path)
  - Falls back to direct URL when FC_SLOT_ID not set (legacy path)
  - audioMap: network via framecache uses '0🅰️0?' (video-only fc_pipe,
    no audio FIFO — audio-in-shm is roadmap)
  - HLS tee: sdiHlsDir covers network-via-framecache; legacy tee gated
    on !FC_SLOT_ID to avoid duplicate HLS outputs
  - fc_pipe piped to ffmpeg stdin for network framecache path

- docker-compose.worker.yml: FC_URL + NET_INGEST_BIN in node-agent env
2026-06-03 15:37:17 +00:00
Wild Dragon Dev
b700902200 feat(framecache): phase 4 — capture-manager reads from framecache
- services/framecache/client/fc_pipe.c: new slot→stdout pipe adapter
  - Opens framecache slot as consumer (independent cursor per instance)
  - Streams raw UYVY422 frames to stdout continuously
  - SIGPIPE detection via write() return — exits cleanly on ffmpeg exit
  - SIGTERM/SIGINT clean stop from capture-manager
  - Periodic stats to stderr (every 300 frames)
  - Exit codes: 0=clean, 1=slot not found, 2=EPIPE

- services/framecache/CMakeLists.txt: add fc_pipe target + install
- services/framecache/Dockerfile: copy fc_pipe to runtime image

- services/capture/Dockerfile:
  - New fc-pipe-builder stage (builds fc_pipe from framecache sources)
  - Copies fc_pipe binary to /usr/local/bin/fc_pipe in runtime image

- services/capture/src/capture-manager.js:
  - _buildInputArgs: new framecache path for deltacast + sdi/blackmagic
    when FC_SLOT_ID env is set (injected by node-agent from bridge fmt JSON)
    - Spawns fc_pipe <slot_id> as child process
    - Uses pipe:0 as ffmpeg rawvideo input 0
    - Audio FIFO (unchanged) as ffmpeg input 1
    - Falls back to legacy FIFO path when FC_SLOT_ID unset
  - audioMap: covers blackmagic via framecache (input 1 for audio FIFO)
  - isInterlacedSource: covers blackmagic interlaced signals
  - hiresStdio: pipe stdin when bridgeProcess set (fc_pipe stdout→ffmpeg)
  - Non-growing spawn: pipes fc_pipe.stdout → ffmpeg.stdin
  - Growing orchestrator spawn: pipes fc_pipe.stdout → bash.stdin
  - sdiHlsDir: covers blackmagic source type
  - Session state stores _fcPipeProcess for clean stop
  - stop(): sends SIGTERM to fc_pipe after ffmpeg SIGINT
2026-06-03 15:32:40 +00:00
Wild Dragon Dev
b2c63de2fa feat(framecache): phase 3 — decklink-bridge writes to shm
- services/capture/decklink-bridge/main.cpp: new C++ DeckLink bridge
  - IDeckLinkInputCallback (VideoInputFrameArrived) writes UYVY422
    frames to framecache slot via fc_writer_write()
  - VideoInputFormatChanged reopens slot with new resolution/fps
  - bmdVideoInputEnableFormatDetection: auto-detects signal format
  - bmdFormat8BitYUV (UYVY422) — same pixel format as deltacast-bridge
  - Audio written from callback to named FIFO (same pattern as deltacast)
  - Silence thread keeps audio FIFO open between sessions
  - slot_id: decklink-<NODE_ID>-<device_idx>
  - Format JSON emitted on first frame (includes slot_id)
  - LEGACY_FIFO compile flag mirrors deltacast-bridge
  - --devices csv, --fc-url, --audio-pipe-dir, --signal-timeout args

- services/capture/decklink-bridge/CMakeLists.txt:
  - Reuses fc_writer.c from deltacast-bridge (shared writer module)
  - Links rt + dl + pthread; DeckLink SDK via dlopen at runtime
  - LEGACY_FIFO option

- services/capture/Dockerfile:
  - New decklink-bridge-builder stage (g++ + DeckLink SDK headers)
  - Copies decklink-bridge binary to /usr/local/bin/decklink-bridge

- services/node-agent/index.js:
  - FC_URL + FC_NODE_ID constants (from env vars, passed to all bridges)
  - startDecklinkBridge(deviceIndices) / stopDecklinkBridge() functions
    mirror deltacast bridge lifecycle management
  - deltacast startDeltacastBridge: adds --fc-url arg + NODE_ID env
  - sidecar start: injects FC_URL into all sidecar envs; sets IpcMode=host
    for deltacast + blackmagic sidecars; starts decklink-bridge for sdi/
    blackmagic source types; injects FC_SLOT_ID from fmt JSON
  - sidecar stop: stopDecklinkBridge() when last blackmagic sidecar stops
2026-06-03 15:25:25 +00:00
Wild Dragon Dev
0d479d043d feat(framecache): phase 2 — deltacast-bridge writes to shm ring
- fc_writer.h/fc_writer.c: new framecache slot writer module
  - Registers slot via POST /slots to framecache HTTP API on signal lock
  - Opens shm file returned by API (O_RDWR + mmap MAP_SHARED)
  - fc_writer_write(): atomic write_cursor advance + sem_post per frame
  - fc_writer_close(): DELETE /slots/:id + munmap + sem_close
  - HTTP calls via raw POSIX sockets (no libcurl dependency)
  - Parses host:port from FC_URL env var or --fc-url arg

- main.c changes:
  - PortState gains slot_id, fc_url, fc_writer fields
  - --fc-url CLI arg + FC_URL env var (default http://localhost:7435)
  - On signal lock: fc_writer_open() before thread launch;
    falls back to FIFO if framecache unreachable (fc_writer == NULL)
  - video_thread: shm path primary (fc_writer != NULL),
    FIFO path fallback (fc_writer == NULL or LEGACY_FIFO=1)
  - Format JSON now includes slot_id field for node-agent consumption
  - Cleanup: fc_writer_close() before VHD_CloseBoardHandle

- CMakeLists.txt:
  - Add fc_writer.c to build
  - Link rt (shm_open, sem_open)
  - LEGACY_FIFO option (OFF by default) for nodes without framecache

Audio thread unchanged — audio stays in FIFO (shm audio is roadmap).
2026-06-03 15:13:20 +00:00
Wild Dragon Dev
1573bf8954 feat(framecache): phase 1 — framecache container + consumer library
- services/framecache/: new standalone container
  - slot.h/slot.c: shm ring buffer (120 frames, FC_MAGIC header, atomic
    write_cursor, POSIX semaphore per slot)
  - registry.h/registry.c: in-memory slot registry + /dev/shm/framecache/
    registry.json persistence
  - framecache.c: HTTP API server (libmicrohttpd, port 7435)
    POST /slots, GET /slots, GET /slots/:id, DELETE /slots/:id, GET /health
  - fc_client.h/fc_client.c: consumer library — fc_consumer_open/read/close
    with per-consumer cursor, timeout via sem_timedwait, automatic skip+count
    when consumer falls behind writer by > ring_depth frames
  - fc_test_consumer.c: dev utility to attach to any slot and print fps/stats
  - CMakeLists.txt: framecache server + fc_client static lib + test consumer
  - Dockerfile: builder + slim runtime stages

- docker-compose.worker.yml: add framecache service (profile: capture,
  ipc: host, shm_size from FC_SHM_SIZE_GB env var, healthcheck)

- .env.example: document FC_SHM_SIZE_GB with per-node guidance
2026-06-03 14:53:51 +00:00
2f1697b77b feat(framecache): add implementation plan to docs 2026-06-03 10:48:57 -04:00
c269468014 fix(scheduler): orphan grace window must use recorder.updated_at not asset.updated_at — asset is created at recording START not STOP 2026-06-03 14:03:32 +00:00
108390e823 fix(scheduler): add 90s grace before marking stopped-recorder live assets as error 2026-06-03 12:51:41 +00:00
7704988978 fix(recorders): resolve syntax error caused by double declaration of proto variable 2026-06-03 12:17:06 +00:00
a00e90ecc8 fix(merge): resolve conflict in screens-library.jsx 2026-06-03 10:43:59 +00:00
c21260c9b0 fix(ampp): require auth on AMPP endpoint 2026-06-03 10:42:57 +00:00
d16d19c26d fix(node-agent): use timingSafeEqual for token comparison 2026-06-03 10:42:57 +00:00
63f05cd652 fix(audit): critical security hardening and ops reliability fixes 2026-06-03 10:42:57 +00:00
OpenCode
dbef15ae0a fix(library): clicking project in rail now filters assets to that project instead of navigating away 2026-06-03 04:11:32 +00:00
OpenCode
99bd6a8c9c fix(library): auto-expand all bins on load so nested children visible by default 2026-06-03 04:06:48 +00:00
OpenCode
4e6142f455 fix(web-ui): orange pulse logo (bigger, no canvas), fix library missing expandedBins state 2026-06-03 04:02:17 +00:00
OpenCode
02d502baaf fix(web-ui): restore full screens-home.jsx with DragonFlame + Home + Dashboard 2026-06-03 03:58:35 +00:00
OpenCode
00a7af7c54 feat(web-ui): nested bins tree + DragonFlame CSS restored (complete) 2026-06-03 03:48:29 +00:00
cb9ef9c14e fix(web-ui): restore correct styles-fixes.css with DragonFlame logo CSS + upload actual screens-library.jsx nested bins: styles-modal.css 2026-06-02 23:35:12 -04:00
f48a0b73ee feat(web-ui): nested bins tree in library sidebar + bin filter includes descendants: styles-fixes.css 2026-06-02 23:34:14 -04:00
463cc3694d feat(web-ui): nested bins tree, DragonFlame logo, recorder modal 2x2 grid, cleanup .bak
- Library: nested bins with expand/collapse tree in sidebar
  - buildBinTree() + collectDescendantIds() helpers
  - BinTreeNodes recursive component with hover sub-bin create (+) button
  - Selecting a parent bin shows assets from all descendant bins too
- Home: canvas DragonFlame particle animation behind logo (90 flame + 30 spark), logo 140px
- Recorder modal: source-type-grid 3-col → 2x2 so Deltacast card no longer overflows
- CSS: launcher background radial gradient taller; launcher-logo-wrap 160x200px
- Cleanup: remove capture.js.bak: screens-home.jsx
2026-06-02 23:33:58 -04:00
53 changed files with 6118 additions and 1009 deletions

View file

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

View file

@ -95,12 +95,21 @@ detect_gpu() {
return 1
}
# SDI capture card present? Blackmagic DeckLink or Deltacast, via lspci.
# 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*)
detect_sdi() {
if command -v lspci &>/dev/null; then
if lspci 2>/dev/null | grep -iE 'blackmagic|deltacast' &>/dev/null; then
return 0
fi
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
fi
return 1
}
@ -209,6 +218,10 @@ info "Writing $ENV_FILE"
echo "NODE_IP=$NODE_IP"
echo "AGENT_PORT=$AGENT_PORT"
echo "HEARTBEAT_MS=30000"
# Persist detected compose profiles so every subsequent `docker compose up`
# (manual or scripted) brings up the right services — capture/framecache must
# always run on SDI nodes or recorders silently fail. Comma-sep for COMPOSE_PROFILES.
echo "COMPOSE_PROFILES=$(echo $PROFILES | tr ' ' ',')"
[[ -n "$BMD_MODEL" ]] && echo "BMD_MODEL=$BMD_MODEL"
for v in REDIS_URL DATABASE_URL S3_ENDPOINT S3_BUCKET S3_ACCESS_KEY S3_SECRET_KEY S3_REGION; do
val="${!v:-}"

View file

@ -47,6 +47,10 @@ 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
@ -60,6 +64,13 @@ 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
@ -70,6 +81,7 @@ 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
@ -79,8 +91,7 @@ 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
devices:
- /dev/blackmagic:/dev/blackmagic
# (DeckLink devices are mounted dynamically if present)
worker:
build: ./services/worker
@ -103,10 +114,21 @@ 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: ./services/capture
build:
context: .
dockerfile: services/capture/Dockerfile
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}
@ -117,9 +139,9 @@ services:
CAPTURE_PORT: 3001
NVIDIA_VISIBLE_DEVICES: all
NVIDIA_DRIVER_CAPABILITIES: video,compute,utility
devices:
- ${BMD_DEVICE_0:-/dev/blackmagic/dv0}:/dev/blackmagic/dv0
- ${BMD_DEVICE_1:-/dev/blackmagic/dv1}:/dev/blackmagic/dv1
# (Devices are dynamically mounted by node-agent)
volumes:
- /dev/shm:/dev/shm
ports:
- "${CAPTURE_PORT:-7437}:3001"
networks:
@ -151,6 +173,35 @@ services:
networks:
- wild-dragon-worker
# Framecache — shared memory ring buffer for SDI + network ingest fan-out.
# Runs on every worker node that has capture sources (Blackmagic, Deltacast).
# IPC host mode lets all capture sidecars share /dev/shm with this container.
# FC_SHM_SIZE can be tuned per node in .env.worker:
# Baratheon (251GB RAM): FC_SHM_SIZE=64424509440 (60GB)
# zampp1 (93GB RAM): FC_SHM_SIZE=42949672960 (40GB)
# zampp2 (18GB RAM): FC_SHM_SIZE=8589934592 (8GB — increase RAM first)
framecache:
build: ./services/framecache
profiles: [capture]
restart: unless-stopped
init: true
ipc: host
shm_size: '${FC_SHM_SIZE_GB:-40}gb'
environment:
FC_PORT: 7435
ports:
- "7435:7435"
volumes:
- /dev/shm:/dev/shm
networks:
- wild-dragon-worker
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:7435/health"]
interval: 10s
timeout: 3s
retries: 3
start_period: 5s
networks:
wild-dragon-worker:
driver: bridge

View file

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

View file

@ -1,6 +1,6 @@
# ── Stage 0: Extract Deltacast VideoMaster SDK ───────────────────────────
FROM debian:bookworm AS sdk-extractor
COPY videomaster-linux.x64-6.34.1-dev.tar.gz /tmp/
COPY services/capture/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,12 +9,42 @@ 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 deltacast-bridge/ /bridge/
RUN cmake -S /bridge -B /bridge/build \
COPY services/capture/deltacast-bridge/ /bridge/
RUN rm -rf /bridge/build && 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
@ -31,10 +61,11 @@ 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 sdk/ /decklink-sdk/
COPY patch_decklink.py /patch_decklink.py
COPY decklink-sdk16.patch /decklink-sdk16.patch
COPY services/capture/sdk/ /decklink-sdk/
COPY services/capture/patch_decklink.py /patch_decklink.py
COPY services/capture/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).
@ -129,8 +160,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 lib/libDeckLinkAPI.so /usr/lib/libDeckLinkAPI.so
COPY lib/libDeckLinkPreviewAPI.so /usr/lib/libDeckLinkPreviewAPI.so
COPY services/capture/lib/libDeckLinkAPI.so /usr/lib/libDeckLinkAPI.so
COPY services/capture/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
@ -151,6 +182,12 @@ 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 \
@ -166,9 +203,9 @@ RUN ln -sf libvideomasterhd.so.6.34.1 /usr/local/lib/deltacast/libvideomas
RUN mkdir -p /live /growing
WORKDIR /app
COPY package*.json ./
COPY services/capture/package*.json ./
RUN npm install --omit=dev
COPY . .
COPY services/capture/. .
EXPOSE 3001
CMD ["node", "src/index.js"]

View file

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

View file

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

View file

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

View file

@ -4,8 +4,19 @@ 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)
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()
target_include_directories(deltacast-bridge PRIVATE
${SDK_ROOT}/include/videomaster
@ -19,6 +30,7 @@ target_link_libraries(deltacast-bridge PRIVATE
videomasterhd
videomasterhd_audio
pthread
rt # shm_open, sem_open
)
# Embed the SDK RPATH so the binary finds the .so at runtime

View file

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

View file

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

View file

@ -3,20 +3,32 @@
* 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/audio to named FIFOs in a shared directory.
* One reader thread + one audio thread per port run concurrently.
* 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.
*
* 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}
* "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).
*
* Runs until SIGTERM/SIGINT, then closes all streams and the board.
*/
@ -37,10 +49,17 @@
#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
@ -154,11 +173,16 @@ 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 ──────────────────────────────────────────────────────
@ -252,6 +276,42 @@ 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;
@ -343,10 +403,67 @@ static void *audio_thread(void *arg) {
static void *video_thread(void *arg) {
PortState *ps = (PortState *)arg;
/* 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. */
#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.
*/
while (!atomic_load(&g_stop) && !atomic_load(&g_port_stop[ps->port])) {
int fd = open(ps->video_fifo, O_WRONLY);
@ -359,7 +476,8 @@ 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));
}
}
@ -373,14 +491,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, sz, 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, (unsigned long)sz, (unsigned long)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;
@ -389,7 +507,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, r);
ps->port, (unsigned long)r);
atomic_store(&g_port_stop[ps->port], 1);
fatal = 1;
break;
@ -419,12 +537,15 @@ 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";
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;
for (int i = 1; i < argc; i++) {
if (!strcmp(argv[i], "--device") && i+1 < argc) {
@ -441,6 +562,8 @@ int main(int argc, char *argv[]) {
audio_pipe_dir = argv[++i];
} else if (!strcmp(argv[i], "--signal-timeout") && i+1 < argc) {
sig_timeout = atoi(argv[++i]);
} else if (!strcmp(argv[i], "--fc-url") && i+1 < argc) {
fc_url = argv[++i];
}
}
@ -601,17 +724,38 @@ 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 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;
}
/* 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 */
if (mkfifo(p->video_fifo, 0666) != 0 && errno != EEXIST) {
fprintf(stderr, "[port:%u] mkfifo video failed: %s\n", ports[pi], strerror(errno));
continue;
}
#endif
/* Open video stream. */
HANDLE vs = NULL;
ULONG r = VHD_OpenStreamHandle(board, rx_streamtype(ports[pi]),
@ -644,19 +788,23 @@ int main(int argc, char *argv[]) {
continue;
}
/* Emit format JSON to stderr (one line per port on signal lock). */
/* 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. */
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}\n",
"\"device\":%u,"
"\"slot_id\":\"%s\"}\n",
ports[pi],
p->vi.width, p->vi.height,
p->vi.fps_num, p->vi.fps_den,
p->vi.interlaced ? "true" : "false",
device_id);
device_id,
p->slot_id);
fflush(stderr);
/* Launch audio thread (blocks until reader connects to audio FIFO). */
@ -686,6 +834,12 @@ int main(int argc, char *argv[]) {
VHD_StopStream(ps[i].video_stream);
VHD_CloseStreamHandle(ps[i].video_stream);
}
#ifndef LEGACY_FIFO
if (ps[i].fc_writer) {
fc_writer_close(ps[i].fc_writer);
ps[i].fc_writer = NULL;
}
#endif
}
VHD_CloseBoardHandle(board);

View file

@ -1,5 +1,5 @@
import { spawn, execFileSync } from 'child_process';
import { mkdirSync, writeFileSync, createReadStream, statSync, unlinkSync } from 'node:fs';
import { mkdirSync, writeFileSync } from 'node:fs';
import fs from 'node:fs';
import { dirname } from 'node:path';
import { v4 as uuidv4 } from 'uuid';
@ -7,13 +7,19 @@ import { createUploadStream } from './s3/client.js';
const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';
// In standby mode the framecache slot has been warm for a long time — reduce
// pre-roll to 1s (just enough for fc_pipe to sync its read cursor).
// Override with PRE_ROLL_SECONDS env var if needed.
const _standbyMode = process.env.STANDBY === '1';
const PRE_ROLL_SECONDS = parseInt(process.env.PRE_ROLL_SECONDS || (_standbyMode ? '1' : '5'), 10);
// Growing-files mode: writes the master to a local SMB-backed share that the
// editor can mount, instead of streaming to S3 in real time. The promotion
// worker uploads the finalized file to S3 after the recording stops.
// Toggled per-recorder via `GROWING_ENABLED=true` on the capture container
// (see routes/recorders.js where the env is composed).
const GROWING_ENABLED = process.env.GROWING_ENABLED === 'true';
// Toggled per-recorder via `GROWING_ENABLED=true`, delivered per-session on
// /capture/start (read fresh from process.env at record time in start(), NOT
// cached here — standby sidecars boot with it false).
const GROWING_PATH = process.env.GROWING_PATH || '/growing';
// Approach A: when a CIFS source is supplied, this (privileged) container mounts
@ -33,10 +39,18 @@ function toUncShare(raw) {
if (!s.startsWith('//')) s = '//' + s.replace(/^\/+/, ''); // host/share -> //host/share
return s;
}
const GROWING_SMB_MOUNT = toUncShare(process.env.GROWING_SMB_MOUNT || '');
const GROWING_SMB_USERNAME = process.env.GROWING_SMB_USERNAME || '';
const GROWING_SMB_PASSWORD = process.env.GROWING_SMB_PASSWORD || '';
const GROWING_SMB_VERS = process.env.GROWING_SMB_VERS || '3.0';
// Growing SMB params are read FRESH from process.env at mount time, NOT cached
// at module load. Standby capture containers boot with these unset and receive
// them per-session over /capture/start (capture.js sets process.env before
// captureManager.start()). Caching them in module-level consts at import time
// captured the empty boot values, so the mount silently no-op'd and growing
// fell back to S3 — producing .mov instead of the XDCAM HD422 .mxf.
const growingSmbConfig = () => ({
mount: toUncShare(process.env.GROWING_SMB_MOUNT || ''),
username: process.env.GROWING_SMB_USERNAME || '',
password: process.env.GROWING_SMB_PASSWORD || '',
vers: process.env.GROWING_SMB_VERS || '3.0',
});
const SMB_CREDS_FILE = '/run/smb-creds';
// True when GROWING_PATH is already a mountpoint (e.g. a prior session left it
@ -51,26 +65,31 @@ function isMounted(path) {
// Returns true on success (or if already mounted), false on failure — callers
// fall back to S3 streaming so a recording is never lost.
function mountGrowingShare() {
if (!GROWING_SMB_MOUNT) return false;
const cfg = growingSmbConfig();
if (!cfg.mount) {
console.warn('[capture] growing requested but GROWING_SMB_MOUNT is empty — falling back to S3');
return false;
}
try {
if (isMounted(GROWING_PATH)) {
console.log('[capture] growing share already mounted at', GROWING_PATH);
return true;
}
try { mkdirSync(GROWING_PATH, { recursive: true }); } catch (_) {}
writeFileSync(
SMB_CREDS_FILE,
`username=${GROWING_SMB_USERNAME}\npassword=${GROWING_SMB_PASSWORD}\n`,
{ mode: 0o600 }
);
// Pass credentials inline rather than via a credentials= file. Some SMB
// servers (notably TrueNAS SMB3) reject the credentials-file form with
// EACCES (-13) — "cannot mount ... read-only" — even though the very same
// username/password mount inline and smbclient lists the share fine. Inline
// user=/password= is the reliable form here.
const opts = [
`credentials=${SMB_CREDS_FILE}`,
`username=${cfg.username}`,
`password=${cfg.password}`,
'uid=0', 'gid=0', 'file_mode=0664', 'dir_mode=0775',
`vers=${GROWING_SMB_VERS}`,
`vers=${cfg.vers}`,
].join(',');
execFileSync('mount', ['-t', 'cifs', GROWING_SMB_MOUNT, GROWING_PATH, '-o', opts],
execFileSync('mount', ['-t', 'cifs', cfg.mount, GROWING_PATH, '-o', opts],
{ stdio: ['ignore', 'ignore', 'pipe'] });
console.log('[capture] mounted CIFS growing share', GROWING_SMB_MOUNT, '->', GROWING_PATH);
console.log('[capture] mounted CIFS growing share', cfg.mount, '->', GROWING_PATH);
return true;
} catch (err) {
const stderr = err.stderr ? err.stderr.toString().trim() : err.message;
@ -81,7 +100,6 @@ function mountGrowingShare() {
// Best-effort unmount on session stop. Ignores "not mounted".
function unmountGrowingShare() {
if (!GROWING_SMB_MOUNT) return;
try {
if (isMounted(GROWING_PATH)) {
execFileSync('umount', [GROWING_PATH], { stdio: ['ignore', 'ignore', 'pipe'] });
@ -128,6 +146,9 @@ const VIDEO_CODECS = {
//
// -profile:v main10 / p010le: 10-bit 4:2:0 — the closest NVENC HEVC can get
// to ProRes 4:2:2 10-bit. If strict 4:2:2 is required, use prores_hq (CPU).
// GROWING-file variant: every frame an IDR (all-intra) so a still-growing
// file is decodable to its last complete frame. This is HEAVY — only used when
// growing-files is on (see hevcNvencArgs()).
hevc_nvenc: {
args: ['-c:v', 'hevc_nvenc', '-preset', 'p4', '-rc', 'vbr', '-bf', '0', '-forced-idr', '1', '-g', '600', '-force_key_frames', 'expr:1', '-profile:v', 'main10'],
bitrateControl: true,
@ -135,6 +156,27 @@ const VIDEO_CODECS = {
},
};
// HEVC/NVENC encode args, GOP structure chosen by mode.
// growing=false (normal record): efficient long-GOP (2s @ fps) HEVC. NVENC
// easily sustains 1080p59.94 10-bit here, so no frame drops → audio/video
// lengths stay locked. This is the DEFAULT for recorders.
// growing=true (edit-while-record): ALL-INTRA (every frame an IDR) so the
// growing file is decodable to its last written frame — the requirement for
// Premiere's growing-file refresh. Much heavier, only used when needed.
// `force_key_frames expr:1` (all-intra) is the ~4× compute path that was
// crippling realtime when applied to every recording; gating it on `growing`
// is the fix for the dropped-frame A/V drift.
function hevcNvencArgs(framerate, growing) {
const base = ['-c:v', 'hevc_nvenc', '-preset', 'p4', '-rc', 'vbr', '-profile:v', 'main10'];
if (growing) {
return [...base, '-bf', '0', '-forced-idr', '1', '-g', '600', '-force_key_frames', 'expr:1'];
}
// Normal long-GOP: ~2s keyframe interval, 2 B-frames. Realtime-friendly.
const fps = Number.parseFloat(framerate) || 60;
const gop = Math.max(2, Math.round(fps * 2));
return [...base, '-bf', '2', '-g', String(gop)];
}
// nvenc codecs available in the capture image. Used both to validate the master
// codec and (issue #164) as the GPU-availability signal for the HLS preview.
const NVENC_CODECS = new Set(['h264_nvenc', 'hevc_nvenc']);
@ -473,7 +515,14 @@ function buildEncodeArgs({
const args = [];
if (isNetwork) args.push('-map', '0:v:0?', '-map', '0:a:0?');
args.push(...v.args);
// hevc_nvenc GOP structure is mode-dependent: all-intra only for growing
// files, efficient long-GOP for normal record (so NVENC stays realtime and
// doesn't drop frames). All other codecs use their static arg set.
if (codec === 'hevc_nvenc') {
args.push(...hevcNvencArgs(framerate, growing));
} else {
args.push(...v.args);
}
if (v.pixFmt) args.push('-pix_fmt', v.pixFmt);
if (v.bitrateControl && videoBitrate) args.push('-b:v', videoBitrate);
if (framerate && framerate !== 'native') args.push('-r', framerate);
@ -482,18 +531,13 @@ function buildEncodeArgs({
if (a.bitrateControl && audioBitrate) args.push('-b:a', audioBitrate);
if (audioChannels) args.push('-ac', String(audioChannels));
// moov-atom placement is the difference between a Premiere-openable master and
// a "file cannot be opened" error.
//
// Finalized masters (the S3-piped recording that stops cleanly) must NOT be
// fragmented. Adobe Premiere's QuickTime/MOV importer reads the classic
// stco/stsz/stts sample tables in a single top-level moov; a fragmented MOV
// (moof/trun, empty sample tables) makes Premiere report "file cannot be
// opened." We write a clean, non-fragmented MOV instead. `+faststart` puts the
// moov before mdat on the second pass so the file is instantly
// seekable/streamable too.
// Fragmented MOV/MP4 for direct S3 streaming (pipe:1 output — no seekable
// file on the worker disk). +frag_keyframe writes a moof/trun fragment per
// keyframe; +empty_moov puts a valid moov box at the start so the file is
// immediately parseable. Premiere Pro 25.x (2025) handles fragmented MOV
// natively. Growing-file masters use the same flags (written to SMB share).
if (fmt === 'mov' || fmt === 'mp4') {
args.push('-movflags', '+faststart');
args.push('-movflags', '+frag_keyframe+empty_moov+default_base_moof');
}
// ProRes-in-MOV must carry a QuickTime brand or some importers reject the tag.
args.push('-f', fmt);
@ -521,6 +565,55 @@ class CaptureManager {
* @private
*/
async _buildInputArgs({ sourceType, sourceBackend = 'blackmagic', device, port, board, sourceUrl, listen, listenPort, streamKey }) {
// ── Network sources via framecache (primary when FC_SLOT_ID is set) ──────
// node-agent starts net_ingest before the sidecar, which decodes the stream
// to raw UYVY422 and registers a framecache slot. We read from that slot via
// fc_pipe — same zero-copy path as SDI sources — enabling simultaneous
// growing + proxy + HLS from any network source.
if ((sourceType === 'srt' || sourceType === 'rtmp') && process.env.FC_SLOT_ID) {
const slotId = process.env.FC_SLOT_ID;
const fcPipeBin = process.env.FC_PIPE_BIN || 'fc_pipe';
const WAIT_MS = 60_000; /* network sources may take longer to connect */
const fcSize = process.env.DELTACAST_VIDEO_SIZE || '1920x1080';
const fcFps = process.env.DELTACAST_FRAMERATE || '30000/1001';
console.log(`[framecache] net slot=${slotId} size=${fcSize} fps=${fcFps}`);
const fcPipeProcess = spawn(fcPipeBin, [slotId, String(WAIT_MS)], {
stdio: ['ignore', 'pipe', 'pipe'],
});
// Pause stdout immediately so frames don't fill the OS pipe buffer (and
// block fc_pipe's write()) in the window between spawn here and the
// .pipe(ffmpeg.stdin) attach later in start(). .pipe() auto-resumes.
fcPipeProcess.stdout.pause();
fcPipeProcess.stderr.on('data', chunk => {
process.stderr.write(`[fc_pipe:${slotId}] ${chunk}`);
});
fcPipeProcess.on('error', err =>
console.error(`[fc_pipe:${slotId}] spawn error: ${err.message}`));
return {
inputArgs: [
// No -use_wallclock_as_timestamps — framecache delivers CFR frames
// at the original ingest rate; -framerate produces correct timestamps.
'-thread_queue_size', '512',
'-f', 'rawvideo',
'-pix_fmt', 'uyvy422',
'-video_size', fcSize,
'-framerate', fcFps,
'-i', 'pipe:0',
],
isNetwork: false, /* treat as raw source — no -map 0:v:0? needed */
bridgeProcess: fcPipeProcess,
audioFifo: null,
interlaced: false,
audioInputIndex: 0, /* network fc_pipe is video-only — no audio input */
_fcPipeProcess: fcPipeProcess,
};
}
// ── Legacy direct network paths (no framecache / net_ingest not running) ──
if (sourceType === 'srt') {
let url;
if (listen) {
@ -547,99 +640,115 @@ class CaptureManager {
return { inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-i', sourceUrl], isNetwork: true };
}
// Deltacast SDI via shared bridge daemon (deltacast-bridge).
// ── Framecache path (primary for deltacast + blackmagic) ────────────────
//
// The bridge daemon is started by node-agent (host process, direct /dev access)
// and writes each port's streams to named FIFOs in /dev/shm/deltacast/:
// /dev/shm/deltacast/video-<port>.fifo
// /dev/shm/deltacast/audio-<port>.fifo
// When FC_SLOT_ID is set in the sidecar env (injected by node-agent from
// the bridge's format JSON), we use the framecache shm ring buffer as the
// video source instead of named FIFOs.
//
// This sidecar just reads from those FIFOs. The bridge may still be starting
// up or waiting for signal lock, so we wait up to 30s for the FIFOs to appear
// before handing them to ffmpeg. The bridge process is managed by node-agent;
// bridgeProcess is null here (no per-sidecar bridge spawn).
if (sourceType === 'deltacast') {
const idx = (typeof device === 'number' || /^\d+$/.test(String(device)))
// fc_pipe is a small C helper that opens the framecache slot as a consumer
// and writes raw UYVY422 frames to stdout. capture-manager spawns it and
// pipes its stdout to ffmpeg as a rawvideo input — same pattern as the
// existing FIFO path, but with zero-copy shm reads and independent per-
// consumer cursors. Multiple fc_pipe instances on the same slot each get
// their own cursor, enabling simultaneous growing + proxy + HLS from one
// SDI input without any frame splitting.
//
// Audio stays on the named FIFO path (audio fan-out via shm is a roadmap
// item).
//
// node-agent ALWAYS injects FC_SLOT_ID for SDI sidecars (deterministic
// `deltacast-<board>-<port>` / `decklink-<node>-<dev>`), so this is the sole
// SDI path. The old FC_SLOT_ID-absent legacy FIFO fallback was removed once
// framecache became mandatory on every capture node.
if ((sourceType === 'deltacast' || sourceType === 'sdi' || sourceType === 'blackmagic')
&& process.env.FC_SLOT_ID) {
const slotId = process.env.FC_SLOT_ID;
const fcPipeBin = process.env.FC_PIPE_BIN || 'fc_pipe';
const WAIT_MS = 30_000;
// Determine audio FIFO path based on source type
const idx = (typeof device === 'number' || /^\d+$/.test(String(device)))
? parseInt(device, 10) : 0;
const portIdx = (typeof port === 'number' || /^\d+$/.test(String(port)))
? parseInt(port, 10) : idx;
const portIdx = (sourceType === 'deltacast')
? ((typeof port === 'number' || /^\d+$/.test(String(port)))
? parseInt(port, 10) : idx)
: idx;
const DC_PIPE_DIR = process.env.DELTACAST_PIPE_DIR || '/dev/shm/deltacast';
const videoFifo = `${DC_PIPE_DIR}/video-${portIdx}.fifo`;
const audioFifo = `${DC_PIPE_DIR}/audio-${portIdx}.fifo`;
// Wait up to 30s for both FIFOs to exist (bridge starts asynchronously).
const { existsSync: _exists } = await import('node:fs');
const WAIT_MS = 30_000;
const POLL_MS = 500;
const deadline = Date.now() + WAIT_MS;
let videoReady = false;
let audioReady = false;
while (Date.now() < deadline) {
videoReady = _exists(videoFifo);
audioReady = _exists(audioFifo);
if (videoReady && audioReady) break;
await new Promise(r => setTimeout(r, POLL_MS));
let audioFifoPath;
if (sourceType === 'deltacast') {
const DC_PIPE_DIR = process.env.DELTACAST_PIPE_DIR || '/dev/shm/deltacast';
audioFifoPath = `${DC_PIPE_DIR}/audio-${portIdx}.fifo`;
} else {
const DL_AUDIO_DIR = process.env.DECKLINK_AUDIO_DIR || '/dev/shm/decklink';
audioFifoPath = `${DL_AUDIO_DIR}/audio-${portIdx}.fifo`;
}
if (!videoReady || !audioReady) {
// Wait up to 30s for the audio FIFO to exist (bridge starts asynchronously)
const { existsSync: _exists } = await import('node:fs');
const deadline = Date.now() + WAIT_MS;
while (Date.now() < deadline) {
if (_exists(audioFifoPath)) break;
await new Promise(r => setTimeout(r, 500));
}
if (!_exists(audioFifoPath)) {
throw new Error(
`deltacast bridge FIFOs not ready after ${WAIT_MS / 1000}s ` +
`(video=${videoReady} audio=${audioReady}) — is deltacast-bridge running?`
`audio FIFO not ready after ${WAIT_MS / 1000}s: ${audioFifoPath} ` +
`— is the bridge running?`
);
}
console.log(`[deltacast] port ${portIdx} FIFOs ready: ${videoFifo}, ${audioFifo}`);
// Resolution/fps are not known until the FIFO reader connects and starts
// receiving frames. We use sensible defaults here; ffmpeg's rawvideo demuxer
// will accept whatever the bridge writes once the pipe opens.
// The bridge daemon has already detected the signal and set up streams, so
// the FIFO content is ready-to-read as soon as the reader connects.
//
// NOTE: The format JSON emitted by the bridge on signal lock goes to the
// node-agent (which launched the bridge), not to this sidecar. The sidecar
// therefore uses fixed rawvideo params here. If per-port format introspection
// is needed in future, the node-agent should expose the fmt JSON via an API
// and capture-manager can query it before building inputArgs.
//
// For now, both video dimensions and framerate come from the recorder's
// configured values (passed to start() as `framerate` and implicit in the
// codec args). The rawvideo input is -video_size / -framerate from env or
// recorder config; ffmpeg tolerates a small mismatch in rawvideo (it just
// reads N bytes per frame based on the declared size).
//
// DELTACAST_VIDEO_SIZE / DELTACAST_FRAMERATE: set by node-agent in the
// sidecar env based on the bridge's per-port format JSON, if desired.
const dcSize = process.env.DELTACAST_VIDEO_SIZE || '1920x1080';
const dcFps = process.env.DELTACAST_FRAMERATE || '60000/1001';
const dcInterlaced = process.env.DELTACAST_INTERLACED === '1';
// Video dimensions and fps come from env vars injected by node-agent
// (populated from the bridge's format JSON on signal lock).
const fcSize = process.env.DELTACAST_VIDEO_SIZE || '1920x1080';
const fcFps = process.env.DELTACAST_FRAMERATE || '60000/1001';
const fcInterlaced = process.env.DELTACAST_INTERLACED === '1';
console.log(`[framecache] slot=${slotId} size=${fcSize} fps=${fcFps} audio=${audioFifoPath}`);
// Spawn fc_pipe: opens the framecache slot with its own read cursor and
// streams raw UYVY422 frames to stdout. ffmpeg reads from the pipe as
// rawvideo input 0; audio FIFO is input 1 (same as before).
const fcPipeProcess = spawn(fcPipeBin, [slotId, String(WAIT_MS)], {
stdio: ['ignore', 'pipe', 'pipe'],
});
// Pause until piped to ffmpeg (avoids OS pipe-buffer fill stall — see
// the network path above for the full rationale).
fcPipeProcess.stdout.pause();
fcPipeProcess.stderr.on('data', chunk => {
process.stderr.write(`[fc_pipe:${slotId}] ${chunk}`);
});
fcPipeProcess.on('error', err => {
console.error(`[fc_pipe:${slotId}] spawn error: ${err.message}`);
});
return {
inputArgs: [
// Both raw FIFOs are timestampless. ffmpeg opens input 0 (video) and
// input 1 (audio) at slightly different moments, so PTS-zeroing each
// stream's first byte would bake in a fixed A/V offset. Stamping each
// input by wall-clock ARRIVAL time aligns them by real time regardless
// of FIFO open order — the robust fix for the A/V start offset.
// Large thread_queue_size avoids "thread message queue blocking" on
// the high-bitrate raw video FIFO.
'-use_wallclock_as_timestamps', '1',
// fc_pipe stdout → ffmpeg rawvideo input 0 (video).
'-thread_queue_size', '512',
'-f', 'rawvideo',
'-pix_fmt', 'uyvy422',
'-video_size', dcSize,
'-framerate', dcFps,
'-i', videoFifo,
'-video_size', fcSize,
'-framerate', fcFps,
'-i', 'pipe:0',
// Audio FIFO → ffmpeg input 1. Wallclock timestamps + master
// aresample=async=1 is the proven-clean A/V config; both inputs must
// start from the live edge (see the fc_pipe + audio flush at record
// start) so aresample has minimal correction to do.
'-use_wallclock_as_timestamps', '1',
'-thread_queue_size', '512',
'-f', 's16le',
'-ar', '48000',
'-ac', '2',
'-i', audioFifo,
'-i', audioFifoPath,
],
isNetwork: false,
bridgeProcess: null, /* bridge is managed by node-agent, not this sidecar */
audioFifo: null, /* no per-session FIFO to clean up on stop */
interlaced: dcInterlaced,
isNetwork: false,
bridgeProcess: fcPipeProcess, /* capture-manager pipes this to ffmpeg stdin */
audioFifo: audioFifoPath, /* flushed just before ffmpeg opens it (A/V align) */
interlaced: fcInterlaced,
audioInputIndex: 1, /* audio FIFO is ffmpeg input 1 */
_fcPipeProcess: fcPipeProcess, /* stored for clean stop */
};
}
@ -786,14 +895,22 @@ OUT=${sh(outPath)}
mkfifo "$VF" "$AF"
PATCHPID=
cleanup() { rm -f "$VF" "$AF"; [ -n "$PATCHPID" ] && kill "$PATCHPID" 2>/dev/null; }
trap cleanup EXIT
trap cleanup EXIT
# Prime both FIFOs read-write (non-blocking) to break the open-order deadlock.
exec 7<>"$VF" 8<>"$AF"
# raw2bmx: close priming FDs (no stray writer) before exec so it sees real EOF.
( exec 7>&- 8>&-; exec ${bmxLine} ) &
# CRITICAL: redirect raw2bmx stdin from /dev/null so it does NOT inherit the
# parent bash stdin. When the video source is fc_pipe (framecache), bash stdin
# carries the raw video stream destined for ffmpeg's pipe:0 if raw2bmx also
# inherited fd 0 it would steal bytes from that stream, corrupting both the
# growing master and the ffmpeg input.
( exec 7>&- 8>&- 0</dev/null; exec ${bmxLine} ) &
BMXPID=$!
# ffmpeg: also closes priming FDs; it opens its own write ends.
( exec 7>&- 8>&-; exec ${ffLine} ) &
# ffmpeg: closes priming FDs and EXPLICITLY inherits bash stdin (fd 0) so that
# 'pipe:0' reads the fc_pipe video stream Node piped into this orchestrator's
# stdin. For non-fc_pipe sources (FIFO/device input) fd 0 is unused and this is
# harmless.
( exec 7>&- 8>&- 0<&0; exec ${ffLine} ) &
FFPID=$!
# Forward a clean stop to ffmpeg; raw2bmx then gets EOF and finalizes the footer.
stop() { kill -INT "$FFPID" 2>/dev/null; }
@ -884,8 +1001,14 @@ exit "$BMXRC"
// Approach A: if a CIFS source is configured, mount it now. A mount failure
// is non-fatal — we fall back to S3 streaming so the recording is never
// lost.
let growingActive = GROWING_ENABLED;
if (growingActive && GROWING_SMB_MOUNT) {
// Read growing flags FRESH from env at record time — standby sidecars boot
// with GROWING_ENABLED=false and receive the real value per-session over
// /capture/start (capture.js sets process.env before this runs). The old
// module-level `const GROWING_ENABLED` / `GROWING_SMB_MOUNT` captured the
// empty boot values, so growing never engaged and every "growing" record
// silently produced HEVC/S3 instead of the XDCAM HD422 MXF.
let growingActive = process.env.GROWING_ENABLED === 'true';
if (growingActive && growingSmbConfig().mount) {
if (!mountGrowingShare()) growingActive = false; // fall back to S3
}
// Growing master is always MXF OP1a / XDCAM HD422 written by raw2bmx (the
@ -920,17 +1043,64 @@ exit "$BMXRC"
// The stop handler sets needsProxy=true so the worker picks it up.
const proxyKey = null;
const startedAt = new Date().toISOString();
this._sessionIdForBridge = sessionId;
const { inputArgs, isNetwork, bridgeProcess = null, audioFifo = null, interlaced = false } = await this._buildInputArgs({
const { inputArgs, isNetwork, bridgeProcess = null, audioFifo = null, interlaced = false, audioInputIndex = 0,
} = await this._buildInputArgs({
sourceType, sourceBackend, device, port, board, sourceUrl, listen, listenPort, streamKey,
});
// Audio input index: the deltacast shared bridge delivers video on input 0
// (video FIFO) and audio on input 1 (audio FIFO), so audioMap is '1:a:0?'.
// DeckLink SDI and network sources carry audio inside input 0.
const audioMap = (sourceType === 'deltacast') ? '1:a:0?' : '0:a:0?';
// ── Pre-roll + A/V alignment ─────────────────────────────────────────────
// The pre-roll drains the VIDEO pipe (fc_pipe) to discard unstable startup
// frames. In STANDBY the framecache slot is already warm, so there are no
// unstable frames — skip the video drain (draining only video while audio
// keeps buffering is exactly what offset the streams, giving "silent first
// second then clean").
if (bridgeProcess && !_standbyMode
&& (sourceType === 'deltacast' || sourceType === 'blackmagic' || sourceType === 'sdi')) {
console.log(`[capture] pre-rolling: discarding ${PRE_ROLL_SECONDS}s of frames`);
bridgeProcess.stdout.on('data', () => {});
await new Promise(r => setTimeout(r, PRE_ROLL_SECONDS * 1000));
bridgeProcess.stdout.removeAllListeners('data');
console.log(`[capture] pre-roll complete.`);
}
// FLUSH STALE AUDIO immediately before ffmpeg opens the FIFO. During standby
// the bridge keeps writing audio into the named FIFO while the idle-preview
// consumes only video, so the FIFO holds up to a full pipe buffer (~0.5s) of
// stale audio. Draining it here (right before the record ffmpeg attaches)
// makes audio start at the live edge, time-aligned with the first video
// frame — eliminating the leading silence + the ~0.5% audio-length surplus.
if (audioFifo) {
try {
const fsSync = await import('node:fs');
const fd = fsSync.openSync(audioFifo, fsSync.constants.O_RDONLY | fsSync.constants.O_NONBLOCK);
const tmp = Buffer.allocUnsafe(1 << 20);
let drained = 0;
for (;;) {
let n = 0;
try { n = fsSync.readSync(fd, tmp, 0, tmp.length, null); }
catch (e) { if (e.code === 'EAGAIN') break; throw e; }
if (n <= 0) break;
drained += n;
}
fsSync.closeSync(fd);
console.log(`[capture] flushed ${drained} bytes of stale standby audio before record`);
} catch (e) {
console.warn(`[capture] audio FIFO pre-flush skipped: ${e.message}`);
}
}
const startedAt = new Date().toISOString();
const recordingStartedAt = Date.now();
// Audio input index is returned EXPLICITLY by _buildInputArgs (audioInputIndex)
// rather than guessed from sourceType/FC_SLOT_ID — that guess was wrong for
// the legacy deltacast FIFO path (which has audio at input 1 but no FC_SLOT_ID),
// silently dropping audio. Each return path now declares its own audio input:
// - deltacast/blackmagic via framecache: audio FIFO = input 1
// - legacy deltacast FIFO: audio FIFO = input 1
// - network (framecache or legacy) + DeckLink-backend SDI: audio in input 0
const audioMap = `${audioInputIndex}:a:0?`;
// Non-growing master: ffmpeg muxes the finalized MOV directly. Growing
// master: raw2bmx muxes the OP1a from elementary FIFOs (handled below via
@ -945,40 +1115,31 @@ exit "$BMXRC"
if (hiresCodecArgs) console.log('[capture] hires ffmpeg args:', hiresCodecArgs.join(' '));
const isInterlacedSource = sourceType === 'sdi' || (sourceType === 'deltacast' && interlaced);
const isInterlacedSource = sourceType === 'sdi'
|| (sourceType === 'deltacast' && interlaced)
|| ((sourceType === 'blackmagic') && interlaced);
const sdiFilterArgs = isInterlacedSource ? ['-vf', 'yadif=mode=1:deint=1'] : [];
// Master output destination (NON-growing path only).
// Master output destination.
//
// - Growing-files on → the growing OP1a MXF is written directly to the SMB
// share by raw2bmx (see the orchestrator below); ffmpeg only produces the
// elementary essence FIFOs + HLS preview. `localMasterPath`/`hiresOutput`
// are unused in this case (the master path is `growingPath`).
// elementary essence FIFOs + HLS preview.
//
// - Growing-files off → ffmpeg writes the MOV master to a LOCAL SEEKABLE
// temp file, then we upload to S3 on stop. We must NOT pipe the MOV muxer
// to S3 directly: the MOV/MP4 muxer cannot write to a non-seekable pipe
// without `empty_moov`, and an empty_moov/fragmented MOV is exactly what
// makes Adobe Premiere report "file cannot be opened" (no classic
// stco/stsz sample tables — samples live in moof/trun). A seekable file
// lets ffmpeg write a single contiguous moov with full sample tables and
// `+faststart` moves it to the front, producing a Premiere-native master.
const localMasterPath = growingPath
? null
: `/tmp/capture/${sessionId}.${hiresExt}`;
if (localMasterPath) {
try { mkdirSync(dirname(localMasterPath), { recursive: true }); }
catch (err) { console.error('[capture] could not create temp master dir:', err.message); }
}
const hiresOutput = localMasterPath;
// Deltacast reads from FIFOs (no stdin pipe needed). DeckLink pipes stdout.
const hiresStdio = ['ignore', 'ignore', 'pipe'];
// - Growing-files off → ffmpeg writes fragmented MOV to pipe:1 (stdout),
// which is piped directly into a multipart S3 upload. No local temp file,
// no worker disk consumed. Premiere Pro 25.x handles fragmented MOV natively.
const hiresOutput = growingPath ? growingPath : 'pipe:1';
// pipe:1 = ffmpeg stdout → S3 stream. bridgeProcess (fc_pipe) uses stdin.
const hiresStdio = bridgeProcess ? ['pipe', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe'];
// For SDI we cannot open the DeckLink device a second time for a preview
// tee, so the live HLS preview is produced as a SECOND OUTPUT of the hires
// ffmpeg: one decklink read -> yadif -> split -> [ProRes/S3] + [H.264/HLS].
// For SDI/framecache sources (including network via framecache) the live
// HLS preview is a SECOND OUTPUT of the hires ffmpeg.
const _viaFcPipeHls = !!process.env.FC_SLOT_ID;
let sdiHlsDir = null;
if ((sourceType === 'sdi' || sourceType === 'deltacast') && this._assetIdForHls) {
if ((sourceType === 'sdi' || sourceType === 'deltacast' || sourceType === 'blackmagic'
|| (_viaFcPipeHls && (sourceType === 'srt' || sourceType === 'rtmp')))
&& this._assetIdForHls) {
const fsMod = await import('node:fs');
sdiHlsDir = '/live/' + this._assetIdForHls;
try { fsMod.mkdirSync(sdiHlsDir, { recursive: true }); } catch (_) {}
@ -1008,57 +1169,88 @@ exit "$BMXRC"
interlaced: isInterlacedSource,
});
console.log('[capture] growing master via raw2bmx; orchestrator script length=' + orchArgs[1].length);
hiresProcess = spawn('bash', orchArgs, { stdio: ['ignore', 'ignore', 'pipe'], detached: true });
hiresProcess = spawn('bash', orchArgs, {
stdio: bridgeProcess ? ['pipe', 'ignore', 'pipe'] : ['ignore', 'ignore', 'pipe'],
detached: true,
});
// When video comes from fc_pipe, pipe its stdout to the bash orchestrator
// stdin (which the orchestrator forwards to the ffmpeg rawvideo input).
if (bridgeProcess && bridgeProcess.stdout && hiresProcess.stdin) {
bridgeProcess.stdout.pipe(hiresProcess.stdin);
bridgeProcess.on('exit', () => {
try { if (hiresProcess.stdin) hiresProcess.stdin.end(); } catch (_) {}
});
}
} else {
// ── Finalized (non-growing) master: ffmpeg muxes the MOV directly ──
let hiresArgs;
if ((sourceType === 'sdi' || sourceType === 'deltacast') && this._assetIdForHls) {
const isSdiLike = sourceType === 'sdi' || sourceType === 'deltacast' || sourceType === 'blackmagic';
// Network via framecache (fc_pipe) also produces its master + HLS as a
// single split ffmpeg, exactly like SDI — it reads pipe:0, not a URL.
const isNetFcPipe = !!process.env.FC_SLOT_ID && (sourceType === 'srt' || sourceType === 'rtmp');
if ((isSdiLike || isNetFcPipe) && this._assetIdForHls) {
const filterStr = isInterlacedSource
? '[0:v]yadif=mode=1:deint=1,split=2[vhi][vlo]'
: '[0:v]split=2[vhi][vlo]';
// Network fc_pipe is video-only (no audio input) — omit audio maps so
// ffmpeg doesn't fail trying to map a nonexistent audio stream.
const hasAudio = audioInputIndex >= 0 && !isNetFcPipe;
const masterAudioMap = hasAudio ? ['-map', audioMap] : [];
const masterAudioFilter = hasAudio
? ['-af', 'aresample=async=1:min_hard_comp=0.100000:first_pts=0'] : [];
const hlsAudioMap = hasAudio ? ['-map', audioMap] : [];
const hlsAudioCodec = hasAudio
? ['-c:a', 'aac', '-b:a', '128k', '-ar', '44100'] : [];
hiresArgs = [
...inputArgs,
'-filter_complex', filterStr,
// Output 0 — ProRes/MOV master (local temp, uploaded to S3 on stop)
'-map', '[vhi]', '-map', audioMap,
// Keep raw audio aligned to the video clock. The two raw FIFOs carry
// no timestamps; -af aresample=async lets ffmpeg stretch/squeeze audio
// to correct any tiny rate mismatch so A/V never drifts over a long
// take. Applies to this output's mapped audio stream.
'-af', 'aresample=async=1:min_hard_comp=0.100000:first_pts=0',
// Output 0 — master (fragmented MOV streamed to S3 via pipe:1)
'-map', '[vhi]', ...masterAudioMap,
...masterAudioFilter,
...hiresCodecArgs,
hiresOutput,
// Output 1 — low-latency H.264 HLS preview for the UI monitor.
// GPU-encoded (h264_nvenc) when the GPU is attached to this sidecar,
// otherwise libx264 (issue #164). GOP is pinned to one IDR per HLS
// segment so segments start on keyframes (avoids black/flashing).
'-map', '[vlo]', '-map', audioMap,
// Output 1 — low-latency H.264 HLS preview for the UI monitor
'-map', '[vlo]', ...hlsAudioMap,
...buildHlsVideoArgs(videoCodec, framerate),
'-c:a', 'aac', '-b:a', '128k', '-ar', '44100',
...hlsAudioCodec,
'-f', 'hls', '-hls_time', '2', '-hls_list_size', '15',
'-hls_flags', 'delete_segments+append_list+omit_endlist',
'-hls_segment_filename', sdiHlsDir + '/seg-%05d.ts',
sdiHlsDir + '/index.m3u8',
];
console.log('[HLS] SDI preview as 2nd output -> ' + sdiHlsDir);
console.log('[HLS] SDI/framecache preview as 2nd output -> ' + sdiHlsDir);
} else {
hiresArgs = [ ...inputArgs, ...sdiFilterArgs, ...hiresCodecArgs, hiresOutput ];
}
hiresProcess = spawn('ffmpeg', hiresArgs, { stdio: hiresStdio });
// When video comes from fc_pipe, pipe its stdout to ffmpeg stdin.
if (bridgeProcess && bridgeProcess.stdout && hiresProcess.stdin) {
bridgeProcess.stdout.pipe(hiresProcess.stdin);
bridgeProcess.on('exit', () => {
try { if (hiresProcess.stdin) hiresProcess.stdin.end(); } catch (_) {}
});
}
}
// Growing-files: nothing to upload here (promotion worker handles S3).
// Non-growing: the master is uploaded from the finalized local file in
// stop(), once ffmpeg has written the moov and exited cleanly — we can't
// upload while recording because the file isn't a valid MOV until finalize.
// bridgeProcess is null for deltacast (bridge managed by node-agent on the host).
// Growing: promotion worker handles S3 upload after stop.
// Non-growing: start streaming stdout directly to S3 now (multipart upload
// completes when ffmpeg exits and closes the pipe).
const processes = { hires: hiresProcess };
const uploads = { hires: growingPath ? Promise.resolve({ growingPath }) : null };
const uploads = {
hires: growingPath
? Promise.resolve({ growingPath })
: createUploadStream(S3_BUCKET, hiresKey, hiresProcess.stdout),
};
// ── HLS tee for network sources (live preview in the UI) ──────────
// ── HLS tee for legacy network sources (live preview in the UI) ──────────
// When network sources come via framecache (FC_SLOT_ID set), HLS preview is
// handled as a 2nd ffmpeg output in the hires process above (sdiHlsDir path).
// This tee is only for the legacy direct-URL network path (no framecache).
let hlsProcess = null;
let hlsDir = null;
if (isNetwork && this._assetIdForHls) {
if (isNetwork && !process.env.FC_SLOT_ID && this._assetIdForHls) {
try {
const fs = await import('node:fs');
hlsDir = '/live/' + this._assetIdForHls;
@ -1066,7 +1258,6 @@ exit "$BMXRC"
const hlsArgs = [
...inputArgs,
'-map', '0:v:0?', '-map', '0:a:0?',
// GPU-gated preview encode, same as the SDI 2nd-output path (#164).
...buildHlsVideoArgs(videoCodec, framerate),
'-c:a', 'aac', '-b:a', '128k', '-ar', '44100',
'-f', 'hls', '-hls_time', '2', '-hls_list_size', '15',
@ -1078,7 +1269,7 @@ exit "$BMXRC"
hlsProcess.stderr.on('data', (d) => { console.error('[HLS] ' + d); });
hlsProcess.on('exit', (c) => console.log('[HLS] exited ' + c));
processes.hls = hlsProcess;
console.log('[HLS] tee started -> ' + hlsDir);
console.log('[HLS] legacy-net tee started -> ' + hlsDir);
} catch (err) {
console.error('[HLS] tee failed:', err.message);
}
@ -1091,12 +1282,13 @@ exit "$BMXRC"
if (m) {
this.state.framesReceived = parseInt(m[1], 10);
this.state.lastFrameAt = new Date().toISOString();
if (this.state.recordingStartedAt) {
const elapsedSec = (Date.now() - this.state.recordingStartedAt) / 1000;
if (elapsedSec > 0) {
this.state.currentFps = Math.round((this.state.framesReceived / elapsedSec) * 100) / 100;
}
}
// Use ffmpeg's own rolling fps value — it is a short-window average
// computed by ffmpeg itself and correctly reflects the true encode rate.
// The previous frame/elapsed cumulative calculation dragged low during
// startup and was permanently wrong for growing-path (bash orchestrator
// stderr doesn't emit frame= lines until ffmpeg flushes them).
const ffmpegFps = parseFloat(m[2]);
if (ffmpegFps > 0) this.state.currentFps = Math.round(ffmpegFps * 100) / 100;
}
if (/Connection refused|No route to host|Connection failed|Input\/output error|Server returned|404 Not Found|Connection timed out/i.test(text)) {
this.state.lastError = text.trim().slice(0, 240);
@ -1126,10 +1318,10 @@ exit "$BMXRC"
hiresKey,
proxyKey,
growingPath,
localMasterPath,
audioFifo,
startedAt,
duration: 0,
_fcPipeProcess: bridgeProcess || null, /* fc_pipe process, if framecache path used */
uploads,
codecs: {
videoCodec, videoBitrate, framerate,
@ -1270,6 +1462,11 @@ exit "$BMXRC"
if (processes.hires) processes.hires.kill('SIGINT');
if (processes.proxy) processes.proxy.kill('SIGINT');
if (processes.hls) { try { processes.hls.kill('SIGINT'); } catch (_) {} }
// fc_pipe process (framecache consumer) — stop after ffmpeg so it sees EOF
// naturally via EPIPE when ffmpeg stdin closes. SIGTERM as belt-and-suspenders.
if (currentSession._fcPipeProcess) {
try { currentSession._fcPipeProcess.kill('SIGTERM'); } catch (_) {}
}
/* processes.bridge: removed — bridge is managed by node-agent, not per-session */
// Wait for the master writer to finalize before we read/upload the file.
@ -1281,30 +1478,11 @@ exit "$BMXRC"
unmountGrowingShare();
try {
// Non-growing: S3 upload was streaming from ffmpeg stdout — it completes
// when ffmpeg exits and closes the pipe (waitExit above ensures that).
// Growing: promotion worker handles S3.
const uploadPromises = [];
// Non-growing: upload the finalized local master file to S3 now that the
// moov has been written. Growing: the promotion worker handles S3.
if (currentSession.localMasterPath) {
let size = 0;
try { size = statSync(currentSession.localMasterPath).size; } catch (_) {}
if (size > 0) {
uploadPromises.push(
createUploadStream(
S3_BUCKET,
currentSession.hiresKey,
createReadStream(currentSession.localMasterPath),
).then(() => {
try { unlinkSync(currentSession.localMasterPath); } catch (_) {}
})
);
} else {
console.warn('[capture] local master is 0 bytes — skipping upload:', currentSession.localMasterPath);
}
} else if (currentSession.uploads.hires) {
uploadPromises.push(currentSession.uploads.hires);
}
if (currentSession.uploads.hires) uploadPromises.push(currentSession.uploads.hires);
if (currentSession.uploads.proxy) uploadPromises.push(currentSession.uploads.proxy);
await Promise.all(uploadPromises);
} catch (error) {

View file

@ -22,12 +22,25 @@ 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;
if (process.env.RECORDER_ID && (_srcType === 'deltacast' || _srcType === 'sdi')) {
setTimeout(() => captureManager.startIdlePreview(), 3000);
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);
}
}
});

View file

@ -301,12 +301,34 @@ 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) {
@ -316,9 +338,9 @@ router.post('/start', async (req, res) => {
}
// Source-specific validation
if (source_type === 'sdi') {
if (source_type === 'sdi' || source_type === 'blackmagic') {
if (device === undefined || device === null) {
return res.status(400).json({ error: 'SDI source requires: device' });
return res.status(400).json({ error: 'SDI/blackmagic source requires: device' });
}
} else if (source_type === 'srt' || source_type === 'rtmp') {
if (!listen && !source_url) {
@ -332,48 +354,93 @@ router.post('/start', async (req, res) => {
}
} else {
return res.status(400).json({
error: `Unknown source_type: ${source_type}. Must be sdi, srt, rtmp, or deltacast`,
error: `Unknown source_type: ${source_type}. Must be sdi, blackmagic, srt, rtmp, or deltacast`,
});
}
// 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}`);
// 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}` });
}
const asset = await mamResponse.json();
assetId = asset.id;
} catch (mamError) {
console.error('Failed to create live asset:', mamError.message);
return res.status(500).json({ error: `Could not create live asset: ${mamError.message}` });
}
// Helper: body value wins over container env var fallback
function bodyOr(bodyVal, envName) {
if (bodyVal !== undefined && bodyVal !== null && bodyVal !== '') return bodyVal;
const v = process.env[envName];
return (v === undefined || v === '') ? undefined : v;
}
function bodyOrInt(bodyVal, envName) {
const v = bodyOr(bodyVal, envName);
if (v === undefined) return undefined;
const n = parseInt(v, 10);
return Number.isFinite(n) ? n : undefined;
}
function bodyOrBool(bodyVal, envName) {
if (bodyVal !== undefined && bodyVal !== null) return Boolean(bodyVal);
const v = process.env[envName];
if (v === undefined) return undefined;
return v === 'true' || v === '1' || v === 'yes';
}
// Inject body-supplied codec/session params into the process env so
// captureManager.start() picks them up via the existing env-read paths.
// This lets the standby container receive per-session params via HTTP.
if (growing_enabled !== undefined) process.env.GROWING_ENABLED = growing_enabled ? 'true' : 'false';
if (growing_smb_mount) process.env.GROWING_SMB_MOUNT = growing_smb_mount;
if (growing_smb_username) process.env.GROWING_SMB_USERNAME = growing_smb_username;
if (growing_smb_password) process.env.GROWING_SMB_PASSWORD = growing_smb_password;
if (growing_smb_vers) process.env.GROWING_SMB_VERS = growing_smb_vers;
const session = await captureManager.start({
projectId: project_id,
binId: bin_id || null,
clipName: clip_name,
device,
device: device !== undefined ? device : parseInt(process.env.DEVICE_INDEX || '0', 10),
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);
@ -417,7 +484,10 @@ 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' },
headers: {
'Content-Type': 'application/json',
...(MAM_API_TOKEN ? { Authorization: `Bearer ${MAM_API_TOKEN}` } : { 'X-Requested-With': 'dragonflight-ui' }),
},
body: JSON.stringify(body),
});
if (!mamResponse.ok) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -858,65 +858,9 @@ 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.
//
// 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}`);
}
}
// 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.
router.get('/:id/video', async (req, res, next) => {
try {
@ -997,39 +941,11 @@ router.get('/:id/video', async (req, res, next) => {
if (etag) headers['ETag'] = etag;
if (lastModified) headers['Last-Modified'] = lastModified.toUTCString();
// For small head-of-file ranges (entirely below the broken threshold)
// a direct ranged GET works and saves the streaming-from-0 cost.
const RUSTFS_RANGE_SAFE_START = parseInt(process.env.RUSTFS_RANGE_SAFE_START || String(5_500_000), 10);
if (start < RUSTFS_RANGE_SAFE_START && end < RUSTFS_RANGE_SAFE_START) {
const s3Res = await s3Client.send(new GetObjectCommand({
Bucket: getS3Bucket(), Key: key, Range: `bytes=${start}-${end}`,
}));
res.writeHead(206, headers);
s3Res.Body.pipe(res);
return;
}
// Otherwise: stream from offset 0, drop bytes below `start`, stop at
// `end`. Browser sees a normal 206; mam-api stays memory-flat.
const s3Res = await s3Client.send(new GetObjectCommand({
Bucket: getS3Bucket(), Key: key, Range: `bytes=${start}-${end}`,
}));
res.writeHead(206, headers);
try {
for await (const buf of stitchedS3Stream(key, start, end)) {
// res.write returns false when backpressure builds — pause and wait.
if (!res.write(buf)) {
await new Promise(r => res.once('drain', r));
}
if (res.destroyed) return;
}
res.end();
} catch (err) {
console.error(`[video] stitch failed for ${key}:`, err.message);
if (!res.headersSent) {
res.writeHead(500, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' });
res.end('Upstream storage error');
} else {
res.destroy(err);
}
}
s3Res.Body.pipe(res);
} catch (err) { next(err); }
});

View file

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

View file

@ -154,7 +154,7 @@ const RECORDER_FIELDS = [
'proxy_audio_codec', 'proxy_audio_bitrate', 'proxy_audio_channels',
'proxy_container',
'project_id', 'node_id', 'device_index',
'growing_enabled',
'growing_enabled', 'label',
];
function pickRecorderFields(body) {
@ -198,7 +198,7 @@ function validateRecorderConfig(cfg, nodeHasGpu = null) {
// NVENC requires a GPU on the target node. Only a hard error when we know the
// node lacks one; unknown capability is left as a soft pass.
if (GPU_CODECS.includes(codec) && nodeHasGpu === false) {
return `Invalid combo: codec ${cfg.recording_codec} requires an NVIDIA GPU, but the target node reports no GPU. Choose a software codec (e.g. prores_hq, dnxhr_hq, h264) or assign a GPU node.`;
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 null;
@ -227,6 +227,133 @@ 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
@ -341,9 +468,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',
proxy_resolution: '1920x1080',
proxy_enabled: true,
proxy_codec: 'h264_nvenc',
proxy_resolution: '1920x1080',
proxy_video_bitrate: '2M',
proxy_audio_codec: 'aac',
proxy_audio_bitrate: '128k',
@ -374,7 +501,111 @@ router.post('/', async (req, res, next) => {
values
);
res.status(201).json(result.rows[0]);
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);
} catch (err) {
next(err);
}
@ -562,7 +793,7 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
`DEVICE_INDEX=${deviceIndex}`,
// Recording codec controls
`RECORDING_CODEC=${recorder.recording_codec || 'prores_hq'}`,
`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 || ''}`,
@ -573,7 +804,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'}`,
`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 || ''}`,
@ -604,6 +835,12 @@ 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) {
@ -636,10 +873,88 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
}
let containerId;
const capturePort = SIDECAR_PORT_BASE + (deviceIndex || 0);
if (isRemote) {
// ── 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) {
// 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' },
@ -658,7 +973,7 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
}
const sidecarData = await sidecarRes.json();
containerId = sidecarData.containerId;
} else {
} else if (!containerId) {
// Local spawn via Docker socket.
const { portBindings, exposedPorts } = buildPortConfig(sourceType, sourceConfig);
const alias = `recorder-${id}`;
@ -778,8 +1093,69 @@ router.post('/:id/stop', requireRecorderEdit, async (req, res, next) => {
return res.json(result.rows[0]);
}
const { remote: isRemote, apiUrl: targetNodeApiUrl } = await resolveNodeTarget(recorder.node_id);
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';
// ── 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',
@ -790,9 +1166,7 @@ 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. 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.
// request returns immediately.
const containerId = recorder.container_id;
(async () => {
try {
@ -803,7 +1177,6 @@ router.post('/:id/stop', requireRecorderEdit, async (req, res, next) => {
}
} catch (e) {
console.error('[recorders] failed local background stop:', e.message);
// Attempt finalize and cleanup even if stop call timed out
await waitForFinalize(recorder).catch(() => {});
await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {});
}
@ -890,7 +1263,9 @@ router.get('/:id/status', async (req, res, next) => {
duration = Math.floor((Date.now() - new Date(container.State.StartedAt).getTime()) / 1000);
try {
const captureRes = await fetch(`http://recorder-${id}:3001/capture/status`, { signal: AbortSignal.timeout(2000) });
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) });
if (captureRes.ok) live = await captureRes.json();
} catch (_) { /* not ready yet */ }
}
@ -993,10 +1368,11 @@ 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' }); }
const proto = (parsed.protocol || '').replace(':', '').toLowerCase();
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)` });
}
@ -1009,6 +1385,7 @@ router.post('/probe', async (req, res) => {
if (parsed.hostname === 'mam-api' || parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1') {
return res.status(403).json({ error: 'Internal probe target is not permitted' });
}
}
// Try the capture service first (5s timeout)
try {
@ -1033,7 +1410,6 @@ router.post('/probe', async (req, res) => {
}
const host = parsed.hostname;
const proto = (parsed.protocol || '').replace(':', '').toLowerCase();
const isUdp = proto === 'srt' || source_type === 'srt';
const port = parseInt(parsed.port, 10) || (isUdp ? 9000 : 1935);

View file

@ -78,14 +78,21 @@ 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 s3Client.send(new HeadBucketCommand({ Bucket: bucket }));
await withTimeout(s3Client.send(new HeadBucketCommand({ Bucket: bucket })), 5000);
out.reachable = true;
out.method = 'HeadBucket';
} catch (headErr) {
// Fall back to a 0-key list for stores that don't expose HeadBucket.
try {
await s3Client.send(new ListObjectsV2Command({ Bucket: bucket, MaxKeys: 0 }));
await withTimeout(s3Client.send(new ListObjectsV2Command({ Bucket: bucket, MaxKeys: 0 })), 5000);
out.reachable = true;
out.method = 'ListObjectsV2';
} catch (listErr) {

View file

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

View file

@ -2,8 +2,20 @@ 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 || '',
@ -23,9 +35,17 @@ function buildClient(cfg) {
secretAccessKey: cfg.secretKey,
},
forcePathStyle: true,
// 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 }),
// 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,
}),
requestChecksumCalculation: 'WHEN_REQUIRED',
responseChecksumValidation: 'WHEN_REQUIRED',
});

View file

@ -137,7 +137,11 @@ async function tick() {
// Orphaned live assets: recorder stopped but asset still 'live'.
// Happens when the capture sidecar crashes before finalize() runs.
// Mark error immediately so the library doesn't show "Recording" forever.
// 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);
const orphanResult = await client.query(
`UPDATE assets a
SET status = 'error', updated_at = NOW()
@ -145,7 +149,9 @@ async function tick() {
WHERE a.status = 'live'
AND a.display_name = r.current_session_id
AND r.status = 'stopped'
RETURNING a.id, a.display_name`
AND r.updated_at < NOW() - ($1 || ' seconds')::INTERVAL
RETURNING a.id, a.display_name`,
[ORPHAN_GRACE_SECONDS]
);
if (orphanResult.rows.length > 0) {
for (const row of orphanResult.rows) {

View file

@ -1,6 +1,7 @@
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(/\/$/, '');
@ -71,13 +72,201 @@ 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)
// port -> fmt JSON from bridge stderr (inject into sidecar env + slot_id)
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;
}
@ -122,12 +311,14 @@ 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');
@ -261,7 +452,14 @@ 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.
@ -269,8 +467,6 @@ 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 */ }
}
@ -308,36 +504,147 @@ 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 sidecar reads FIFOs produced by the bridge;
// it does NOT open the board handle itself (no BufMngr.c:781 race).
// starting the sidecar. The bridge writes frames to the framecache shm ring;
// the sidecar reads via the consumer library (fc_client).
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) && _dcPortFmt.has(_portNum)) {
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)) {
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} interlaced=${_fmt.interlaced}`);
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)`);
}
hostConfig.IpcMode = 'host';
}
let containerId;
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 });
// 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 });
}
containerId = createRes.data.Id;
const _u = (env.find(e => e.startsWith('MAM_API_URL=')) || '').slice(12);
@ -345,21 +652,25 @@ 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) {
if (sourceType === 'deltacast') {
_dcSidecarCount--;
if (_dcSidecarCount <= 0) { _dcSidecarCount = 0; stopDeltacastBridge(); }
}
await _cleanupOnFailure();
throw err;
}
} catch (err) {
@ -367,23 +678,201 @@ async function handleSidecarStart(body, res) {
}
}
async function fetchContainerLogs(containerId) {
// 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) {
return await new Promise((resolve) => {
const options = {
socketPath: '/var/run/docker.sock',
path: `/v1.43/containers/${containerId}/logs?stdout=1&stderr=1&tail=200`,
path: `/v1.43/containers/${containerId}/logs?stdout=1&stderr=1&tail=${tail}&timestamps=1`,
method: 'GET',
};
const req = http.request(options, res => {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8').replace(/[\x00-\x08]/g, '')));
res.on('end', () => resolve(_demuxDocker(Buffer.concat(chunks))));
});
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) {
const img = c.Image || '';
if (!/wild-dragon-capture/.test(img)) continue;
// Inspect to read the PORT env (list payload doesn't include env).
try {
const insp = await dockerApi('GET', `/containers/${c.Id}/json`);
const cenv = (insp.status === 200 && insp.data?.Config?.Env) || [];
const portEnv = cenv.find(e => e.startsWith('PORT='));
const p = portEnv ? parseInt(portEnv.split('=')[1], 10) : NaN;
if (p === capturePort) {
console.log(`[sidecar] force-freeing capture port ${capturePort}: removing stale container ${c.Id.slice(0, 12)}`);
await dockerApi('DELETE', `/containers/${c.Id}?force=true`).catch(() => {});
}
} catch (_) { /* container vanished mid-scan — fine */ }
}
} catch (e) {
console.warn(`[sidecar] freeCapturePort(${capturePort}) scan failed (continuing): ${e.message}`);
}
}
async function handleSidecarStandby(body, res) {
try {
const {
image = 'wild-dragon-capture:latest',
env = [],
capturePort = 3001,
sourceType = 'sdi',
useGpu = false,
gpuUuid = null,
} = body;
// Reclaim the port first so a re-Enable (or a stale container surviving a
// node-agent restart) can never collide on bind.
await freeCapturePort(capturePort);
const binds = [`${LIVE_DIR}:/live`];
if (fs.existsSync('/dev/shm')) binds.push('/dev/shm:/dev/shm');
if (sourceType === 'sdi' || sourceType === 'blackmagic') binds.unshift('/dev/blackmagic:/dev/blackmagic');
if (sourceType === 'deltacast') {
try {
const dcEntries = fs.readdirSync('/dev').filter(n => /^deltacast\d+$/.test(n));
for (const d of dcEntries) binds.push(`/dev/${d}:/dev/${d}`);
} catch (_) {}
}
const sidecarEnv = [...env, `PORT=${capturePort}`, 'STANDBY=1'];
if (useGpu) {
const visibleDevices = (gpuUuid != null && String(gpuUuid).trim() !== '')
? String(gpuUuid).trim() : 'all';
sidecarEnv.push(`NVIDIA_VISIBLE_DEVICES=${visibleDevices}`);
sidecarEnv.push('NVIDIA_DRIVER_CAPABILITIES=video,compute,utility');
}
sidecarEnv.push(`FC_URL=${FC_URL}`);
const hostConfig = { NetworkMode: 'host', Privileged: true, Binds: binds };
if (useGpu) {
hostConfig.Runtime = 'nvidia';
hostConfig.DeviceRequests = [{ Driver: 'nvidia', Count: -1, Capabilities: [['gpu']] }];
}
// Warm up the bridge and inject FC_SLOT_ID (same as handleSidecarStart).
if (sourceType === 'deltacast') {
_dcSidecarCount++;
startDeltacastBridge();
const _srcCfg = (env.find(e => e.startsWith('SOURCE_CONFIG=')) || '').slice(14);
let _portNum = NaN;
try { _portNum = JSON.parse(_srcCfg).port; } catch (_) {}
if (!Number.isFinite(_portNum)) _portNum = 0;
const _slotId = `deltacast-${DC_BOARD}-${_portNum}`;
sidecarEnv.push(`FC_SLOT_ID=${_slotId}`);
if (_dcPortFmt.has(_portNum)) {
const _fmt = _dcPortFmt.get(_portNum);
const _fps = (_fmt.fps_den && _fmt.fps_den !== 1) ? `${_fmt.fps_num}/${_fmt.fps_den}` : String(_fmt.fps_num);
sidecarEnv.push(`DELTACAST_VIDEO_SIZE=${_fmt.width}x${_fmt.height}`);
sidecarEnv.push(`DELTACAST_FRAMERATE=${_fps}`);
sidecarEnv.push(`DELTACAST_INTERLACED=${_fmt.interlaced ? '1' : '0'}`);
}
hostConfig.IpcMode = 'host';
}
if (sourceType === 'sdi' || sourceType === 'blackmagic') {
_dlSidecarCount++;
const _bmdDevices = [];
try {
const _bmdEntries = fs.readdirSync('/dev/blackmagic').filter(n => /^(dv|io)\d+$/.test(n));
_bmdEntries.forEach((_, i) => _bmdDevices.push(i));
} catch (_) { _bmdDevices.push(0); }
await startDecklinkBridge(_bmdDevices);
const _srcCfg = (env.find(e => e.startsWith('SOURCE_CONFIG=')) || '').slice(14);
let _devIdx = NaN;
try { _devIdx = JSON.parse(_srcCfg).device ?? JSON.parse(_srcCfg).index; } catch (_) {}
if (!Number.isFinite(_devIdx)) _devIdx = parseInt((env.find(e => e.startsWith('BMD_DEVICE_INDEX=')) || '=0').split('=')[1]) || 0;
const _slotId = `decklink-${FC_NODE_ID}-${_devIdx}`;
sidecarEnv.push(`FC_SLOT_ID=${_slotId}`);
if (_dlDevFmt.has(_devIdx)) {
const _fmt = _dlDevFmt.get(_devIdx);
const _fps = (_fmt.fps_den && _fmt.fps_den !== 1) ? `${_fmt.fps_num}/${_fmt.fps_den}` : String(_fmt.fps_num);
sidecarEnv.push(`DELTACAST_VIDEO_SIZE=${_fmt.width}x${_fmt.height}`);
sidecarEnv.push(`DELTACAST_FRAMERATE=${_fps}`);
sidecarEnv.push(`DELTACAST_INTERLACED=${_fmt.interlaced ? '1' : '0'}`);
}
hostConfig.IpcMode = 'host';
}
const _cleanupOnFailure = async () => {
if (sourceType === 'deltacast') {
_dcSidecarCount--;
if (_dcSidecarCount <= 0) { _dcSidecarCount = 0; stopDeltacastBridge(); }
} else if (sourceType === 'sdi' || sourceType === 'blackmagic') {
_dlSidecarCount--;
if (_dlSidecarCount <= 0) { _dlSidecarCount = 0; await stopDecklinkBridge(); }
}
};
let containerId;
try {
const createRes = await dockerApi('POST', '/containers/create', { Image: image, Env: sidecarEnv, HostConfig: hostConfig });
if (createRes.status !== 201) {
await _cleanupOnFailure();
return jsonResponse(res, 502, { error: 'Failed to create standby container', details: createRes.data });
}
containerId = createRes.data.Id;
console.log(`[sidecar-standby] ${containerId} image=${image} src=${sourceType}`);
const startRes = await dockerApi('POST', `/containers/${containerId}/start`);
if (startRes.status !== 204) {
await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {});
await _cleanupOnFailure();
return jsonResponse(res, 502, { error: 'Failed to start standby container', details: startRes.data });
}
if (sourceType === 'deltacast') _containerSourceType.set(containerId, 'deltacast');
if (sourceType === 'sdi' || sourceType === 'blackmagic') _containerSourceType.set(containerId, 'blackmagic');
jsonResponse(res, 201, { containerId, capturePort });
} catch (err) {
await _cleanupOnFailure();
throw err;
}
} catch (err) {
jsonResponse(res, 500, { error: err.message });
}
}
async function handleSidecarStop(containerId, res) {
try {
console.log(`[sidecar-stop] stopping ${containerId} (grace 180s)...`);
@ -399,16 +888,23 @@ 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(() => {});
// Deltacast bridge lifecycle: decrement sidecar count; stop bridge when last.
if (_containerSourceType.get(containerId) === 'deltacast') {
_containerSourceType.delete(containerId);
// Bridge lifecycle: decrement sidecar count; stop bridge when last sidecar stops.
const _srcType = _containerSourceType.get(containerId);
_containerSourceType.delete(containerId);
if (_srcType === 'deltacast') {
_dcSidecarCount--;
if (_dcSidecarCount <= 0) {
_dcSidecarCount = 0;
stopDeltacastBridge();
}
} else {
_containerSourceType.delete(containerId);
} else if (_srcType === 'blackmagic') {
_dlSidecarCount--;
if (_dlSidecarCount <= 0) {
_dlSidecarCount = 0;
await stopDecklinkBridge();
}
} else if (_srcType === 'srt' || _srcType === 'rtmp') {
stopNetIngest(containerId);
}
} catch (err) {
console.error(`[sidecar-stop] background cleanup failed for ${containerId}:`, err.message);
@ -422,6 +918,15 @@ 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`);
@ -457,11 +962,27 @@ 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) return true;
if (!NODE_TOKEN && !CLUSTER_READ_TOKEN) return true;
const hdr = req.headers['authorization'] || '';
const m = /^Bearer\s+(.+)$/i.exec(hdr);
return !!m && m[1] === NODE_TOKEN;
if (!m) return false;
const token = m[1];
return _bearerEq(token, NODE_TOKEN) || _bearerEq(token, CLUSTER_READ_TOKEN);
}
// ── Driver/SDK install ────────────────────────────────────────────────────
@ -983,7 +1504,7 @@ function serveLiveFile(pathname, res) {
}
// ── HTTP server ───────────────────────────────────────────────────────────
const server = http.createServer((req, res) => {
const server = http.createServer(async (req, res) => {
const { pathname } = new URL(req.url, 'http://localhost');
if (req.method === 'GET' && pathname === '/health') {
@ -997,6 +1518,11 @@ const server = http.createServer((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))
@ -1011,6 +1537,15 @@ const server = http.createServer((req, res) => {
const id = pathname.slice('/sidecar/'.length, -'/status'.length);
handleSidecarStatus(id, res);
} else if (req.method === 'GET' && pathname === '/containers') {
if (!checkAgentAuth(req)) return jsonResponse(res, 401, { error: 'Unauthorized' });
const cRes = await dockerApi('GET', '/containers/json?all=true');
jsonResponse(res, cRes.status, cRes.data);
} else if (req.method === 'GET' && /^\/sidecar\/[^/]+\/logs$/.test(pathname)) {
const id = pathname.slice('/sidecar/'.length, -'/logs'.length);
handleSidecarLogs(id, res);
} else if (req.method === 'GET' && pathname === '/driver/status') {
if (!checkAgentAuth(req)) return jsonResponse(res, 401, { error: 'Unauthorized' });
handleDriverStatus(res);

View file

@ -81,7 +81,7 @@ function App() {
capture: ['Ingest', 'Capture'], monitors: ['Ingest', 'Monitors'],
jobs: ['Jobs'], editor: ['Editor'], playout: ['Operations', 'Playout'],
users: ['Admin', 'Users & Groups'], tokens: ['Admin', 'Tokens'],
containers: ['Admin', 'Containers'], cluster: ['Admin', 'Cluster'],
containers: ['Admin', 'Containers'], logs: ['Admin', 'Logs'], 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', 'cluster', 'settings']);
const ADMIN_ROUTES = new Set(['users', 'containers', 'logs', '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)} />; break;
case 'library': content = <Library navigate={navigate} onOpenAsset={setOpenAsset} openProject={openProject} onClearProject={() => setOpenProject(null)} onOpenProject={openProjectFromAnywhere} />; 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,6 +137,7 @@ function App() {
case 'tokens': content = <Tokens />; break;
case 'billing': content = <TokensParody />; break;
case 'containers':content = <Containers />; break;
case 'logs': content = <Logs />; break;
case 'cluster': content = <Cluster />; break;
case 'settings': content = <Settings />; break;
default: content = <Home navigate={navigate} />;

View file

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

View file

@ -418,7 +418,6 @@ 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' : ''}`}
@ -437,15 +436,8 @@ 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">All-Intra HEVC (NVENC, GPU, growing)</option>
<option value="hevc_nvenc">HEVC (NVENC, GPU)</option>
<option value="h264_nvenc">H.264 (NVENC, GPU)</option>
<option value="prores_hq">ProRes 422 HQ (4:2:2, CPU)</option>
<option value="prores">ProRes 422</option>
<option value="prores_lt">ProRes 422 LT</option>
<option value="prores_proxy">ProRes 422 Proxy</option>
<option value="dnxhr_hq">DNxHR HQ</option>
<option value="libx264">H.264 (x264, CPU)</option>
<option value="libx265">H.265 (x265, CPU)</option>
</select>
</div>
{showBitrate ? (

View file

@ -1041,14 +1041,22 @@ function Containers() {
flashTimerRef.current = setTimeout(() => setRestartFlashSafe(null), ms);
};
function load() {
setContainers(null);
// 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);
window.ZAMPP_API.fetch('/cluster/containers')
.then(data => setContainers(Array.isArray(data) ? data : (data.containers || [])))
.catch(() => setContainers([]));
.catch(() => setContainers(c => (c == null ? [] : c)));
}
React.useEffect(() => { load(); }, []);
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);
}, []);
const running = (containers || []).filter(c => c.state === 'running').length;
@ -1056,7 +1064,12 @@ function Containers() {
const logsModal = logsModalState;
const setLogsModal = setLogsModalState;
const showLogs = (c) => setLogsModal(c);
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 restartContainer = async (c) => {
if (!(await confirm({ title: 'Restart container?', message: 'Restart container "' + c.name + '"?\nIn-flight requests will be dropped.', confirmLabel: 'Restart' }))) return;
@ -1114,16 +1127,9 @@ function Containers() {
<button className="icon-btn" aria-label="Close" onClick={() => setLogsModal(null)}><Icon name="x" /></button>
</div>
<div className="modal-body">
<div style={{ fontSize: 12, color: 'var(--text-3)', marginBottom: 8 }}>
Live log streaming over the websocket isn't wired yet. SSH to the host that runs this container and tail the logs directly:
</div>
<code className="mono" style={{ display: 'block', background: 'var(--bg-2)', padding: 10, borderRadius: 5, fontSize: 11.5, overflowX: 'auto' }}>
docker compose logs -f {logsModal.name}
</code>
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 10 }}>
Or grab the last 200 lines:&nbsp;
<span className="mono">docker logs --tail 200 {logsModal.name}</span>
</div>
<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>
<div className="modal-foot">
<button className="btn ghost sm" onClick={() => {
@ -1148,6 +1154,7 @@ 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>
@ -1158,6 +1165,7 @@ 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>
@ -1188,7 +1196,145 @@ 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>
);

View file

@ -243,7 +243,11 @@ function AssetDetail({ asset, onClose }) {
setDownloading(true);
window.ZAMPP_API.fetch('/assets/' + assetId + '/hires')
.then(function(r) {
if (!r || !r.url) { window.alert('No hi-res source available for this asset.'); return; }
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;
}
const a = document.createElement('a');
a.href = r.url;
a.download = r.filename || (asset.name + '.' + (r.ext || 'mov'));
@ -253,7 +257,10 @@ function AssetDetail({ asset, onClose }) {
a.click();
document.body.removeChild(a);
})
.catch(function(e) { window.alert('Download failed: ' + (e.message || 'unknown error')); })
.catch(function(e) {
if (window.toast) window.toast.error('Download failed: ' + (e.message || 'unknown error'));
else window.alert('Download failed: ' + (e.message || 'unknown error'));
})
.finally(function() { setDownloading(false); });
};
@ -279,7 +286,10 @@ function AssetDetail({ asset, onClose }) {
}))) return;
window.ZAMPP_API.fetch('/assets/' + assetId + '?hard=true', { method: 'DELETE' })
.then(function() { onClose && onClose(); })
.catch(function(e) { window.alert('Delete failed: ' + e.message); });
.catch(function(e) {
if (window.toast) window.toast.error('Delete failed: ' + e.message);
else window.alert('Delete failed: ' + e.message);
});
};
const retryProcessing = function() {
@ -287,9 +297,13 @@ function AssetDetail({ asset, onClose }) {
setRetrying(true);
window.ZAMPP_API.fetch('/assets/' + assetId + '/retry', { method: 'POST' })
.then(function() {
window.alert('Re-queued for processing. The proxy worker will pick it up shortly; refresh in a minute to see the player.');
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'));
})
.catch(function(e) { window.alert('Retry failed: ' + (e.message || 'unknown error')); })
.finally(function() { setRetrying(false); });
};
@ -298,16 +312,26 @@ function AssetDetail({ asset, onClose }) {
setReprocessing(type);
window.ZAMPP_API.fetch('/assets/' + assetId + '/reprocess?type=' + type, { method: 'POST' })
.then(function() {
window.alert((type === 'proxy' ? 'Proxy' : 'Thumbnail') + ' job queued. Refresh in a moment to see the result.');
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'));
})
.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() { window.alert('Filmstrip job queued: it will appear automatically when ready.'); })
.catch(function(e) { window.alert('Failed to queue filmstrip: ' + (e.message || 'unknown error')); });
.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'));
});
};
// Map a /assets/:id/comments row into the legacy shape the consumer
@ -352,7 +376,8 @@ function AssetDetail({ asset, onClose }) {
setComments(function(c) { return [...c, _normalizeComment(row)]; });
})
.catch(function(e) {
window.alert('Could not post comment: ' + (e.message || 'unknown error'));
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'));
setNewComment(text);
});
};
@ -374,7 +399,10 @@ function AssetDetail({ asset, onClose }) {
.then(function() {
setComments(function(prev) { return prev.filter(x => x.id !== c.id); });
})
.catch(function(e) { window.alert('Delete failed: ' + e.message); });
.catch(function(e) {
if (window.toast) window.toast.error('Delete failed: ' + e.message);
else window.alert('Delete failed: ' + e.message);
});
};
const visibleComments = comments.filter(function(c) { return showResolved || !c.resolved; });

View file

@ -601,39 +601,233 @@ function HlsPreviewUrl({ url }) {
}
/* ===== Recorders ===== */
// Per-recorder config editor. Recorders are physical ports this PATCHes the
// existing row in place (never delete/recreate), so codec/growing/label/project
// changes persist across enable/disable. If the recorder is currently ENABLED,
// saving bounces its standby sidecar (disableenable) so the new env takes
// effect; the operator is told. Refuses while recording.
function RecorderConfigModal({ recorder, onClose, onSaved }) {
const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
const GROWING_CODEC = 'hevc_nvenc';
const BITRATE_CODECS = new Set(['hevc_nvenc', 'h264_nvenc', 'libx264', 'libx265', 'dnxhd', 'dnxhr_hq']);
const [label, setLabel] = React.useState(recorder.label || '');
const [codec, setCodec] = React.useState(recorder.recording_codec || 'hevc_nvenc');
// Seed bitrate from the stored value; fall back to a mode-appropriate default
// (50 Mbps for growing XDCAM HD422, 25 Mbps for a GPU master).
const _seedBitrate = (recorder.recording_video_bitrate || (recorder.growing_enabled === true ? '50' : '25')).replace(/M$/i, '');
const [bitrate, setBitrate] = React.useState(_seedBitrate);
const [growing, setGrowing] = React.useState(recorder.growing_enabled === true);
const [projectId, setProjectId] = React.useState(recorder.project_id || PROJECTS[0]?.id || '');
const [saving, setSaving] = React.useState(false);
const [err, setErr] = React.useState(null);
const isRec = recorder.status === 'recording';
const showBitrate = growing || BITRATE_CODECS.has(codec);
const submit = () => {
if (saving || isRec) return;
setSaving(true); setErr(null);
// Growing forces the XDCAM/HEVC master path on the backend; send the GPU
// master codec so the row is coherent if growing is later turned off.
const effCodec = growing ? GROWING_CODEC : codec;
const body = {
label: label.trim() || null,
recording_codec: effCodec,
growing_enabled: growing,
project_id: projectId || null,
};
if (showBitrate && bitrate) body.recording_video_bitrate = String(bitrate).replace(/M$/i, '') + 'M';
window.ZAMPP_API.fetch('/recorders/' + recorder.id, { method: 'PATCH', body: JSON.stringify(body) })
.then(async () => {
// If enabled, bounce the standby sidecar so the new env is applied.
if (recorder.enabled) {
await window.ZAMPP_API.fetch('/recorders/' + recorder.id + '/disable', { method: 'POST' }).catch(() => {});
await window.ZAMPP_API.fetch('/recorders/' + recorder.id + '/enable', { method: 'POST' }).catch(() => {});
}
setSaving(false);
onSaved();
})
.catch(e => { setSaving(false); setErr(e.message || 'Save failed'); });
};
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" style={{ width: 460 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div>
<div style={{ fontSize: 15, fontWeight: 600 }}>Configure recorder</div>
<div className="mono" style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 2 }}>
{recorder.hwName}{recorder.capturePort ? ' · ' + recorder.capturePort : ''}
</div>
</div>
<button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
</div>
<div className="modal-body">
{isRec && (
<div style={{ marginBottom: 12, padding: 10, background: 'var(--danger-soft, rgba(255,80,80,0.1))', borderRadius: 6, fontSize: 12, color: 'var(--danger)' }}>
Recorder is recording stop it before changing config.
</div>
)}
<div className="field">
<label className="field-label">Label (friendly name)</label>
<input className="field-input" value={label} disabled={isRec}
onChange={e => setLabel(e.target.value)} maxLength={60}
placeholder={recorder.hwName} />
<div className="mono" style={{ fontSize: 10.5, color: 'var(--text-3)', marginTop: 4 }}>
Blank = show hardware name ({recorder.hwName})
</div>
</div>
{/* Recording mode — clean segmented control instead of a tickbox. */}
<div className="field">
<label className="field-label">Recording mode</label>
<div className="rec-mode-seg" role="tablist">
<button type="button" role="tab"
className={'rec-mode-opt' + (!growing ? ' active' : '')}
disabled={isRec} onClick={() => setGrowing(false)}>
<Icon name="video" size={14} />
<div className="rec-mode-txt">
<span className="rec-mode-name">Standard</span>
<span className="rec-mode-desc">GPU master library</span>
</div>
</button>
<button type="button" role="tab"
className={'rec-mode-opt' + (growing ? ' active' : '')}
disabled={isRec} onClick={() => setGrowing(true)}>
<Icon name="edit" size={14} />
<div className="rec-mode-txt">
<span className="rec-mode-name">Growing</span>
<span className="rec-mode-desc">Edit while recording</span>
</div>
</button>
</div>
<div className="rec-mode-hint">
{growing
? 'Writes a growing XDCAM HD422 MXF (OP1a) to the SMB share so editors can cut the clip live in Premiere.'
: 'Encodes a GPU master (HEVC/H.264) streamed straight to the library on stop.'}
</div>
</div>
{/* Standard mode: GPU codec + bitrate. Growing mode: bitrate only
(codec is fixed to XDCAM HD422 MXF, but the target bitrate of the
growing essence is still operator-tunable). */}
{!growing ? (
<div className="rec-cfg-grid">
<div className="field">
<label className="field-label">Video codec</label>
<select className="field-input" value={codec}
onChange={e => setCodec(e.target.value)} disabled={isRec}
style={{ appearance: 'auto' }}>
<option value="hevc_nvenc">HEVC (NVENC, GPU)</option>
<option value="h264_nvenc">H.264 (NVENC, GPU)</option>
</select>
</div>
{showBitrate && (
<div className="field">
<label className="field-label">Bitrate (Mbps)</label>
<input className="field-input" type="number" min="1" max="400" step="1"
value={bitrate} disabled={isRec}
onChange={e => setBitrate(e.target.value)} />
</div>
)}
</div>
) : (
<div className="field">
<label className="field-label">XDCAM HD422 bitrate (Mbps)</label>
<input className="field-input" type="number" min="1" max="400" step="1"
value={bitrate} disabled={isRec}
onChange={e => setBitrate(e.target.value)} />
<div className="mono" style={{ fontSize: 10.5, color: 'var(--text-3)', marginTop: 4 }}>
Target bitrate of the growing MXF essence. Broadcast XDCAM HD422 is 50 Mbps; raise for higher quality.
</div>
</div>
)}
<div className="field">
<label className="field-label">Default project</label>
<select className="field-input" value={projectId} disabled={isRec}
onChange={e => setProjectId(e.target.value)} style={{ appearance: 'auto' }}>
<option value="">(none)</option>
{PROJECTS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
{err && <div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 4 }}>{err}</div>}
</div>
<div className="modal-foot">
<button className="btn ghost sm" onClick={onClose}>Cancel</button>
<button className="btn primary sm" onClick={submit} disabled={saving || isRec}>
{saving ? 'Saving…' : 'Save config'}
</button>
</div>
</div>
</div>
);
}
function _normRecorder(r) {
const cfg = r.source_config || {};
// Surface the 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.
// 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;
let capturePort = null;
if (r.source_type === 'deltacast') {
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]}`;
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;
}
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')
@ -648,12 +842,24 @@ 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 creates/starts/stops/deletes a recorder dispatches
// Any screen that enables/disables/records a recorder dispatches
// df:recorders-changed; refresh immediately instead of waiting for the tick.
const onChange = () => refresh();
window.addEventListener('df:recorders-changed', onChange);
@ -665,12 +871,35 @@ 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">Live ingest from SRT, RTMP, and SDI sources</span>
<span className="subtitle">Physical capture ports one per SDI / Deltacast input</span>
<div className="spacer" />
{(liveCount > 0 || errCount > 0) && (
<div className="status-pip">
@ -678,26 +907,55 @@ 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 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 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>
) : (
<div className="recorders-list">
{recorders.map(r => <RecorderRow key={r.id} recorder={r} onRefresh={refresh} />)}
</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>
{configRecorder && (
<RecorderConfigModal
recorder={configRecorder}
onClose={() => setConfigRecorder(null)}
onSaved={() => { setConfigRecorder(null); refresh(); window.dispatchEvent(new CustomEvent('df:recorders-changed')); }}
/>
)}
</div>
);
}
function RecorderRow({ recorder: initialRecorder, onRefresh }) {
function RecorderRow({ recorder: initialRecorder, onRefresh, onConfigure, nodeOnline }) {
const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
const [recorder, setRecorder] = React.useState(initialRecorder);
const [pending, setPending] = React.useState(false);
@ -759,14 +1017,6 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
String(d % 60).padStart(2, '0');
}, [isRec, elapsedSecs]);
// 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…' : '·');
@ -805,44 +1055,60 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
.catch(e => { setPending(false); setErr(e.message || 'Failed'); setRecorder(initialRecorder); });
};
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' })
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' })
.then(() => {
setPending(false);
onRefresh();
window.dispatchEvent(new CustomEvent('df:recorders-changed'));
window.dispatchEvent(new CustomEvent('df:assets-changed'));
})
.catch(e => setErr(e.message || 'Delete failed'));
.catch(e => { setPending(false); setErr(e.message || (ep + ' failed')); });
};
return (
<div className={'recorder-row ' + recorder.status}>
<div className={'recorder-row ' + recorder.status + (isEnabled ? (isRec ? '' : ' is-armed') : ' is-disabled')}>
{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' : 'video'} size={20} style={{ opacity: 0.4 }} /></div>}
: <div className="recorder-empty"><Icon name={recorder.status === 'error' ? 'alert' : (isEnabled ? 'video' : 'power')} size={20} style={{ opacity: 0.4 }} /></div>}
</div>
<div className="recorder-info">
<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>
<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>}
<span className="badge outline">{recorder.source}</span>
{recorder.capturePort && (
<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)' }}>
<span className="badge recorder-port-chip" title="Capture port">
<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>·</span>
<span>{recorder.res}</span>
<span>{recorder.codec}</span><span className="recorder-sub-sep">·</span>
<span>{recorder.res}</span><span className="recorder-sub-sep">·</span>
<span>{recorder.framerate}</span>
</div>
{err && <div style={{ marginTop: 4, fontSize: 11, color: 'var(--danger)' }}>{err}</div>}
{liveStatus?.lastError && isRec && (
@ -861,58 +1127,68 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
{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">
{!isRec && (
<>
{/* Record controls only when ENABLED (standby sidecar up) and not recording. */}
{isEnabled && !isRec && (
<div className="recorder-take">
{PROJECTS.length > 0 && (
<select
className="field-input"
className="field-input recorder-take-project"
value={takeProjectId}
onChange={e => setTakeProjectId(e.target.value)}
disabled={pending}
style={{ width: 160, padding: '5px 8px', fontSize: 12, appearance: 'auto' }}
style={{ 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"
className="field-input recorder-take-clip"
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>
)}
{isRec
? <button className="btn danger sm" onClick={toggle} disabled={pending}>
<div className="recorder-controls">
{isRec ? (
<button className="btn danger sm recorder-rec-btn" onClick={toggle} disabled={pending}>
{pending ? '…' : <><span className="rec-dot" />Stop</>}
</button>
: <button className="btn subtle sm" onClick={toggle} disabled={pending}>
) : isEnabled ? (
<button className="btn subtle sm recorder-rec-btn" onClick={toggle} disabled={pending}>
{pending ? '…' : <><span className="rec-dot" style={{ background: 'var(--live)' }} />Record</>}
</button>}
<button className="icon-btn" onClick={handleDelete} title="Delete recorder" aria-label="Delete recorder" style={{ color: 'var(--text-3)' }}>
<Icon name="x" />
</button>
</button>
) : null}
{/* Enable / Disable — the lifecycle control. Hidden while recording. */}
{!isRec && (
isEnabled
? <button className="btn ghost sm recorder-life-btn" onClick={() => setEnabled(false)} disabled={pending} title="Stop the standby sidecar and free the capture port">
<Icon name="power" size={12} />Disable
</button>
: <button className="btn primary sm recorder-life-btn is-enable" onClick={() => setEnabled(true)} disabled={pending || offline}
title={offline ? 'Node offline — cannot enable' : 'Start the standby sidecar so this port is ready to record'}>
<Icon name="power" size={12} />Enable
</button>
)}
<button className="icon-btn recorder-cfg-btn" onClick={onConfigure} title="Configure recorder" aria-label="Configure recorder">
<Icon name="settings" />
</button>
</div>
</div>
</div>
);
}
function badgeForStatus(s) {
return { recording: 'live', armed: 'accent', idle: 'neutral', error: 'danger', offline: 'neutral', stopped: 'neutral' }[s] || 'neutral';
}
/* ===== Capture ===== */
function _captureSignalChip(sig) {
@ -958,11 +1234,6 @@ function CapturePortChip({ port, sigEntry }) {
<span style={{ fontSize: 9.5, fontWeight: 700, letterSpacing: '0.05em', color }}>
{label}
</span>
{sigEntry && sigEntry.currentFps != null && (
<span style={{ fontSize: 9.5, color: 'var(--text-4)', fontFamily: 'var(--font-mono)' }}>
{Number(sigEntry.currentFps).toFixed(1)} fps
</span>
)}
</div>
</div>
</div>

View file

@ -1,6 +1,9 @@
// screens-library.jsx
function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
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 }) {
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
@ -14,6 +17,8 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
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]);
@ -25,21 +30,44 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
return function() { window.removeEventListener('df:bins-changed', onBinsChanged); };
}, [refreshBins]);
const createBin = () => {
if (!openProject) { window.alert('Open a project first (Projects → click a project), then create a bin inside it.'); return; }
setNewBinName(''); setCreatingBin(true);
};
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; });
};
const submitBin = (name) => {
if (!name || !name.trim()) { setCreatingBin(false); return; }
if (!name || !name.trim()) { setCreatingBin(false); setCreatingChildOf(null); 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() }),
body: JSON.stringify({ project_id: openProject.id, name: name.trim(), parent_id: parentId || null }),
})
.then(() => window.ZAMPP_API.fetch('/bins?project_id=' + openProject.id))
.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));
.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);
});
};
const [view, setView] = React.useState('grid');
const [filter, setFilter] = React.useState('all'); // 'all', 'ready', 'processing', 'live', 'error', 'recent'
@ -285,12 +313,13 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
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) assets = assets.filter(function(a) { return a.bin_id === selectedBinId; });
if (selectedBinId) { var sids=collectDescendantIds(selectedBinId,BINS); assets=assets.filter(function(a){return sids.has(a.bin_id);}); }
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;
@ -309,7 +338,7 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
{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() { navigate('projects'); }}
onClick={function() { if (onOpenProject) onOpenProject(p); }}
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>
@ -329,45 +358,30 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
</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>
) : 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>
);
})}
) : (
<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>
)}
</div>
</div>
<div>
@ -596,7 +610,8 @@ 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();
window.alert('Promotion job queued. The file is being uploaded to S3 in the background.');
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.');
})
.catch(function(e) { alert('Promotion failed: ' + e.message); });
};
@ -873,5 +888,6 @@ function DownloadWarningModal({ asset, onClose, onConfirm }) {
);
}
function BinTreeNodes(p){var nodes=p.nodes,depth=p.depth,selId=p.selectedBinId,setSel=p.setSelectedBinId;var drag=p.draggingAssetId,over=p.dragOverBinId;var onOver=p.onBinDragOver,onDrop=p.onBinDrop,onLeave=p.onBinDragLeave;var expanded=p.expandedBins,toggle=p.toggleBinExpanded;var creat=p.creatingBin,parentOf=p.creatingChildOf;var newName=p.newBinName,setName=p.setNewBinName;var submit=p.submitBin,setCreat=p.setCreatingBin,setParent=p.setCreatingChildOf;var createSub=p.createSubBin,proj=p.openProject;if(!nodes||!nodes.length)return null;return nodes.map(function(b){var act=selId===b.id,idt=drag!==null&&over===b.id,hasC=b.children&&b.children.length>0,exp=expanded.has(b.id),isCk=creat&&parentOf===b.id,ind=depth*14;return React.createElement(React.Fragment,{key:b.id},React.createElement("div",{className:"rail-item"+(act?" active":"")+(idt?" droppable":""),onClick:function(){setSel(act?null:b.id);},onDragOver:function(e){onOver(b.id,e);},onDrop:function(e){onDrop(b.id,e);},onDragLeave:onLeave,style:{cursor:"pointer",paddingLeft:8+ind}},React.createElement("span",{style:{display:"inline-flex",alignItems:"center",width:16,height:16,flexShrink:0,marginRight:2,color:hasC?"var(--text-3)":"transparent",transition:"transform 120ms",transform:hasC&&exp?"rotate(90deg)":"none"},onClick:function(e){if(!hasC)return;e.stopPropagation();toggle(b.id);}},hasC&&React.createElement("svg",{width:8,height:8,viewBox:"0 0 8 8",fill:"currentColor"},React.createElement("path",{d:"M2 1l4 3-4 3V1z"}))),React.createElement(Icon,{name:binIcon(b.icon),size:13,className:"rail-icon",style:{marginRight:4}}),React.createElement("span",{style:{flex:1,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}},b.name),React.createElement("span",{className:"rail-count"},b.count),proj&&React.createElement("button",{className:"icon-btn bin-add-child-btn","aria-label":"Create sub-bin",onClick:function(e){e.stopPropagation();createSub(b.id);},style:{opacity:0,transition:"opacity 100ms",marginLeft:2,flexShrink:0},onFocus:function(e){e.currentTarget.style.opacity="1";},onBlur:function(e){e.currentTarget.style.opacity="";}},React.createElement(Icon,{name:"plus",size:10}))),isCk&&React.createElement("div",{style:{paddingLeft:8+ind+14,paddingRight:6,paddingTop:4,paddingBottom:4,display:"flex",gap:4,alignItems:"center"}},React.createElement("input",{className:"field-input",autoFocus:true,value:newName,onChange:function(e){setName(e.target.value);},onKeyDown:function(e){if(e.key==="Enter")submit(newName);if(e.key==="Escape"){setCreat(false);setParent(null);}},onBlur:function(){submit(newName);},placeholder:"Sub-bin name",style:{fontSize:12,height:26,padding:"0 6px",flex:1}})),hasC&&exp&&React.createElement(BinTreeNodes,Object.assign({},p,{nodes:b.children,depth:depth+1})));});}
window.Library = Library;
window.AssetCard = AssetCard;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,8 +1,11 @@
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',
@ -10,6 +13,14 @@ const CONTENT_TYPES = {
'.mp4': 'video/mp4',
};
// Keep-alive agents + a long request timeout. The proxy/conform jobs download
// full master files (hundreds of MB) and upload HLS segments; the SDK defaults
// (no keep-alive, 0/short timeouts under contention) caused master downloads to
// stall and abort, leaving assets stuck in 'processing'. Generous timeout +
// pooled sockets make these large transfers reliable.
const _httpAgent = new http.Agent({ keepAlive: true, maxSockets: 128, timeout: 600_000 });
const _httpsAgent = new https.Agent({ keepAlive: true, maxSockets: 128, timeout: 600_000 });
const createS3Client = () => {
return new S3Client({
region: process.env.S3_REGION || 'us-east-1',
@ -19,6 +30,12 @@ const createS3Client = () => {
secretAccessKey: process.env.S3_SECRET_KEY,
},
forcePathStyle: true,
requestHandler: new NodeHttpHandler({
httpAgent: _httpAgent,
httpsAgent: _httpsAgent,
requestTimeout: 600_000,
connectionTimeout: 15_000,
}),
});
};