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:
parent
f1f4f50714
commit
35fb84af4d
5 changed files with 226 additions and 156 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -36,3 +36,4 @@ services/capture/lib/
|
|||
*.bak2
|
||||
.env.bak.*
|
||||
.env.worker
|
||||
services/capture/videomaster-linux.x64-6.34.1-dev.tar.gz
|
||||
|
|
|
|||
|
|
@ -385,23 +385,30 @@ const GROWING_EXT = 'mxf';
|
|||
// which the growing file's recorded duration advances. ~1s at 25/29.97 fps.
|
||||
const GROWING_PART_INTERVAL_FRAMES = 30;
|
||||
|
||||
// Growing-file codec selector. Read FRESH from env at record time (standby
|
||||
// sidecars boot with it unset and receive it per-session via /capture/start).
|
||||
// 'avci100' -> AVC-Intra 100 (CPU libx264, 4:2:2 10-bit) in MXF OP1a via
|
||||
// raw2bmx. True-1080p59.94 mastering codec (default).
|
||||
// 'hevc_nvenc' -> all-intra HEVC (NVENC GPU, 4:2:0 10-bit) in fragmented MOV.
|
||||
// GPU-offloaded; frees CPU. Lower chroma, .mov not .mxf.
|
||||
// Valid growing codec values: 'avci50' | 'avci100' | 'avci200' | 'hevc_nvenc'
|
||||
const GROWING_AVC_CODECS = new Set(['avci50', 'avci100', 'avci200']);
|
||||
// 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']);
|
||||
const growingCodec = () => {
|
||||
const v = process.env.GROWING_CODEC;
|
||||
if (v === 'hevc_nvenc') return 'hevc_nvenc';
|
||||
if (v === 'avci50') return 'avci50';
|
||||
if (v === 'avci200') return 'avci200';
|
||||
return 'avci100'; // default
|
||||
if (v === 'vc3_220') return 'vc3_220';
|
||||
return 'vc3_90'; // default
|
||||
};
|
||||
// File extension per growing codec.
|
||||
const growingExtFor = (codec) => (codec === 'hevc_nvenc' ? 'mov' : 'mxf');
|
||||
// Bitrate for the dnxhd encoder, per growing codec value.
|
||||
const growingVc3Bitrate = (codec) => (codec === 'vc3_220' ? '220M' : '90M');
|
||||
// File extension per growing codec — always MXF for VC-3.
|
||||
const growingExtFor = (_codec) => 'mxf';
|
||||
// Default bitrate for the HEVC-NVENC growing master (all-intra 10-bit is heavy).
|
||||
const GROWING_HEVC_DEFAULT_BITRATE = '80M';
|
||||
|
||||
|
|
@ -883,6 +890,55 @@ class CaptureManager {
|
|||
* 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.
|
||||
*
|
||||
* 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 }) {
|
||||
const vb = videoBitrate || GROWING_HEVC_DEFAULT_BITRATE;
|
||||
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 }) {
|
||||
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;
|
||||
|
||||
// ffmpeg argv (shell-quoted). One decklink read → yadif → split → 3 outputs.
|
||||
|
|
@ -939,10 +995,13 @@ class CaptureManager {
|
|||
const ffArgs = [
|
||||
...inputArgs,
|
||||
'-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]',
|
||||
...growingVideoElementaryArgs(growingCodecName),
|
||||
'-b:v', vb, '-minrate', vb, '-maxrate', vb, '-bufsize', vb,
|
||||
'-r', ffRate,
|
||||
'-f', 'h264', '@VF@',
|
||||
// (b) PCM s16le audio → "$AF"
|
||||
|
|
@ -993,11 +1052,12 @@ class CaptureManager {
|
|||
// 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,
|
||||
'--part', String(GROWING_PART_INTERVAL_FRAMES),
|
||||
'--index-follows',
|
||||
rawFlag, '"$VF"',
|
||||
'-s', '48000', '-q', '16', '--audio-chan', String(ach), '--pcm', '"$AF"',
|
||||
'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)))
|
||||
|
|
@ -1012,11 +1072,23 @@ const bmx = [
|
|||
// 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 = `
|
||||
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
|
||||
|
|
@ -1025,9 +1097,20 @@ FFPID=$!
|
|||
exec 9<&-
|
||||
exec ${bmxLine} >/tmp/raw2bmx.log 2>&1 &
|
||||
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
|
||||
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"
|
||||
|
|
@ -1244,71 +1327,29 @@ exit "$BMXRC"
|
|||
}
|
||||
|
||||
let hiresProcess;
|
||||
if (growingPath && _growCodec === 'hevc_nvenc') {
|
||||
// ── GPU-OFFLOAD GROWING master: HEVC NVENC -> fragmented MOV ──
|
||||
if (growingPath) {
|
||||
// ── GROWING master: VC-3 / DNxHD -> MXF OP1a (ffmpeg-native) ──────────
|
||||
// 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
|
||||
// stays decodable to the last complete fragment. Proven live on zampp3.
|
||||
const hevcArgs = this._buildGrowingHevcMov({
|
||||
inputArgs, videoBitrate, framerate, audioChannels,
|
||||
// stdin (pipe:0). ffmpeg's MXF muxer writes a frame-wrapped OP1a whose body
|
||||
// grows readably mid-write — USER-CONFIRMED: imports + grows live in Adobe
|
||||
// Premiere (matches the vMix edit-while-record workflow). _growCodec is
|
||||
// 'vc3_220' or 'vc3_90'; growingVc3Bitrate() maps it to the dnxhd -b:v.
|
||||
// SIGINT flushes the MXF footer cleanly, so the standard SIGINT stop applies.
|
||||
const vc3Args = this._buildGrowingVc3Mxf({
|
||||
inputArgs, framerate, audioChannels,
|
||||
outPath: growingPath, audioMap,
|
||||
hlsDir: (sourceType === 'sdi' || sourceType === 'deltacast') ? sdiHlsDir : null,
|
||||
videoCodec, interlaced: isInterlacedSource,
|
||||
vc3Bitrate: growingVc3Bitrate(_growCodec),
|
||||
});
|
||||
console.log('[capture] growing master via HEVC-NVENC frag-MOV; args=' + hevcArgs.length);
|
||||
hiresProcess = spawn('ffmpeg', hevcArgs, {
|
||||
console.log(`[capture] growing master via VC-3/DNxHD MXF (ffmpeg-native, ${growingVc3Bitrate(_growCodec)}); args=` + vc3Args.length);
|
||||
hiresProcess = spawn('ffmpeg', vc3Args, {
|
||||
stdio: bridgeProcess ? ['pipe', 'ignore', 'pipe'] : ['ignore', 'ignore', 'pipe'],
|
||||
detached: true,
|
||||
});
|
||||
if (bridgeProcess && bridgeProcess.stdout && hiresProcess.stdin) {
|
||||
hiresProcess.stdin.on('error', (e) => {
|
||||
if (e && e.code !== 'EPIPE') console.warn(`[capture] hevc 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}`);
|
||||
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}`);
|
||||
|
|
@ -1454,6 +1495,7 @@ exit "$BMXRC"
|
|||
hiresKey,
|
||||
proxyKey,
|
||||
growingPath,
|
||||
growingCodec: growingPath ? _growCodec : null, // which growing path: avci100 / vc3 / hevc_nvenc
|
||||
audioFifo,
|
||||
startedAt,
|
||||
duration: 0,
|
||||
|
|
@ -1566,6 +1608,10 @@ 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
|
||||
|
|
@ -1595,13 +1641,46 @@ exit "$BMXRC"
|
|||
}, finalizeTimeoutMs);
|
||||
});
|
||||
|
||||
if (processes.hires) processes.hires.kill('SIGINT');
|
||||
if (processes.proxy) processes.proxy.kill('SIGINT');
|
||||
if (processes.hls) { try { processes.hls.kill('SIGINT'); } catch (_) {} }
|
||||
// fc_pipe process (framecache consumer) — stop after ffmpeg so it sees EOF
|
||||
// naturally via EPIPE when ffmpeg stdin closes. SIGTERM as belt-and-suspenders.
|
||||
if (currentSession._fcPipeProcess) {
|
||||
try { currentSession._fcPipeProcess.kill('SIGTERM'); } catch (_) {}
|
||||
// ── 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 (_) {}
|
||||
}
|
||||
}
|
||||
/* processes.bridge: removed — bridge is managed by node-agent, not per-session */
|
||||
|
||||
|
|
|
|||
|
|
@ -818,9 +818,9 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
|
|||
`ASSET_ID=${assetIdLive}`,
|
||||
`MAM_API_TOKEN=${process.env.CAPTURE_TOKEN || ''}`,
|
||||
`GROWING_ENABLED=${growingEnabled ? 'true' : 'false'}`,
|
||||
// Growing codec: 'avci100' (CPU AVC-Intra 100 MXF, default) or
|
||||
// 'hevc_nvenc' (GPU all-intra HEVC frag-MOV). capture-manager reads this.
|
||||
`GROWING_CODEC=${['avci50','avci100','avci200','hevc_nvenc'].includes(recorder.growing_codec) ? recorder.growing_codec : 'avci100'}`,
|
||||
// Growing codec: VC-3/DNxHD MXF (ffmpeg-direct, Premiere-native edit-while-
|
||||
// record — matches vMix). 'vc3_90' (90 Mbps, default) or 'vc3_220' (220 Mbps).
|
||||
`GROWING_CODEC=${['vc3_220','vc3_90'].includes(recorder.growing_codec) ? recorder.growing_codec : 'vc3_90'}`,
|
||||
`GROWING_PATH=/growing`,
|
||||
// SMB mount details for the in-container CIFS mount (Approach A). Empty
|
||||
// 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_password: growingInfra.growing_smb_password || '',
|
||||
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, {
|
||||
method: 'POST',
|
||||
|
|
|
|||
|
|
@ -162,12 +162,11 @@ function NewRecorderModal({ open, onClose }) {
|
|||
const codecUsesBitrate = BITRATE_CODECS.has(recCodec);
|
||||
const [proxyOn, setProxyOn] = React.useState(true);
|
||||
const [growingOn, setGrowingOn] = React.useState(false);
|
||||
// Growing-files mode forces the master to XDCAM HD422 / MXF OP1a in the capture
|
||||
// backend (the only growing format Premiere can import live), but the target
|
||||
// bitrate is still operator-controlled and applied via -b:v. Keep the bitrate
|
||||
// input visible/editable whenever growing is on, even if the selected (and
|
||||
// soon-to-be-overridden) codec would normally be quality-driven (ProRes).
|
||||
const showBitrate = codecUsesBitrate || growingOn;
|
||||
// Growing master is VC-3 / DNxHD in MXF OP1a (ffmpeg-direct, Premiere-native
|
||||
// edit-while-record — matches vMix). Two bitrates: 90 Mbps (default, lighter)
|
||||
// or 220 Mbps (highest quality). Both 8-bit 4:2:2 @ 1080p59.94.
|
||||
const [growingCodec, setGrowingCodec] = React.useState('vc3_90');
|
||||
const showBitrate = codecUsesBitrate;
|
||||
const [projectId, setProjectId] = React.useState(PROJECTS[0]?.id || '');
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
const [submitErr, setSubmitErr] = React.useState(null);
|
||||
|
|
@ -214,16 +213,16 @@ function NewRecorderModal({ open, onClose }) {
|
|||
project_id: projectId || undefined,
|
||||
generate_proxy: proxyOn,
|
||||
growing_enabled: growingOn,
|
||||
growing_codec: growingOn ? growingCodec : undefined,
|
||||
recording_codec: recCodec,
|
||||
recording_container: recContainer,
|
||||
// Framerate + resolution are auto-detected from the source signal/stream.
|
||||
recording_framerate: '', // empty = match source
|
||||
recording_resolution: 'native',
|
||||
};
|
||||
// Custom bitrate applies to bitrate-controlled codecs AND to growing-files
|
||||
// mode (which forces H.264/TS in capture but still honors -b:v). ProRes
|
||||
// without growing ignores bitrate, so we omit it there.
|
||||
if ((codecUsesBitrate || growingOn) && recBitrate) {
|
||||
// Custom bitrate applies to non-growing bitrate-controlled codecs only.
|
||||
// VC-3 growing bitrate is selected via the growing codec (vc3_90 / vc3_220).
|
||||
if (codecUsesBitrate && !growingOn && recBitrate) {
|
||||
body.recording_video_bitrate = `${recBitrate}M`;
|
||||
}
|
||||
|
||||
|
|
@ -430,15 +429,24 @@ function NewRecorderModal({ open, onClose }) {
|
|||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||
<div className="field">
|
||||
<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>
|
||||
<select className="field-input" value={growingOn ? 'h264_growing' : recCodec}
|
||||
onChange={e => setRecCodec(e.target.value)} disabled={growingOn}
|
||||
style={{ appearance: 'auto', opacity: growingOn ? 0.6 : 1 }}>
|
||||
{growingOn && <option value="h264_growing">XDCAM HD422 (MXF OP1a, growing)</option>}
|
||||
<option value="hevc_nvenc">HEVC (NVENC, GPU)</option>
|
||||
<option value="h264_nvenc">H.264 (NVENC, GPU)</option>
|
||||
</select>
|
||||
{growingOn ? (
|
||||
<select className="field-input" value={growingCodec}
|
||||
onChange={e => setGrowingCodec(e.target.value)}
|
||||
style={{ appearance: 'auto' }}>
|
||||
<option value="vc3_90">VC-3 / DNxHD 90 (MXF OP1a, 4:2:2, ~90 Mbps)</option>
|
||||
<option value="vc3_220">VC-3 / DNxHD 220 (MXF OP1a, 4:2:2, ~220 Mbps)</option>
|
||||
</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>
|
||||
{showBitrate ? (
|
||||
<div className="field">
|
||||
|
|
@ -487,7 +495,7 @@ function NewRecorderModal({ open, onClose }) {
|
|||
<Field label="Container"
|
||||
value={growingOn ? 'MXF OP1a (growing, auto)' : (recContainer === 'mp4' ? 'MP4 (auto)' : 'Fragmented MOV (auto)')} select />
|
||||
<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>
|
||||
|
|
@ -518,11 +526,9 @@ function NewRecorderModal({ open, onClose }) {
|
|||
Requires the SMB share to be configured in Settings → Storage.
|
||||
</div>
|
||||
{growingOn && (
|
||||
<div style={{ fontSize: 11, color: 'var(--warn, #d9a441)', 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.
|
||||
Premiere can import while it's still being written. The codec and container above
|
||||
are overridden for this recorder (the target bitrate still applies). Turn growing
|
||||
off to record your selected master codec/container.
|
||||
<div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 6 }}>
|
||||
Records VC-3 / DNxHD (8-bit 4:2:2) in MXF OP1a — Premiere-native edit-while-record,
|
||||
imports and grows live in Premiere. {growingCodec === 'vc3_220' ? '~220 Mbps (highest quality).' : '~90 Mbps (lighter storage, default).'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -609,24 +609,31 @@ function HlsPreviewUrl({ url }) {
|
|||
// effect; the operator is told. Refuses while recording.
|
||||
function RecorderConfigModal({ recorder, onClose, onSaved }) {
|
||||
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 BITRATE_CODECS = new Set(['hevc_nvenc', 'h264_nvenc', 'libx264', 'libx265', 'dnxhd', 'dnxhr_hq']);
|
||||
|
||||
const [label, setLabel] = React.useState(recorder.label || '');
|
||||
const [codec, setCodec] = React.useState(recorder.recording_codec || 'hevc_nvenc');
|
||||
// Seed bitrate from the stored value; fall back to a mode-appropriate default
|
||||
// (50 Mbps for growing XDCAM HD422, 25 Mbps for a GPU master).
|
||||
const _seedBitrate = (recorder.recording_video_bitrate || (recorder.growing_enabled === true ? '50' : '25')).replace(/M$/i, '');
|
||||
// Seed bitrate from the stored value (non-growing only; growing uses a
|
||||
// codec-fixed VC-3 bitrate). Default 25 Mbps for a GPU master.
|
||||
const _seedBitrate = (recorder.recording_video_bitrate || '25').replace(/M$/i, '');
|
||||
const [bitrate, setBitrate] = React.useState(_seedBitrate);
|
||||
const [growing, setGrowing] = React.useState(recorder.growing_enabled === true);
|
||||
const _validGC = new Set(['avci50','avci100','avci200','hevc_nvenc']);
|
||||
const [growingCodec, setGrowingCodec] = React.useState(_validGC.has(recorder.growing_codec) ? recorder.growing_codec : 'avci100');
|
||||
const [growing, setGrowing] = React.useState(recorder.growing_enabled === true);
|
||||
// Growing master is VC-3 / DNxHD MXF (ffmpeg-direct, Premiere-native). 90 or 220 Mbps.
|
||||
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 [saving, setSaving] = React.useState(false);
|
||||
const [err, setErr] = React.useState(null);
|
||||
|
||||
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 = () => {
|
||||
if (saving || isRec) return;
|
||||
|
|
@ -714,9 +721,8 @@ function RecorderConfigModal({ recorder, onClose, onSaved }) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Standard mode: GPU codec + bitrate. Growing mode: bitrate only
|
||||
(codec is fixed to XDCAM HD422 MXF, but the target bitrate of the
|
||||
growing essence is still operator-tunable). */}
|
||||
{/* Standard mode: GPU codec + bitrate. Growing mode: VC-3 codec select
|
||||
only (90 or 220 Mbps) — bitrate is fixed by the chosen VC-3 profile. */}
|
||||
{!growing ? (
|
||||
<div className="rec-cfg-grid">
|
||||
<div className="field">
|
||||
|
|
@ -744,28 +750,11 @@ function RecorderConfigModal({ recorder, onClose, onSaved }) {
|
|||
<select className="field-input" value={growingCodec}
|
||||
onChange={e => setGrowingCodec(e.target.value)} disabled={isRec}
|
||||
style={{ appearance: 'auto' }}>
|
||||
<option value="avci50">AVC-Intra 50 (CPU, MXF · ~100 Mbps @ 59.94)</option>
|
||||
<option value="avci100">AVC-Intra 100 (CPU, MXF · ~200 Mbps @ 59.94)</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>
|
||||
<option value="vc3_90">VC-3 / DNxHD 90 (MXF OP1a, ~90 Mbps)</option>
|
||||
<option value="vc3_220">VC-3 / DNxHD 220 (MXF OP1a, ~220 Mbps)</option>
|
||||
</select>
|
||||
<div className="mono" style={{ fontSize: 10.5, color: 'var(--text-3)', marginTop: 4 }}>
|
||||
{growingCodec === 'hevc_nvenc'
|
||||
? '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.
|
||||
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).'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -823,8 +812,8 @@ function _normRecorder(r) {
|
|||
codec: r.recording_codec || '·',
|
||||
res: r.recording_resolution || '·',
|
||||
framerate: r.recording_framerate || 'native',
|
||||
growing: r.growing_enabled === true,
|
||||
growingCodec: (['avci50','avci100','avci200','hevc_nvenc'].includes(r.growing_codec) ? r.growing_codec : 'avci100'),
|
||||
growing: r.growing_enabled === true,
|
||||
growingCodec: (['vc3_220','vc3_90'].includes(r.growing_codec) ? r.growing_codec : 'vc3_90'),
|
||||
nodeId: r.node_id || null,
|
||||
node: r.node_id ? r.node_id.slice(0, 8) : 'primary',
|
||||
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}
|
||||
</span>
|
||||
)}
|
||||
{recorder.growing && (
|
||||
<span className="badge accent" title={recorder.growingCodec === 'hevc_nvenc'
|
||||
? 'Growing-file · HEVC all-intra (NVENC GPU, fragmented MOV)'
|
||||
: 'Growing-file · AVC-Intra 100 (CPU, MXF OP1a)'}>
|
||||
GROWING · {recorder.growingCodec === 'hevc_nvenc' ? 'GPU/HEVC'
|
||||
: recorder.growingCodec === 'avci50' ? 'CPU/AVCI-50'
|
||||
: recorder.growingCodec === 'avci200' ? 'CPU/AVCI-200'
|
||||
: 'CPU/AVCI-100'}
|
||||
</span>
|
||||
)}
|
||||
{recorder.growing && (
|
||||
<span className="badge accent" title={`Growing-file · VC-3/DNxHD ${recorder.growingCodec === 'vc3_220' ? '220' : '90'} (MXF OP1a)`}>
|
||||
GROWING · VC-3/{recorder.growingCodec === 'vc3_220' ? '220' : '90'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="recorder-sub">
|
||||
<span>{recorder.codec}</span><span className="recorder-sub-sep">·</span>
|
||||
|
|
|
|||
Loading…
Reference in a new issue