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.
This commit is contained in:
parent
b287ad08ef
commit
9b4677cec7
1 changed files with 45 additions and 492 deletions
|
|
@ -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 <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_*.
|
||||
// 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 */
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue