From 35fb84af4d2a27ca28937689198ce2c209d5071a Mon Sep 17 00:00:00 2001 From: OpenCode Date: Fri, 5 Jun 2026 03:04:45 +0000 Subject: [PATCH] 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. --- .gitignore | 1 + services/capture/src/capture-manager.js | 251 ++++++++++++------ services/mam-api/src/routes/recorders.js | 8 +- services/web-ui/public/modal-new-recorder.jsx | 54 ++-- services/web-ui/public/screens-ingest.jsx | 68 ++--- 5 files changed, 226 insertions(+), 156 deletions(-) diff --git a/.gitignore b/.gitignore index dbdc34c..73c2f47 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ services/capture/lib/ *.bak2 .env.bak.* .env.worker +services/capture/videomaster-linux.x64-6.34.1-dev.tar.gz diff --git a/services/capture/src/capture-manager.js b/services/capture/src/capture-manager.js index 006e8c7..39433a1 100644 --- a/services/capture/src/capture-manager.js +++ b/services/capture/src/capture-manager.js @@ -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 */ diff --git a/services/mam-api/src/routes/recorders.js b/services/mam-api/src/routes/recorders.js index 2eb0417..c6c80d0 100644 --- a/services/mam-api/src/routes/recorders.js +++ b/services/mam-api/src/routes/recorders.js @@ -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', diff --git a/services/web-ui/public/modal-new-recorder.jsx b/services/web-ui/public/modal-new-recorder.jsx index 32ad08f..bbeb486 100644 --- a/services/web-ui/public/modal-new-recorder.jsx +++ b/services/web-ui/public/modal-new-recorder.jsx @@ -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 }) {
- + {growingOn ? ( + + ) : ( + + )}
{showBitrate ? (
@@ -487,7 +495,7 @@ function NewRecorderModal({ open, onClose }) { + value={growingOn ? 'On (edit-while-record)' : (recContainer === 'mov' ? 'Supported (edit-while-record)' : 'No')} select />
)}
@@ -518,11 +526,9 @@ function NewRecorderModal({ open, onClose }) { Requires the SMB share to be configured in Settings → Storage. {growingOn && ( -
- 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. +
+ 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).'}
)}
diff --git a/services/web-ui/public/screens-ingest.jsx b/services/web-ui/public/screens-ingest.jsx index bc73570..17b8dcf 100644 --- a/services/web-ui/public/screens-ingest.jsx +++ b/services/web-ui/public/screens-ingest.jsx @@ -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 }) { - {/* 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 ? (
@@ -744,28 +750,11 @@ function RecorderConfigModal({ recorder, onClose, onSaved }) {
- {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.'} -
-
-
- - setBitrate(e.target.value)} /> -
- 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).'}
@@ -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 {recorder.capturePort} )} - {recorder.growing && ( - - GROWING · {recorder.growingCodec === 'hevc_nvenc' ? 'GPU/HEVC' - : recorder.growingCodec === 'avci50' ? 'CPU/AVCI-50' - : recorder.growingCodec === 'avci200' ? 'CPU/AVCI-200' - : 'CPU/AVCI-100'} - - )} + {recorder.growing && ( + + GROWING · VC-3/{recorder.growingCodec === 'vc3_220' ? '220' : '90'} + + )}
{recorder.codec}·