2026-06-02 17:31:46 -04:00
import { spawn , execFileSync } from 'child_process' ;
2026-06-03 17:40:58 -04:00
import { mkdirSync , writeFileSync } from 'node:fs' ;
2026-06-02 17:31:46 -04:00
import fs from 'node:fs' ;
import { dirname } from 'node:path' ;
import { v4 as uuidv4 } from 'uuid' ;
import { createUploadStream } from './s3/client.js' ;
const S3 _BUCKET = process . env . S3 _BUCKET || 'wild-dragon' ;
2026-06-03 18:05:11 -04:00
// 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 ) ;
2026-06-03 13:08:09 -04:00
2026-06-02 17:31:46 -04:00
// 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.
2026-06-04 09:02:23 -04:00
// 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).
2026-06-02 17:31:46 -04:00
const GROWING _PATH = process . env . GROWING _PATH || '/growing' ;
// Approach A: when a CIFS source is supplied, this (privileged) container mounts
// the SMB landing-zone share at GROWING_PATH itself, using credentials supplied
// by the API from global Settings. Empty GROWING_SMB_MOUNT → no system mount
// (the host-bound /growing volume is used instead, or S3 streaming if growing
// is off).
// mount.cifs needs a UNC source (//host/share). Operators (and Settings) often
// store the share as an `smb://host/share` URL or a Windows `\\host\share`
// path; the kernel rejects those outright ("Mounting cifs URL not implemented
// yet"), which silently drops us back to S3. Normalize any of these forms to
// the `//host/share` UNC the mount helper accepts.
function toUncShare ( raw ) {
if ( ! raw ) return '' ;
let s = String ( raw ) . trim ( ) . replace ( /\\/g , '/' ) ; // \\host\share -> //host/share
s = s . replace ( /^smb:\/\//i , '//' ) ; // smb://host/share -> //host/share
if ( ! s . startsWith ( '//' ) ) s = '//' + s . replace ( /^\/+/ , '' ) ; // host/share -> //host/share
return s ;
}
2026-06-04 08:51:32 -04:00
// 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
refactor(capture): remove dead raw2bmx/AVC-Intra/HEVC growing code paths
Frame-coupled VC-3/DNxHD MXF (_buildGrowingVc3Mxf) is now the only growing
master path. Delete provably-unreferenced legacy builders and their constants,
and update stale comments to match the VC-3 reality. No live-path behavior
change: the removed stop() raw2bmx branch was gated on a hard-coded false, so
the kept SIGINT path is byte-identical to the previously-executed else branch.
Removed (all confirmed unreferenced repo-wide outside this file):
- _buildGrowingOrchestrator() + JSDoc (raw2bmx bash orchestrator)
- _buildGrowingHevcMov() + JSDoc (HEVC fragmented-MOV growing)
- deriveGrowingRaster() (raw2bmx raster-flag mapper)
- growingVideoElementaryArgs() + GROWING_AVCI_CLASS
- GROWING_DEFAULT_BITRATE, GROWING_PART_INTERVAL_FRAMES,
GROWING_HEVC_DEFAULT_BITRATE, GROWING_VC3_CODECS (unused Set)
- isRaw2bmxGrowing dead branch in stop()
- stale raw2bmx/XDCAM HD422/AVC-Intra/frozen-picture comment blocks
Kept (live): _buildGrowingVc3Mxf, growingCodec, growingVc3Bitrate,
growingExtFor, GROWING_EXT, audioOffsetArgs (+AUDIO_OFFSET_MS), all
non-growing + network/SRT/RTMP/fc_pipe paths.
node --check passes.
2026-06-05 10:28:36 -04:00
// fell back to S3 — producing .mov instead of the VC-3/DNxHD .mxf.
2026-06-04 08:51:32 -04:00
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' ,
} ) ;
2026-06-02 17:31:46 -04:00
const SMB _CREDS _FILE = '/run/smb-creds' ;
// True when GROWING_PATH is already a mountpoint (e.g. a prior session left it
// mounted, or a host bind-mount is present).
function isMounted ( path ) {
try { execFileSync ( 'mountpoint' , [ '-q' , path ] ) ; return true ; }
catch { return false ; }
}
// Mount the CIFS growing share at GROWING_PATH. Credentials go in a root-only
// file (NOT the command line) so they never appear in `ps`/process listings.
// 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 ( ) {
2026-06-04 08:51:32 -04:00
const cfg = growingSmbConfig ( ) ;
if ( ! cfg . mount ) {
console . warn ( '[capture] growing requested but GROWING_SMB_MOUNT is empty — falling back to S3' ) ;
return false ;
}
2026-06-02 17:31:46 -04:00
try {
if ( isMounted ( GROWING _PATH ) ) {
console . log ( '[capture] growing share already mounted at' , GROWING _PATH ) ;
return true ;
}
try { mkdirSync ( GROWING _PATH , { recursive : true } ) ; } catch ( _ ) { }
2026-06-04 08:42:39 -04:00
// 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.
2026-06-02 17:31:46 -04:00
const opts = [
2026-06-04 08:51:32 -04:00
` username= ${ cfg . username } ` ,
` password= ${ cfg . password } ` ,
2026-06-02 17:31:46 -04:00
'uid=0' , 'gid=0' , 'file_mode=0664' , 'dir_mode=0775' ,
2026-06-04 08:51:32 -04:00
` vers= ${ cfg . vers } ` ,
2026-06-02 17:31:46 -04:00
] . join ( ',' ) ;
2026-06-04 08:51:32 -04:00
execFileSync ( 'mount' , [ '-t' , 'cifs' , cfg . mount , GROWING _PATH , '-o' , opts ] ,
2026-06-02 17:31:46 -04:00
{ stdio : [ 'ignore' , 'ignore' , 'pipe' ] } ) ;
2026-06-04 08:51:32 -04:00
console . log ( '[capture] mounted CIFS growing share' , cfg . mount , '->' , GROWING _PATH ) ;
2026-06-02 17:31:46 -04:00
return true ;
} catch ( err ) {
const stderr = err . stderr ? err . stderr . toString ( ) . trim ( ) : err . message ;
console . error ( '[capture] CIFS mount failed (falling back to S3 streaming):' , stderr ) ;
return false ;
}
}
// Best-effort unmount on session stop. Ignores "not mounted".
function unmountGrowingShare ( ) {
try {
if ( isMounted ( GROWING _PATH ) ) {
execFileSync ( 'umount' , [ GROWING _PATH ] , { stdio : [ 'ignore' , 'ignore' , 'pipe' ] } ) ;
console . log ( '[capture] unmounted growing share at' , GROWING _PATH ) ;
}
} catch ( err ) {
const stderr = err . stderr ? err . stderr . toString ( ) . trim ( ) : err . message ;
console . warn ( '[capture] growing share unmount failed (ignored):' , stderr ) ;
}
}
// ── Codec catalogue ──────────────────────────────────────────────────────
// Each entry maps the UI value to a base ffmpeg flag set. Bitrate / framerate
// / pix_fmt are layered on top from the per-recorder configuration.
const VIDEO _CODECS = {
prores _hq : { args : [ '-c:v' , 'prores_ks' , '-profile:v' , '3' ] , bitrateControl : false , pixFmt : 'yuv422p10le' } ,
prores _422 : { args : [ '-c:v' , 'prores_ks' , '-profile:v' , '2' ] , bitrateControl : false , pixFmt : 'yuv422p10le' } ,
prores _lt : { args : [ '-c:v' , 'prores_ks' , '-profile:v' , '1' ] , bitrateControl : false , pixFmt : 'yuv422p10le' } ,
prores _proxy : { args : [ '-c:v' , 'prores_ks' , '-profile:v' , '0' ] , bitrateControl : false , pixFmt : 'yuv422p10le' } ,
dnxhd : { args : [ '-c:v' , 'dnxhd' ] , bitrateControl : true , pixFmt : 'yuv422p' } ,
dnxhr _hq : { args : [ '-c:v' , 'dnxhd' , '-profile:v' , 'dnxhr_hq' ] , bitrateControl : false , pixFmt : 'yuv422p' } ,
dnxhr _sq : { args : [ '-c:v' , 'dnxhd' , '-profile:v' , 'dnxhr_sq' ] , bitrateControl : false , pixFmt : 'yuv422p' } ,
h264 : { args : [ '-c:v' , 'libx264' , '-preset' , 'medium' ] , bitrateControl : true , pixFmt : 'yuv420p' } ,
h264 _nvenc : { args : [ '-c:v' , 'h264_nvenc' , '-preset' , 'p5' ] , bitrateControl : true , pixFmt : 'yuv420p' } ,
h265 : { args : [ '-c:v' , 'libx265' , '-preset' , 'medium' ] , bitrateControl : true , pixFmt : 'yuv420p' } ,
// All-Intra HEVC on NVENC — the growing-file master codec.
// Goal: every frame an IDR (all-intra), so a still-growing file is decodable
// to its last complete frame — the prerequisite for edit-while-record.
//
// NVENC will NOT accept `-g 1`: InitializeEncoder enforces
// "GopLength > numBFrames + 1", so with -bf 0 the minimum GOP is 2 and -g 1
// is rejected with EINVAL (validated on the L4, driver 595). The working
// recipe for true all-intra is therefore:
// -bf 0 no B-frames
// -g 600 large GOP just to satisfy the init check
// -forced-idr 1 forced keyframes are emitted as IDR
// -force_key_frames expr:1 force a keyframe on EVERY frame
// → ffprobe confirms pict_type = I for all frames.
//
// Container: fragmented MOV (+frag_keyframe+empty_moov+default_base_moof),
// NOT MXF — this ffmpeg's MXF muxer rejects HEVC ("Operation not permitted").
// The frag-MOV index is not deferred to EOF, so the file stays readable while
// growing. (See docs/design/2026-05-29-all-intra-hevc-ingest.md §8.)
//
// -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).
2026-06-04 00:09:14 -04:00
// 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()).
2026-06-02 17:31:46 -04:00
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 ,
pixFmt : 'p010le' ,
} ,
} ;
2026-06-04 00:09:14 -04:00
// 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.
2026-06-04 11:56:44 -04:00
// Parse a framerate that may be a rational like "60000/1001" (=59.94) OR a plain
// "59.94"/"60". Number.parseFloat("60000/1001") returns 60000 (stops at '/'),
// which made the GOP 120000 instead of ~120 — effectively open-GOP. Handle the
// rational form explicitly.
function parseFps ( framerate , fallback = 60 ) {
if ( framerate == null ) return fallback ;
const s = String ( framerate ) . trim ( ) ;
if ( s . includes ( '/' ) ) {
const [ n , d ] = s . split ( '/' ) . map ( Number ) ;
if ( Number . isFinite ( n ) && Number . isFinite ( d ) && d !== 0 ) return n / d ;
}
const f = Number . parseFloat ( s ) ;
return Number . isFinite ( f ) && f > 0 ? f : fallback ;
}
2026-06-04 12:07:59 -04:00
// Which physical GPU this sidecar's NVENC encodes should use. node-agent
// round-robins capture ports across the host's GPUs and passes the index here.
// We MUST select it explicitly with ffmpeg's `-gpu N` because the capture
// sidecars run Privileged (so they see every /dev/nvidiaN regardless of
// NVIDIA_VISIBLE_DEVICES) — without -gpu, nvenc defaults every session to GPU 0
// and all 8 ports pile onto one card → it falls below realtime → video freezes.
const CAPTURE _GPU _INDEX = ( ( ) => {
const v = process . env . CAPTURE _GPU _INDEX ;
if ( v == null || v === '' || v === 'all' ) return null ;
const n = parseInt ( v , 10 ) ;
return Number . isInteger ( n ) && n >= 0 ? n : null ;
} ) ( ) ;
// `-gpu N` must come BEFORE the input/encoder is initialized; ffmpeg accepts it
// as an encoder option right after -c:v. Returns [] when no pin is configured.
const nvencGpuSel = ( ) => ( CAPTURE _GPU _INDEX != null ? [ '-gpu' , String ( CAPTURE _GPU _INDEX ) ] : [ ] ) ;
2026-06-05 01:27:27 -04:00
// Optional fixed A/V alignment trim for the SDI/Deltacast audio input. The
// deltacast bridge captures audio and video on separate VHD streams; any
// constant capture-path latency difference between them shows as a fixed A/V
// offset (e.g. audio slightly ahead of video) even though stream LENGTHS stay
// locked (no drift). AUDIO_OFFSET_MS lets an operator dial that out without a
// rebuild: POSITIVE value DELAYS audio (use when audio is AHEAD of video),
// NEGATIVE advances it. Applied as ffmpeg `-itsoffset` on the audio input only.
// Default 0 = no change (fully non-destructive). Range-clamped to ±1000 ms.
const audioOffsetArgs = ( ) => {
const raw = parseFloat ( process . env . AUDIO _OFFSET _MS || '0' ) ;
if ( ! Number . isFinite ( raw ) || raw === 0 ) return [ ] ;
const ms = Math . max ( - 1000 , Math . min ( 1000 , raw ) ) ;
return [ '-itsoffset' , ( ms / 1000 ) . toFixed ( 4 ) ] ;
} ;
2026-06-04 00:09:14 -04:00
function hevcNvencArgs ( framerate , growing ) {
2026-06-04 12:07:59 -04:00
const base = [ '-c:v' , 'hevc_nvenc' , ... nvencGpuSel ( ) , '-preset' , 'p4' , '-rc' , 'vbr' , '-profile:v' , 'main10' ] ;
2026-06-04 00:09:14 -04:00
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.
2026-06-04 11:56:44 -04:00
const fps = parseFps ( framerate , 60 ) ;
2026-06-04 00:09:14 -04:00
const gop = Math . max ( 2 , Math . round ( fps * 2 ) ) ;
return [ ... base , '-bf' , '2' , '-g' , String ( gop ) ] ;
}
2026-06-02 17:31:46 -04:00
// 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' ] ) ;
// ── GPU availability for this sidecar (issue #164) ───────────────────────
// The HLS monitor preview should be GPU-encoded (h264_nvenc) when — and only
// when — the GPU is actually attached to this capture container. A non-GPU
// recorder must keep using libx264, otherwise ffmpeg would fail to open the
// nvenc encoder and break the preview.
//
// Two signals, OR'd for robustness:
// 1) The master video codec is an nvenc codec. recorders.js derives `useGpu`
// from exactly this (GPU_CODECS = [hevc_nvenc, h264_nvenc]) and node-agent
// only attaches the NVIDIA runtime when useGpu is set — so an nvenc master
// codec is a reliable proxy for "this sidecar has the GPU".
// 2) node-agent injects NVIDIA_VISIBLE_DEVICES into the sidecar env whenever
// useGpu is set. This is the most direct in-process evidence the runtime
// attached a GPU, and covers the (currently unused) case where the GPU is
// present but the master codec is a CPU codec.
function gpuAvailableForPreview ( masterCodec ) {
if ( NVENC _CODECS . has ( masterCodec ) ) return true ;
const vis = process . env . NVIDIA _VISIBLE _DEVICES ;
if ( vis && vis !== 'void' && vis !== 'none' ) return true ;
return false ;
}
// Build the HLS preview video-encode args. `segTime` is the HLS segment length
// (seconds); we pin the GOP/keyframe interval to one IDR per segment so every
// segment starts on a keyframe (misaligned keyframes were the root cause of the
// playout preview black/flashing bug — keep the preview robust).
function buildHlsVideoArgs ( masterCodec , framerate ) {
// Frames-per-segment for keyframe alignment. The SDI preview runs at the
// capture framerate; default to 30 (matches the test-card rate) when unknown.
2026-06-04 11:56:44 -04:00
const fps = parseFps ( framerate , 30 ) ;
2026-06-02 17:31:46 -04:00
const segTime = 2 ; // matches -hls_time below
const gop = Math . max ( 1 , Math . round ( fps * segTime ) ) ;
if ( gpuAvailableForPreview ( masterCodec ) ) {
// Low-latency NVENC preset (p1 + ll tune). forced-idr + a keyframe every GOP
// frames keeps segment boundaries on IDR frames so hls.js can sync cleanly.
return [
2026-06-04 12:07:59 -04:00
'-c:v' , 'h264_nvenc' , ... nvencGpuSel ( ) , '-preset' , 'p1' , '-tune' , 'll' ,
2026-06-02 17:31:46 -04:00
'-pix_fmt' , 'yuv420p' , '-b:v' , '2M' ,
'-g' , String ( gop ) , '-forced-idr' , '1' , '-sc_threshold' , '0' ,
] ;
}
// No GPU → keep the original CPU encode (must not break a non-GPU recorder).
return [
'-c:v' , 'libx264' , '-preset' , 'veryfast' , '-tune' , 'zerolatency' ,
'-pix_fmt' , 'yuv420p' , '-b:v' , '2M' ,
'-g' , String ( gop ) , '-sc_threshold' , '0' ,
] ;
}
const AUDIO _CODECS = {
pcm _s16le : { args : [ '-c:a' , 'pcm_s16le' ] , bitrateControl : false } ,
pcm _s24le : { args : [ '-c:a' , 'pcm_s24le' ] , bitrateControl : false } ,
pcm _s32le : { args : [ '-c:a' , 'pcm_s32le' ] , bitrateControl : false } ,
aac : { args : [ '-c:a' , 'aac' ] , bitrateControl : true } ,
ac3 : { args : [ '-c:a' , 'ac3' ] , bitrateControl : true } ,
opus : { args : [ '-c:a' , 'libopus' ] , bitrateControl : true } ,
flac : { args : [ '-c:a' , 'flac' ] , bitrateControl : false } ,
} ;
const CONTAINER _FMT = {
mov : 'mov' ,
mp4 : 'mp4' ,
mkv : 'matroska' ,
mxf : 'mxf' ,
ts : 'mpegts' ,
} ;
const CONTAINER _EXT = {
mov : 'mov' , mp4 : 'mp4' , mkv : 'mkv' , mxf : 'mxf' , ts : 'ts' ,
} ;
refactor(capture): remove dead raw2bmx/AVC-Intra/HEVC growing code paths
Frame-coupled VC-3/DNxHD MXF (_buildGrowingVc3Mxf) is now the only growing
master path. Delete provably-unreferenced legacy builders and their constants,
and update stale comments to match the VC-3 reality. No live-path behavior
change: the removed stop() raw2bmx branch was gated on a hard-coded false, so
the kept SIGINT path is byte-identical to the previously-executed else branch.
Removed (all confirmed unreferenced repo-wide outside this file):
- _buildGrowingOrchestrator() + JSDoc (raw2bmx bash orchestrator)
- _buildGrowingHevcMov() + JSDoc (HEVC fragmented-MOV growing)
- deriveGrowingRaster() (raw2bmx raster-flag mapper)
- growingVideoElementaryArgs() + GROWING_AVCI_CLASS
- GROWING_DEFAULT_BITRATE, GROWING_PART_INTERVAL_FRAMES,
GROWING_HEVC_DEFAULT_BITRATE, GROWING_VC3_CODECS (unused Set)
- isRaw2bmxGrowing dead branch in stop()
- stale raw2bmx/XDCAM HD422/AVC-Intra/frozen-picture comment blocks
Kept (live): _buildGrowingVc3Mxf, growingCodec, growingVc3Bitrate,
growingExtFor, GROWING_EXT, audioOffsetArgs (+AUDIO_OFFSET_MS), all
non-growing + network/SRT/RTMP/fc_pipe paths.
node --check passes.
2026-06-05 10:28:36 -04:00
// Growing-file (edit-while-record) master format — VC-3 / DNxHD in MXF OP1a,
// written DIRECTLY by ffmpeg's native MXF muxer (NO raw2bmx, NO FIFO, NO
// elementary-essence orchestration). ffmpeg writes a frame-wrapped OP1a whose
// BODY grows readably while still being written: the partial file opens as
// 'mxf' and decodes mid-write, and finalizes with a valid Duration + footer on
// a clean SIGINT. This is the same growing VC-3 workflow vMix uses and that
// Adobe Premiere imports live (with "Automatically refresh growing files"
// enabled). The single-input fc_pipe AVI feeds it (video + frame-coupled
// embedded audio in one stream); see _buildGrowingVc3Mxf() and start().
2026-06-02 17:31:46 -04:00
//
refactor(capture): remove dead raw2bmx/AVC-Intra/HEVC growing code paths
Frame-coupled VC-3/DNxHD MXF (_buildGrowingVc3Mxf) is now the only growing
master path. Delete provably-unreferenced legacy builders and their constants,
and update stale comments to match the VC-3 reality. No live-path behavior
change: the removed stop() raw2bmx branch was gated on a hard-coded false, so
the kept SIGINT path is byte-identical to the previously-executed else branch.
Removed (all confirmed unreferenced repo-wide outside this file):
- _buildGrowingOrchestrator() + JSDoc (raw2bmx bash orchestrator)
- _buildGrowingHevcMov() + JSDoc (HEVC fragmented-MOV growing)
- deriveGrowingRaster() (raw2bmx raster-flag mapper)
- growingVideoElementaryArgs() + GROWING_AVCI_CLASS
- GROWING_DEFAULT_BITRATE, GROWING_PART_INTERVAL_FRAMES,
GROWING_HEVC_DEFAULT_BITRATE, GROWING_VC3_CODECS (unused Set)
- isRaw2bmxGrowing dead branch in stop()
- stale raw2bmx/XDCAM HD422/AVC-Intra/frozen-picture comment blocks
Kept (live): _buildGrowingVc3Mxf, growingCodec, growingVc3Bitrate,
growingExtFor, GROWING_EXT, audioOffsetArgs (+AUDIO_OFFSET_MS), all
non-growing + network/SRT/RTMP/fc_pipe paths.
node --check passes.
2026-06-05 10:28:36 -04:00
// 'vc3_90' -> VC-3 90 Mbps, lighter storage. DEFAULT.
// 'vc3_220' -> VC-3/DNxHD 220 Mbps (VC3_1080p_1238), highest quality.
2026-06-02 17:31:46 -04:00
//
2026-06-04 23:04:45 -04:00
// growingCodec() reads GROWING_CODEC fresh from env at record time (standby
// sidecars boot unset and receive it per-session via /capture/start).
refactor(capture): remove dead raw2bmx/AVC-Intra/HEVC growing code paths
Frame-coupled VC-3/DNxHD MXF (_buildGrowingVc3Mxf) is now the only growing
master path. Delete provably-unreferenced legacy builders and their constants,
and update stale comments to match the VC-3 reality. No live-path behavior
change: the removed stop() raw2bmx branch was gated on a hard-coded false, so
the kept SIGINT path is byte-identical to the previously-executed else branch.
Removed (all confirmed unreferenced repo-wide outside this file):
- _buildGrowingOrchestrator() + JSDoc (raw2bmx bash orchestrator)
- _buildGrowingHevcMov() + JSDoc (HEVC fragmented-MOV growing)
- deriveGrowingRaster() (raw2bmx raster-flag mapper)
- growingVideoElementaryArgs() + GROWING_AVCI_CLASS
- GROWING_DEFAULT_BITRATE, GROWING_PART_INTERVAL_FRAMES,
GROWING_HEVC_DEFAULT_BITRATE, GROWING_VC3_CODECS (unused Set)
- isRaw2bmxGrowing dead branch in stop()
- stale raw2bmx/XDCAM HD422/AVC-Intra/frozen-picture comment blocks
Kept (live): _buildGrowingVc3Mxf, growingCodec, growingVc3Bitrate,
growingExtFor, GROWING_EXT, audioOffsetArgs (+AUDIO_OFFSET_MS), all
non-growing + network/SRT/RTMP/fc_pipe paths.
node --check passes.
2026-06-05 10:28:36 -04:00
const GROWING _EXT = 'mxf' ;
2026-06-04 17:51:45 -04:00
const growingCodec = ( ) => {
const v = process . env . GROWING _CODEC ;
2026-06-04 23:04:45 -04:00
if ( v === 'vc3_220' ) return 'vc3_220' ;
return 'vc3_90' ; // default
2026-06-04 17:51:45 -04:00
} ;
2026-06-04 23:04:45 -04:00
// Bitrate for the dnxhd encoder, per growing codec value.
const growingVc3Bitrate = ( codec ) => ( codec === 'vc3_220' ? '220M' : '90M' ) ;
// File extension per growing codec — always MXF for VC-3.
const growingExtFor = ( _codec ) => 'mxf' ;
2026-06-02 17:31:46 -04:00
// ── Source-backend abstraction (issue #168) ──────────────────────────────
// The capture input was historically hard-wired to a single `-f decklink -i …`
// construction. To allow other SDI capture cards (Deltacast, AJA) to be added
// later without touching the encode/output/HLS pipeline, the per-backend FFmpeg
// INPUT-arg construction now lives behind this map. Each backend exposes:
//
// buildInput(ctx) -> { inputArgs, isNetwork } (may be async)
//
// where `ctx` carries the resolved recorder fields the backend needs (device).
// The rest of capture-manager consumes the returned `inputArgs` unchanged, so
// adding a backend is purely additive.
//
// IMPORTANT: `blackmagic` is a behaviour-preserving extraction of the previous
// default DeckLink path — for an existing DeckLink recorder the produced ffmpeg
// input args are byte-for-byte identical to the pre-refactor code. The
// `deltacast`/`aja` entries are stubs that throw until the hardware/SDK plumbing
// lands.
const sourceBackends = {
// BlackMagic DeckLink over SDI (the only backend implemented today).
// device may be an integer index (0-based) or a full device name string.
// FFmpeg 7.x DeckLink requires the full name (e.g. 'DeckLink Duo 2 (2)').
// Map integer index -> name using ffmpeg -sources decklink at runtime.
//
// ffmpeg -sources decklink output format:
// Auto-detected sources for decklink:
// DeckLink Duo 2
// DeckLink Duo 2 (2)
// Lines containing device names start with whitespace; the header line
// starts with a non-space character. Previous code used a v4l2-style
// hex-address regex that never matched DeckLink output → index 1+ always
// fell through to a wrong fallback, producing black output from port 2+.
blackmagic : {
async buildInput ( { device } ) {
let deckLinkName = String ( device ) ;
if ( typeof device === 'number' || /^\d+$/ . test ( String ( device ) ) ) {
const idx = parseInt ( device , 10 ) ;
try {
const { execSync } = await import ( 'child_process' ) ;
const out = execSync ( 'ffmpeg -hide_banner -sources decklink 2>&1' , { encoding : 'utf-8' , timeout : 5000 } ) ;
const names = [ ] ;
for ( const line of out . split ( '\n' ) ) {
// DeckLink source lines: " 81:76669a80:00000000 [DeckLink Duo (1)] (none)"
const m = line . match ( /^\s+[0-9a-f:]+\s+\[([^\]]+)\]/ ) ;
if ( m ) names . push ( m [ 1 ] ) ;
}
if ( names [ idx ] ) {
deckLinkName = names [ idx ] ;
console . log ( ` [capture] DeckLink index ${ idx } → " ${ deckLinkName } " (from ${ names . length } detected: ${ names . join ( ', ' ) } ) ` ) ;
} else {
// Fallback: cannot determine model name without enumeration.
// Log a warning — operator should check the detected device list.
console . warn ( ` [capture] DeckLink index ${ idx } out of range (detected ${ names . length } devices: ${ names . join ( ', ' ) } ). Falling back to index-only input — capture may fail. ` ) ;
deckLinkName = ` DeckLink ( ${ idx } ) ` ;
}
} catch ( err ) {
console . warn ( ` [capture] ffmpeg -sources decklink failed: ${ err . message } . Using index ${ device } directly. ` ) ;
// Pass the numeric index directly; some ffmpeg builds accept it.
deckLinkName = String ( device ) ;
}
}
return {
inputArgs : [ '-f' , 'decklink' , '-i' , deckLinkName ] ,
isNetwork : false ,
} ;
} ,
} ,
deltacast : {
// Unused stub — deltacast capture uses sourceType='deltacast' path in
// _buildInputArgs, not the sourceBackends map.
buildInput ( ) {
throw new Error ( 'deltacast: use sourceType="deltacast" not sourceBackend' ) ;
} ,
} ,
aja : {
buildInput ( ) {
throw new Error ( 'aja backend not yet implemented — requires hardware' ) ;
} ,
} ,
} ;
function buildEncodeArgs ( {
codec , videoBitrate , framerate ,
audioCodec , audioBitrate , audioChannels ,
container , isNetwork , isProxy = false ,
growing = false ,
} ) {
refactor(capture): remove dead raw2bmx/AVC-Intra/HEVC growing code paths
Frame-coupled VC-3/DNxHD MXF (_buildGrowingVc3Mxf) is now the only growing
master path. Delete provably-unreferenced legacy builders and their constants,
and update stale comments to match the VC-3 reality. No live-path behavior
change: the removed stop() raw2bmx branch was gated on a hard-coded false, so
the kept SIGINT path is byte-identical to the previously-executed else branch.
Removed (all confirmed unreferenced repo-wide outside this file):
- _buildGrowingOrchestrator() + JSDoc (raw2bmx bash orchestrator)
- _buildGrowingHevcMov() + JSDoc (HEVC fragmented-MOV growing)
- deriveGrowingRaster() (raw2bmx raster-flag mapper)
- growingVideoElementaryArgs() + GROWING_AVCI_CLASS
- GROWING_DEFAULT_BITRATE, GROWING_PART_INTERVAL_FRAMES,
GROWING_HEVC_DEFAULT_BITRATE, GROWING_VC3_CODECS (unused Set)
- isRaw2bmxGrowing dead branch in stop()
- stale raw2bmx/XDCAM HD422/AVC-Intra/frozen-picture comment blocks
Kept (live): _buildGrowingVc3Mxf, growingCodec, growingVc3Bitrate,
growingExtFor, GROWING_EXT, audioOffsetArgs (+AUDIO_OFFSET_MS), all
non-growing + network/SRT/RTMP/fc_pipe paths.
node --check passes.
2026-06-05 10:28:36 -04:00
// NOTE: the growing master is NOT muxed here — the growing VC-3/DNxHD MXF is
// built by _buildGrowingVc3Mxf() and spawned directly in start(). So
// buildEncodeArgs is never called with growing=true. The `growing` param is
// retained for call-site compatibility; if ever set, fall through to the
// finalized path so we never silently produce a wrong file.
2026-06-02 17:31:46 -04:00
const v = VIDEO _CODECS [ codec ] || ( isProxy ? VIDEO _CODECS . h264 : VIDEO _CODECS . prores _hq ) ;
const a = AUDIO _CODECS [ audioCodec ] || ( isProxy ? AUDIO _CODECS . aac : AUDIO _CODECS . pcm _s24le ) ;
const fmt = CONTAINER _FMT [ container ] || ( isProxy ? 'mp4' : 'mov' ) ;
const args = [ ] ;
if ( isNetwork ) args . push ( '-map' , '0:v:0?' , '-map' , '0:a:0?' ) ;
2026-06-04 00:09:14 -04:00
// 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 ) ;
}
2026-06-02 17:31:46 -04:00
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 ) ;
args . push ( ... a . args ) ;
if ( a . bitrateControl && audioBitrate ) args . push ( '-b:a' , audioBitrate ) ;
if ( audioChannels ) args . push ( '-ac' , String ( audioChannels ) ) ;
2026-06-03 17:40:58 -04:00
// 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).
2026-06-02 17:31:46 -04:00
if ( fmt === 'mov' || fmt === 'mp4' ) {
2026-06-03 17:40:58 -04:00
args . push ( '-movflags' , '+frag_keyframe+empty_moov+default_base_moof' ) ;
2026-06-02 17:31:46 -04:00
}
// ProRes-in-MOV must carry a QuickTime brand or some importers reject the tag.
args . push ( '-f' , fmt ) ;
return args ;
}
class CaptureManager {
constructor ( ) {
this . state = {
recording : false ,
sessionId : null ,
processes : { } ,
currentSession : { } ,
framesReceived : 0 ,
currentFps : 0 ,
lastFrameAt : null ,
lastError : null ,
} ;
}
/ * *
* Build FFmpeg input arguments based on source type .
* Returns { inputArgs , isNetwork }
* @ private
* /
async _buildInputArgs ( { sourceType , sourceBackend = 'blackmagic' , device , port , board , sourceUrl , listen , listenPort , streamKey } ) {
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:a: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 11:37:17 -04:00
// ── 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' ] ,
} ) ;
2026-06-03 12:25:34 -04:00
// 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 ( ) ;
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:a: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 11:37:17 -04:00
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 : [
2026-06-03 18:30:03 -04:00
// No -use_wallclock_as_timestamps — framecache delivers CFR frames
// at the original ingest rate; -framerate produces correct timestamps.
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:a: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 11:37:17 -04:00
'-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 ,
2026-06-03 12:25:34 -04:00
audioInputIndex : 0 , /* network fc_pipe is video-only — no audio input */
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:a: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 11:37:17 -04:00
_fcPipeProcess : fcPipeProcess ,
} ;
}
// ── Legacy direct network paths (no framecache / net_ingest not running) ──
2026-06-02 17:31:46 -04:00
if ( sourceType === 'srt' ) {
let url ;
if ( listen ) {
const port = listenPort || 9000 ;
url = ` srt://0.0.0.0: ${ port } ?mode=listener ` ;
} else {
url = sourceUrl ;
if ( ! url . includes ( 'mode=' ) ) {
url += ( url . includes ( '?' ) ? '&' : '?' ) + 'mode=caller' ;
}
}
return { inputArgs : [ '-probesize' , '32M' , '-analyzeduration' , '10M' , '-fflags' , '+genpts' , '-i' , url ] , isNetwork : true } ;
}
if ( sourceType === 'rtmp' ) {
if ( listen ) {
const port = listenPort || 1935 ;
const key = streamKey || 'stream' ;
return {
inputArgs : [ '-probesize' , '32M' , '-analyzeduration' , '10M' , '-fflags' , '+genpts' , '-listen' , '1' , '-i' , ` rtmp://0.0.0.0: ${ port } /live/ ${ key } ` ] ,
isNetwork : true ,
} ;
}
return { inputArgs : [ '-probesize' , '32M' , '-analyzeduration' , '10M' , '-fflags' , '+genpts' , '-i' , sourceUrl ] , isNetwork : true } ;
}
2026-06-03 11:32:40 -04:00
// ── Framecache path (primary for deltacast + blackmagic) ────────────────
2026-06-02 17:31:46 -04:00
//
2026-06-03 11:32:40 -04:00
// 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.
2026-06-02 17:31:46 -04:00
//
2026-06-03 11:32:40 -04:00
// 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.
2026-06-02 17:31:46 -04:00
//
2026-06-03 22:50:57 -04:00
// Audio stays on the named FIFO path (audio fan-out via shm is a roadmap
// item).
2026-06-02 17:31:46 -04:00
//
2026-06-03 22:50:57 -04:00
// 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.
2026-06-03 11:32:40 -04:00
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 ;
2026-06-05 10:06:35 -04:00
// Single-input AVI: fc_pipe muxes video+audio into ONE streaming AVI
// container on stdout. ffmpeg reads it as a SINGLE input (-f avi -i pipe:0),
// which eliminates the confirmed two-live-pipe deadlock (ffmpeg given a raw
// video pipe AND a separate live audio FIFO stalled forever probing input 0).
// No audio FIFO is created or used on this path anymore: audio rides inside
// the AVI as interleaved 01wb chunks, frame-coupled to each 00dc video chunk
// (both come from the SAME framecache ring entry in fc_pipe's read loop).
2026-06-03 11:32:40 -04:00
// Video dimensions and fps come from env vars injected by node-agent
2026-06-05 10:06:35 -04:00
// (populated from the bridge's format JSON on signal lock). fc_pipe also
// reads them from the slot header for the AVI header; these stay for logging.
2026-06-03 11:32:40 -04:00
const fcSize = process . env . DELTACAST _VIDEO _SIZE || '1920x1080' ;
const fcFps = process . env . DELTACAST _FRAMERATE || '60000/1001' ;
const fcInterlaced = process . env . DELTACAST _INTERLACED === '1' ;
2026-06-05 10:06:35 -04:00
console . log ( ` [framecache] slot= ${ slotId } size= ${ fcSize } fps= ${ fcFps } mode=avi (single-input video+audio, frame-coupled) ` ) ;
2026-06-03 11:32:40 -04:00
2026-06-05 10:06:35 -04:00
// Spawn fc_pipe in AVI mode: for each ring entry it emits a 00dc video chunk
// followed by a 01wb audio chunk into one AVI byte stream on stdout. ffmpeg
// reads that single stream and maps 0:v / 0:a. Because video and its audio
// are interleaved from the same ring entry, audio can never drift from video.
// argv: <slot_id> <wait_ms> --avi
const fcPipeProcess = spawn ( fcPipeBin , [ slotId , String ( WAIT _MS ) , '--avi' ] , {
2026-06-03 11:32:40 -04:00
stdio : [ 'ignore' , 'pipe' , 'pipe' ] ,
} ) ;
2026-06-05 10:06:35 -04:00
// Pause until piped to ffmpeg (avoids OS pipe-buffer fill stall - see
2026-06-03 12:25:34 -04:00
// the network path above for the full rationale).
fcPipeProcess . stdout . pause ( ) ;
2026-06-03 11:32:40 -04:00
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 : [
2026-06-05 10:06:35 -04:00
// fc_pipe stdout -> ffmpeg AVI input 0. ONE input carries both streams:
// 0:v = UYVY422 video (00dc chunks), 0:a = pcm_s16le audio (01wb chunks).
// The AVI demuxer reads the strf headers + the chunk stream with no index
// and no seeking, so streaming over a pipe is fine (RIFF/movi sizes are
// left as the streaming sentinel by fc_pipe).
2026-06-03 11:32:40 -04:00
'-thread_queue_size' , '512' ,
2026-06-05 10:06:35 -04:00
'-f' , 'avi' ,
2026-06-05 01:27:27 -04:00
// Optional fixed A/V trim (env AUDIO_OFFSET_MS); default empty = no shift.
2026-06-05 10:06:35 -04:00
// Applied as an input option so it shifts the AVI's audio relative to video.
2026-06-05 01:27:27 -04:00
... audioOffsetArgs ( ) ,
2026-06-05 10:06:35 -04:00
'-i' , 'pipe:0' ,
2026-06-03 11:32:40 -04:00
] ,
isNetwork : false ,
bridgeProcess : fcPipeProcess , /* capture-manager pipes this to ffmpeg stdin */
2026-06-05 10:06:35 -04:00
audioFifo : null , /* no separate audio FIFO on the AVI path */
2026-06-03 11:32:40 -04:00
interlaced : fcInterlaced ,
2026-06-05 10:06:35 -04:00
audioInputIndex : 0 , /* audio is inside the single AVI input (0:a) */
2026-06-03 11:32:40 -04:00
_fcPipeProcess : fcPipeProcess , /* stored for clean stop */
} ;
}
2026-06-02 17:31:46 -04:00
// Default: SDI via a pluggable source backend (issue #168). The backend
// selection defaults to `blackmagic` (DeckLink) so existing SDI recorders
// behave exactly as before. Deltacast/AJA backends throw until their
// hardware/SDK plumbing lands.
const backend = sourceBackends [ sourceBackend ] ;
if ( ! backend ) {
throw new Error ( ` Unknown source backend " ${ sourceBackend } " — expected one of: ${ Object . keys ( sourceBackends ) . join ( ', ' ) } ` ) ;
}
return await backend . buildInput ( { device } ) ;
}
2026-06-04 23:04:45 -04:00
/ * *
* Build the single - ffmpeg argv for a GROWING VC - 3 / DNxHD master in MXF OP1a .
*
* KEY : ffmpeg ' s native MXF muxer writes a frame - wrapped OP1a whose BODY grows
* readably while still being written — proven on - node : the partial file opens
* as 'mxf' and decodes 0 errors at t = 4 s / 6 s mid - write , and finalizes with a
* valid Duration + footer on clean stop . This is exactly how vMix records
* growing VC - 3 that Premiere imports live . So NO raw2bmx , NO FIFO orchestrator ,
* NO footer - finalize ordering — one ffmpeg writes the MXF straight to the share .
*
* Valid VC - 3 profiles at 1080 p59 . 94 ( 8 - bit 4 : 2 : 2 , confirmed on - node ) :
* 220 Mbps - > classic DNxHD ( essence VC3 _1080p _1238 ) , highest quality .
* 90 Mbps - > DNxHR - LB - class , lighter storage . Both grow + import in Premiere
* via the ffmpeg - direct MXF path ( raw2bmx is NOT involved here , so
* the DNxHR - 90 profile is fine even though raw2bmx can ' t parse it ) .
* Bitrate comes from ` vc3Bitrate ` ( '220M' default | '90M' ) . On SIGINT ffmpeg
* flushes the MXF footer cleanly , so the normal SIGINT stop works here .
* /
_buildGrowingVc3Mxf ( { inputArgs , framerate , audioChannels , outPath , audioMap = '0:a:0?' , hlsDir = null , videoCodec = 'h264_nvenc' , interlaced = false , vc3Bitrate = '90M' } ) {
const ach = audioChannels ? Number ( audioChannels ) : 2 ;
const vb = ( vc3Bitrate === '90M' || vc3Bitrate === '220M' ) ? vc3Bitrate : '90M' ;
const args = [ '-y' , '-hide_banner' , '-loglevel' , 'warning' , '-stats' , ... inputArgs ] ;
// Deinterlace (SDI) then split: master VC-3 + optional HLS preview tap.
const filterComplex = hlsDir
? ( interlaced ? '[0:v]yadif=mode=1:deint=1,split=2[vhi][vlo]' : '[0:v]split=2[vhi][vlo]' )
: ( interlaced ? '[0:v]yadif=mode=1:deint=1,split=1[vhi]' : '[0:v]split=1[vhi]' ) ;
args . push ( '-filter_complex' , filterComplex ) ;
// (a) VC-3/DNxHD master (8-bit 4:2:2) -> MXF OP1a, growing-readable.
2026-06-04 23:12:42 -04:00
// `-threads 16 -thread_type slice`: CRITICAL for the first ~10s. With ffmpeg's
// DEFAULT frame-threading, dnxhd buffers a long pipeline before output starts
// — it runs at ~0.27x for the first few seconds, so the fc_pipe ring overflows
// and ~344 startup frames are DROPPED (spotty audio+video for ~10s). Explicit
// slice threading makes dnxhd encode >= realtime from frame 1 (measured 1.3x
// at start vs 0.27x), eliminating the startup backlog and the dropped frames.
2026-06-04 23:04:45 -04:00
args . push ( '-map' , '[vhi]' ,
2026-06-04 23:12:42 -04:00
'-c:v' , 'dnxhd' , '-threads' , '32' , '-thread_type' , 'slice' ,
'-b:v' , vb , '-pix_fmt' , 'yuv422p' ,
2026-06-04 23:04:45 -04:00
'-r' , framerate || '60000/1001' ,
'-map' , audioMap , '-c:a' , 'pcm_s24le' , '-ar' , '48000' , '-ac' , String ( ach ) ,
'-f' , 'mxf' , outPath ) ;
// (b) optional H.264 HLS preview -> second output (keeps the UI monitor live).
if ( hlsDir ) {
args . push ( '-map' , '[vlo]' , '-map' , audioMap ,
... buildHlsVideoArgs ( videoCodec , framerate ) ,
'-c:a' , 'aac' , '-b:a' , '128k' , '-ar' , '44100' ,
'-f' , 'hls' , '-hls_time' , '2' , '-hls_list_size' , '15' ,
'-hls_flags' , 'delete_segments+append_list+omit_endlist' ,
'-hls_segment_filename' , ` ${ hlsDir } /seg-%05d.ts ` ,
` ${ hlsDir } /index.m3u8 ` ) ;
}
return args ;
}
2026-06-02 17:31:46 -04:00
/ * *
* Start a new capture session .
*
* Codec parameters all have sensible defaults so legacy callers ( no codec
* args ) still produce ProRes HQ master + H . 264 proxy .
* /
async start ( {
assetId ,
projectId ,
binId ,
clipName ,
device ,
// Deltacast: one board (index 0) with 8 channels. `port` selects the
// channel; `board` selects the physical board (default 0).
port ,
board ,
sourceType = 'sdi' ,
// Source-backend selection for SDI capture (issue #168). Defaults to
// `blackmagic` (DeckLink) so existing recorders are unaffected.
sourceBackend = 'blackmagic' ,
sourceUrl ,
listen = false ,
listenPort ,
streamKey ,
// ── Recording codec ─────────────────────────────────────────────
videoCodec = 'prores_hq' ,
videoBitrate = null ,
framerate = null ,
audioCodec = 'pcm_s24le' ,
audioBitrate = null ,
audioChannels = 2 ,
container = 'mov' ,
// ── Proxy codec ─────────────────────────────────────────────────
proxyEnabled = true ,
proxyVideoCodec = 'h264' ,
proxyVideoBitrate = '8M' ,
proxyFramerate = null ,
proxyAudioCodec = 'aac' ,
proxyAudioBitrate = '192k' ,
proxyAudioChannels = 2 ,
proxyContainer = 'mp4' ,
} ) {
this . _assetIdForHls = assetId || null ;
if ( this . state . recording ) {
throw new Error ( 'Capture already in progress' ) ;
}
2026-06-02 17:40:52 -04:00
// Stop the idle confidence monitor BEFORE touching the FIFO. A second
// reader on the video FIFO halves the capture rate (~29 fps) and desyncs
// audio — so the monitor must fully release the FIFO before recording.
this . stopIdlePreview ( ) ;
2026-06-02 17:31:46 -04:00
const sessionId = uuidv4 ( ) ;
const proxyExt = CONTAINER _EXT [ proxyContainer ] || 'mp4' ;
// Growing-files: write master to the SMB share instead of streaming to S3.
// Path is relative to the container's GROWING_PATH mount.
//
// 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.
2026-06-04 09:02:23 -04:00
// 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
refactor(capture): remove dead raw2bmx/AVC-Intra/HEVC growing code paths
Frame-coupled VC-3/DNxHD MXF (_buildGrowingVc3Mxf) is now the only growing
master path. Delete provably-unreferenced legacy builders and their constants,
and update stale comments to match the VC-3 reality. No live-path behavior
change: the removed stop() raw2bmx branch was gated on a hard-coded false, so
the kept SIGINT path is byte-identical to the previously-executed else branch.
Removed (all confirmed unreferenced repo-wide outside this file):
- _buildGrowingOrchestrator() + JSDoc (raw2bmx bash orchestrator)
- _buildGrowingHevcMov() + JSDoc (HEVC fragmented-MOV growing)
- deriveGrowingRaster() (raw2bmx raster-flag mapper)
- growingVideoElementaryArgs() + GROWING_AVCI_CLASS
- GROWING_DEFAULT_BITRATE, GROWING_PART_INTERVAL_FRAMES,
GROWING_HEVC_DEFAULT_BITRATE, GROWING_VC3_CODECS (unused Set)
- isRaw2bmxGrowing dead branch in stop()
- stale raw2bmx/XDCAM HD422/AVC-Intra/frozen-picture comment blocks
Kept (live): _buildGrowingVc3Mxf, growingCodec, growingVc3Bitrate,
growingExtFor, GROWING_EXT, audioOffsetArgs (+AUDIO_OFFSET_MS), all
non-growing + network/SRT/RTMP/fc_pipe paths.
node --check passes.
2026-06-05 10:28:36 -04:00
// silently produced HEVC/S3 instead of the VC-3/DNxHD MXF.
2026-06-04 09:02:23 -04:00
let growingActive = process . env . GROWING _ENABLED === 'true' ;
if ( growingActive && growingSmbConfig ( ) . mount ) {
2026-06-02 17:31:46 -04:00
if ( ! mountGrowingShare ( ) ) growingActive = false ; // fall back to S3
}
refactor(capture): remove dead raw2bmx/AVC-Intra/HEVC growing code paths
Frame-coupled VC-3/DNxHD MXF (_buildGrowingVc3Mxf) is now the only growing
master path. Delete provably-unreferenced legacy builders and their constants,
and update stale comments to match the VC-3 reality. No live-path behavior
change: the removed stop() raw2bmx branch was gated on a hard-coded false, so
the kept SIGINT path is byte-identical to the previously-executed else branch.
Removed (all confirmed unreferenced repo-wide outside this file):
- _buildGrowingOrchestrator() + JSDoc (raw2bmx bash orchestrator)
- _buildGrowingHevcMov() + JSDoc (HEVC fragmented-MOV growing)
- deriveGrowingRaster() (raw2bmx raster-flag mapper)
- growingVideoElementaryArgs() + GROWING_AVCI_CLASS
- GROWING_DEFAULT_BITRATE, GROWING_PART_INTERVAL_FRAMES,
GROWING_HEVC_DEFAULT_BITRATE, GROWING_VC3_CODECS (unused Set)
- isRaw2bmxGrowing dead branch in stop()
- stale raw2bmx/XDCAM HD422/AVC-Intra/frozen-picture comment blocks
Kept (live): _buildGrowingVc3Mxf, growingCodec, growingVc3Bitrate,
growingExtFor, GROWING_EXT, audioOffsetArgs (+AUDIO_OFFSET_MS), all
non-growing + network/SRT/RTMP/fc_pipe paths.
node --check passes.
2026-06-05 10:28:36 -04:00
// Growing master is always VC-3/DNxHD in MXF OP1a, written directly by
// ffmpeg's native MXF muxer (see _buildGrowingVc3Mxf), regardless of the
// recorder's configured container — so it gets a .mxf extension.
2026-06-04 16:53:13 -04:00
const _growCodec = growingActive ? growingCodec ( ) : null ;
const _growExt = _growCodec ? growingExtFor ( _growCodec ) : GROWING _EXT ;
2026-06-02 17:31:46 -04:00
const growingPath = growingActive
2026-06-04 16:53:13 -04:00
? ` ${ GROWING _PATH } / ${ projectId } / ${ clipName } . ${ _growExt } `
2026-06-02 17:31:46 -04:00
: null ;
// hiresKey MUST match the actual master format/destination:
// - growing active → the master is a growing OP1a MXF on the share; the
// promotion worker uploads it to this key, so it has the .mxf extension.
// (A stale .mov key here would make the proxy job download a nonexistent
// object → "unable to open the file on disk".)
// - growing fell back to S3 → the normal container extension.
2026-06-04 16:53:13 -04:00
const hiresExt = growingPath ? _growExt : ( CONTAINER _EXT [ container ] || 'mov' ) ;
2026-06-02 17:31:46 -04:00
const hiresKey = ` projects/ ${ projectId } /masters/ ${ clipName } . ${ hiresExt } ` ;
if ( growingPath ) {
try { mkdirSync ( dirname ( growingPath ) , { recursive : true } ) ; }
catch ( err ) { console . error ( '[capture] could not create growing dir:' , err . message ) ; }
}
// DeckLink hardware does NOT support concurrent capture from the same port.
// Opening a second ffmpeg process on the same DeckLink input while the first
// is already capturing causes "Cannot Autodetect input stream or No signal"
// on the second process — making the proxy empty and potentially crashing the
// container before the hires upload completes.
//
// Treat SDI the same as SRT/RTMP: set proxyKey=null here and let the BullMQ
// worker generate the proxy from the hires master after the recording stops.
// The stop handler sets needsProxy=true so the worker picks it up.
const proxyKey = null ;
this . _sessionIdForBridge = sessionId ;
2026-06-03 23:34:41 -04:00
const { inputArgs , isNetwork , bridgeProcess = null , audioFifo = null , interlaced = false , audioInputIndex = 0 ,
2026-06-04 00:33:34 -04:00
} = await this . _buildInputArgs ( {
2026-06-02 17:31:46 -04:00
sourceType , sourceBackend , device , port , board , sourceUrl , listen , listenPort , streamKey ,
} ) ;
2026-06-04 00:49:53 -04:00
// ── 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' ) ) {
2026-06-03 13:08:09 -04:00
console . log ( ` [capture] pre-rolling: discarding ${ PRE _ROLL _SECONDS } s of frames ` ) ;
2026-06-04 00:49:53 -04:00
bridgeProcess . stdout . on ( 'data' , ( ) => { } ) ;
2026-06-03 13:08:09 -04:00
await new Promise ( r => setTimeout ( r , PRE _ROLL _SECONDS * 1000 ) ) ;
bridgeProcess . stdout . removeAllListeners ( 'data' ) ;
console . log ( ` [capture] pre-roll complete. ` ) ;
}
2026-06-05 08:46:22 -04:00
// FLUSH STALE AUDIO immediately before ffmpeg opens the FIFO.
//
// With frame-coupled audio (FC_VERSION 2) fc_pipe only writes the audio FIFO
// once a reader attaches, and each audio chunk is bound to its video frame in
// the same ring entry — so there is normally NO stale standby backlog. This
// drain is retained as a harmless belt-and-suspenders: it reads whatever (if
// anything) is buffered and returns immediately on EAGAIN, guaranteeing the
// record ffmpeg attaches at the live edge. fc_pipe reattaches automatically
// if it briefly saw this drain as its reader.
2026-06-04 00:49:53 -04:00
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 } ` ) ;
}
}
2026-06-03 13:08:09 -04:00
const startedAt = new Date ( ) . toISOString ( ) ;
const recordingStartedAt = Date . now ( ) ;
2026-06-03 12:25:34 -04:00
// 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? ` ;
2026-06-02 17:31:46 -04:00
// Non-growing master: ffmpeg muxes the finalized MOV directly. Growing
refactor(capture): remove dead raw2bmx/AVC-Intra/HEVC growing code paths
Frame-coupled VC-3/DNxHD MXF (_buildGrowingVc3Mxf) is now the only growing
master path. Delete provably-unreferenced legacy builders and their constants,
and update stale comments to match the VC-3 reality. No live-path behavior
change: the removed stop() raw2bmx branch was gated on a hard-coded false, so
the kept SIGINT path is byte-identical to the previously-executed else branch.
Removed (all confirmed unreferenced repo-wide outside this file):
- _buildGrowingOrchestrator() + JSDoc (raw2bmx bash orchestrator)
- _buildGrowingHevcMov() + JSDoc (HEVC fragmented-MOV growing)
- deriveGrowingRaster() (raw2bmx raster-flag mapper)
- growingVideoElementaryArgs() + GROWING_AVCI_CLASS
- GROWING_DEFAULT_BITRATE, GROWING_PART_INTERVAL_FRAMES,
GROWING_HEVC_DEFAULT_BITRATE, GROWING_VC3_CODECS (unused Set)
- isRaw2bmxGrowing dead branch in stop()
- stale raw2bmx/XDCAM HD422/AVC-Intra/frozen-picture comment blocks
Kept (live): _buildGrowingVc3Mxf, growingCodec, growingVc3Bitrate,
growingExtFor, GROWING_EXT, audioOffsetArgs (+AUDIO_OFFSET_MS), all
non-growing + network/SRT/RTMP/fc_pipe paths.
node --check passes.
2026-06-05 10:28:36 -04:00
// master: _buildGrowingVc3Mxf builds its own ffmpeg argv below, so we don't
// build ffmpeg codec args here for it.
2026-06-02 17:31:46 -04:00
const hiresCodecArgs = growingPath ? null : buildEncodeArgs ( {
codec : videoCodec , videoBitrate , framerate ,
audioCodec , audioBitrate , audioChannels ,
container ,
isNetwork ,
isProxy : false ,
} ) ;
if ( hiresCodecArgs ) console . log ( '[capture] hires ffmpeg args:' , hiresCodecArgs . join ( ' ' ) ) ;
2026-06-03 11:32:40 -04:00
const isInterlacedSource = sourceType === 'sdi'
|| ( sourceType === 'deltacast' && interlaced )
|| ( ( sourceType === 'blackmagic' ) && interlaced ) ;
2026-06-02 17:31:46 -04:00
const sdiFilterArgs = isInterlacedSource ? [ '-vf' , 'yadif=mode=1:deint=1' ] : [ ] ;
2026-06-03 17:40:58 -04:00
// Master output destination.
2026-06-02 17:31:46 -04:00
//
refactor(capture): remove dead raw2bmx/AVC-Intra/HEVC growing code paths
Frame-coupled VC-3/DNxHD MXF (_buildGrowingVc3Mxf) is now the only growing
master path. Delete provably-unreferenced legacy builders and their constants,
and update stale comments to match the VC-3 reality. No live-path behavior
change: the removed stop() raw2bmx branch was gated on a hard-coded false, so
the kept SIGINT path is byte-identical to the previously-executed else branch.
Removed (all confirmed unreferenced repo-wide outside this file):
- _buildGrowingOrchestrator() + JSDoc (raw2bmx bash orchestrator)
- _buildGrowingHevcMov() + JSDoc (HEVC fragmented-MOV growing)
- deriveGrowingRaster() (raw2bmx raster-flag mapper)
- growingVideoElementaryArgs() + GROWING_AVCI_CLASS
- GROWING_DEFAULT_BITRATE, GROWING_PART_INTERVAL_FRAMES,
GROWING_HEVC_DEFAULT_BITRATE, GROWING_VC3_CODECS (unused Set)
- isRaw2bmxGrowing dead branch in stop()
- stale raw2bmx/XDCAM HD422/AVC-Intra/frozen-picture comment blocks
Kept (live): _buildGrowingVc3Mxf, growingCodec, growingVc3Bitrate,
growingExtFor, GROWING_EXT, audioOffsetArgs (+AUDIO_OFFSET_MS), all
non-growing + network/SRT/RTMP/fc_pipe paths.
node --check passes.
2026-06-05 10:28:36 -04:00
// - Growing-files on → the growing VC-3/DNxHD OP1a MXF is written directly
// to the SMB share by a single ffmpeg (see _buildGrowingVc3Mxf), which
// also taps the HLS preview.
2026-06-02 17:31:46 -04:00
//
2026-06-03 17:40:58 -04:00
// - 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' ] ;
2026-06-02 17:31:46 -04:00
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:a: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 11:37:17 -04:00
// 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 ;
2026-06-02 17:31:46 -04:00
let sdiHlsDir = null ;
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:a: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 11:37:17 -04:00
if ( ( sourceType === 'sdi' || sourceType === 'deltacast' || sourceType === 'blackmagic'
|| ( _viaFcPipeHls && ( sourceType === 'srt' || sourceType === 'rtmp' ) ) )
2026-06-03 11:32:40 -04:00
&& this . _assetIdForHls ) {
2026-06-02 17:31:46 -04:00
const fsMod = await import ( 'node:fs' ) ;
sdiHlsDir = '/live/' + this . _assetIdForHls ;
try { fsMod . mkdirSync ( sdiHlsDir , { recursive : true } ) ; } catch ( _ ) { }
}
let hiresProcess ;
2026-06-04 23:04:45 -04:00
if ( growingPath ) {
// ── GROWING master: VC-3 / DNxHD -> MXF OP1a (ffmpeg-native) ──────────
2026-06-04 16:53:13 -04:00
// Single ffmpeg, NO raw2bmx / NO FIFO orchestrator. Video from fc_pipe
2026-06-04 23:04:45 -04:00
// stdin (pipe:0). ffmpeg's MXF muxer writes a frame-wrapped OP1a whose body
// grows readably mid-write — USER-CONFIRMED: imports + grows live in Adobe
// Premiere (matches the vMix edit-while-record workflow). _growCodec is
// 'vc3_220' or 'vc3_90'; growingVc3Bitrate() maps it to the dnxhd -b:v.
// SIGINT flushes the MXF footer cleanly, so the standard SIGINT stop applies.
const vc3Args = this . _buildGrowingVc3Mxf ( {
inputArgs , framerate , audioChannels ,
2026-06-04 16:53:13 -04:00
outPath : growingPath , audioMap ,
hlsDir : ( sourceType === 'sdi' || sourceType === 'deltacast' ) ? sdiHlsDir : null ,
videoCodec , interlaced : isInterlacedSource ,
2026-06-04 23:04:45 -04:00
vc3Bitrate : growingVc3Bitrate ( _growCodec ) ,
2026-06-04 16:53:13 -04:00
} ) ;
2026-06-04 23:04:45 -04:00
console . log ( ` [capture] growing master via VC-3/DNxHD MXF (ffmpeg-native, ${ growingVc3Bitrate ( _growCodec ) } ); args= ` + vc3Args . length ) ;
hiresProcess = spawn ( 'ffmpeg' , vc3Args , {
2026-06-04 16:53:13 -04:00
stdio : bridgeProcess ? [ 'pipe' , 'ignore' , 'pipe' ] : [ 'ignore' , 'ignore' , 'pipe' ] ,
detached : true ,
} ) ;
if ( bridgeProcess && bridgeProcess . stdout && hiresProcess . stdin ) {
hiresProcess . stdin . on ( 'error' , ( e ) => {
2026-06-04 23:04:45 -04:00
if ( e && e . code !== 'EPIPE' ) console . warn ( ` [capture] vc3 growing stdin error: ${ e . message } ` ) ;
2026-06-04 14:59:53 -04:00
} ) ;
bridgeProcess . stdout . on ( 'error' , ( e ) => {
console . warn ( ` [capture] fc_pipe stdout error: ${ e && e . message } ` ) ;
} ) ;
2026-06-03 11:32:40 -04:00
bridgeProcess . stdout . pipe ( hiresProcess . stdin ) ;
bridgeProcess . on ( 'exit' , ( ) => {
try { if ( hiresProcess . stdin ) hiresProcess . stdin . end ( ) ; } catch ( _ ) { }
} ) ;
}
2026-06-02 17:31:46 -04:00
} else {
// ── Finalized (non-growing) master: ffmpeg muxes the MOV directly ──
let hiresArgs ;
2026-06-03 11:32:40 -04:00
const isSdiLike = sourceType === 'sdi' || sourceType === 'deltacast' || sourceType === 'blackmagic' ;
2026-06-03 12:25:34 -04:00
// 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 ) {
2026-06-02 17:31:46 -04:00
const filterStr = isInterlacedSource
? '[0:v]yadif=mode=1:deint=1,split=2[vhi][vlo]'
: '[0:v]split=2[vhi][vlo]' ;
2026-06-03 12:25:34 -04:00
// 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 ] : [ ] ;
2026-06-04 00:33:34 -04:00
const masterAudioFilter = hasAudio
? [ '-af' , 'aresample=async=1:min_hard_comp=0.100000:first_pts=0' ] : [ ] ;
2026-06-03 12:25:34 -04:00
const hlsAudioMap = hasAudio ? [ '-map' , audioMap ] : [ ] ;
const hlsAudioCodec = hasAudio
2026-06-04 00:33:34 -04:00
? [ '-c:a' , 'aac' , '-b:a' , '128k' , '-ar' , '44100' ] : [ ] ;
2026-06-02 17:31:46 -04:00
hiresArgs = [
... inputArgs ,
'-filter_complex' , filterStr ,
2026-06-03 17:40:58 -04:00
// Output 0 — master (fragmented MOV streamed to S3 via pipe:1)
2026-06-03 12:25:34 -04:00
'-map' , '[vhi]' , ... masterAudioMap ,
... masterAudioFilter ,
2026-06-02 17:31:46 -04:00
... hiresCodecArgs ,
hiresOutput ,
2026-06-03 11:32:40 -04:00
// Output 1 — low-latency H.264 HLS preview for the UI monitor
2026-06-03 12:25:34 -04:00
'-map' , '[vlo]' , ... hlsAudioMap ,
2026-06-02 17:31:46 -04:00
... buildHlsVideoArgs ( videoCodec , framerate ) ,
2026-06-03 12:25:34 -04:00
... hlsAudioCodec ,
2026-06-02 17:31:46 -04:00
'-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' ,
] ;
2026-06-03 11:32:40 -04:00
console . log ( '[HLS] SDI/framecache preview as 2nd output -> ' + sdiHlsDir ) ;
2026-06-02 17:31:46 -04:00
} else {
hiresArgs = [ ... inputArgs , ... sdiFilterArgs , ... hiresCodecArgs , hiresOutput ] ;
}
hiresProcess = spawn ( 'ffmpeg' , hiresArgs , { stdio : hiresStdio } ) ;
2026-06-03 11:32:40 -04:00
// 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 ( _ ) { }
} ) ;
}
2026-06-02 17:31:46 -04:00
}
2026-06-03 17:40:58 -04:00
// 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).
2026-06-02 17:31:46 -04:00
const processes = { hires : hiresProcess } ;
2026-06-03 17:40:58 -04:00
const uploads = {
hires : growingPath
? Promise . resolve ( { growingPath } )
: createUploadStream ( S3 _BUCKET , hiresKey , hiresProcess . stdout ) ,
} ;
2026-06-02 17:31:46 -04:00
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:a: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 11:37:17 -04:00
// ── 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).
2026-06-02 17:31:46 -04:00
let hlsProcess = null ;
let hlsDir = null ;
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:a: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 11:37:17 -04:00
if ( isNetwork && ! process . env . FC _SLOT _ID && this . _assetIdForHls ) {
2026-06-02 17:31:46 -04:00
try {
const fs = await import ( 'node:fs' ) ;
hlsDir = '/live/' + this . _assetIdForHls ;
fs . mkdirSync ( hlsDir , { recursive : true } ) ;
const hlsArgs = [
... inputArgs ,
'-map' , '0:v:0?' , '-map' , '0:a:0?' ,
... buildHlsVideoArgs ( videoCodec , framerate ) ,
'-c:a' , 'aac' , '-b:a' , '128k' , '-ar' , '44100' ,
'-f' , 'hls' , '-hls_time' , '2' , '-hls_list_size' , '15' ,
'-hls_flags' , 'delete_segments+append_list+omit_endlist' ,
'-hls_segment_filename' , hlsDir + '/seg-%05d.ts' ,
hlsDir + '/index.m3u8' ,
] ;
hlsProcess = spawn ( 'ffmpeg' , hlsArgs , { stdio : [ 'ignore' , 'pipe' , 'pipe' ] } ) ;
hlsProcess . stderr . on ( 'data' , ( d ) => { console . error ( '[HLS] ' + d ) ; } ) ;
hlsProcess . on ( 'exit' , ( c ) => console . log ( '[HLS] exited ' + c ) ) ;
processes . hls = hlsProcess ;
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:a: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 11:37:17 -04:00
console . log ( '[HLS] legacy-net tee started -> ' + hlsDir ) ;
2026-06-02 17:31:46 -04:00
} catch ( err ) {
console . error ( '[HLS] tee failed:' , err . message ) ;
}
}
hiresProcess . stderr . on ( 'data' , ( data ) => {
const text = data . toString ( ) ;
console . error ( ` [HIRES] ${ text } ` ) ;
const m = text . match ( /frame=\s*(\d+)\s+fps=\s*([\d.]+)/ ) ;
if ( m ) {
this . state . framesReceived = parseInt ( m [ 1 ] , 10 ) ;
this . state . lastFrameAt = new Date ( ) . toISOString ( ) ;
2026-06-03 12:14:22 -04:00
// 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
refactor(capture): remove dead raw2bmx/AVC-Intra/HEVC growing code paths
Frame-coupled VC-3/DNxHD MXF (_buildGrowingVc3Mxf) is now the only growing
master path. Delete provably-unreferenced legacy builders and their constants,
and update stale comments to match the VC-3 reality. No live-path behavior
change: the removed stop() raw2bmx branch was gated on a hard-coded false, so
the kept SIGINT path is byte-identical to the previously-executed else branch.
Removed (all confirmed unreferenced repo-wide outside this file):
- _buildGrowingOrchestrator() + JSDoc (raw2bmx bash orchestrator)
- _buildGrowingHevcMov() + JSDoc (HEVC fragmented-MOV growing)
- deriveGrowingRaster() (raw2bmx raster-flag mapper)
- growingVideoElementaryArgs() + GROWING_AVCI_CLASS
- GROWING_DEFAULT_BITRATE, GROWING_PART_INTERVAL_FRAMES,
GROWING_HEVC_DEFAULT_BITRATE, GROWING_VC3_CODECS (unused Set)
- isRaw2bmxGrowing dead branch in stop()
- stale raw2bmx/XDCAM HD422/AVC-Intra/frozen-picture comment blocks
Kept (live): _buildGrowingVc3Mxf, growingCodec, growingVc3Bitrate,
growingExtFor, GROWING_EXT, audioOffsetArgs (+AUDIO_OFFSET_MS), all
non-growing + network/SRT/RTMP/fc_pipe paths.
node --check passes.
2026-06-05 10:28:36 -04:00
// startup.
2026-06-03 12:14:22 -04:00
const ffmpegFps = parseFloat ( m [ 2 ] ) ;
if ( ffmpegFps > 0 ) this . state . currentFps = Math . round ( ffmpegFps * 100 ) / 100 ;
2026-06-02 17:31:46 -04:00
}
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 ) ;
}
} ) ;
// Proxy is generated after stop by the BullMQ worker (same as SRT/RTMP).
// DeckLink hardware does not support two concurrent readers on the same port.
this . state . recording = true ;
this . state . sessionId = sessionId ;
this . state . processes = processes ;
this . state . framesReceived = 0 ;
this . state . currentFps = 0 ;
this . state . lastFrameAt = null ;
this . state . lastError = null ;
this . state . recordingStartedAt = Date . now ( ) ;
this . state . currentSession = {
sessionId ,
projectId ,
binId ,
clipName ,
device ,
sourceType ,
sourceUrl ,
assetId ,
hiresKey ,
proxyKey ,
growingPath ,
refactor(capture): remove dead raw2bmx/AVC-Intra/HEVC growing code paths
Frame-coupled VC-3/DNxHD MXF (_buildGrowingVc3Mxf) is now the only growing
master path. Delete provably-unreferenced legacy builders and their constants,
and update stale comments to match the VC-3 reality. No live-path behavior
change: the removed stop() raw2bmx branch was gated on a hard-coded false, so
the kept SIGINT path is byte-identical to the previously-executed else branch.
Removed (all confirmed unreferenced repo-wide outside this file):
- _buildGrowingOrchestrator() + JSDoc (raw2bmx bash orchestrator)
- _buildGrowingHevcMov() + JSDoc (HEVC fragmented-MOV growing)
- deriveGrowingRaster() (raw2bmx raster-flag mapper)
- growingVideoElementaryArgs() + GROWING_AVCI_CLASS
- GROWING_DEFAULT_BITRATE, GROWING_PART_INTERVAL_FRAMES,
GROWING_HEVC_DEFAULT_BITRATE, GROWING_VC3_CODECS (unused Set)
- isRaw2bmxGrowing dead branch in stop()
- stale raw2bmx/XDCAM HD422/AVC-Intra/frozen-picture comment blocks
Kept (live): _buildGrowingVc3Mxf, growingCodec, growingVc3Bitrate,
growingExtFor, GROWING_EXT, audioOffsetArgs (+AUDIO_OFFSET_MS), all
non-growing + network/SRT/RTMP/fc_pipe paths.
node --check passes.
2026-06-05 10:28:36 -04:00
growingCodec : growingPath ? _growCodec : null , // growing codec: 'vc3_90' | 'vc3_220'
2026-06-02 17:31:46 -04:00
audioFifo ,
startedAt ,
duration : 0 ,
2026-06-03 11:32:40 -04:00
_fcPipeProcess : bridgeProcess || null , /* fc_pipe process, if framecache path used */
2026-06-02 17:31:46 -04:00
uploads ,
codecs : {
videoCodec , videoBitrate , framerate ,
audioCodec , audioBitrate , audioChannels , container ,
proxyEnabled , proxyVideoCodec , proxyVideoBitrate ,
proxyAudioCodec , proxyAudioBitrate , proxyAudioChannels , proxyContainer ,
} ,
} ;
// Fire-and-forget: grab the first frame for the live poster thumbnail.
// Only for sources that produce an HLS dir (sdi/deltacast); never blocks start().
if ( sdiHlsDir && assetId ) {
this . _publishLiveThumbnail ( { assetId , hlsDir : sdiHlsDir } ) . catch ( ( ) => { } ) ;
}
return this . _formatSessionResponse ( ) ;
}
2026-06-02 17:40:52 -04:00
// ── Idle confidence monitor ────────────────────────────────────────────
// A low-rate (1 fps) single-JPEG confidence snapshot for the recorder tile
// when the recorder is NOT actively recording.
//
// CRITICAL: this must NEVER read the video FIFO while a recording is active.
// A second continuous reader on the same /dev/shm/deltacast/video-N.fifo
// splits the frames between the two readers, halving the capture rate to
// ~29 fps (the root cause of the out-of-sync / fast-playback bug). So the
// monitor:
// 1. runs ONLY when this.state.recording === false
// 2. opens the FIFO, grabs ONE frame, scales to a small JPEG, exits
// 3. sleeps 1s, repeats — yielding the FIFO completely between grabs
// 4. is fully stopped the instant a recording starts (see start())
2026-06-02 17:31:46 -04:00
async startIdlePreview ( ) {
2026-06-02 17:40:52 -04:00
if ( this . _previewTimer || this . _previewProc ) return ; // already running
if ( this . state . recording ) return ; // never run during an active recording
2026-06-02 17:31:46 -04:00
const sourceType = process . env . SOURCE _TYPE ;
const recorderId = process . env . RECORDER _ID ;
if ( ! recorderId || ! [ 'deltacast' , 'sdi' ] . includes ( sourceType ) ) return ;
2026-06-02 17:40:52 -04:00
if ( sourceType !== 'deltacast' ) return ; // SDI/blackmagic snapshot TBD
2026-06-02 17:31:46 -04:00
const previewDir = ` /live/preview- ${ recorderId } ` ;
try { await fs . promises . mkdir ( previewDir , { recursive : true } ) ; } catch ( _ ) { }
2026-06-02 17:40:52 -04:00
const size = process . env . DELTACAST _VIDEO _SIZE || '1920x1080' ;
let cfg = { } ;
try { cfg = JSON . parse ( process . env . SOURCE _CONFIG || '{}' ) ; } catch ( _ ) { }
const port = cfg . port ? ? 0 ;
const videoFifo = ( process . env . DELTACAST _PIPE _DIR || '/dev/shm/deltacast' ) + ` /video- ${ port } .fifo ` ;
const outJpg = previewDir + '/frame.jpg' ;
const tmpJpg = previewDir + '/frame.tmp.jpg' ;
this . _previewStop = false ;
console . log ( '[preview] starting 1fps confidence monitor for' , recorderId ) ;
const grabOnce = ( ) => new Promise ( ( resolve ) => {
// Never compete with an active recording.
if ( this . _previewStop || this . state . recording ) return resolve ( ) ;
// -frames:v 1 reads exactly ONE frame then exits, releasing the FIFO.
// Read-rate is capped by -readrate 1 so the single-frame read consumes
// ~1 frame worth of FIFO data, not a burst.
const ff = spawn ( 'ffmpeg' , [
'-y' ,
'-f' , 'rawvideo' , '-pix_fmt' , 'uyvy422' , '-s' , size ,
'-i' , videoFifo ,
'-frames:v' , '1' ,
'-vf' , 'scale=480:-2' ,
'-q:v' , '5' ,
tmpJpg ,
] , { stdio : [ 'ignore' , 'ignore' , 'ignore' ] } ) ;
this . _previewProc = ff ;
const killTimer = setTimeout ( ( ) => { try { ff . kill ( 'SIGKILL' ) ; } catch ( _ ) { } } , 4000 ) ;
ff . on ( 'exit' , ( ) => {
clearTimeout ( killTimer ) ;
this . _previewProc = null ;
// Atomic-ish swap so the served frame is never half-written.
fs . rename ( tmpJpg , outJpg , ( ) => resolve ( ) ) ;
} ) ;
ff . on ( 'error' , ( ) => { clearTimeout ( killTimer ) ; this . _previewProc = null ; resolve ( ) ; } ) ;
2026-06-02 17:31:46 -04:00
} ) ;
2026-06-02 17:40:52 -04:00
const loop = async ( ) => {
while ( ! this . _previewStop ) {
await grabOnce ( ) ;
if ( this . _previewStop ) break ;
await new Promise ( r => { this . _previewTimer = setTimeout ( r , 1000 ) ; } ) ;
}
} ;
loop ( ) ;
2026-06-02 17:31:46 -04:00
}
stopIdlePreview ( ) {
2026-06-02 17:40:52 -04:00
this . _previewStop = true ;
if ( this . _previewTimer ) { clearTimeout ( this . _previewTimer ) ; this . _previewTimer = null ; }
if ( this . _previewProc ) {
try { this . _previewProc . kill ( 'SIGKILL' ) ; } catch ( _ ) { }
this . _previewProc = null ;
}
2026-06-02 17:31:46 -04:00
}
async stop ( sessionId ) {
if ( ! this . state . recording || this . state . sessionId !== sessionId ) {
throw new Error ( 'No active capture session or session ID mismatch' ) ;
}
this . stopIdlePreview ( ) ;
const { processes , currentSession } = this . state ;
const isGrowing = ! ! currentSession . growingPath ;
// Send SIGINT and WAIT for the master writer to exit cleanly.
// - Non-growing: SIGINT flushes ffmpeg's MOV trailer (the moov atom with
// full sample tables). Uploading before finalize → "moov atom not found".
refactor(capture): remove dead raw2bmx/AVC-Intra/HEVC growing code paths
Frame-coupled VC-3/DNxHD MXF (_buildGrowingVc3Mxf) is now the only growing
master path. Delete provably-unreferenced legacy builders and their constants,
and update stale comments to match the VC-3 reality. No live-path behavior
change: the removed stop() raw2bmx branch was gated on a hard-coded false, so
the kept SIGINT path is byte-identical to the previously-executed else branch.
Removed (all confirmed unreferenced repo-wide outside this file):
- _buildGrowingOrchestrator() + JSDoc (raw2bmx bash orchestrator)
- _buildGrowingHevcMov() + JSDoc (HEVC fragmented-MOV growing)
- deriveGrowingRaster() (raw2bmx raster-flag mapper)
- growingVideoElementaryArgs() + GROWING_AVCI_CLASS
- GROWING_DEFAULT_BITRATE, GROWING_PART_INTERVAL_FRAMES,
GROWING_HEVC_DEFAULT_BITRATE, GROWING_VC3_CODECS (unused Set)
- isRaw2bmxGrowing dead branch in stop()
- stale raw2bmx/XDCAM HD422/AVC-Intra/frozen-picture comment blocks
Kept (live): _buildGrowingVc3Mxf, growingCodec, growingVc3Bitrate,
growingExtFor, GROWING_EXT, audioOffsetArgs (+AUDIO_OFFSET_MS), all
non-growing + network/SRT/RTMP/fc_pipe paths.
node --check passes.
2026-06-05 10:28:36 -04:00
// - Growing (VC-3/DNxHD MXF): `processes.hires` is a single ffmpeg writing
// the OP1a directly. A plain SIGINT makes ffmpeg flush the MXF footer
// (Duration + index) cleanly, exactly like the non-growing MOV trailer.
// Awaiting it guarantees the finalized, valid MXF is on the share before
// the promotion worker uploads it. The footer flush of a long recording
// can take a moment, so the growing safety-net timeout is more generous.
2026-06-02 17:31:46 -04:00
const finalizeTimeoutMs = isGrowing ? 60000 : 15000 ;
const waitExit = ( proc ) => new Promise ( ( resolve ) => {
if ( ! proc || proc . exitCode !== null || proc . signalCode !== null ) return resolve ( ) ;
let done = false ;
const finish = ( ) => { if ( ! done ) { done = true ; resolve ( ) ; } } ;
proc . once ( 'exit' , finish ) ;
// Safety net: don't hang stop() forever if the writer refuses to exit.
setTimeout ( ( ) => {
try {
refactor(capture): remove dead raw2bmx/AVC-Intra/HEVC growing code paths
Frame-coupled VC-3/DNxHD MXF (_buildGrowingVc3Mxf) is now the only growing
master path. Delete provably-unreferenced legacy builders and their constants,
and update stale comments to match the VC-3 reality. No live-path behavior
change: the removed stop() raw2bmx branch was gated on a hard-coded false, so
the kept SIGINT path is byte-identical to the previously-executed else branch.
Removed (all confirmed unreferenced repo-wide outside this file):
- _buildGrowingOrchestrator() + JSDoc (raw2bmx bash orchestrator)
- _buildGrowingHevcMov() + JSDoc (HEVC fragmented-MOV growing)
- deriveGrowingRaster() (raw2bmx raster-flag mapper)
- growingVideoElementaryArgs() + GROWING_AVCI_CLASS
- GROWING_DEFAULT_BITRATE, GROWING_PART_INTERVAL_FRAMES,
GROWING_HEVC_DEFAULT_BITRATE, GROWING_VC3_CODECS (unused Set)
- isRaw2bmxGrowing dead branch in stop()
- stale raw2bmx/XDCAM HD422/AVC-Intra/frozen-picture comment blocks
Kept (live): _buildGrowingVc3Mxf, growingCodec, growingVc3Bitrate,
growingExtFor, GROWING_EXT, audioOffsetArgs (+AUDIO_OFFSET_MS), all
non-growing + network/SRT/RTMP/fc_pipe paths.
node --check passes.
2026-06-05 10:28:36 -04:00
// The growing ffmpeg is spawned detached (its own process group) →
// SIGKILL the whole group; otherwise just the process.
2026-06-02 17:31:46 -04:00
if ( isGrowing && proc . pid ) { try { process . kill ( - proc . pid , 'SIGKILL' ) ; } catch ( _ ) { } }
proc . kill ( 'SIGKILL' ) ;
} catch ( _ ) { }
finish ( ) ;
} , finalizeTimeoutMs ) ;
} ) ;
refactor(capture): remove dead raw2bmx/AVC-Intra/HEVC growing code paths
Frame-coupled VC-3/DNxHD MXF (_buildGrowingVc3Mxf) is now the only growing
master path. Delete provably-unreferenced legacy builders and their constants,
and update stale comments to match the VC-3 reality. No live-path behavior
change: the removed stop() raw2bmx branch was gated on a hard-coded false, so
the kept SIGINT path is byte-identical to the previously-executed else branch.
Removed (all confirmed unreferenced repo-wide outside this file):
- _buildGrowingOrchestrator() + JSDoc (raw2bmx bash orchestrator)
- _buildGrowingHevcMov() + JSDoc (HEVC fragmented-MOV growing)
- deriveGrowingRaster() (raw2bmx raster-flag mapper)
- growingVideoElementaryArgs() + GROWING_AVCI_CLASS
- GROWING_DEFAULT_BITRATE, GROWING_PART_INTERVAL_FRAMES,
GROWING_HEVC_DEFAULT_BITRATE, GROWING_VC3_CODECS (unused Set)
- isRaw2bmxGrowing dead branch in stop()
- stale raw2bmx/XDCAM HD422/AVC-Intra/frozen-picture comment blocks
Kept (live): _buildGrowingVc3Mxf, growingCodec, growingVc3Bitrate,
growingExtFor, GROWING_EXT, audioOffsetArgs (+AUDIO_OFFSET_MS), all
non-growing + network/SRT/RTMP/fc_pipe paths.
node --check passes.
2026-06-05 10:28:36 -04:00
// Stop: a plain SIGINT flushes the container footer/trailer cleanly for both
// the non-growing master and the single-ffmpeg VC-3/DNxHD growing MXF writer.
if ( processes . hires ) processes . hires . kill ( 'SIGINT' ) ;
if ( processes . proxy ) processes . proxy . kill ( 'SIGINT' ) ;
if ( processes . hls ) { try { processes . hls . kill ( 'SIGINT' ) ; } catch ( _ ) { } }
if ( currentSession . _fcPipeProcess ) {
try { currentSession . _fcPipeProcess . kill ( 'SIGTERM' ) ; } catch ( _ ) { }
2026-06-03 11:32:40 -04:00
}
2026-06-02 17:31:46 -04:00
/* 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.
await waitExit ( processes . hires ) ;
// Release the CIFS mount (best-effort) once the ffmpeg writers are done with
// it. The promotion worker reads the staged file from the host/S3 side, not
// through this container's mount, so unmounting here is safe.
unmountGrowingShare ( ) ;
try {
2026-06-03 17:40:58 -04:00
// 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.
2026-06-02 17:31:46 -04:00
const uploadPromises = [ ] ;
2026-06-03 17:40:58 -04:00
if ( currentSession . uploads . hires ) uploadPromises . push ( currentSession . uploads . hires ) ;
2026-06-02 17:31:46 -04:00
if ( currentSession . uploads . proxy ) uploadPromises . push ( currentSession . uploads . proxy ) ;
await Promise . all ( uploadPromises ) ;
} catch ( error ) {
console . error ( 'Error during upload completion:' , error ) ;
}
if ( currentSession . audioFifo ) {
try { unlinkSync ( currentSession . audioFifo ) ; } catch ( _ ) { }
}
const stoppedAt = new Date ( ) . toISOString ( ) ;
const startTime = new Date ( currentSession . startedAt ) ;
const stopTime = new Date ( stoppedAt ) ;
const duration = Math . round ( ( stopTime - startTime ) / 1000 ) ;
this . state . recording = false ;
this . state . sessionId = null ;
this . state . processes = { } ;
this . state . recordingStartedAt = null ;
// No frames received → the upload (if any) produced a 0-byte object.
// Surface that so the shutdown handler can mark the asset as 'error'
// instead of posting a broken hi-res key downstream.
const framesReceived = this . state . framesReceived ;
return {
sessionId ,
assetId : currentSession . assetId ,
projectId : currentSession . projectId ,
binId : currentSession . binId ,
clipName : currentSession . clipName ,
sourceType : currentSession . sourceType ,
hiresKey : currentSession . hiresKey ,
proxyKey : currentSession . proxyKey ,
growingPath : currentSession . growingPath || null ,
startedAt : currentSession . startedAt ,
stoppedAt ,
duration ,
framesReceived ,
empty : framesReceived === 0 ,
} ;
}
// Grab the first video frame from the live HLS output and publish it as the
// asset's poster thumbnail, so the library shows a real frame instead of the
// "connecting…" placeholder while recording is still in progress.
//
// Runs entirely on the sidecar (where the HLS segments physically exist):
// 1. poll /live/<assetId> for the first seg-*.ts (bridge/ffmpeg warm-up)
// 2. ffmpeg -i <segment> -frames:v 1 -> scaled JPEG
// 3. upload JPEG to S3 at thumbnails/<assetId>.jpg (matches mam-api convention)
// 4. POST /assets/<assetId>/live-thumbnail so the row gets thumbnail_s3_key
//
// Best-effort and non-blocking: any failure is logged and swallowed — the
// post-stop thumbnail job still produces the final thumbnail regardless.
async _publishLiveThumbnail ( { assetId , hlsDir } ) {
if ( ! assetId || ! hlsDir ) return ;
const mamUrl = process . env . MAM _API _URL || 'http://mam-api:3000' ;
const tmpJpg = ` /tmp/livethumb- ${ assetId } .jpg ` ;
const thumbKey = ` thumbnails/ ${ assetId } .jpg ` ;
try {
// 1. Wait up to 30s for the first HLS segment to appear.
const deadline = Date . now ( ) + 30_000 ;
let segment = null ;
while ( Date . now ( ) < deadline && this . state . recording && this . state . currentSession . assetId === assetId ) {
try {
const entries = await fs . promises . readdir ( hlsDir ) ;
const segs = entries . filter ( f => / ^ seg - \ d + \ . ts$ / . test ( f ) ) . sort ( ) ;
if ( segs . length > 0 ) { segment = ` ${ hlsDir } / ${ segs [ 0 ] } ` ; break ; }
} catch ( _ ) { /* dir not created yet */ }
await new Promise ( r => setTimeout ( r , 500 ) ) ;
}
if ( ! segment ) { console . warn ( ` [livethumb] no segment for ${ assetId } within 30s ` ) ; return ; }
// 2. Extract the first frame, scaled to 640px wide (yuvj420p for broad JPEG
// decoder compatibility), as a single still.
await new Promise ( ( resolve , reject ) => {
const ff = spawn ( 'ffmpeg' , [
'-y' , '-i' , segment ,
'-frames:v' , '1' ,
'-vf' , 'scale=640:-2' ,
'-pix_fmt' , 'yuvj420p' ,
tmpJpg ,
] , { stdio : [ 'ignore' , 'ignore' , 'pipe' ] } ) ;
let err = '' ;
ff . stderr . on ( 'data' , d => { err += d . toString ( ) ; } ) ;
ff . on ( 'error' , reject ) ;
ff . on ( 'exit' , code => code === 0 ? resolve ( ) : reject ( new Error ( ` ffmpeg exit ${ code } : ${ err . slice ( - 200 ) } ` ) ) ) ;
} ) ;
// 3. Upload to S3.
const size = statSync ( tmpJpg ) . size ;
if ( size <= 0 ) throw new Error ( 'extracted thumbnail is 0 bytes' ) ;
await createUploadStream ( S3 _BUCKET , thumbKey , createReadStream ( tmpJpg ) ) ;
// 4. Tell mam-api the key (only sticks while the asset is still 'live').
const resp = await fetch ( ` ${ mamUrl } /api/v1/assets/ ${ assetId } /live-thumbnail ` , {
method : 'POST' ,
headers : {
'Content-Type' : 'application/json' ,
... ( process . env . MAM _API _TOKEN ? { Authorization : ` Bearer ${ process . env . MAM _API _TOKEN } ` } : { } ) ,
} ,
body : JSON . stringify ( { thumbnailKey : thumbKey } ) ,
} ) ;
if ( ! resp . ok ) throw new Error ( ` mam-api ${ resp . status } : ${ ( await resp . text ( ) ) . slice ( 0 , 200 ) } ` ) ;
console . log ( ` [livethumb] published poster for ${ assetId } ( ${ thumbKey } ) ` ) ;
} catch ( err ) {
console . warn ( ` [livethumb] failed for ${ assetId } : ` , err . message ) ;
} finally {
try { unlinkSync ( tmpJpg ) ; } catch ( _ ) { }
}
}
getStatus ( ) {
if ( ! this . state . recording ) return { recording : false } ;
const startTime = new Date ( this . state . currentSession . startedAt ) ;
const now = new Date ( ) ;
const duration = Math . round ( ( now - startTime ) / 1000 ) ;
const lastFrameAt = this . state . lastFrameAt ;
const msSinceFrame = lastFrameAt ? ( Date . now ( ) - new Date ( lastFrameAt ) . getTime ( ) ) : null ;
let signal = 'connecting' ;
if ( this . state . framesReceived > 0 ) {
signal = ( msSinceFrame !== null && msSinceFrame < 5000 ) ? 'receiving' : 'lost' ;
} else if ( this . state . lastError ) {
signal = 'error' ;
}
return {
recording : true ,
sessionId : this . state . sessionId ,
sourceType : this . state . currentSession . sourceType ,
device : this . state . currentSession . device ,
clipName : this . state . currentSession . clipName ,
projectId : this . state . currentSession . projectId ,
binId : this . state . currentSession . binId ,
duration ,
startedAt : this . state . currentSession . startedAt ,
signal ,
framesReceived : this . state . framesReceived ,
currentFps : this . state . currentFps ,
lastFrameAt ,
msSinceFrame ,
lastError : this . state . lastError ,
codecs : this . state . currentSession . codecs ,
} ;
}
_formatSessionResponse ( ) {
const { currentSession , sessionId } = this . state ;
return {
sessionId ,
projectId : currentSession . projectId ,
binId : currentSession . binId ,
clipName : currentSession . clipName ,
device : currentSession . device ,
sourceType : currentSession . sourceType ,
hiresKey : currentSession . hiresKey ,
proxyKey : currentSession . proxyKey ,
startedAt : currentSession . startedAt ,
codecs : currentSession . codecs ,
} ;
}
}
export default new CaptureManager ( ) ;
export { VIDEO _CODECS , AUDIO _CODECS , CONTAINER _FMT , CONTAINER _EXT , sourceBackends } ;