From 9b4677cec75e3f40ca2fc951ba33f16732a931b6 Mon Sep 17 00:00:00 2001 From: OpenCode Date: Fri, 5 Jun 2026 14:28:36 +0000 Subject: [PATCH] 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. --- services/capture/src/capture-manager.js | 537 ++---------------------- 1 file changed, 45 insertions(+), 492 deletions(-) diff --git a/services/capture/src/capture-manager.js b/services/capture/src/capture-manager.js index cbb96dd..11ab62e 100644 --- a/services/capture/src/capture-manager.js +++ b/services/capture/src/capture-manager.js @@ -44,7 +44,7 @@ function toUncShare(raw) { // them per-session over /capture/start (capture.js sets process.env before // captureManager.start()). Caching them in module-level consts at import time // captured the empty boot values, so the mount silently no-op'd and growing -// fell back to S3 — producing .mov instead of the XDCAM HD422 .mxf. +// fell back to S3 — producing .mov instead of the VC-3/DNxHD .mxf. const growingSmbConfig = () => ({ mount: toUncShare(process.env.GROWING_SMB_MOUNT || ''), username: process.env.GROWING_SMB_USERNAME || '', @@ -298,123 +298,22 @@ 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. +// 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(). // -// 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 ` 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_*. -// AVC-Intra Class 100 — the growing essence that supports TRUE 1080p59.94. -// XDCAM HD422 (MPEG-2 422) cannot do 1080p59.94 (raw2bmx rejects 60000/1001), -// so for native 59.94p we use AVC-Intra 100 = H.264 High 4:2:2 Intra (10-bit, -// all-intra). NVENC h264 cannot produce 4:2:2, so this is libx264 (CPU). The -// essence is a raw H.264 stream (-f h264) wrapped by raw2bmx --avci100_1080p -// at -f 60000/1001, clip type op1a. Verified on-node: produces a valid -// "h264 / High 4:2:2 Intra / yuv422p10le / 60000/1001" MXF. -// AVC-Intra elementary encode args per class (50 / 100 / 200). -const GROWING_AVCI_CLASS = { avci50: 50, avci100: 100, avci200: 200 }; -function growingVideoElementaryArgs(codec) { - const cls = GROWING_AVCI_CLASS[codec] || 100; - return [ - '-c:v', 'libx264', '-profile:v', 'high422', '-level', '4.2', - '-preset', 'ultrafast', '-tune', 'zerolatency', - '-pix_fmt', 'yuv422p10le', - '-x264-params', `avcintra-class=${cls}:bframes=0:keyint=1:scenecut=0`, - '-aud', '1', - ]; -} -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; - -// Growing-file codec — AVC-Intra 100 ONLY. This is the production growing master: -// CPU libx264 High 4:2:2 Intra 10-bit, CBR at the class-fixed ~110 Mbps, wrapped -// by raw2bmx into MXF OP1a (RDD-9). True-1080p59.94, Premiere-native edit-while- -// record. The avci50/avci200/hevc_nvenc alternatives were removed (avci50 needs a -// libx264 rebuild; avci200 is 2x the bitrate; hevc_nvenc frag-MOV does not import -// live in Premiere). NO -b:v/-minrate/-maxrate is applied — the class governs the -// rate; clamping it corrupts the essence (frozen picture in Premiere). -// growingCodec() reads GROWING_CODEC fresh from env at record time (standby -// sidecars boot unset and receive it per-session via /capture/start). -// VC-3 / DNxHD in MXF OP1a, written DIRECTLY by ffmpeg's MXF muxer (NO raw2bmx, -// NO FIFO). Grows readably mid-write + imports live in Premiere (matches vMix). // 'vc3_90' -> VC-3 90 Mbps, lighter storage. DEFAULT. // 'vc3_220' -> VC-3/DNxHD 220 Mbps (VC3_1080p_1238), highest quality. -// AVC-Intra was removed — Premiere rejected it as "unsupported/damaged". -const GROWING_VC3_CODECS = new Set(['vc3_220', 'vc3_90']); +// +// growingCodec() reads GROWING_CODEC fresh from env at record time (standby +// sidecars boot unset and receive it per-session via /capture/start). +const GROWING_EXT = 'mxf'; const growingCodec = () => { const v = process.env.GROWING_CODEC; if (v === 'vc3_220') return 'vc3_220'; @@ -424,83 +323,6 @@ const growingCodec = () => { const growingVc3Bitrate = (codec) => (codec === 'vc3_220' ? '220M' : '90M'); // File extension per growing codec — always MXF for VC-3. const growingExtFor = (_codec) => 'mxf'; -// Default bitrate for the HEVC-NVENC growing master (all-intra 10-bit is heavy). -const GROWING_HEVC_DEFAULT_BITRATE = '80M'; - -// 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, scanHint = null, codec = 'avci100') { - // 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. AVC-Intra 100 supports TRUE 1080p59.94, - // so a 1080p59.94 SDI feed is wrapped at its native 60000/1001 (no frame drop). - function rates(fps) { - if (fps == null) return { ff: '60000/1001', raw: '60000/1001' }; - if (Math.abs(fps - 59.94) < 0.2) return { ff: '60000/1001', raw: '60000/1001' }; - if (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: '50', raw: '50' }; - 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) }; - } - - // AVC-Intra wraps progressive natively. Deltacast reports progressive; honor it. - if (scan == null) scan = scanHint || ((height >= 1080) ? 'i' : 'p'); - const r = rates(fpsNum); - - // AVC-Intra raster flags — class from codec name ('avci50'/'avci100'/'avci200'). - const avciClass = GROWING_AVCI_CLASS[codec] || 100; - let rawFlag; - if (height >= 1080) { - rawFlag = (scan === 'i') ? `--avci${avciClass}_1080i` : `--avci${avciClass}_1080p`; - } else if (height >= 720) { - rawFlag = `--avci${avciClass}_720p`; - if (fpsNum == null) { r.ff = '60000/1001'; r.raw = '60000/1001'; } - } else { - rawFlag = '--mpeg2lg_422p_ml_576i'; - 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 …` @@ -589,13 +411,11 @@ function buildEncodeArgs({ 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. + // 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. 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); @@ -826,53 +646,6 @@ class CaptureManager { 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). - */ - - /** - * Build the single-ffmpeg argv for a GPU-OFFLOADED growing master: - * all-intra HEVC (NVENC, 10-bit 4:2:0) in a fragmented MOV. - * - * Unlike the AVC-Intra/raw2bmx path, this needs NO FIFO orchestrator and NO - * raw2bmx: ffmpeg writes the growing fragmented-MOV directly to the share. - * +empty_moov writes a valid moov up-front and +frag_keyframe flushes a moof - * fragment per keyframe, so the file is readable (and its duration advances) - * while still growing. force_key_frames expr:1 makes every frame an IDR - * (all-intra) so the growing head is always decodable to the last COMPLETE - * fragment. PROVEN live on zampp3: size + ffprobe duration grow monotonically - * mid-write; finalized file decodes RC=0 (hevc Main10 yuv420p10le 1080p59.94). - * - * GPU pinning via nvencGpuSel() (privileged sidecars see every /dev/nvidiaN, - * so -gpu N is the only reliable NVENC pin). Returns ffmpeg argv (no bash). - */ /** * Build the single-ffmpeg argv for a GROWING VC-3 / DNxHD master in MXF OP1a. * @@ -929,186 +702,6 @@ class CaptureManager { return args; } - _buildGrowingHevcMov({ inputArgs, videoBitrate, framerate, audioChannels, outPath, audioMap = '0:a:0?', hlsDir = null, videoCodec = 'hevc_nvenc', interlaced = false }) { - const vb = videoBitrate || GROWING_HEVC_DEFAULT_BITRATE; - const ach = audioChannels ? Number(audioChannels) : 2; - const args = ['-y', '-hide_banner', '-loglevel', 'warning', '-stats', ...inputArgs]; - - // Deinterlace (SDI) then split: master HEVC + 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) GPU all-intra HEVC 10-bit master -> fragmented MOV at outPath. - args.push('-map', '[vhi]', - '-c:v', 'hevc_nvenc', ...nvencGpuSel(), - '-preset', 'p4', '-rc', 'vbr', '-profile:v', 'main10', '-pix_fmt', 'p010le', - '-bf', '0', '-forced-idr', '1', '-g', '600', '-force_key_frames', 'expr:1', - '-b:v', vb, - '-map', audioMap, '-c:a', 'aac', '-b:a', '256k', '-ar', '48000', '-ac', String(ach), - '-movflags', '+frag_keyframe+empty_moov+default_base_moof', - '-f', 'mov', outPath); - - // (b) optional H.264 HLS preview (unchanged behaviour) -> second output. - 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; - } - - _buildGrowingOrchestrator({ inputArgs, videoBitrate, resolution, framerate, audioChannels, outPath, hlsDir, videoCodec, growingCodecName = 'avci100', audioMap = '0:a:0?', interlaced = false }) { - const { rawFlag, frameRate, ffRate } = deriveGrowingRaster(resolution, framerate, interlaced ? 'i' : 'p', growingCodecName); - // videoBitrate intentionally ignored for AVC-Intra (CBR at class-fixed rate). - 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 - ? (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]'); - const ffArgs = [ - ...inputArgs, - '-filter_complex', filterComplex, - // (a) AVC-Intra elementary video → "$VF". NO -b:v/-minrate/-maxrate/-bufsize: - // AVC-Intra is CBR at the class-fixed bitrate (50/100/200). avcintra-class=N - // fully governs the rate; clamping -maxrate (e.g. 50M on class-100 ≈110Mbps) - // starves x264's rate control and produces corrupt essence (Premiere shows a - // frozen picture even though frames are present). Let the class drive it. - '-map', '[vhi]', - ...growingVideoElementaryArgs(growingCodecName), - '-r', ffRate, - '-f', 'h264', '@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', 'op1a', '-o', '"$OUT"', '-f', frameRate, - '-y', '"$TOD"', - '--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 -exec 9<&0 -VF=$(mktemp -u /tmp/grow_v.XXXXXX); AF=$(mktemp -u /tmp/grow_a.XXXXXX) -OUT=${sh(outPath)} -# Time-of-day timecode at record start. 59.94 (60000/1001) is drop-frame: -# raw2bmx uses ';' as the frame separator for DF. For integer rates use ':'. -TOD=$(python3 -c " -import datetime -now = datetime.datetime.now() -fps = ${Math.round(parseFloat(frameRate.split('/')[0]) / (parseFloat(frameRate.split('/')[1]) || 1))} -is_df = ('${frameRate}' in ('60000/1001','30000/1001')) -sep = ';' if is_df else ':' -h,m,s = now.hour, now.minute, now.second -frames = min(int(now.microsecond / 1e6 * fps), fps - 1) -print('%02d:%02d:%02d%s%02d' % (h, m, s, sep, frames)) -") -mkfifo "$VF" "$AF" -cleanup() { rm -f "$VF" "$AF"; } -trap cleanup EXIT -( exec 0<&9 9<&-; exec ${ffLine} ) & -FFPID=$! -exec 9<&- -exec ${bmxLine} >/tmp/raw2bmx.log 2>&1 & -BMXPID=$! -# Stop handler: SIGKILL ffmpeg, do NOT try to let it shut down gracefully. -# ffmpeg with dual FIFO outputs + a never-ending audio FIFO (the shared -# deltacast bridge keeps feeding) DEADLOCKS on SIGINT/SIGTERM — it never -# flushes/closes the FIFOs, so raw2bmx never gets EOF and never writes the -# OP1a footer (file stays incomplete, Duration=-1, Premiere rejects it). -# PROVEN on-node: SIGKILL'ing ffmpeg closes its FIFO write-fds via process -# death; raw2bmx then reads EOF on BOTH FIFOs, drains its buffered frames, -# and writes the finalized footer cleanly (rc=0, Duration set, complete). -# We then wait on raw2bmx so the footer is on disk before we report done. -stop() { kill -9 "$FFPID" 2>/dev/null; } -trap stop INT TERM -wait "$FFPID"; FFRC=$? -# Ensure ffmpeg's FIFO write-ends are closed even on a natural ffmpeg exit. -kill -9 "$FFPID" 2>/dev/null -wait "$BMXPID"; BMXRC=$? -echo "[grow] ffmpeg rc=$FFRC raw2bmx rc=$BMXRC out=$OUT" >&2 -exit "$BMXRC" -`; - return ['-c', script]; - return ['-c', script]; - } - /** * Start a new capture session. * @@ -1175,15 +768,14 @@ exit "$BMXRC" // /capture/start (capture.js sets process.env before this runs). The old // module-level `const GROWING_ENABLED` / `GROWING_SMB_MOUNT` captured the // empty boot values, so growing never engaged and every "growing" record - // silently produced HEVC/S3 instead of the XDCAM HD422 MXF. + // silently produced HEVC/S3 instead of the VC-3/DNxHD MXF. let growingActive = process.env.GROWING_ENABLED === 'true'; if (growingActive && growingSmbConfig().mount) { if (!mountGrowingShare()) growingActive = false; // fall back to S3 } - // Growing master is always MXF OP1a / XDCAM HD422 written by raw2bmx (the - // 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. + // 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. const _growCodec = growingActive ? growingCodec() : null; const _growExt = _growCodec ? growingExtFor(_growCodec) : GROWING_EXT; const growingPath = growingActive @@ -1277,8 +869,8 @@ exit "$BMXRC" const audioMap = `${audioInputIndex}:a:0?`; // Non-growing master: ffmpeg muxes the finalized MOV directly. Growing - // master: raw2bmx muxes the OP1a from elementary FIFOs (handled below via - // the orchestrator), so we don't build ffmpeg codec args here for it. + // master: _buildGrowingVc3Mxf builds its own ffmpeg argv below, so we don't + // build ffmpeg codec args here for it. const hiresCodecArgs = growingPath ? null : buildEncodeArgs({ codec: videoCodec, videoBitrate, framerate, audioCodec, audioBitrate, audioChannels, @@ -1296,9 +888,9 @@ exit "$BMXRC" // Master output destination. // - // - Growing-files on → the growing OP1a MXF is written directly to the SMB - // share by raw2bmx (see the orchestrator below); ffmpeg only produces the - // elementary essence FIFOs + HLS preview. + // - 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. // // - Growing-files off → ffmpeg writes fragmented MOV to pipe:1 (stdout), // which is piped directly into a multipart S3 upload. No local temp file, @@ -1455,8 +1047,7 @@ exit "$BMXRC" // Use ffmpeg's own rolling fps value — it is a short-window average // computed by ffmpeg itself and correctly reflects the true encode rate. // The previous frame/elapsed cumulative calculation dragged low during - // startup and was permanently wrong for growing-path (bash orchestrator - // stderr doesn't emit frame= lines until ffmpeg flushes them). + // startup. const ffmpegFps = parseFloat(m[2]); if (ffmpegFps > 0) this.state.currentFps = Math.round(ffmpegFps * 100) / 100; } @@ -1488,7 +1079,7 @@ exit "$BMXRC" hiresKey, proxyKey, growingPath, - growingCodec: growingPath ? _growCodec : null, // which growing path: avci100 / vc3 / hevc_nvenc + growingCodec: growingPath ? _growCodec : null, // growing codec: 'vc3_90' | 'vc3_220' audioFifo, startedAt, duration: 0, @@ -1601,21 +1192,16 @@ exit "$BMXRC" const { processes, currentSession } = this.state; const isGrowing = !!currentSession.growingPath; - // VC-3 growing is a single ffmpeg-direct MXF writer whose footer flushes - // cleanly on a plain SIGINT (same as the non-growing master). No raw2bmx / - // FIFO orchestrator remains, so no special kill-ordering is needed. - const isRaw2bmxGrowing = false; // 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. + // - 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. const finalizeTimeoutMs = isGrowing ? 60000 : 15000; const waitExit = (proc) => new Promise((resolve) => { if (!proc || proc.exitCode !== null || proc.signalCode !== null) return resolve(); @@ -1625,8 +1211,8 @@ exit "$BMXRC" // 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. + // The growing ffmpeg is spawned detached (its own process group) → + // SIGKILL the whole group; otherwise just the process. if (isGrowing && proc.pid) { try { process.kill(-proc.pid, 'SIGKILL'); } catch (_) {} } proc.kill('SIGKILL'); } catch (_) {} @@ -1634,46 +1220,13 @@ exit "$BMXRC" }, finalizeTimeoutMs); }); - // ── GROWING stop: signal the bash orchestrator; its trap SIGKILLs ffmpeg ── - // CRITICAL: ffmpeg with dual FIFO outputs + the shared, never-ending - // deltacast audio FIFO DEADLOCKS on SIGINT/SIGTERM (it never flushes/closes - // the FIFOs), so raw2bmx never gets EOF and never writes the OP1a footer → - // file is "incomplete" (Duration=-1) and Premiere rejects it ("unsupported - // or damaged"). PROVEN on-node: SIGKILL'ing ffmpeg closes its FIFO write-fds - // via process death; raw2bmx then reads EOF on both FIFOs, drains, and writes - // the finalized footer (rc=0, Duration set, --check-complete passes). - // - // The orchestrator's `trap stop INT TERM` runs `kill -9 $FFPID` then - // `wait $BMXPID`, so signaling the bash with SIGTERM triggers exactly that - // finalize sequence. We then await the orchestrator exit (footer on disk). - if (isRaw2bmxGrowing) { - // Stop the framecache reader so no new frames arrive (best-effort). - if (currentSession._fcPipeProcess) { - try { currentSession._fcPipeProcess.kill('SIGTERM'); } catch (_) {} - } - // Signal ONLY the bash orchestrator process (NOT the process group). Its - // `trap stop INT TERM` runs `kill -9 $FFPID` then `wait $BMXPID`, letting - // raw2bmx finalize the footer. A process-GROUP signal would hit raw2bmx - // directly (rc=143/SIGTERM) and kill it before it writes the footer — the - // exact bug that left files incomplete (Duration=-1). So target the bash - // PID alone and let its trap orchestrate the ordered shutdown. - try { - if (processes.hires && processes.hires.pid) { - processes.hires.kill('SIGTERM'); - } - } catch (_) {} - if (processes.proxy) { try { processes.proxy.kill('SIGINT'); } catch (_) {} } - if (processes.hls) { try { processes.hls.kill('SIGINT'); } catch (_) {} } - } else { - // NON-GROWING, plus the single-ffmpeg GROWING writers (VC-3 MXF, HEVC MOV): - // a plain SIGINT flushes the container footer/trailer cleanly. No raw2bmx, - // no FIFO, so the kill-9 dance is unnecessary and would only truncate. - 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 (_) {} - } + // 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 (_) {} } /* processes.bridge: removed — bridge is managed by node-agent, not per-session */