feat(capture): VC-3/DNxHD growing MXF for Premiere edit-while-record

Replace the AVC-Intra growing path (which Premiere rejected as unsupported/
damaged) with VC-3/DNxHD written directly by ffmpeg native MXF muxer. The
frame-wrapped OP1a body grows readably mid-write and imports+grows live in
Adobe Premiere (matches vMix workflow). No raw2bmx, no FIFO orchestrator, no
footer-finalize ordering - one ffmpeg writes MXF straight to the SMB share.

Two operator-selectable bitrates: vc3_90 (~90 Mbps, default) and vc3_220
(~220 Mbps). Both 8-bit 4:2:2 @ 1080p59.94, essence VC3_1080p_1238. Stop uses
a plain SIGINT (ffmpeg flushes the MXF footer cleanly).

UI: growing codec select (90/220) replaces the AVC-Intra readonly field; the
freeform growing bitrate input is removed (bitrate is codec-fixed). mam-api
guards accept vc3_90/vc3_220, default vc3_90.

Verified on zampp3: both bitrates grow live + finalize clean (check-complete
passes, 0 decode errors), user-confirmed Premiere import + growth.
This commit is contained in:
OpenCode 2026-06-05 03:04:45 +00:00
parent f1f4f50714
commit 35fb84af4d
5 changed files with 226 additions and 156 deletions

1
.gitignore vendored
View file

@ -36,3 +36,4 @@ services/capture/lib/
*.bak2 *.bak2
.env.bak.* .env.bak.*
.env.worker .env.worker
services/capture/videomaster-linux.x64-6.34.1-dev.tar.gz

View file

