2026-05-31 14:50:31 -04:00
import { spawn , execFileSync } from 'child_process' ;
2026-05-31 18:14:59 -04:00
import { mkdirSync , writeFileSync , createReadStream , statSync , unlinkSync } from 'node:fs' ;
2026-06-02 07:23:39 -04:00
import fs from 'node:fs' ;
feat: live HLS preview, proxy worker fixes, Settings tabs, growing-files + Premier panel
- worker/proxy: scale-to-even filter, analyzeduration 100M, skip images, hasAudio
- worker/promotion: SMB landing zone -> S3 on idle, queues proxy job, status='ready'
- web-ui screens-ingest: HlsPreview component replaces fake LiveStrip/FauxFrame
- web-ui screens-admin: functional Settings tabs (S3, GPU, Growing, SDI, AMPP)
- mam-api /settings/growing: GET/PUT growing-files config
- mam-api /assets/:id/live-path: SMB UNC/POSIX path for live growing assets
- capture-manager: GROWING_ENABLED -> write hires to /growing instead of S3 stream
- recorders.js: pass GROWING_ENABLED to capture container, bind /growing mount
- docker-compose: mount /mnt/NVME/MAM/wild-dragon-growing on mam-api + worker
- premiere-plugin: Mount Live button, Relink-to-HiRes, live->ready status poll
2026-05-22 19:12:53 -04:00
import { dirname } from 'node:path' ;
2026-04-07 21:58:29 -04:00
import { v4 as uuidv4 } from 'uuid' ;
import { createUploadStream } from './s3/client.js' ;
2026-06-01 19:09:21 -04:00
const S3 _BUCKET = process . env . S3 _BUCKET || 'wild-dragon' ;
// Growing-files mode: writes the master to a local SMB-backed share that the
// editor can mount, instead of streaming to S3 in real time. The promotion
// worker uploads the finalized file to S3 after the recording stops.
// Toggled per-recorder via `GROWING_ENABLED=true` on the capture container
// (see routes/recorders.js where the env is composed).
const GROWING _ENABLED = process . env . GROWING _ENABLED === 'true' ;
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 ;
}
const GROWING _SMB _MOUNT = toUncShare ( process . env . GROWING _SMB _MOUNT || '' ) ;
const GROWING _SMB _USERNAME = process . env . GROWING _SMB _USERNAME || '' ;
const GROWING _SMB _PASSWORD = process . env . GROWING _SMB _PASSWORD || '' ;
const GROWING _SMB _VERS = process . env . GROWING _SMB _VERS || '3.0' ;
const SMB _CREDS _FILE = '/run/smb-creds' ;
// True when GROWING_PATH is already a mountpoint (e.g. a prior session left it
// 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 ( ) {
if ( ! GROWING _SMB _MOUNT ) return false ;
try {
if ( isMounted ( GROWING _PATH ) ) {
console . log ( '[capture] growing share already mounted at' , GROWING _PATH ) ;
return true ;
}
try { mkdirSync ( GROWING _PATH , { recursive : true } ) ; } catch ( _ ) { }
writeFileSync (
SMB _CREDS _FILE ,
` username= ${ GROWING _SMB _USERNAME } \n password= ${ GROWING _SMB _PASSWORD } \n ` ,
{ mode : 0o600 }
) ;
const opts = [
` credentials= ${ SMB _CREDS _FILE } ` ,
'uid=0' , 'gid=0' , 'file_mode=0664' , 'dir_mode=0775' ,
` vers= ${ GROWING _SMB _VERS } ` ,
] . join ( ',' ) ;
execFileSync ( 'mount' , [ '-t' , 'cifs' , GROWING _SMB _MOUNT , GROWING _PATH , '-o' , opts ] ,
{ stdio : [ 'ignore' , 'ignore' , 'pipe' ] } ) ;
console . log ( '[capture] mounted CIFS growing share' , GROWING _SMB _MOUNT , '->' , GROWING _PATH ) ;
return true ;
} catch ( err ) {
const stderr = err . stderr ? err . stderr . toString ( ) . trim ( ) : err . message ;
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 ( ) {
if ( ! GROWING _SMB _MOUNT ) return ;
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).
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' ,
} ,
} ;
// 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.
const fps = Number . parseFloat ( framerate ) || 30 ;
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 [
'-c:v' , 'h264_nvenc' , '-preset' , 'p1' , '-tune' , 'll' ,
'-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' ,
} ;
// Growing-file (edit-while-record) master format — MXF OP1a / XDCAM HD422,
// written by bmx (raw2bmx), NOT by ffmpeg's MXF muxer.
//
// This is the SIXTH iteration. The five prior attempts and WHY they failed
// (root-caused with authoritative sources + live structural analysis on the
// zampp2 capture image):
//
// 1) Fragmented MP4/MOV (+frag_keyframe+empty_moov): Premiere's QuickTime
// importer needs the classic stco/stsz/stts sample tables in one top-level
// moov; a fragmented MOV never has them while growing → "unable to open".
//
// 2) MXF OP1a / DNxHR HQ via ffmpeg: a DNxHR MXF SIGKILLed mid-write has ZERO
// body partitions and probes duration=N/A — DNxHR's large VBR frames don't
// trigger ffmpeg's per-partition flush, so only the header is on disk.
//
// 3) MPEG-TS H.264 High 4:2:2: Premiere's H.264 importer only accepts 8-bit
// 4:2:0.
//
// 4) MPEG-TS H.264 High 4:2:0 all-intra + AAC: STILL "unsupported compression
// type" — Premiere does not treat a raw .ts elementary stream as a clean
// importable growing clip.
//
// 5) MXF OP1a / XDCAM HD422 (MPEG-2 422) via ffmpeg's `-f mxf` muxer: this was
// believed to flush incremental body partitions, but PROVEN unable to
// produce a TRUE growing file — ffmpeg's MXF muxer writes the real
// duration/index only in the FOOTER at av_write_trailer (close). A
// metadata-only probe of the mid-write file reports duration=N/A right up
// until the writer exits, so Premiere's growing-file refresh never sees the
// file extend. (Same muxer that defers the index to EOF.)
//
// FIX — MXF OP1a carrying XDCAM HD422 (MPEG-2 422 @ 50 Mbps-class) + PCM, muxed
// by bmx/raw2bmx (the reference growing-OP1a writer, used by BBC/broadcast):
//
// WHY raw2bmx (the key discovery, PROVEN live on zampp2):
// * raw2bmx with `-t op1a --part <interval>` writes a NEW body partition PLUS
// a NEW IndexTableSegment (carrying an updated IndexDuration) at the
// interval. The recorded duration is therefore readable — and INCREASES —
// from the header+index ALONE while the file is still being written, no
// footer needed. Verified by snapshotting the growing file mid-write and
// parsing the IndexTableSegment IndexDuration (local tag 0x3F0C):
// T= 3s: 7 partitions, max IndexDuration = 43 frames
// T= 8s: 17 partitions, max IndexDuration = 193 frames
// T=15s: 31 partitions, max IndexDuration = 403 frames
// The recorded frame count grows monotonically, lagging the record head by
// ~one partition interval — exactly the editable-head behaviour Premiere's
// growing-MXF reader consumes. A mid-write snapshot also decodes cleanly
// (mpeg2video 1920x1080 + 2× PCM, ffmpeg decode exit 0). Contrast with the
// ffmpeg `-f mxf` path (attempt #5): duration=N/A until close.
// * Adobe OFFICIALLY recommends MXF for growing-file workflows; XDCAM HD422
// (MPEG-2 422 in MXF OP1a) + PCM is read by Premiere's built-in MXF reader
// with no plugin and is the broadcast-standard growing acquisition format.
//
// Pipeline (single SDI read — DeckLink cannot be opened twice):
// ffmpeg decklink → yadif → split →
// (a) MPEG-2 422 elementary VIDEO → named FIFO ┐
// (b) PCM s16le AUDIO → named FIFO ├→ raw2bmx -t op1a
// (c) H.264 HLS preview (unchanged, keeps monitor live)
// raw2bmx reads the two essence FIFOs and writes the growing OP1a MXF to the
// CIFS share. On stop, ffmpeg is stopped cleanly so raw2bmx gets EOF and
// finalizes the footer; we await raw2bmx exit before reporting complete.
//
// Audio: PCM s16le — the native, broadcast-standard MXF audio mapping
// Premiere's MXF reader expects (NOT AAC).
//
// HONEST CAVEAT (cannot be verified without real Premiere on the workstation):
// the growing IndexDuration / body-partition structure is PROVEN above and
// matches Adobe's documented growing-MXF requirement — but only the user
// opening the growing .mxf in actual Premiere Pro (with "Automatically refresh
// growing files" enabled in Preferences > Media) can confirm the end-to-end
// edit-while-record.
//
// ── ffmpeg elementary-essence args (input to the FIFOs) ───────────────────
// (a) MPEG-2 422, 8-bit 4:2:2 (Premiere-native XDCAM HD422). `-dc 10` + the CBR
// bitrate (operator target, default 50 Mbps) match XDCAM HD422 essence. `-g 15`
// keeps a short GOP. Muxed to a raw `mpeg2video` elementary stream (no
// container) so raw2bmx ingests it via --mpeg2lg_*.
const GROWING _VIDEO _ELEMENTARY _ARGS = [
'-c:v' , 'mpeg2video' , '-pix_fmt' , 'yuv422p' ,
'-dc' , '10' , '-g' , '15' , '-bf' , '2' ,
] ;
const GROWING _DEFAULT _BITRATE = '25M' ;
const GROWING _EXT = 'mxf' ;
// Video essence partition interval (frames). raw2bmx starts a new body partition
// + IndexTableSegment every PART_INTERVAL frames; this is the granularity at
// which the growing file's recorded duration advances. ~1s at 25/29.97 fps.
const GROWING _PART _INTERVAL _FRAMES = 30 ;
// Map the recorder's resolution/fps to (1) the raw2bmx MPEG-2 Long GOP essence
// input flag and (2) the ffmpeg edit-rate (`-r`). raw2bmx needs the correct
// raster flag so the essence is wrapped as the right XDCAM HD422 variant; an
// 1080i59.94 default is used when the recorder fields are absent (the most
// common SDI broadcast raster). Returns:
// { rawFlag, frameRate, ffRate }
// where rawFlag is e.g. '--mpeg2lg_422p_hl_1080i', frameRate is the raw2bmx
// `-f` value (e.g. '30000/1001'), and ffRate is the ffmpeg `-r` value.
//
// NOTE: the exact interlaced-vs-progressive raster and the fps for a real
// DeckLink SDI feed can only be confirmed against the live signal. This derives
// a sensible value from the recorder's configured resolution/framerate; if those
// are absent or ambiguous it defaults to 1080i59.94. A live DeckLink confirm of
// the actual SDI raster/fps is advised before production use (see report).
function deriveGrowingRaster ( resolution , framerate ) {
// Normalise fps. Accept '59.94', '60000/1001', '25', '50', '30', '29.97'…
let fpsNum = null ;
const fr = ( framerate == null ) ? '' : String ( framerate ) . trim ( ) ;
if ( /^\d+\/\d+$/ . test ( fr ) ) {
const [ n , d ] = fr . split ( '/' ) . map ( Number ) ;
if ( d ) fpsNum = n / d ;
} else if ( fr && fr !== 'native' ) {
const f = Number . parseFloat ( fr ) ;
if ( Number . isFinite ( f ) ) fpsNum = f ;
}
// Resolution → height + scan. Accept '1920x1080', '1080i', '1080p', '720p',
// '720', '576i', etc.
const res = ( resolution == null ) ? '' : String ( resolution ) . trim ( ) . toLowerCase ( ) ;
let height = null ;
let scan = null ; // 'i' | 'p' | null
const mDim = res . match ( /(\d{3,4})x(\d{3,4})/ ) ;
if ( mDim ) height = parseInt ( mDim [ 2 ] , 10 ) ;
const mH = res . match ( /(\d{3,4})\s*([ip])/ ) ;
if ( mH ) { height = parseInt ( mH [ 1 ] , 10 ) ; scan = mH [ 2 ] ; }
if ( height == null ) {
const only = res . match ( /\b(2160|1080|720|576|480)\b/ ) ;
if ( only ) height = parseInt ( only [ 1 ] , 10 ) ;
}
if ( height == null ) height = 1080 ; // default raster
// ffmpeg rate + raw2bmx rate strings for the common broadcast rates.
function rates ( fps ) {
if ( fps == null ) return { ff : '30000/1001' , raw : '30000/1001' } ; // 1080i59.94 default
if ( Math . abs ( fps - 59.94 ) < 0.2 || Math . abs ( fps - 29.97 ) < 0.05 )
return { ff : '30000/1001' , raw : '30000/1001' } ;
if ( Math . abs ( fps - 60 ) < 0.05 ) return { ff : '60' , raw : '60' } ;
if ( Math . abs ( fps - 50 ) < 0.05 ) return { ff : '25' , raw : '25' } ; // 1080i50 → 25 fps frames
if ( Math . abs ( fps - 25 ) < 0.05 ) return { ff : '25' , raw : '25' } ;
if ( Math . abs ( fps - 24 ) < 0.2 ) return { ff : '24000/1001' , raw : '24000/1001' } ;
if ( Math . abs ( fps - 30 ) < 0.05 ) return { ff : '30' , raw : '30' } ;
return { ff : String ( fps ) , raw : String ( fps ) } ;
}
// Default scan: 1080 → interlaced (broadcast SDI default), 720/below → p.
if ( scan == null ) scan = ( height >= 1080 ) ? 'i' : 'p' ;
const r = rates ( fpsNum ) ;
let rawFlag ;
if ( height >= 1080 ) {
rawFlag = ( scan === 'p' ) ? '--mpeg2lg_422p_hl_1080p' : '--mpeg2lg_422p_hl_1080i' ;
} else if ( height >= 720 ) {
rawFlag = '--mpeg2lg_422p_hl_720p' ; // 720 is always progressive
if ( fpsNum == null ) { r . ff = '60000/1001' ; r . raw = '60000/1001' ; }
} else {
rawFlag = '--mpeg2lg_422p_ml_576i' ; // SD 576i (PAL); 25 fps
r . ff = '25' ; r . raw = '25' ;
}
return { rawFlag , frameRate : r . raw , ffRate : r . ff } ;
}
// ── 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 : {
feat(deltacast): replace per-port bridges with shared multi-port daemon
The old architecture spawned one deltacast-capture per recorder port; each
called VHD_OpenBoardHandle, triggering a BufMngr.c:781 OOB fault in the
delta_x300 kernel driver whenever two opens raced.
Fix: a single deltacast-bridge daemon opens the board once, opens RX
streams for all requested ports concurrently, and writes each port's
video/audio to named FIFOs (/dev/shm/deltacast/video-<N>.fifo,
/dev/shm/deltacast/audio-<N>.fifo). Capture sidecars read from those
FIFOs directly — no board handle, no race, no flock.
Changes:
services/capture/deltacast-bridge/main.c
- Complete rewrite: --ports csv arg, board opened once, one
video+audio thread pair per port, FIFO paths per port, format
JSON emitted per port on signal lock, SIGTERM clean shutdown.
- flock/serialize logic removed (no longer needed).
- --port single-port compat alias retained.
services/capture/deltacast-bridge/CMakeLists.txt
- Rename target deltacast-capture -> deltacast-bridge.
- POST_BUILD symlink deltacast-capture -> deltacast-bridge for compat.
services/capture/src/capture-manager.js
- deltacast _buildInputArgs: remove bridge spawn; wait up to 30s
for FIFOs to exist (bridge may be starting); return rawvideo +
s16le FIFO inputArgs. bridgeProcess=null.
- audioMap: keyed on sourceType instead of bridgeProcess (both
inputs are always present for deltacast).
- Remove readFirstStderrLine helper (no longer needed).
- Remove bridgeProcess.stdout.pipe / processes.bridge stop signal.
services/node-agent/index.js
- Add import spawn for bridge daemon management.
- Add startDeltacastBridge / stopDeltacastBridge: host-process
lifecycle for the shared bridge, ref-counted by sidecar count.
- handleSidecarStart: on deltacast, increment counter + start bridge;
decrement on container create/start failure.
- handleSidecarStop: decrement counter; stop bridge when last sidecar.
- _containerSourceType map tracks containerId->sourceType for stop.
- Old acquireDcLock mutex retained but no longer called.
2026-06-01 20:21:52 -04:00
// Unused stub — deltacast capture uses sourceType='deltacast' path in
// _buildInputArgs, not the sourceBackends map.
2026-06-01 19:09:21 -04:00
buildInput ( ) {
feat(deltacast): replace per-port bridges with shared multi-port daemon
The old architecture spawned one deltacast-capture per recorder port; each
called VHD_OpenBoardHandle, triggering a BufMngr.c:781 OOB fault in the
delta_x300 kernel driver whenever two opens raced.
Fix: a single deltacast-bridge daemon opens the board once, opens RX
streams for all requested ports concurrently, and writes each port's
video/audio to named FIFOs (/dev/shm/deltacast/video-<N>.fifo,
/dev/shm/deltacast/audio-<N>.fifo). Capture sidecars read from those
FIFOs directly — no board handle, no race, no flock.
Changes:
services/capture/deltacast-bridge/main.c
- Complete rewrite: --ports csv arg, board opened once, one
video+audio thread pair per port, FIFO paths per port, format
JSON emitted per port on signal lock, SIGTERM clean shutdown.
- flock/serialize logic removed (no longer needed).
- --port single-port compat alias retained.
services/capture/deltacast-bridge/CMakeLists.txt
- Rename target deltacast-capture -> deltacast-bridge.
- POST_BUILD symlink deltacast-capture -> deltacast-bridge for compat.
services/capture/src/capture-manager.js
- deltacast _buildInputArgs: remove bridge spawn; wait up to 30s
for FIFOs to exist (bridge may be starting); return rawvideo +
s16le FIFO inputArgs. bridgeProcess=null.
- audioMap: keyed on sourceType instead of bridgeProcess (both
inputs are always present for deltacast).
- Remove readFirstStderrLine helper (no longer needed).
- Remove bridgeProcess.stdout.pipe / processes.bridge stop signal.
services/node-agent/index.js
- Add import spawn for bridge daemon management.
- Add startDeltacastBridge / stopDeltacastBridge: host-process
lifecycle for the shared bridge, ref-counted by sidecar count.
- handleSidecarStart: on deltacast, increment counter + start bridge;
decrement on container create/start failure.
- handleSidecarStop: decrement counter; stop bridge when last sidecar.
- _containerSourceType map tracks containerId->sourceType for stop.
- Old acquireDcLock mutex retained but no longer called.
2026-06-01 20:21:52 -04:00
throw new Error ( 'deltacast: use sourceType="deltacast" not sourceBackend' ) ;
2026-06-01 19:09:21 -04:00
} ,
} ,
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 ,
} ) {
// NOTE: the growing master is NOT muxed by ffmpeg any more — raw2bmx writes
// the growing OP1a MXF from elementary essence FIFOs (see start()). The
// growing ffmpeg command (elementary MPEG-2 422 video + PCM audio to FIFOs,
// plus the HLS preview) is constructed directly in start(), so buildEncodeArgs
// is no longer 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.
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?' ) ;
args . push ( ... v . args ) ;
if ( v . pixFmt ) args . push ( '-pix_fmt' , v . pixFmt ) ;
if ( v . bitrateControl && videoBitrate ) args . push ( '-b:v' , videoBitrate ) ;
if ( framerate && framerate !== 'native' ) args . push ( '-r' , framerate ) ;
args . push ( ... a . args ) ;
if ( a . bitrateControl && audioBitrate ) args . push ( '-b:a' , audioBitrate ) ;
if ( audioChannels ) args . push ( '-ac' , String ( audioChannels ) ) ;
// moov-atom placement is the difference between a Premiere-openable master and
// a "file cannot be opened" error.
//
// Finalized masters (the S3-piped recording that stops cleanly) must NOT be
// fragmented. Adobe Premiere's QuickTime/MOV importer reads the classic
// stco/stsz/stts sample tables in a single top-level moov; a fragmented MOV
// (moof/trun, empty sample tables) makes Premiere report "file cannot be
// opened." We write a clean, non-fragmented MOV instead. `+faststart` puts the
// moov before mdat on the second pass so the file is instantly
// seekable/streamable too.
if ( fmt === 'mov' || fmt === 'mp4' ) {
args . push ( '-movflags' , '+faststart' ) ;
}
// 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 } ) {
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 } ;
}
feat(deltacast): replace per-port bridges with shared multi-port daemon
The old architecture spawned one deltacast-capture per recorder port; each
called VHD_OpenBoardHandle, triggering a BufMngr.c:781 OOB fault in the
delta_x300 kernel driver whenever two opens raced.
Fix: a single deltacast-bridge daemon opens the board once, opens RX
streams for all requested ports concurrently, and writes each port's
video/audio to named FIFOs (/dev/shm/deltacast/video-<N>.fifo,
/dev/shm/deltacast/audio-<N>.fifo). Capture sidecars read from those
FIFOs directly — no board handle, no race, no flock.
Changes:
services/capture/deltacast-bridge/main.c
- Complete rewrite: --ports csv arg, board opened once, one
video+audio thread pair per port, FIFO paths per port, format
JSON emitted per port on signal lock, SIGTERM clean shutdown.
- flock/serialize logic removed (no longer needed).
- --port single-port compat alias retained.
services/capture/deltacast-bridge/CMakeLists.txt
- Rename target deltacast-capture -> deltacast-bridge.
- POST_BUILD symlink deltacast-capture -> deltacast-bridge for compat.
services/capture/src/capture-manager.js
- deltacast _buildInputArgs: remove bridge spawn; wait up to 30s
for FIFOs to exist (bridge may be starting); return rawvideo +
s16le FIFO inputArgs. bridgeProcess=null.
- audioMap: keyed on sourceType instead of bridgeProcess (both
inputs are always present for deltacast).
- Remove readFirstStderrLine helper (no longer needed).
- Remove bridgeProcess.stdout.pipe / processes.bridge stop signal.
services/node-agent/index.js
- Add import spawn for bridge daemon management.
- Add startDeltacastBridge / stopDeltacastBridge: host-process
lifecycle for the shared bridge, ref-counted by sidecar count.
- handleSidecarStart: on deltacast, increment counter + start bridge;
decrement on container create/start failure.
- handleSidecarStop: decrement counter; stop bridge when last sidecar.
- _containerSourceType map tracks containerId->sourceType for stop.
- Old acquireDcLock mutex retained but no longer called.
2026-06-01 20:21:52 -04:00
// Deltacast SDI via shared bridge daemon (deltacast-bridge).
//
// The bridge daemon is started by node-agent (host process, direct /dev access)
// and writes each port's streams to named FIFOs in /dev/shm/deltacast/:
// /dev/shm/deltacast/video-<port>.fifo
// /dev/shm/deltacast/audio-<port>.fifo
//
// This sidecar just reads from those FIFOs. The bridge may still be starting
// up or waiting for signal lock, so we wait up to 30s for the FIFOs to appear
// before handing them to ffmpeg. The bridge process is managed by node-agent;
// bridgeProcess is null here (no per-sidecar bridge spawn).
2026-06-01 19:09:21 -04:00
if ( sourceType === 'deltacast' ) {
feat(deltacast): replace per-port bridges with shared multi-port daemon
The old architecture spawned one deltacast-capture per recorder port; each
called VHD_OpenBoardHandle, triggering a BufMngr.c:781 OOB fault in the
delta_x300 kernel driver whenever two opens raced.
Fix: a single deltacast-bridge daemon opens the board once, opens RX
streams for all requested ports concurrently, and writes each port's
video/audio to named FIFOs (/dev/shm/deltacast/video-<N>.fifo,
/dev/shm/deltacast/audio-<N>.fifo). Capture sidecars read from those
FIFOs directly — no board handle, no race, no flock.
Changes:
services/capture/deltacast-bridge/main.c
- Complete rewrite: --ports csv arg, board opened once, one
video+audio thread pair per port, FIFO paths per port, format
JSON emitted per port on signal lock, SIGTERM clean shutdown.
- flock/serialize logic removed (no longer needed).
- --port single-port compat alias retained.
services/capture/deltacast-bridge/CMakeLists.txt
- Rename target deltacast-capture -> deltacast-bridge.
- POST_BUILD symlink deltacast-capture -> deltacast-bridge for compat.
services/capture/src/capture-manager.js
- deltacast _buildInputArgs: remove bridge spawn; wait up to 30s
for FIFOs to exist (bridge may be starting); return rawvideo +
s16le FIFO inputArgs. bridgeProcess=null.
- audioMap: keyed on sourceType instead of bridgeProcess (both
inputs are always present for deltacast).
- Remove readFirstStderrLine helper (no longer needed).
- Remove bridgeProcess.stdout.pipe / processes.bridge stop signal.
services/node-agent/index.js
- Add import spawn for bridge daemon management.
- Add startDeltacastBridge / stopDeltacastBridge: host-process
lifecycle for the shared bridge, ref-counted by sidecar count.
- handleSidecarStart: on deltacast, increment counter + start bridge;
decrement on container create/start failure.
- handleSidecarStop: decrement counter; stop bridge when last sidecar.
- _containerSourceType map tracks containerId->sourceType for stop.
- Old acquireDcLock mutex retained but no longer called.
2026-06-01 20:21:52 -04:00
const idx = ( typeof device === 'number' || /^\d+$/ . test ( String ( device ) ) )
2026-06-01 19:09:21 -04:00
? parseInt ( device , 10 ) : 0 ;
const portIdx = ( typeof port === 'number' || /^\d+$/ . test ( String ( port ) ) )
? parseInt ( port , 10 ) : idx ;
feat(deltacast): replace per-port bridges with shared multi-port daemon
The old architecture spawned one deltacast-capture per recorder port; each
called VHD_OpenBoardHandle, triggering a BufMngr.c:781 OOB fault in the
delta_x300 kernel driver whenever two opens raced.
Fix: a single deltacast-bridge daemon opens the board once, opens RX
streams for all requested ports concurrently, and writes each port's
video/audio to named FIFOs (/dev/shm/deltacast/video-<N>.fifo,
/dev/shm/deltacast/audio-<N>.fifo). Capture sidecars read from those
FIFOs directly — no board handle, no race, no flock.
Changes:
services/capture/deltacast-bridge/main.c
- Complete rewrite: --ports csv arg, board opened once, one
video+audio thread pair per port, FIFO paths per port, format
JSON emitted per port on signal lock, SIGTERM clean shutdown.
- flock/serialize logic removed (no longer needed).
- --port single-port compat alias retained.
services/capture/deltacast-bridge/CMakeLists.txt
- Rename target deltacast-capture -> deltacast-bridge.
- POST_BUILD symlink deltacast-capture -> deltacast-bridge for compat.
services/capture/src/capture-manager.js
- deltacast _buildInputArgs: remove bridge spawn; wait up to 30s
for FIFOs to exist (bridge may be starting); return rawvideo +
s16le FIFO inputArgs. bridgeProcess=null.
- audioMap: keyed on sourceType instead of bridgeProcess (both
inputs are always present for deltacast).
- Remove readFirstStderrLine helper (no longer needed).
- Remove bridgeProcess.stdout.pipe / processes.bridge stop signal.
services/node-agent/index.js
- Add import spawn for bridge daemon management.
- Add startDeltacastBridge / stopDeltacastBridge: host-process
lifecycle for the shared bridge, ref-counted by sidecar count.
- handleSidecarStart: on deltacast, increment counter + start bridge;
decrement on container create/start failure.
- handleSidecarStop: decrement counter; stop bridge when last sidecar.
- _containerSourceType map tracks containerId->sourceType for stop.
- Old acquireDcLock mutex retained but no longer called.
2026-06-01 20:21:52 -04:00
const DC _PIPE _DIR = process . env . DELTACAST _PIPE _DIR || '/dev/shm/deltacast' ;
const videoFifo = ` ${ DC _PIPE _DIR } /video- ${ portIdx } .fifo ` ;
const audioFifo = ` ${ DC _PIPE _DIR } /audio- ${ portIdx } .fifo ` ;
// Wait up to 30s for both FIFOs to exist (bridge starts asynchronously).
const { existsSync : _exists } = await import ( 'node:fs' ) ;
const WAIT _MS = 30_000 ;
const POLL _MS = 500 ;
const deadline = Date . now ( ) + WAIT _MS ;
let videoReady = false ;
let audioReady = false ;
while ( Date . now ( ) < deadline ) {
videoReady = _exists ( videoFifo ) ;
audioReady = _exists ( audioFifo ) ;
if ( videoReady && audioReady ) break ;
await new Promise ( r => setTimeout ( r , POLL _MS ) ) ;
}
if ( ! videoReady || ! audioReady ) {
throw new Error (
` deltacast bridge FIFOs not ready after ${ WAIT _MS / 1000 } s ` +
` (video= ${ videoReady } audio= ${ audioReady } ) — is deltacast-bridge running? `
) ;
}
console . log ( ` [deltacast] port ${ portIdx } FIFOs ready: ${ videoFifo } , ${ audioFifo } ` ) ;
// Resolution/fps are not known until the FIFO reader connects and starts
// receiving frames. We use sensible defaults here; ffmpeg's rawvideo demuxer
// will accept whatever the bridge writes once the pipe opens.
// The bridge daemon has already detected the signal and set up streams, so
// the FIFO content is ready-to-read as soon as the reader connects.
//
// NOTE: The format JSON emitted by the bridge on signal lock goes to the
// node-agent (which launched the bridge), not to this sidecar. The sidecar
// therefore uses fixed rawvideo params here. If per-port format introspection
// is needed in future, the node-agent should expose the fmt JSON via an API
// and capture-manager can query it before building inputArgs.
//
// For now, both video dimensions and framerate come from the recorder's
// configured values (passed to start() as `framerate` and implicit in the
// codec args). The rawvideo input is -video_size / -framerate from env or
// recorder config; ffmpeg tolerates a small mismatch in rawvideo (it just
// reads N bytes per frame based on the declared size).
//
// DELTACAST_VIDEO_SIZE / DELTACAST_FRAMERATE: set by node-agent in the
// sidecar env based on the bridge's per-port format JSON, if desired.
const dcSize = process . env . DELTACAST _VIDEO _SIZE || '1920x1080' ;
2026-06-01 21:15:42 -04:00
const dcFps = process . env . DELTACAST _FRAMERATE || '60000/1001' ;
feat(deltacast): replace per-port bridges with shared multi-port daemon
The old architecture spawned one deltacast-capture per recorder port; each
called VHD_OpenBoardHandle, triggering a BufMngr.c:781 OOB fault in the
delta_x300 kernel driver whenever two opens raced.
Fix: a single deltacast-bridge daemon opens the board once, opens RX
streams for all requested ports concurrently, and writes each port's
video/audio to named FIFOs (/dev/shm/deltacast/video-<N>.fifo,
/dev/shm/deltacast/audio-<N>.fifo). Capture sidecars read from those
FIFOs directly — no board handle, no race, no flock.
Changes:
services/capture/deltacast-bridge/main.c
- Complete rewrite: --ports csv arg, board opened once, one
video+audio thread pair per port, FIFO paths per port, format
JSON emitted per port on signal lock, SIGTERM clean shutdown.
- flock/serialize logic removed (no longer needed).
- --port single-port compat alias retained.
services/capture/deltacast-bridge/CMakeLists.txt
- Rename target deltacast-capture -> deltacast-bridge.
- POST_BUILD symlink deltacast-capture -> deltacast-bridge for compat.
services/capture/src/capture-manager.js
- deltacast _buildInputArgs: remove bridge spawn; wait up to 30s
for FIFOs to exist (bridge may be starting); return rawvideo +
s16le FIFO inputArgs. bridgeProcess=null.
- audioMap: keyed on sourceType instead of bridgeProcess (both
inputs are always present for deltacast).
- Remove readFirstStderrLine helper (no longer needed).
- Remove bridgeProcess.stdout.pipe / processes.bridge stop signal.
services/node-agent/index.js
- Add import spawn for bridge daemon management.
- Add startDeltacastBridge / stopDeltacastBridge: host-process
lifecycle for the shared bridge, ref-counted by sidecar count.
- handleSidecarStart: on deltacast, increment counter + start bridge;
decrement on container create/start failure.
- handleSidecarStop: decrement counter; stop bridge when last sidecar.
- _containerSourceType map tracks containerId->sourceType for stop.
- Old acquireDcLock mutex retained but no longer called.
2026-06-01 20:21:52 -04:00
const dcInterlaced = process . env . DELTACAST _INTERLACED === '1' ;
2026-06-01 19:09:21 -04:00
return {
inputArgs : [
'-f' , 'rawvideo' ,
feat(deltacast): replace per-port bridges with shared multi-port daemon
The old architecture spawned one deltacast-capture per recorder port; each
called VHD_OpenBoardHandle, triggering a BufMngr.c:781 OOB fault in the
delta_x300 kernel driver whenever two opens raced.
Fix: a single deltacast-bridge daemon opens the board once, opens RX
streams for all requested ports concurrently, and writes each port's
video/audio to named FIFOs (/dev/shm/deltacast/video-<N>.fifo,
/dev/shm/deltacast/audio-<N>.fifo). Capture sidecars read from those
FIFOs directly — no board handle, no race, no flock.
Changes:
services/capture/deltacast-bridge/main.c
- Complete rewrite: --ports csv arg, board opened once, one
video+audio thread pair per port, FIFO paths per port, format
JSON emitted per port on signal lock, SIGTERM clean shutdown.
- flock/serialize logic removed (no longer needed).
- --port single-port compat alias retained.
services/capture/deltacast-bridge/CMakeLists.txt
- Rename target deltacast-capture -> deltacast-bridge.
- POST_BUILD symlink deltacast-capture -> deltacast-bridge for compat.
services/capture/src/capture-manager.js
- deltacast _buildInputArgs: remove bridge spawn; wait up to 30s
for FIFOs to exist (bridge may be starting); return rawvideo +
s16le FIFO inputArgs. bridgeProcess=null.
- audioMap: keyed on sourceType instead of bridgeProcess (both
inputs are always present for deltacast).
- Remove readFirstStderrLine helper (no longer needed).
- Remove bridgeProcess.stdout.pipe / processes.bridge stop signal.
services/node-agent/index.js
- Add import spawn for bridge daemon management.
- Add startDeltacastBridge / stopDeltacastBridge: host-process
lifecycle for the shared bridge, ref-counted by sidecar count.
- handleSidecarStart: on deltacast, increment counter + start bridge;
decrement on container create/start failure.
- handleSidecarStop: decrement counter; stop bridge when last sidecar.
- _containerSourceType map tracks containerId->sourceType for stop.
- Old acquireDcLock mutex retained but no longer called.
2026-06-01 20:21:52 -04:00
'-pix_fmt' , 'uyvy422' ,
'-video_size' , dcSize ,
'-framerate' , dcFps ,
'-i' , videoFifo ,
2026-06-01 19:09:21 -04:00
'-f' , 's16le' ,
feat(deltacast): replace per-port bridges with shared multi-port daemon
The old architecture spawned one deltacast-capture per recorder port; each
called VHD_OpenBoardHandle, triggering a BufMngr.c:781 OOB fault in the
delta_x300 kernel driver whenever two opens raced.
Fix: a single deltacast-bridge daemon opens the board once, opens RX
streams for all requested ports concurrently, and writes each port's
video/audio to named FIFOs (/dev/shm/deltacast/video-<N>.fifo,
/dev/shm/deltacast/audio-<N>.fifo). Capture sidecars read from those
FIFOs directly — no board handle, no race, no flock.
Changes:
services/capture/deltacast-bridge/main.c
- Complete rewrite: --ports csv arg, board opened once, one
video+audio thread pair per port, FIFO paths per port, format
JSON emitted per port on signal lock, SIGTERM clean shutdown.
- flock/serialize logic removed (no longer needed).
- --port single-port compat alias retained.
services/capture/deltacast-bridge/CMakeLists.txt
- Rename target deltacast-capture -> deltacast-bridge.
- POST_BUILD symlink deltacast-capture -> deltacast-bridge for compat.
services/capture/src/capture-manager.js
- deltacast _buildInputArgs: remove bridge spawn; wait up to 30s
for FIFOs to exist (bridge may be starting); return rawvideo +
s16le FIFO inputArgs. bridgeProcess=null.
- audioMap: keyed on sourceType instead of bridgeProcess (both
inputs are always present for deltacast).
- Remove readFirstStderrLine helper (no longer needed).
- Remove bridgeProcess.stdout.pipe / processes.bridge stop signal.
services/node-agent/index.js
- Add import spawn for bridge daemon management.
- Add startDeltacastBridge / stopDeltacastBridge: host-process
lifecycle for the shared bridge, ref-counted by sidecar count.
- handleSidecarStart: on deltacast, increment counter + start bridge;
decrement on container create/start failure.
- handleSidecarStop: decrement counter; stop bridge when last sidecar.
- _containerSourceType map tracks containerId->sourceType for stop.
- Old acquireDcLock mutex retained but no longer called.
2026-06-01 20:21:52 -04:00
'-ar' , '48000' ,
'-ac' , '2' ,
2026-06-01 19:09:21 -04:00
'-i' , audioFifo ,
] ,
feat(deltacast): replace per-port bridges with shared multi-port daemon
The old architecture spawned one deltacast-capture per recorder port; each
called VHD_OpenBoardHandle, triggering a BufMngr.c:781 OOB fault in the
delta_x300 kernel driver whenever two opens raced.
Fix: a single deltacast-bridge daemon opens the board once, opens RX
streams for all requested ports concurrently, and writes each port's
video/audio to named FIFOs (/dev/shm/deltacast/video-<N>.fifo,
/dev/shm/deltacast/audio-<N>.fifo). Capture sidecars read from those
FIFOs directly — no board handle, no race, no flock.
Changes:
services/capture/deltacast-bridge/main.c
- Complete rewrite: --ports csv arg, board opened once, one
video+audio thread pair per port, FIFO paths per port, format
JSON emitted per port on signal lock, SIGTERM clean shutdown.
- flock/serialize logic removed (no longer needed).
- --port single-port compat alias retained.
services/capture/deltacast-bridge/CMakeLists.txt
- Rename target deltacast-capture -> deltacast-bridge.
- POST_BUILD symlink deltacast-capture -> deltacast-bridge for compat.
services/capture/src/capture-manager.js
- deltacast _buildInputArgs: remove bridge spawn; wait up to 30s
for FIFOs to exist (bridge may be starting); return rawvideo +
s16le FIFO inputArgs. bridgeProcess=null.
- audioMap: keyed on sourceType instead of bridgeProcess (both
inputs are always present for deltacast).
- Remove readFirstStderrLine helper (no longer needed).
- Remove bridgeProcess.stdout.pipe / processes.bridge stop signal.
services/node-agent/index.js
- Add import spawn for bridge daemon management.
- Add startDeltacastBridge / stopDeltacastBridge: host-process
lifecycle for the shared bridge, ref-counted by sidecar count.
- handleSidecarStart: on deltacast, increment counter + start bridge;
decrement on container create/start failure.
- handleSidecarStop: decrement counter; stop bridge when last sidecar.
- _containerSourceType map tracks containerId->sourceType for stop.
- Old acquireDcLock mutex retained but no longer called.
2026-06-01 20:21:52 -04:00
isNetwork : false ,
bridgeProcess : null , /* bridge is managed by node-agent, not this sidecar */
audioFifo : null , /* no per-session FIFO to clean up on stop */
interlaced : dcInterlaced ,
2026-06-01 19:09:21 -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 } ) ;
}
/ * *
* Build the bash orchestrator command for the GROWING master ( raw2bmx ) .
*
* One ffmpeg reads the source once ( DeckLink can ' t be opened twice ) and writes
* THREE outputs :
* ( a ) MPEG - 2 422 elementary VIDEO → video FIFO ─ ┐ raw2bmx - t op1a reads
* ( b ) PCM s16le AUDIO → audio FIFO ─ ┘ these and writes the
* growing OP1a MXF .
* ( c ) H . 264 HLS preview ( unchanged ) — keeps the UI monitor live .
*
* FIFO orchestration ( the tricky part — proven on the live capture node ) :
* raw2bmx opens its inputs lazily ( video first , reads the header , THEN opens
* audio ) , while ffmpeg opens ALL its outputs up - front and blocks on the
* audio FIFO until a reader appears → classic open - order deadlock . We break
* it by having the parent shell PRIME both FIFOs read - write ( non - blocking
* open ) so neither child blocks on open . CRUCIAL : the children must NOT
* inherit a priming * writer * ( it would keep the FIFO open and starve raw2bmx
* of EOF forever ) , so each child closes the priming FDs before exec . The
* parent holds the priming FDs ( as a reader / writer ) only until raw2bmx has
* opened BOTH FIFOs , then drops them — leaving ffmpeg as the SOLE writer , so
* when ffmpeg exits raw2bmx gets a clean EOF and finalizes the MXF footer .
*
* Stop / finalize : the orchestrator traps SIGINT / SIGTERM and forwards SIGINT to
* ffmpeg ( clean stop → EOF to raw2bmx ) , then ` wait ` s for raw2bmx and exits
* with raw2bmx ' s status . The Node side spawns this with detached : true and , on
* stop ( ) , signals it and AWAITS its exit — so the finalized , valid MXF is on
* the share before the promotion worker uploads it .
*
* Returns the argv for spawn ( 'bash' , argv ) .
* /
_buildGrowingOrchestrator ( { inputArgs , videoBitrate , resolution , framerate , audioChannels , outPath , hlsDir , videoCodec , audioMap = '0:a:0?' } ) {
const { rawFlag , frameRate , ffRate } = deriveGrowingRaster ( resolution , framerate ) ;
const vb = videoBitrate || GROWING _DEFAULT _BITRATE ;
const ach = audioChannels ? Number ( audioChannels ) : 2 ;
// ffmpeg argv (shell-quoted). One decklink read → yadif → split → 3 outputs.
const sh = ( a ) => "'" + String ( a ) . replace ( /'/g , ` ' \\ '' ` ) + "'" ;
// `-y`: the FIFOs are pre-created by mkfifo, so ffmpeg must overwrite them
// without the interactive "File already exists. Overwrite? [y/N]" prompt
// (which would otherwise abort the video/audio outputs and produce nothing).
const ff = [ 'ffmpeg' , '-y' , '-hide_banner' , '-loglevel' , 'warning' , '-stats' ] ;
// SDI input is interlaced; yadif then split into the master + preview taps.
// When there's an HLS dir we split the decode into the master ([vhi]) and
// the H.264 preview ([vlo]); with no HLS dir, split=1 (master only) so no
// split output is ever left unconnected (deltacast growing master had no
// HLS dir, leaving [vlo] orphaned -> 'split output 1 (vlo) unconnected').
const filterComplex = hlsDir
? '[0:v]yadif=mode=1:deint=1,split=2[vhi][vlo]'
: '[0:v]yadif=mode=1:deint=1,split=1[vhi]' ;
const ffArgs = [
... inputArgs ,
'-filter_complex' , filterComplex ,
// (a) MPEG-2 422 elementary video → "$VF"
'-map' , '[vhi]' ,
... GROWING _VIDEO _ELEMENTARY _ARGS ,
'-b:v' , vb , '-minrate' , vb , '-maxrate' , vb , '-bufsize' , vb ,
'-r' , ffRate ,
'-f' , 'mpeg2video' , '@VF@' ,
// (b) PCM s16le audio → "$AF"
'-map' , audioMap ,
'-c:a' , 'pcm_s16le' , '-ar' , '48000' , '-ac' , String ( ach ) ,
'-f' , 's16le' , '@AF@' ,
] ;
let ffHls = [ ] ;
if ( hlsDir ) {
ffHls = [
// (c) H.264 HLS preview — GPU-gated, unchanged behaviour.
'-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 ` ,
] ;
}
// @VF@/@AF@ are placeholders for the FIFO path shell variables; emit them as
// unquoted "$VF"/"$AF" so the shell expands them, and shell-quote everything
// else.
const placeholder = ( t ) => ( t === '@VF@' ? '"$VF"' : t === '@AF@' ? '"$AF"' : sh ( t ) ) ;
const ffLine = [ ... ff , ... ffArgs , ... ffHls ] . map ( placeholder ) . join ( ' ' ) ;
// raw2bmx argv. Audio is de-interleaved by raw2bmx into mono PCM tracks
// (the standard MXF mapping); --part starts a new body partition +
// IndexTableSegment every GROWING_PART_INTERVAL_FRAMES frames.
//
// CLIP TYPE: rdd9 (SMPTE RDD-9 / "Sony MXF") — NOT plain op1a and NOT
// --avid-gf. This is the make-or-break choice for Adobe Premiere:
// * --avid-gf produces an *Avid OP-Atom* growing file. That flavour needs a
// companion AAF to register the clip and is only read live by Avid Media
// Composer — Premiere cannot open it as a growing file. (Confirmed via the
// bmx mailing list + Softron/Drastic edit-while-ingest docs.) So it is
// removed.
// * Premiere's documented edit-while-ingest path expects XDCAM essence
// (MPEG-2 422 Long GOP, which we emit) wrapped as RDD-9. raw2bmx's `rdd9`
// clip type emits exactly that structure.
// --index-follows: write the IndexTableSegment in the *same* partition as the
// essence it indexes (rather than a trailing index-only partition). This is
// what lets a reader that re-scans body partitions on refresh find an index
// covering the newly-written frames — required so Premiere can seek past its
// original frame map toward the record head.
// The header Duration still starts at -1 and is only finalised in the footer
// on stop, so the inline Python dur-patch below overwrites the header Duration
// fields with the live frame count every 3s (Premiere reads the header
// Duration on each refresh; without the patch it sees duration=N/A).
const bmx = [
'raw2bmx' , '-t' , 'rdd9' , '-o' , '"$OUT"' , '-f' , frameRate ,
'--part' , String ( GROWING _PART _INTERVAL _FRAMES ) ,
'--index-follows' ,
rawFlag , '"$VF"' ,
'-s' , '48000' , '-q' , '16' , '--audio-chan' , String ( ach ) , '--pcm' , '"$AF"' ,
] ;
const bmxLine = bmx
. map ( ( t ) => ( t . startsWith ( '"$' ) ? t : sh ( t ) ) )
. join ( ' ' ) ;
// The orchestration script. `set -m` is intentionally NOT used; we manage
// children explicitly. Priming FDs 7/8; children close them before exec.
// PATCHPID: inline Python duration-patcher that runs alongside raw2bmx and
// patches the MXF header's Duration=-1 fields with the actual frame count
// every 3 seconds. Without this Premiere sees Duration=N/A even as the file
// grows, so the timeline never extends. The patcher reads the last body
// partition's IndexTableSegment (IndexStartPosition+IndexDuration) to get
// an exact frame count, then seeks back to the header Duration fields and
// overwrites them in-place. It is killed by the cleanup trap on exit.
const script = `
set - u
VF = $ ( mktemp - u / tmp / grow _v . XXXXXX ) ; AF = $ ( mktemp - u / tmp / grow _a . XXXXXX )
OUT = $ { sh ( outPath ) }
mkfifo "$VF" "$AF"
PATCHPID =
cleanup ( ) { rm - f "$VF" "$AF" ; [ - n "$PATCHPID" ] && kill "$PATCHPID" 2 > / d e v / n u l l ; }
trap cleanup EXIT
# Prime both FIFOs read - write ( non - blocking ) to break the open - order deadlock .
exec 7 < > "$VF" 8 < > "$AF"
# raw2bmx : close priming FDs ( no stray writer ) before exec so it sees real EOF .
( exec 7 > & - 8 > & - ; exec $ { bmxLine } ) &
BMXPID = $ !
# ffmpeg : also closes priming FDs ; it opens its own write ends .
( exec 7 > & - 8 > & - ; exec $ { ffLine } ) &
FFPID = $ !
# Forward a clean stop to ffmpeg ; raw2bmx then gets EOF and finalizes the footer .
stop ( ) { kill - INT "$FFPID" 2 > / d e v / n u l l ; }
trap stop INT TERM
# Drop the parent priming FDs once raw2bmx has opened BOTH FIFOs , so ffmpeg is
# the sole writer ( its EOF reaches raw2bmx ) . If raw2bmx dies early , bail .
for i in $ ( seq 1 200 ) ; do
kill - 0 "$BMXPID" 2 > / d e v / n u l l | | b r e a k
n = $ ( ls - l / proc / $BMXPID / fd 2 > / d e v / n u l l | g r e p - c - - " $ V F \ \ | $ A F " )
[ "\${n:-0}" - ge 2 ] && break
sleep 0.1
done
exec 7 > & - 8 > & -
# No header - duration patcher is needed . In this bmx v1 . 6 build , raw2bmx ' s rdd9
# writer with -- part maintains a live , correct header Duration as the file grows
# ( verified on - node : ffprobe reads a growing duration mid - write , e . g . 2.04 s of a
# 10 s clip while still recording ) . A patcher ( the earlier dur - patch . py ) was a
# no - op here — it searched for Duration = - 1 , which rdd9 never writes — and opening
# the file r + b while raw2bmx appends over CIFS only adds concurrency risk .
PATCHPID =
# Wait for ffmpeg ( source end ) , then for raw2bmx to finalize the footer .
wait "$FFPID" ; FFRC = $ ?
wait "$BMXPID" ; BMXRC = $ ?
echo "[grow] ffmpeg rc=$FFRC raw2bmx rc=$BMXRC out=$OUT" > & 2
exit "$BMXRC"
` ;
return [ '-c' , script ] ;
}
/ * *
* 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' ) ;
}
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.
let growingActive = GROWING _ENABLED ;
if ( growingActive && GROWING _SMB _MOUNT ) {
if ( ! mountGrowingShare ( ) ) growingActive = false ; // fall back to S3
}
// Growing master is always MXF OP1a / XDCAM HD422 written by raw2bmx (the
// format Premiere reads while growing — see GROWING_VIDEO_ELEMENTARY_ARGS /
// _buildGrowingOrchestrator), regardless of the recorder's configured
// container — so it gets a .mxf extension, not the container's.
const growingPath = growingActive
? ` ${ GROWING _PATH } / ${ projectId } / ${ clipName } . ${ GROWING _EXT } `
: 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.
const hiresExt = growingPath ? GROWING _EXT : ( CONTAINER _EXT [ container ] || 'mov' ) ;
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 ;
const startedAt = new Date ( ) . toISOString ( ) ;
this . _sessionIdForBridge = sessionId ;
const { inputArgs , isNetwork , bridgeProcess = null , audioFifo = null , interlaced = false } = await this . _buildInputArgs ( {
sourceType , sourceBackend , device , port , board , sourceUrl , listen , listenPort , streamKey ,
} ) ;
feat(deltacast): replace per-port bridges with shared multi-port daemon
The old architecture spawned one deltacast-capture per recorder port; each
called VHD_OpenBoardHandle, triggering a BufMngr.c:781 OOB fault in the
delta_x300 kernel driver whenever two opens raced.
Fix: a single deltacast-bridge daemon opens the board once, opens RX
streams for all requested ports concurrently, and writes each port's
video/audio to named FIFOs (/dev/shm/deltacast/video-<N>.fifo,
/dev/shm/deltacast/audio-<N>.fifo). Capture sidecars read from those
FIFOs directly — no board handle, no race, no flock.
Changes:
services/capture/deltacast-bridge/main.c
- Complete rewrite: --ports csv arg, board opened once, one
video+audio thread pair per port, FIFO paths per port, format
JSON emitted per port on signal lock, SIGTERM clean shutdown.
- flock/serialize logic removed (no longer needed).
- --port single-port compat alias retained.
services/capture/deltacast-bridge/CMakeLists.txt
- Rename target deltacast-capture -> deltacast-bridge.
- POST_BUILD symlink deltacast-capture -> deltacast-bridge for compat.
services/capture/src/capture-manager.js
- deltacast _buildInputArgs: remove bridge spawn; wait up to 30s
for FIFOs to exist (bridge may be starting); return rawvideo +
s16le FIFO inputArgs. bridgeProcess=null.
- audioMap: keyed on sourceType instead of bridgeProcess (both
inputs are always present for deltacast).
- Remove readFirstStderrLine helper (no longer needed).
- Remove bridgeProcess.stdout.pipe / processes.bridge stop signal.
services/node-agent/index.js
- Add import spawn for bridge daemon management.
- Add startDeltacastBridge / stopDeltacastBridge: host-process
lifecycle for the shared bridge, ref-counted by sidecar count.
- handleSidecarStart: on deltacast, increment counter + start bridge;
decrement on container create/start failure.
- handleSidecarStop: decrement counter; stop bridge when last sidecar.
- _containerSourceType map tracks containerId->sourceType for stop.
- Old acquireDcLock mutex retained but no longer called.
2026-06-01 20:21:52 -04:00
// Audio input index: the deltacast shared bridge delivers video on input 0
// (video FIFO) and audio on input 1 (audio FIFO), so audioMap is '1:a:0?'.
// DeckLink SDI and network sources carry audio inside input 0.
const audioMap = ( sourceType === 'deltacast' ) ? '1:a:0?' : '0:a:0?' ;
2026-06-01 19:09:21 -04:00
// Non-growing master: ffmpeg muxes the finalized MOV directly. Growing
// master: raw2bmx muxes the OP1a from elementary FIFOs (handled below via
// the orchestrator), so we don't build ffmpeg codec args here for it.
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 ( ' ' ) ) ;
const isInterlacedSource = sourceType === 'sdi' || ( sourceType === 'deltacast' && interlaced ) ;
const sdiFilterArgs = isInterlacedSource ? [ '-vf' , 'yadif=mode=1:deint=1' ] : [ ] ;
// Master output destination (NON-growing path only).
//
// - Growing-files on → the growing OP1a MXF is written directly to the SMB
// share by raw2bmx (see the orchestrator below); ffmpeg only produces the
// elementary essence FIFOs + HLS preview. `localMasterPath`/`hiresOutput`
// are unused in this case (the master path is `growingPath`).
//
// - Growing-files off → ffmpeg writes the MOV master to a LOCAL SEEKABLE
// temp file, then we upload to S3 on stop. We must NOT pipe the MOV muxer
// to S3 directly: the MOV/MP4 muxer cannot write to a non-seekable pipe
// without `empty_moov`, and an empty_moov/fragmented MOV is exactly what
// makes Adobe Premiere report "file cannot be opened" (no classic
// stco/stsz sample tables — samples live in moof/trun). A seekable file
// lets ffmpeg write a single contiguous moov with full sample tables and
// `+faststart` moves it to the front, producing a Premiere-native master.
const localMasterPath = growingPath
? null
: ` /tmp/capture/ ${ sessionId } . ${ hiresExt } ` ;
if ( localMasterPath ) {
try { mkdirSync ( dirname ( localMasterPath ) , { recursive : true } ) ; }
catch ( err ) { console . error ( '[capture] could not create temp master dir:' , err . message ) ; }
}
const hiresOutput = localMasterPath ;
feat(deltacast): replace per-port bridges with shared multi-port daemon
The old architecture spawned one deltacast-capture per recorder port; each
called VHD_OpenBoardHandle, triggering a BufMngr.c:781 OOB fault in the
delta_x300 kernel driver whenever two opens raced.
Fix: a single deltacast-bridge daemon opens the board once, opens RX
streams for all requested ports concurrently, and writes each port's
video/audio to named FIFOs (/dev/shm/deltacast/video-<N>.fifo,
/dev/shm/deltacast/audio-<N>.fifo). Capture sidecars read from those
FIFOs directly — no board handle, no race, no flock.
Changes:
services/capture/deltacast-bridge/main.c
- Complete rewrite: --ports csv arg, board opened once, one
video+audio thread pair per port, FIFO paths per port, format
JSON emitted per port on signal lock, SIGTERM clean shutdown.
- flock/serialize logic removed (no longer needed).
- --port single-port compat alias retained.
services/capture/deltacast-bridge/CMakeLists.txt
- Rename target deltacast-capture -> deltacast-bridge.
- POST_BUILD symlink deltacast-capture -> deltacast-bridge for compat.
services/capture/src/capture-manager.js
- deltacast _buildInputArgs: remove bridge spawn; wait up to 30s
for FIFOs to exist (bridge may be starting); return rawvideo +
s16le FIFO inputArgs. bridgeProcess=null.
- audioMap: keyed on sourceType instead of bridgeProcess (both
inputs are always present for deltacast).
- Remove readFirstStderrLine helper (no longer needed).
- Remove bridgeProcess.stdout.pipe / processes.bridge stop signal.
services/node-agent/index.js
- Add import spawn for bridge daemon management.
- Add startDeltacastBridge / stopDeltacastBridge: host-process
lifecycle for the shared bridge, ref-counted by sidecar count.
- handleSidecarStart: on deltacast, increment counter + start bridge;
decrement on container create/start failure.
- handleSidecarStop: decrement counter; stop bridge when last sidecar.
- _containerSourceType map tracks containerId->sourceType for stop.
- Old acquireDcLock mutex retained but no longer called.
2026-06-01 20:21:52 -04:00
// Deltacast reads from FIFOs (no stdin pipe needed). DeckLink pipes stdout.
const hiresStdio = [ 'ignore' , 'ignore' , 'pipe' ] ;
2026-06-01 19:09:21 -04:00
// For SDI we cannot open the DeckLink device a second time for a preview
// tee, so the live HLS preview is produced as a SECOND OUTPUT of the hires
// ffmpeg: one decklink read -> yadif -> split -> [ProRes/S3] + [H.264/HLS].
let sdiHlsDir = null ;
if ( ( sourceType === 'sdi' || sourceType === 'deltacast' ) && this . _assetIdForHls ) {
const fsMod = await import ( 'node:fs' ) ;
sdiHlsDir = '/live/' + this . _assetIdForHls ;
try { fsMod . mkdirSync ( sdiHlsDir , { recursive : true } ) ; } catch ( _ ) { }
}
let hiresProcess ;
if ( growingPath ) {
// ── GROWING master: raw2bmx orchestrator ──────────────────────────
// One ffmpeg (single SDI read) → MPEG-2 422 elementary + PCM to FIFOs +
// the H.264 HLS preview; raw2bmx muxes the growing OP1a MXF from the FIFOs.
// Spawned via bash so the FIFO priming / EOF / stop-forwarding logic (see
// _buildGrowingOrchestrator) runs as one supervised unit. detached:true so
// it leads its own process group and we can clean-stop the whole pipeline.
const orchArgs = this . _buildGrowingOrchestrator ( {
inputArgs ,
videoBitrate ,
// Recorder raster for the raw2bmx essence flag. recorders.js sets
// RECORDING_RESOLUTION (e.g. '1920x1080' / '1080i' / 'native'); when
// 'native'/absent, deriveGrowingRaster defaults to 1080i59.94.
resolution : process . env . RECORDING _RESOLUTION || null ,
framerate ,
audioChannels ,
outPath : growingPath ,
hlsDir : ( sourceType === 'sdi' || sourceType === 'deltacast' ) ? sdiHlsDir : null ,
videoCodec ,
audioMap ,
} ) ;
console . log ( '[capture] growing master via raw2bmx; orchestrator script length=' + orchArgs [ 1 ] . length ) ;
hiresProcess = spawn ( 'bash' , orchArgs , { stdio : [ 'ignore' , 'ignore' , 'pipe' ] , detached : true } ) ;
} else {
// ── Finalized (non-growing) master: ffmpeg muxes the MOV directly ──
let hiresArgs ;
if ( ( sourceType === 'sdi' || sourceType === 'deltacast' ) && this . _assetIdForHls ) {
hiresArgs = [
... inputArgs ,
'-filter_complex' , '[0:v]yadif=mode=1:deint=1,split=2[vhi][vlo]' ,
// Output 0 — ProRes/MOV master (local temp, uploaded to S3 on stop)
'-map' , '[vhi]' , '-map' , audioMap ,
... hiresCodecArgs ,
hiresOutput ,
// Output 1 — low-latency H.264 HLS preview for the UI monitor.
// GPU-encoded (h264_nvenc) when the GPU is attached to this sidecar,
// otherwise libx264 (issue #164). GOP is pinned to one IDR per HLS
// segment so segments start on keyframes (avoids black/flashing).
'-map' , '[vlo]' , '-map' , audioMap ,
... 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' , sdiHlsDir + '/seg-%05d.ts' ,
sdiHlsDir + '/index.m3u8' ,
] ;
console . log ( '[HLS] SDI preview as 2nd output -> ' + sdiHlsDir ) ;
} else {
hiresArgs = [ ... inputArgs , ... sdiFilterArgs , ... hiresCodecArgs , hiresOutput ] ;
}
hiresProcess = spawn ( 'ffmpeg' , hiresArgs , { stdio : hiresStdio } ) ;
}
// Growing-files: nothing to upload here (promotion worker handles S3).
// Non-growing: the master is uploaded from the finalized local file in
// stop(), once ffmpeg has written the moov and exited cleanly — we can't
// upload while recording because the file isn't a valid MOV until finalize.
feat(deltacast): replace per-port bridges with shared multi-port daemon
The old architecture spawned one deltacast-capture per recorder port; each
called VHD_OpenBoardHandle, triggering a BufMngr.c:781 OOB fault in the
delta_x300 kernel driver whenever two opens raced.
Fix: a single deltacast-bridge daemon opens the board once, opens RX
streams for all requested ports concurrently, and writes each port's
video/audio to named FIFOs (/dev/shm/deltacast/video-<N>.fifo,
/dev/shm/deltacast/audio-<N>.fifo). Capture sidecars read from those
FIFOs directly — no board handle, no race, no flock.
Changes:
services/capture/deltacast-bridge/main.c
- Complete rewrite: --ports csv arg, board opened once, one
video+audio thread pair per port, FIFO paths per port, format
JSON emitted per port on signal lock, SIGTERM clean shutdown.
- flock/serialize logic removed (no longer needed).
- --port single-port compat alias retained.
services/capture/deltacast-bridge/CMakeLists.txt
- Rename target deltacast-capture -> deltacast-bridge.
- POST_BUILD symlink deltacast-capture -> deltacast-bridge for compat.
services/capture/src/capture-manager.js
- deltacast _buildInputArgs: remove bridge spawn; wait up to 30s
for FIFOs to exist (bridge may be starting); return rawvideo +
s16le FIFO inputArgs. bridgeProcess=null.
- audioMap: keyed on sourceType instead of bridgeProcess (both
inputs are always present for deltacast).
- Remove readFirstStderrLine helper (no longer needed).
- Remove bridgeProcess.stdout.pipe / processes.bridge stop signal.
services/node-agent/index.js
- Add import spawn for bridge daemon management.
- Add startDeltacastBridge / stopDeltacastBridge: host-process
lifecycle for the shared bridge, ref-counted by sidecar count.
- handleSidecarStart: on deltacast, increment counter + start bridge;
decrement on container create/start failure.
- handleSidecarStop: decrement counter; stop bridge when last sidecar.
- _containerSourceType map tracks containerId->sourceType for stop.
- Old acquireDcLock mutex retained but no longer called.
2026-06-01 20:21:52 -04:00
// bridgeProcess is null for deltacast (bridge managed by node-agent on the host).
2026-06-01 19:09:21 -04:00
const processes = { hires : hiresProcess } ;
const uploads = { hires : growingPath ? Promise . resolve ( { growingPath } ) : null } ;
// ── HLS tee for network sources (live preview in the UI) ──────────
let hlsProcess = null ;
let hlsDir = null ;
if ( isNetwork && this . _assetIdForHls ) {
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?' ,
// GPU-gated preview encode, same as the SDI 2nd-output path (#164).
... buildHlsVideoArgs ( videoCodec , framerate ) ,
'-c:a' , 'aac' , '-b:a' , '128k' , '-ar' , '44100' ,
'-f' , 'hls' , '-hls_time' , '2' , '-hls_list_size' , '15' ,
'-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 ;
console . log ( '[HLS] tee started -> ' + hlsDir ) ;
} 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 . currentFps = parseFloat ( m [ 2 ] ) ;
this . state . lastFrameAt = new Date ( ) . toISOString ( ) ;
}
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 . currentSession = {
sessionId ,
projectId ,
binId ,
clipName ,
device ,
sourceType ,
sourceUrl ,
hiresKey ,
proxyKey ,
growingPath ,
localMasterPath ,
audioFifo ,
startedAt ,
duration : 0 ,
uploads ,
codecs : {
videoCodec , videoBitrate , framerate ,
audioCodec , audioBitrate , audioChannels , container ,
proxyEnabled , proxyVideoCodec , proxyVideoBitrate ,
proxyAudioCodec , proxyAudioBitrate , proxyAudioChannels , proxyContainer ,
} ,
} ;
return this . _formatSessionResponse ( ) ;
}
2026-06-02 07:23:39 -04:00
async startIdlePreview ( ) {
if ( this . _previewProc ) return ; // already running
const sourceType = process . env . SOURCE _TYPE ;
const recorderId = process . env . RECORDER _ID ;
if ( ! recorderId || ! [ 'deltacast' , 'sdi' ] . includes ( sourceType ) ) return ;
const previewDir = ` /live/preview- ${ recorderId } ` ;
try { await fs . promises . mkdir ( previewDir , { recursive : true } ) ; } catch ( _ ) { }
let inputArgs ;
if ( sourceType === 'deltacast' ) {
const size = process . env . DELTACAST _VIDEO _SIZE || '1920x1080' ;
const fps = process . env . DELTACAST _FRAMERATE || '60000/1001' ;
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 ` ;
inputArgs = [ '-f' , 'rawvideo' , '-pix_fmt' , 'uyvy422' , '-s' , size , '-r' , fps , '-i' , videoFifo ] ;
} else {
// SDI (blackmagic): not yet implemented — skip
return ;
}
const outputArgs = [
'-an' ,
'-c:v' , 'libx264' , '-preset' , 'ultrafast' , '-tune' , 'zerolatency' ,
'-b:v' , '600k' , '-maxrate' , '800k' , '-bufsize' , '1200k' ,
'-g' , '30' , '-sc_threshold' , '0' ,
'-hls_time' , '1' , '-hls_list_size' , '4' ,
'-hls_flags' , 'delete_segments+omit_endlist+independent_segments' ,
'-hls_segment_type' , 'mpegts' ,
'-hls_segment_filename' , previewDir + '/seg-%05d.ts' ,
'-f' , 'hls' , previewDir + '/index.m3u8' ,
] ;
console . log ( '[preview] starting idle preview for' , recorderId ) ;
this . _previewProc = spawn ( 'ffmpeg' , [ ... inputArgs , ... outputArgs ] , {
stdio : [ 'ignore' , 'ignore' , 'pipe' ] ,
} ) ;
this . _previewProc . stderr . on ( 'data' , ( ) => { /* swallow */ } ) ;
this . _previewProc . on ( 'exit' , ( code ) => {
console . log ( '[preview] idle preview exited' , code ) ;
this . _previewProc = null ;
} ) ;
this . _previewProc . on ( 'error' , ( e ) => {
console . error ( '[preview] idle preview error:' , e . message ) ;
this . _previewProc = null ;
} ) ;
}
stopIdlePreview ( ) {
if ( ! this . _previewProc ) return ;
try { this . _previewProc . kill ( 'SIGTERM' ) ; } catch ( _ ) { }
this . _previewProc = null ;
}
2026-06-01 19:09:21 -04:00
async stop ( sessionId ) {
if ( ! this . state . recording || this . state . sessionId !== sessionId ) {
throw new Error ( 'No active capture session or session ID mismatch' ) ;
}
2026-06-02 07:23:39 -04:00
this . stopIdlePreview ( ) ;
2026-06-01 19:09:21 -04:00
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".
// - Growing: `processes.hires` is the bash ORCHESTRATOR (detached group
// leader). SIGINT hits its trap, which forwards SIGINT to ffmpeg; ffmpeg
// stops → raw2bmx gets EOF → raw2bmx writes the OP1a FOOTER and exits;
// only then does the orchestrator exit. Awaiting it guarantees the
// finalized, valid MXF is on the share before the promotion worker
// uploads it. raw2bmx footer finalize of a long recording can take longer
// than a MOV trailer flush, so the growing safety-net is more generous.
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 {
// Detached orchestrator → kill the whole process group (ffmpeg +
// raw2bmx + bash); otherwise just the process.
if ( isGrowing && proc . pid ) { try { process . kill ( - proc . pid , 'SIGKILL' ) ; } catch ( _ ) { } }
proc . kill ( 'SIGKILL' ) ;
} catch ( _ ) { }
finish ( ) ;
} , finalizeTimeoutMs ) ;
} ) ;
if ( processes . hires ) processes . hires . kill ( 'SIGINT' ) ;
if ( processes . proxy ) processes . proxy . kill ( 'SIGINT' ) ;
if ( processes . hls ) { try { processes . hls . kill ( 'SIGINT' ) ; } catch ( _ ) { } }
feat(deltacast): replace per-port bridges with shared multi-port daemon
The old architecture spawned one deltacast-capture per recorder port; each
called VHD_OpenBoardHandle, triggering a BufMngr.c:781 OOB fault in the
delta_x300 kernel driver whenever two opens raced.
Fix: a single deltacast-bridge daemon opens the board once, opens RX
streams for all requested ports concurrently, and writes each port's
video/audio to named FIFOs (/dev/shm/deltacast/video-<N>.fifo,
/dev/shm/deltacast/audio-<N>.fifo). Capture sidecars read from those
FIFOs directly — no board handle, no race, no flock.
Changes:
services/capture/deltacast-bridge/main.c
- Complete rewrite: --ports csv arg, board opened once, one
video+audio thread pair per port, FIFO paths per port, format
JSON emitted per port on signal lock, SIGTERM clean shutdown.
- flock/serialize logic removed (no longer needed).
- --port single-port compat alias retained.
services/capture/deltacast-bridge/CMakeLists.txt
- Rename target deltacast-capture -> deltacast-bridge.
- POST_BUILD symlink deltacast-capture -> deltacast-bridge for compat.
services/capture/src/capture-manager.js
- deltacast _buildInputArgs: remove bridge spawn; wait up to 30s
for FIFOs to exist (bridge may be starting); return rawvideo +
s16le FIFO inputArgs. bridgeProcess=null.
- audioMap: keyed on sourceType instead of bridgeProcess (both
inputs are always present for deltacast).
- Remove readFirstStderrLine helper (no longer needed).
- Remove bridgeProcess.stdout.pipe / processes.bridge stop signal.
services/node-agent/index.js
- Add import spawn for bridge daemon management.
- Add startDeltacastBridge / stopDeltacastBridge: host-process
lifecycle for the shared bridge, ref-counted by sidecar count.
- handleSidecarStart: on deltacast, increment counter + start bridge;
decrement on container create/start failure.
- handleSidecarStop: decrement counter; stop bridge when last sidecar.
- _containerSourceType map tracks containerId->sourceType for stop.
- Old acquireDcLock mutex retained but no longer called.
2026-06-01 20:21:52 -04:00
/* processes.bridge: removed — bridge is managed by node-agent, not per-session */
2026-06-01 19:09:21 -04:00
// 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 {
const uploadPromises = [ ] ;
// Non-growing: upload the finalized local master file to S3 now that the
// moov has been written. Growing: the promotion worker handles S3.
if ( currentSession . localMasterPath ) {
let size = 0 ;
try { size = statSync ( currentSession . localMasterPath ) . size ; } catch ( _ ) { }
if ( size > 0 ) {
uploadPromises . push (
createUploadStream (
S3 _BUCKET ,
currentSession . hiresKey ,
createReadStream ( currentSession . localMasterPath ) ,
) . then ( ( ) => {
try { unlinkSync ( currentSession . localMasterPath ) ; } catch ( _ ) { }
} )
) ;
} else {
console . warn ( '[capture] local master is 0 bytes — skipping upload:' , currentSession . localMasterPath ) ;
}
} else if ( currentSession . uploads . hires ) {
uploadPromises . push ( currentSession . uploads . hires ) ;
}
if ( currentSession . uploads . 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 = { } ;
// 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 ,
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 ,
} ;
}
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 } ;