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
.env.bak.*
.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.
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 */

View file

@ -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',

View file

@ -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>

View file

@ -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>