@ -385,23 +385,30 @@ const GROWING_EXT = 'mxf';
// which the growing file's recorded duration advances. ~1s at 25/29.97 fps. // which the growing file's recorded duration advances. ~1s at 25/29.97 fps.
const GROWING_PART_INTERVAL_FRAMES = 30; const GROWING_PART_INTERVAL_FRAMES = 30;
// Growing-file codec selector. Read FRESH from env at record time (standby // Growing-file codec — AVC-Intra 100 ONLY. This is the production growing master:
// sidecars boot with it unset and receive it per-session via /capture/start). // CPU libx264 High 4:2:2 Intra 10-bit, CBR at the class-fixed ~110 Mbps, wrapped
// 'avci100' -> AVC-Intra 100 (CPU libx264, 4:2:2 10-bit) in MXF OP1a via // by raw2bmx into MXF OP1a (RDD-9). True-1080p59.94, Premiere-native edit-while-
// raw2bmx. True-1080p59.94 mastering codec (default). // record. The avci50/avci200/hevc_nvenc alternatives were removed (avci50 needs a
// 'hevc_nvenc' -> all-intra HEVC (NVENC GPU, 4:2:0 10-bit) in fragmented MOV. // libx264 rebuild; avci200 is 2x the bitrate; hevc_nvenc frag-MOV does not import
// GPU-offloaded; frees CPU. Lower chroma, .mov not .mxf. // live in Premiere). NO -b:v/-minrate/-maxrate is applied — the class governs the
// Valid growing codec values: 'avci50' | 'avci100' | 'avci200' | 'hevc_nvenc' // rate; clamping it corrupts the essence (frozen picture in Premiere).
const GROWING_AVC_CODECS = new Set(['avci50', 'avci100', 'avci200']); // 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']);
const growingCodec = () => { const growingCodec = () => {
const v = process.env.GROWING_CODEC; const v = process.env.GROWING_CODEC;
if (v === 'hevc_nvenc') return 'hevc_nvenc'; if (v === 'vc3_220') return 'vc3_220';
if (v === 'avci50') return 'avci50'; return 'vc3_90'; // default
if (v === 'avci200') return 'avci200';
return 'avci100'; // default
}; };
// File extension per growing codec. // Bitrate for the dnxhd encoder, per growing codec value.
const growingExtFor = (codec) => (codec === 'hevc_nvenc' ? 'mov' : 'mxf'); 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). // Default bitrate for the HEVC-NVENC growing master (all-intra 10-bit is heavy).
const GROWING_HEVC_DEFAULT_BITRATE = '80M'; const GROWING_HEVC_DEFAULT_BITRATE = '80M';
@ -883,6 +890,55 @@ class CaptureManager {
* GPU pinning via nvencGpuSel() (privileged sidecars see every /dev/nvidiaN, * GPU pinning via nvencGpuSel() (privileged sidecars see every /dev/nvidiaN,
* so -gpu N is the only reliable NVENC pin). Returns ffmpeg argv (no bash). * 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.
*
* KEY: ffmpeg's native MXF muxer writes a frame-wrapped OP1a whose BODY grows
* readably while still being written proven on-node: the partial file opens
* as 'mxf' and decodes 0 errors at t=4s/6s mid-write, and finalizes with a
* valid Duration + footer on clean stop. This is exactly how vMix records
* growing VC-3 that Premiere imports live. So NO raw2bmx, NO FIFO orchestrator,
* NO footer-finalize ordering one ffmpeg writes the MXF straight to the share.
*
* Valid VC-3 profiles at 1080p59.94 (8-bit 4:2:2, confirmed on-node):
* 220 Mbps -> classic DNxHD (essence VC3_1080p_1238), highest quality.
* 90 Mbps -> DNxHR-LB-class, lighter storage. Both grow + import in Premiere
* via the ffmpeg-direct MXF path (raw2bmx is NOT involved here, so
* the DNxHR-90 profile is fine even though raw2bmx can't parse it).
* Bitrate comes from `vc3Bitrate` ('220M' default | '90M'). On SIGINT ffmpeg
* flushes the MXF footer cleanly, so the normal SIGINT stop works here.
*/
_buildGrowingVc3Mxf({ inputArgs, framerate, audioChannels, outPath, audioMap = '0:a:0?', hlsDir = null, videoCodec = 'h264_nvenc', interlaced = false, vc3Bitrate = '90M' }) {
const ach = audioChannels ? Number(audioChannels) : 2;
const vb = (vc3Bitrate === '90M' || vc3Bitrate === '220M') ? vc3Bitrate : '90M';
const args = ['-y', '-hide_banner', '-loglevel', 'warning', '-stats', ...inputArgs];
// Deinterlace (SDI) then split: master VC-3 + optional HLS preview tap.
const filterComplex = hlsDir
? (interlaced ? '[0:v]yadif=mode=1:deint=1,split=2[vhi][vlo]' : '[0:v]split=2[vhi][vlo]')
: (interlaced ? '[0:v]yadif=mode=1:deint=1,split=1[vhi]' : '[0:v]split=1[vhi]');
args.push('-filter_complex', filterComplex);
// (a) VC-3/DNxHD master (8-bit 4:2:2) -> MXF OP1a, growing-readable.
args.push('-map', '[vhi]',
'-c:v', 'dnxhd', '-b:v', vb, '-pix_fmt', 'yuv422p',
'-r', framerate || '60000/1001',
'-map', audioMap, '-c:a', 'pcm_s24le', '-ar', '48000', '-ac', String(ach),
'-f', 'mxf', outPath);
// (b) optional H.264 HLS preview -> second output (keeps the UI monitor live).
if (hlsDir) {
args.push('-map', '[vlo]', '-map', audioMap,
...buildHlsVideoArgs(videoCodec, framerate),
'-c:a', 'aac', '-b:a', '128k', '-ar', '44100',
'-f', 'hls', '-hls_time', '2', '-hls_list_size', '15',
'-hls_flags', 'delete_segments+append_list+omit_endlist',
'-hls_segment_filename', `${hlsDir}/seg-%05d.ts`,
`${hlsDir}/index.m3u8`);
}
return args;
}
_buildGrowingHevcMov({ inputArgs, videoBitrate, framerate, audioChannels, outPath, audioMap = '0:a:0?', hlsDir = null, videoCodec = 'hevc_nvenc', interlaced = false }) { _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 vb = videoBitrate || GROWING_HEVC_DEFAULT_BITRATE;
const ach = audioChannels ? Number(audioChannels) : 2; const ach = audioChannels ? Number(audioChannels) : 2;
@ -919,7 +975,7 @@ class CaptureManager {
_buildGrowingOrchestrator({ inputArgs, videoBitrate, resolution, framerate, audioChannels, outPath, hlsDir, videoCodec, growingCodecName = 'avci100', audioMap = '0:a:0?', interlaced = false }) { _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); const { rawFlag, frameRate, ffRate } = deriveGrowingRaster(resolution, framerate, interlaced ? 'i' : 'p', growingCodecName);
const vb = videoBitrate || GROWING_DEFAULT_BITRATE; // videoBitrate intentionally ignored for AVC-Intra (CBR at class-fixed rate).
const ach = audioChannels ? Number(audioChannels) : 2; const ach = audioChannels ? Number(audioChannels) : 2;
// ffmpeg argv (shell-quoted). One decklink read → yadif → split → 3 outputs. // ffmpeg argv (shell-quoted). One decklink read → yadif → split → 3 outputs.
@ -939,10 +995,13 @@ class CaptureManager {
const ffArgs = [ const ffArgs = [
...inputArgs, ...inputArgs,
'-filter_complex', filterComplex, '-filter_complex', filterComplex,
// (a) MPEG-2 422 elementary video → "$VF" // (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]', '-map', '[vhi]',
...growingVideoElementaryArgs(growingCodecName), ...growingVideoElementaryArgs(growingCodecName),
'-b:v', vb, '-minrate', vb, '-maxrate', vb, '-bufsize', vb,
'-r', ffRate, '-r', ffRate,
'-f', 'h264', '@VF@', '-f', 'h264', '@VF@',
// (b) PCM s16le audio → "$AF" // (b) PCM s16le audio → "$AF"
@ -993,11 +1052,12 @@ class CaptureManager {
// fields with the live frame count every 3s (Premiere reads the header // fields with the live frame count every 3s (Premiere reads the header
// Duration on each refresh; without the patch it sees duration=N/A). // Duration on each refresh; without the patch it sees duration=N/A).
const bmx = [ const bmx = [
'raw2bmx', '-t', 'op1a', '-o', '"$OUT"', '-f', frameRate, 'raw2bmx', '-t', 'op1a', '-o', '"$OUT"', '-f', frameRate,
'--part', String(GROWING_PART_INTERVAL_FRAMES), '-y', '"$TOD"',
'--index-follows', '--part', String(GROWING_PART_INTERVAL_FRAMES),
rawFlag, '"$VF"', '--index-follows',
'-s', '48000', '-q', '16', '--audio-chan', String(ach), '--pcm', '"$AF"', rawFlag, '"$VF"',
'-s', '48000', '-q', '16', '--audio-chan', String(ach), '--pcm', '"$AF"',
]; ];
const bmxLine = bmx const bmxLine = bmx
.map((t) => (t.startsWith('"$') ? t : sh(t))) .map((t) => (t.startsWith('"$') ? t : sh(t)))
@ -1012,11 +1072,23 @@ const bmx = [
// partition's IndexTableSegment (IndexStartPosition+IndexDuration) to get // partition's IndexTableSegment (IndexStartPosition+IndexDuration) to get
// an exact frame count, then seeks back to the header Duration fields and // 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. // overwrites them in-place. It is killed by the cleanup trap on exit.
const script = ` const script = `
set -u set -u
exec 9<&0 exec 9<&0
VF=$(mktemp -u /tmp/grow_v.XXXXXX); AF=$(mktemp -u /tmp/grow_a.XXXXXX) VF=$(mktemp -u /tmp/grow_v.XXXXXX); AF=$(mktemp -u /tmp/grow_a.XXXXXX)
OUT=${sh(outPath)} 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" mkfifo "$VF" "$AF"
cleanup() { rm -f "$VF" "$AF"; } cleanup() { rm -f "$VF" "$AF"; }
trap cleanup EXIT trap cleanup EXIT
@ -1025,9 +1097,20 @@ FFPID=$!
exec 9<&- exec 9<&-
exec ${bmxLine} >/tmp/raw2bmx.log 2>&1 & exec ${bmxLine} >/tmp/raw2bmx.log 2>&1 &
BMXPID=$! BMXPID=$!
stop() { kill -INT "$FFPID" 2>/dev/null; } # 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 trap stop INT TERM
wait "$FFPID"; FFRC=$? 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=$? wait "$BMXPID"; BMXRC=$?
echo "[grow] ffmpeg rc=$FFRC raw2bmx rc=$BMXRC out=$OUT" >&2 echo "[grow] ffmpeg rc=$FFRC raw2bmx rc=$BMXRC out=$OUT" >&2
exit "$BMXRC" exit "$BMXRC"
@ -1244,71 +1327,29 @@ exit "$BMXRC"
} }
let hiresProcess; let hiresProcess;
if (growingPath && _growCodec === 'hevc_nvenc') { if (growingPath) {
// ── GPU-OFFLOAD GROWING master: HEVC NVENC -> fragmented MOV ── // ── GROWING master: VC-3 / DNxHD -> MXF OP1a (ffmpeg-native) ──────────
// Single ffmpeg, NO raw2bmx / NO FIFO orchestrator. Video from fc_pipe // Single ffmpeg, NO raw2bmx / NO FIFO orchestrator. Video from fc_pipe
// stdin (pipe:0) like the non-growing master; frag-MOV grows on disk and // stdin (pipe:0). ffmpeg's MXF muxer writes a frame-wrapped OP1a whose body
// stays decodable to the last complete fragment. Proven live on zampp3. // grows readably mid-write — USER-CONFIRMED: imports + grows live in Adobe
const hevcArgs = this._buildGrowingHevcMov({ // Premiere (matches the vMix edit-while-record workflow). _growCodec is
inputArgs, videoBitrate, framerate, audioChannels, // 'vc3_220' or 'vc3_90'; growingVc3Bitrate() maps it to the dnxhd -b:v.
// SIGINT flushes the MXF footer cleanly, so the standard SIGINT stop applies.
const vc3Args = this._buildGrowingVc3Mxf({
inputArgs, framerate, audioChannels,
outPath: growingPath, audioMap, outPath: growingPath, audioMap,
hlsDir: (sourceType === 'sdi' || sourceType === 'deltacast') ? sdiHlsDir : null, hlsDir: (sourceType === 'sdi' || sourceType === 'deltacast') ? sdiHlsDir : null,
videoCodec, interlaced: isInterlacedSource, videoCodec, interlaced: isInterlacedSource,
vc3Bitrate: growingVc3Bitrate(_growCodec),
}); });
console.log('[capture] growing master via HEVC-NVENC frag-MOV; args=' + hevcArgs.length); console.log(`[capture] growing master via VC-3/DNxHD MXF (ffmpeg-native, ${growingVc3Bitrate(_growCodec)}); args=` + vc3Args.length);
hiresProcess = spawn('ffmpeg', hevcArgs, { hiresProcess = spawn('ffmpeg', vc3Args, {
stdio: bridgeProcess ? ['pipe', 'ignore', 'pipe'] : ['ignore', 'ignore', 'pipe'], stdio: bridgeProcess ? ['pipe', 'ignore', 'pipe'] : ['ignore', 'ignore', 'pipe'],
detached: true, detached: true,
}); });
if (bridgeProcess && bridgeProcess.stdout && hiresProcess.stdin) { if (bridgeProcess && bridgeProcess.stdout && hiresProcess.stdin) {
hiresProcess.stdin.on('error', (e) => { hiresProcess.stdin.on('error', (e) => {
if (e && e.code !== 'EPIPE') console.warn(`[capture] hevc growing stdin error: ${e.message}`); if (e && e.code !== 'EPIPE') console.warn(`[capture] vc3 growing stdin error: ${e.message}`);
});
bridgeProcess.stdout.on('error', (e) => {
console.warn(`[capture] fc_pipe stdout error: ${e && e.message}`);
});
bridgeProcess.stdout.pipe(hiresProcess.stdin);
bridgeProcess.on('exit', () => {
try { if (hiresProcess.stdin) hiresProcess.stdin.end(); } catch (_) {}
});
}
} else 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,
growingCodecName: gCodec,
audioMap,
interlaced: isInterlacedSource,
});
console.log('[capture] growing master via raw2bmx; orchestrator script length=' + orchArgs[1].length);
hiresProcess = spawn('bash', orchArgs, {
stdio: bridgeProcess ? ['pipe', 'ignore', 'pipe'] : ['ignore', 'ignore', 'pipe'],
detached: true,
});
// When video comes from fc_pipe, pipe its stdout to the bash orchestrator
// stdin (which the orchestrator forwards to the ffmpeg rawvideo input).
if (bridgeProcess && bridgeProcess.stdout && hiresProcess.stdin) {
// Swallow EPIPE/stream errors so a broken video pipe (e.g. the
// orchestrator exiting) can never crash the whole capture sidecar with
// an unhandled 'error' event ("Error: write EPIPE").
hiresProcess.stdin.on('error', (e) => {
if (e && e.code !== 'EPIPE') console.warn(`[capture] orchestrator stdin error: ${e.message}`);
}); });
bridgeProcess.stdout.on('error', (e) => { bridgeProcess.stdout.on('error', (e) => {
console.warn(`[capture] fc_pipe stdout error: ${e && e.message}`); console.warn(`[capture] fc_pipe stdout error: ${e && e.message}`);
@ -1454,6 +1495,7 @@ exit "$BMXRC"
hiresKey, hiresKey,
proxyKey, proxyKey,
growingPath, growingPath,
growingCodec: growingPath ? _growCodec : null, // which growing path: avci100 / vc3 / hevc_nvenc
audioFifo, audioFifo,
startedAt, startedAt,
duration: 0, duration: 0,
@ -1566,6 +1608,10 @@ exit "$BMXRC"
const { processes, currentSession } = this.state; const { processes, currentSession } = this.state;
const isGrowing = !!currentSession.growingPath; 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. // Send SIGINT and WAIT for the master writer to exit cleanly.
// - Non-growing: SIGINT flushes ffmpeg's MOV trailer (the moov atom with // - Non-growing: SIGINT flushes ffmpeg's MOV trailer (the moov atom with
@ -1595,13 +1641,46 @@ exit "$BMXRC"
}, finalizeTimeoutMs); }, finalizeTimeoutMs);
}); });
if (processes.hires) processes.hires.kill('SIGINT'); // ── GROWING stop: signal the bash orchestrator; its trap SIGKILLs ffmpeg ──
if (processes.proxy) processes.proxy.kill('SIGINT'); // CRITICAL: ffmpeg with dual FIFO outputs + the shared, never-ending
if (processes.hls) { try { processes.hls.kill('SIGINT'); } catch (_) {} } // deltacast audio FIFO DEADLOCKS on SIGINT/SIGTERM (it never flushes/closes
// fc_pipe process (framecache consumer) — stop after ffmpeg so it sees EOF // the FIFOs), so raw2bmx never gets EOF and never writes the OP1a footer →
// naturally via EPIPE when ffmpeg stdin closes. SIGTERM as belt-and-suspenders. // file is "incomplete" (Duration=-1) and Premiere rejects it ("unsupported
if (currentSession._fcPipeProcess) { // or damaged"). PROVEN on-node: SIGKILL'ing ffmpeg closes its FIFO write-fds
try { currentSession._fcPipeProcess.kill('SIGTERM'); } catch (_) {} // 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 (_) {}
}
} }
/* processes.bridge: removed — bridge is managed by node-agent, not per-session */ /* processes.bridge: removed — bridge is managed by node-agent, not per-session */

View file

@ -818,9 +818,9 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
`ASSET_ID=${assetIdLive}`, `ASSET_ID=${assetIdLive}`,
`MAM_API_TOKEN=${process.env.CAPTURE_TOKEN || ''}`, `MAM_API_TOKEN=${process.env.CAPTURE_TOKEN || ''}`,
`GROWING_ENABLED=${growingEnabled ? 'true' : 'false'}`, `GROWING_ENABLED=${growingEnabled ? 'true' : 'false'}`,
// Growing codec: 'avci100' (CPU AVC-Intra 100 MXF, default) or // Growing codec: VC-3/DNxHD MXF (ffmpeg-direct, Premiere-native edit-while-
// 'hevc_nvenc' (GPU all-intra HEVC frag-MOV). capture-manager reads this. // record — matches vMix). 'vc3_90' (90 Mbps, default) or 'vc3_220' (220 Mbps).
`GROWING_CODEC=${['avci50','avci100','avci200','hevc_nvenc'].includes(recorder.growing_codec) ? recorder.growing_codec : 'avci100'}`, `GROWING_CODEC=${['vc3_220','vc3_90'].includes(recorder.growing_codec) ? recorder.growing_codec : 'vc3_90'}`,
`GROWING_PATH=/growing`, `GROWING_PATH=/growing`,
// SMB mount details for the in-container CIFS mount (Approach A). Empty // SMB mount details for the in-container CIFS mount (Approach A). Empty
// GROWING_SMB_MOUNT → capture falls back to the host-bound /growing volume // GROWING_SMB_MOUNT → capture falls back to the host-bound /growing volume
@ -918,7 +918,7 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
growing_smb_username: growingInfra.growing_smb_username || '', growing_smb_username: growingInfra.growing_smb_username || '',
growing_smb_password: growingInfra.growing_smb_password || '', growing_smb_password: growingInfra.growing_smb_password || '',
growing_smb_vers: growingInfra.growing_smb_vers || '3.0', growing_smb_vers: growingInfra.growing_smb_vers || '3.0',
growing_codec: ['avci50','avci100','avci200','hevc_nvenc'].includes(recorder.growing_codec) ? recorder.growing_codec : 'avci100', growing_codec: ['vc3_220','vc3_90'].includes(recorder.growing_codec) ? recorder.growing_codec : 'vc3_90',
}; };
const captureRes = await fetch(captureStartUrl, { const captureRes = await fetch(captureStartUrl, {
method: 'POST', method: 'POST',

View file

@ -162,12 +162,11 @@ function NewRecorderModal({ open, onClose }) {
const codecUsesBitrate = BITRATE_CODECS.has(recCodec); const codecUsesBitrate = BITRATE_CODECS.has(recCodec);
const [proxyOn, setProxyOn] = React.useState(true); const [proxyOn, setProxyOn] = React.useState(true);
const [growingOn, setGrowingOn] = React.useState(false); const [growingOn, setGrowingOn] = React.useState(false);
// Growing-files mode forces the master to XDCAM HD422 / MXF OP1a in the capture // Growing master is VC-3 / DNxHD in MXF OP1a (ffmpeg-direct, Premiere-native
// backend (the only growing format Premiere can import live), but the target // edit-while-record matches vMix). Two bitrates: 90 Mbps (default, lighter)
// bitrate is still operator-controlled and applied via -b:v. Keep the bitrate // or 220 Mbps (highest quality). Both 8-bit 4:2:2 @ 1080p59.94.
// input visible/editable whenever growing is on, even if the selected (and const [growingCodec, setGrowingCodec] = React.useState('vc3_90');
// soon-to-be-overridden) codec would normally be quality-driven (ProRes). const showBitrate = codecUsesBitrate;
const showBitrate = codecUsesBitrate || growingOn;
const [projectId, setProjectId] = React.useState(PROJECTS[0]?.id || ''); const [projectId, setProjectId] = React.useState(PROJECTS[0]?.id || '');
const [submitting, setSubmitting] = React.useState(false); const [submitting, setSubmitting] = React.useState(false);
const [submitErr, setSubmitErr] = React.useState(null); const [submitErr, setSubmitErr] = React.useState(null);
@ -214,16 +213,16 @@ function NewRecorderModal({ open, onClose }) {
project_id: projectId || undefined, project_id: projectId || undefined,
generate_proxy: proxyOn, generate_proxy: proxyOn,
growing_enabled: growingOn, growing_enabled: growingOn,
growing_codec: growingOn ? growingCodec : undefined,
recording_codec: recCodec, recording_codec: recCodec,
recording_container: recContainer, recording_container: recContainer,
// Framerate + resolution are auto-detected from the source signal/stream. // Framerate + resolution are auto-detected from the source signal/stream.
recording_framerate: '', // empty = match source recording_framerate: '', // empty = match source
recording_resolution: 'native', recording_resolution: 'native',
}; };
// Custom bitrate applies to bitrate-controlled codecs AND to growing-files // Custom bitrate applies to non-growing bitrate-controlled codecs only.
// mode (which forces H.264/TS in capture but still honors -b:v). ProRes // VC-3 growing bitrate is selected via the growing codec (vc3_90 / vc3_220).
// without growing ignores bitrate, so we omit it there. if (codecUsesBitrate && !growingOn && recBitrate) {
if ((codecUsesBitrate || growingOn) && recBitrate) {
body.recording_video_bitrate = `${recBitrate}M`; body.recording_video_bitrate = `${recBitrate}M`;
} }
@ -430,15 +429,24 @@ function NewRecorderModal({ open, onClose }) {
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<div className="field"> <div className="field">
<label className="field-label"> <label className="field-label">
Video codec{growingOn && <span style={{ marginLeft: 6, fontSize: 10, fontWeight: 700, letterSpacing: '0.06em', color: 'var(--warn, #d9a441)' }}>· LOCKED BY GROWING</span>} {growingOn ? 'Growing master codec' : 'Video codec'}
{growingOn && <span style={{ marginLeft: 6, fontSize: 10, fontWeight: 700, letterSpacing: '0.06em', color: 'var(--text-3)' }}>· PREMIERE-NATIVE</span>}
</label> </label>
<select className="field-input" value={growingOn ? 'h264_growing' : recCodec} {growingOn ? (
onChange={e => setRecCodec(e.target.value)} disabled={growingOn} <select className="field-input" value={growingCodec}
style={{ appearance: 'auto', opacity: growingOn ? 0.6 : 1 }}> onChange={e => setGrowingCodec(e.target.value)}
{growingOn && <option value="h264_growing">XDCAM HD422 (MXF OP1a, growing)</option>} style={{ appearance: 'auto' }}>
<option value="hevc_nvenc">HEVC (NVENC, GPU)</option> <option value="vc3_90">VC-3 / DNxHD 90 (MXF OP1a, 4:2:2, ~90 Mbps)</option>
<option value="h264_nvenc">H.264 (NVENC, GPU)</option> <option value="vc3_220">VC-3 / DNxHD 220 (MXF OP1a, 4:2:2, ~220 Mbps)</option>
</select> </select>
) : (
<select className="field-input" value={recCodec}
onChange={e => setRecCodec(e.target.value)}
style={{ appearance: 'auto' }}>
<option value="hevc_nvenc">HEVC (NVENC, GPU)</option>
<option value="h264_nvenc">H.264 (NVENC, GPU)</option>
</select>
)}
</div> </div>
{showBitrate ? ( {showBitrate ? (
<div className="field"> <div className="field">
@ -487,7 +495,7 @@ function NewRecorderModal({ open, onClose }) {
<Field label="Container" <Field label="Container"
value={growingOn ? 'MXF OP1a (growing, auto)' : (recContainer === 'mp4' ? 'MP4 (auto)' : 'Fragmented MOV (auto)')} select /> value={growingOn ? 'MXF OP1a (growing, auto)' : (recContainer === 'mp4' ? 'MP4 (auto)' : 'Fragmented MOV (auto)')} select />
<Field label="Growing-file" <Field label="Growing-file"
value={growingOn ? 'On (edit-while-record, locked)' : (recContainer === 'mov' ? 'Supported (edit-while-record)' : 'No')} select /> value={growingOn ? 'On (edit-while-record)' : (recContainer === 'mov' ? 'Supported (edit-while-record)' : 'No')} select />
</div> </div>
)} )}
</div> </div>
@ -518,11 +526,9 @@ function NewRecorderModal({ open, onClose }) {
Requires the SMB share to be configured in Settings Storage. Requires the SMB share to be configured in Settings Storage.
</div> </div>
{growingOn && ( {growingOn && (
<div style={{ fontSize: 11, color: 'var(--warn, #d9a441)', marginTop: 6 }}> <div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 6 }}>
Growing-files mode records XDCAM HD422 (MPEG-2 4:2:2 CBR) in MXF OP1a the format Premiere supports for edit-while-record growing files. Bitrate below still applies. Records VC-3 / DNxHD (8-bit 4:2:2) in MXF OP1a Premiere-native edit-while-record,
Premiere can import while it's still being written. The codec and container above imports and grows live in Premiere. {growingCodec === 'vc3_220' ? '~220 Mbps (highest quality).' : '~90 Mbps (lighter storage, default).'}
are overridden for this recorder (the target bitrate still applies). Turn growing
off to record your selected master codec/container.
</div> </div>
)} )}
</div> </div>

View file

@ -609,24 +609,31 @@ function HlsPreviewUrl({ url }) {
// effect; the operator is told. Refuses while recording. // effect; the operator is told. Refuses while recording.
function RecorderConfigModal({ recorder, onClose, onSaved }) { function RecorderConfigModal({ recorder, onClose, onSaved }) {
const PROJECTS = window.ZAMPP_DATA?.PROJECTS || []; const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
// Underlying GPU master codec stored on the row when growing is on (the growing
// essence itself is VC-3, set separately via growing_codec). Keeps the row
// coherent if growing is later turned off.
const GROWING_CODEC = 'hevc_nvenc'; const GROWING_CODEC = 'hevc_nvenc';
const BITRATE_CODECS = new Set(['hevc_nvenc', 'h264_nvenc', 'libx264', 'libx265', 'dnxhd', 'dnxhr_hq']); const BITRATE_CODECS = new Set(['hevc_nvenc', 'h264_nvenc', 'libx264', 'libx265', 'dnxhd', 'dnxhr_hq']);
const [label, setLabel] = React.useState(recorder.label || ''); const [label, setLabel] = React.useState(recorder.label || '');
const [codec, setCodec] = React.useState(recorder.recording_codec || 'hevc_nvenc'); const [codec, setCodec] = React.useState(recorder.recording_codec || 'hevc_nvenc');
// Seed bitrate from the stored value; fall back to a mode-appropriate default // Seed bitrate from the stored value (non-growing only; growing uses a
// (50 Mbps for growing XDCAM HD422, 25 Mbps for a GPU master). // codec-fixed VC-3 bitrate). Default 25 Mbps for a GPU master.
const _seedBitrate = (recorder.recording_video_bitrate || (recorder.growing_enabled === true ? '50' : '25')).replace(/M$/i, ''); const _seedBitrate = (recorder.recording_video_bitrate || '25').replace(/M$/i, '');
const [bitrate, setBitrate] = React.useState(_seedBitrate); const [bitrate, setBitrate] = React.useState(_seedBitrate);
const [growing, setGrowing] = React.useState(recorder.growing_enabled === true); const [growing, setGrowing] = React.useState(recorder.growing_enabled === true);
const _validGC = new Set(['avci50','avci100','avci200','hevc_nvenc']); // Growing master is VC-3 / DNxHD MXF (ffmpeg-direct, Premiere-native). 90 or 220 Mbps.
const [growingCodec, setGrowingCodec] = React.useState(_validGC.has(recorder.growing_codec) ? recorder.growing_codec : 'avci100'); const [growingCodec, setGrowingCodec] = React.useState(
['vc3_220','vc3_90'].includes(recorder.growing_codec) ? recorder.growing_codec : 'vc3_90'
);
const [projectId, setProjectId] = React.useState(recorder.project_id || PROJECTS[0]?.id || ''); const [projectId, setProjectId] = React.useState(recorder.project_id || PROJECTS[0]?.id || '');
const [saving, setSaving] = React.useState(false); const [saving, setSaving] = React.useState(false);
const [err, setErr] = React.useState(null); const [err, setErr] = React.useState(null);
const isRec = recorder.status === 'recording'; const isRec = recorder.status === 'recording';
const showBitrate = growing || BITRATE_CODECS.has(codec); // Growing uses VC-3 with a codec-fixed bitrate (vc3_90 / vc3_220) never the
// freeform bitrate field. Only show/send bitrate for non-growing tunable codecs.
const showBitrate = !growing && BITRATE_CODECS.has(codec);
const submit = () => { const submit = () => {
if (saving || isRec) return; if (saving || isRec) return;
@ -714,9 +721,8 @@ function RecorderConfigModal({ recorder, onClose, onSaved }) {
</div> </div>
</div> </div>
{/* Standard mode: GPU codec + bitrate. Growing mode: bitrate only {/* Standard mode: GPU codec + bitrate. Growing mode: VC-3 codec select
(codec is fixed to XDCAM HD422 MXF, but the target bitrate of the only (90 or 220 Mbps) bitrate is fixed by the chosen VC-3 profile. */}
growing essence is still operator-tunable). */}
{!growing ? ( {!growing ? (
<div className="rec-cfg-grid"> <div className="rec-cfg-grid">
<div className="field"> <div className="field">
@ -744,28 +750,11 @@ function RecorderConfigModal({ recorder, onClose, onSaved }) {
<select className="field-input" value={growingCodec} <select className="field-input" value={growingCodec}
onChange={e => setGrowingCodec(e.target.value)} disabled={isRec} onChange={e => setGrowingCodec(e.target.value)} disabled={isRec}
style={{ appearance: 'auto' }}> style={{ appearance: 'auto' }}>
<option value="avci50">AVC-Intra 50 (CPU, MXF · ~100 Mbps @ 59.94)</option> <option value="vc3_90">VC-3 / DNxHD 90 (MXF OP1a, ~90 Mbps)</option>
<option value="avci100">AVC-Intra 100 (CPU, MXF · ~200 Mbps @ 59.94)</option> <option value="vc3_220">VC-3 / DNxHD 220 (MXF OP1a, ~220 Mbps)</option>
<option value="avci200">AVC-Intra 200 (CPU, MXF · ~400 Mbps @ 59.94)</option>
<option value="hevc_nvenc">HEVC all-intra (GPU/NVENC, frag-MOV · experimental)</option>
</select> </select>
<div className="mono" style={{ fontSize: 10.5, color: 'var(--text-3)', marginTop: 4 }}> <div className="mono" style={{ fontSize: 10.5, color: 'var(--text-3)', marginTop: 4 }}>
{growingCodec === 'hevc_nvenc' VC-3 / DNxHD 8-bit 4:2:2 @ 1080p59.94, MXF OP1a. Premiere-native edit-while-record (imports + grows live). {growingCodec === 'vc3_220' ? '~220 Mbps, highest quality.' : '~90 Mbps, lighter storage (default).'}
? 'GPU-offloaded HEVC 10-bit 4:2:0 in fragmented MOV. NOTE: Premiere does not import frag-MOV growing files.'
: growingCodec === 'avci50'
? 'AVC-Intra Class 50 — ~100 Mbps @ 1080p59.94. Lowest storage, broadcast-grade 4:2:2 10-bit. Premiere-native.'
: growingCodec === 'avci200'
? 'AVC-Intra Class 200 — ~400 Mbps @ 1080p59.94. Highest quality, 4:2:2 10-bit. Premiere-native.'
: 'AVC-Intra Class 100 — ~200 Mbps @ 1080p59.94. Balanced quality and storage. Premiere-native.'}
</div>
</div>
<div className="field">
<label className="field-label">Bitrate (Mbps)</label>
<input className="field-input" type="number" min="1" max="400" step="1"
value={bitrate} disabled={isRec}
onChange={e => setBitrate(e.target.value)} />
<div className="mono" style={{ fontSize: 10.5, color: 'var(--text-3)', marginTop: 4 }}>
Target bitrate of the growing essence. AVC-Intra 100 is ~226 Mbps; HEVC tunable.
</div> </div>
</div> </div>
</div> </div>
@ -823,8 +812,8 @@ function _normRecorder(r) {
codec: r.recording_codec || '·', codec: r.recording_codec || '·',
res: r.recording_resolution || '·', res: r.recording_resolution || '·',
framerate: r.recording_framerate || 'native', framerate: r.recording_framerate || 'native',
growing: r.growing_enabled === true, growing: r.growing_enabled === true,
growingCodec: (['avci50','avci100','avci200','hevc_nvenc'].includes(r.growing_codec) ? r.growing_codec : 'avci100'), growingCodec: (['vc3_220','vc3_90'].includes(r.growing_codec) ? r.growing_codec : 'vc3_90'),
nodeId: r.node_id || null, nodeId: r.node_id || null,
node: r.node_id ? r.node_id.slice(0, 8) : 'primary', node: r.node_id ? r.node_id.slice(0, 8) : 'primary',
deviceIndex: portIdx ?? null, deviceIndex: portIdx ?? null,
@ -1138,16 +1127,11 @@ function RecorderRow({ recorder: initialRecorder, onRefresh, onConfigure, nodeOn
<Icon name="signal" size={10} style={{ marginRight: 4, verticalAlign: -1 }} />{recorder.capturePort} <Icon name="signal" size={10} style={{ marginRight: 4, verticalAlign: -1 }} />{recorder.capturePort}
</span> </span>
)} )}
{recorder.growing && ( {recorder.growing && (
<span className="badge accent" title={recorder.growingCodec === 'hevc_nvenc' <span className="badge accent" title={`Growing-file · VC-3/DNxHD ${recorder.growingCodec === 'vc3_220' ? '220' : '90'} (MXF OP1a)`}>
? 'Growing-file · HEVC all-intra (NVENC GPU, fragmented MOV)' GROWING · VC-3/{recorder.growingCodec === 'vc3_220' ? '220' : '90'}
: 'Growing-file · AVC-Intra 100 (CPU, MXF OP1a)'}> </span>
GROWING · {recorder.growingCodec === 'hevc_nvenc' ? 'GPU/HEVC' )}
: recorder.growingCodec === 'avci50' ? 'CPU/AVCI-50'
: recorder.growingCodec === 'avci200' ? 'CPU/AVCI-200'
: 'CPU/AVCI-100'}
</span>
)}
</div> </div>
<div className="recorder-sub"> <div className="recorder-sub">
<span>{recorder.codec}</span><span className="recorder-sub-sep">·</span> <span>{recorder.codec}</span><span className="recorder-sub-sep">·</span>