feat: AVC-Intra 50/100/200 growing codec selector

This commit is contained in:
OpenCode 2026-06-04 21:51:45 +00:00
parent 071e1f6461
commit c8cddc19b2
3 changed files with 49 additions and 24 deletions

View file

@ -366,13 +366,18 @@ const CONTAINER_EXT = {
// essence is a raw H.264 stream (-f h264) wrapped by raw2bmx --avci100_1080p // essence is a raw H.264 stream (-f h264) wrapped by raw2bmx --avci100_1080p
// at -f 60000/1001, clip type op1a. Verified on-node: produces a valid // at -f 60000/1001, clip type op1a. Verified on-node: produces a valid
// "h264 / High 4:2:2 Intra / yuv422p10le / 60000/1001" MXF. // "h264 / High 4:2:2 Intra / yuv422p10le / 60000/1001" MXF.
const GROWING_VIDEO_ELEMENTARY_ARGS = [ // AVC-Intra elementary encode args per class (50 / 100 / 200).
const GROWING_AVCI_CLASS = { avci50: 50, avci100: 100, avci200: 200 };
function growingVideoElementaryArgs(codec) {
const cls = GROWING_AVCI_CLASS[codec] || 100;
return [
'-c:v', 'libx264', '-profile:v', 'high422', '-level', '4.2', '-c:v', 'libx264', '-profile:v', 'high422', '-level', '4.2',
'-preset', 'ultrafast', '-tune', 'zerolatency', '-preset', 'ultrafast', '-tune', 'zerolatency',
'-pix_fmt', 'yuv422p10le', '-pix_fmt', 'yuv422p10le',
'-x264-params', 'avcintra-class=100:bframes=0:keyint=1:scenecut=0', '-x264-params', `avcintra-class=${cls}:bframes=0:keyint=1:scenecut=0`,
'-aud', '1', '-aud', '1',
]; ];
}
const GROWING_DEFAULT_BITRATE = '25M'; const GROWING_DEFAULT_BITRATE = '25M';
const GROWING_EXT = 'mxf'; const GROWING_EXT = 'mxf';
// Video essence partition interval (frames). raw2bmx starts a new body partition // Video essence partition interval (frames). raw2bmx starts a new body partition
@ -386,7 +391,15 @@ const GROWING_PART_INTERVAL_FRAMES = 30;
// raw2bmx. True-1080p59.94 mastering codec (default). // raw2bmx. True-1080p59.94 mastering codec (default).
// 'hevc_nvenc' -> all-intra HEVC (NVENC GPU, 4:2:0 10-bit) in fragmented MOV. // 'hevc_nvenc' -> all-intra HEVC (NVENC GPU, 4:2:0 10-bit) in fragmented MOV.
// GPU-offloaded; frees CPU. Lower chroma, .mov not .mxf. // GPU-offloaded; frees CPU. Lower chroma, .mov not .mxf.
const growingCodec = () => (process.env.GROWING_CODEC === 'hevc_nvenc' ? 'hevc_nvenc' : 'avci100'); // Valid growing codec values: 'avci50' | 'avci100' | 'avci200' | 'hevc_nvenc'
const GROWING_AVC_CODECS = new Set(['avci50', 'avci100', 'avci200']);
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
};
// File extension per growing codec. // File extension per growing codec.
const growingExtFor = (codec) => (codec === 'hevc_nvenc' ? 'mov' : 'mxf'); const growingExtFor = (codec) => (codec === 'hevc_nvenc' ? 'mov' : 'mxf');
// Default bitrate for the HEVC-NVENC growing master (all-intra 10-bit is heavy). // Default bitrate for the HEVC-NVENC growing master (all-intra 10-bit is heavy).
@ -406,7 +419,7 @@ const GROWING_HEVC_DEFAULT_BITRATE = '80M';
// a sensible value from the recorder's configured resolution/framerate; if those // a sensible value from the recorder's configured resolution/framerate; if those
// are absent or ambiguous it defaults to 1080i59.94. A live DeckLink confirm of // are absent or ambiguous it defaults to 1080i59.94. A live DeckLink confirm of
// the actual SDI raster/fps is advised before production use (see report). // the actual SDI raster/fps is advised before production use (see report).
function deriveGrowingRaster(resolution, framerate, scanHint = null) { function deriveGrowingRaster(resolution, framerate, scanHint = null, codec = 'avci100') {
// Normalise fps. Accept '59.94', '60000/1001', '25', '50', '30', '29.97'… // Normalise fps. Accept '59.94', '60000/1001', '25', '50', '30', '29.97'…
let fpsNum = null; let fpsNum = null;
const fr = (framerate == null) ? '' : String(framerate).trim(); const fr = (framerate == null) ? '' : String(framerate).trim();
@ -451,12 +464,13 @@ function deriveGrowingRaster(resolution, framerate, scanHint = null) {
if (scan == null) scan = scanHint || ((height >= 1080) ? 'i' : 'p'); if (scan == null) scan = scanHint || ((height >= 1080) ? 'i' : 'p');
const r = rates(fpsNum); const r = rates(fpsNum);
// AVC-Intra 100 raster flags. --avci100_1080p accepts true 1080p59.94 (verified). // AVC-Intra raster flags — class from codec name ('avci50'/'avci100'/'avci200').
const avciClass = GROWING_AVCI_CLASS[codec] || 100;
let rawFlag; let rawFlag;
if (height >= 1080) { if (height >= 1080) {
rawFlag = (scan === 'i') ? '--avci100_1080i' : '--avci100_1080p'; rawFlag = (scan === 'i') ? `--avci${avciClass}_1080i` : `--avci${avciClass}_1080p`;
} else if (height >= 720) { } else if (height >= 720) {
rawFlag = '--avci100_720p'; rawFlag = `--avci${avciClass}_720p`;
if (fpsNum == null) { r.ff = '60000/1001'; r.raw = '60000/1001'; } if (fpsNum == null) { r.ff = '60000/1001'; r.raw = '60000/1001'; }
} else { } else {
rawFlag = '--mpeg2lg_422p_ml_576i'; rawFlag = '--mpeg2lg_422p_ml_576i';
@ -903,8 +917,8 @@ class CaptureManager {
return args; return args;
} }
_buildGrowingOrchestrator({ inputArgs, videoBitrate, resolution, framerate, audioChannels, outPath, hlsDir, videoCodec, audioMap = '0:a:0?', interlaced = false }) { _buildGrowingOrchestrator({ inputArgs, videoBitrate, resolution, framerate, audioChannels, outPath, hlsDir, videoCodec, growingCodecName = 'avci100', audioMap = '0:a:0?', interlaced = false }) {
const { rawFlag, frameRate, ffRate } = deriveGrowingRaster(resolution, framerate, interlaced ? 'i' : 'p'); const { rawFlag, frameRate, ffRate } = deriveGrowingRaster(resolution, framerate, interlaced ? 'i' : 'p', growingCodecName);
const vb = videoBitrate || GROWING_DEFAULT_BITRATE; const vb = videoBitrate || GROWING_DEFAULT_BITRATE;
const ach = audioChannels ? Number(audioChannels) : 2; const ach = audioChannels ? Number(audioChannels) : 2;
@ -927,7 +941,7 @@ class CaptureManager {
'-filter_complex', filterComplex, '-filter_complex', filterComplex,
// (a) MPEG-2 422 elementary video → "$VF" // (a) MPEG-2 422 elementary video → "$VF"
'-map', '[vhi]', '-map', '[vhi]',
...GROWING_VIDEO_ELEMENTARY_ARGS, ...growingVideoElementaryArgs(growingCodecName),
'-b:v', vb, '-minrate', vb, '-maxrate', vb, '-bufsize', vb, '-b:v', vb, '-minrate', vb, '-maxrate', vb, '-bufsize', vb,
'-r', ffRate, '-r', ffRate,
'-f', 'h264', '@VF@', '-f', 'h264', '@VF@',
@ -1277,6 +1291,7 @@ exit "$BMXRC"
outPath: growingPath, outPath: growingPath,
hlsDir: (sourceType === 'sdi' || sourceType === 'deltacast') ? sdiHlsDir : null, hlsDir: (sourceType === 'sdi' || sourceType === 'deltacast') ? sdiHlsDir : null,
videoCodec, videoCodec,
growingCodecName: gCodec,
audioMap, audioMap,
interlaced: isInterlacedSource, interlaced: isInterlacedSource,
}); });

View file

@ -820,7 +820,7 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
`GROWING_ENABLED=${growingEnabled ? 'true' : 'false'}`, `GROWING_ENABLED=${growingEnabled ? 'true' : 'false'}`,
// Growing codec: 'avci100' (CPU AVC-Intra 100 MXF, default) or // Growing codec: 'avci100' (CPU AVC-Intra 100 MXF, default) or
// 'hevc_nvenc' (GPU all-intra HEVC frag-MOV). capture-manager reads this. // 'hevc_nvenc' (GPU all-intra HEVC frag-MOV). capture-manager reads this.
`GROWING_CODEC=${recorder.growing_codec === 'hevc_nvenc' ? 'hevc_nvenc' : 'avci100'}`, `GROWING_CODEC=${['avci50','avci100','avci200','hevc_nvenc'].includes(recorder.growing_codec) ? recorder.growing_codec : 'avci100'}`,
`GROWING_PATH=/growing`, `GROWING_PATH=/growing`,
// SMB mount details for the in-container CIFS mount (Approach A). Empty // SMB mount details for the in-container CIFS mount (Approach A). Empty
// GROWING_SMB_MOUNT → capture falls back to the host-bound /growing volume // GROWING_SMB_MOUNT → capture falls back to the host-bound /growing volume
@ -918,7 +918,7 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
growing_smb_username: growingInfra.growing_smb_username || '', growing_smb_username: growingInfra.growing_smb_username || '',
growing_smb_password: growingInfra.growing_smb_password || '', growing_smb_password: growingInfra.growing_smb_password || '',
growing_smb_vers: growingInfra.growing_smb_vers || '3.0', growing_smb_vers: growingInfra.growing_smb_vers || '3.0',
growing_codec: recorder.growing_codec === 'hevc_nvenc' ? 'hevc_nvenc' : 'avci100', growing_codec: ['avci50','avci100','avci200','hevc_nvenc'].includes(recorder.growing_codec) ? recorder.growing_codec : 'avci100',
}; };
const captureRes = await fetch(captureStartUrl, { const captureRes = await fetch(captureStartUrl, {
method: 'POST', method: 'POST',

View file

@ -619,7 +619,8 @@ function RecorderConfigModal({ recorder, onClose, onSaved }) {
const _seedBitrate = (recorder.recording_video_bitrate || (recorder.growing_enabled === true ? '50' : '25')).replace(/M$/i, ''); const _seedBitrate = (recorder.recording_video_bitrate || (recorder.growing_enabled === true ? '50' : '25')).replace(/M$/i, '');
const [bitrate, setBitrate] = React.useState(_seedBitrate); const [bitrate, setBitrate] = React.useState(_seedBitrate);
const [growing, setGrowing] = React.useState(recorder.growing_enabled === true); const [growing, setGrowing] = React.useState(recorder.growing_enabled === true);
const [growingCodec, setGrowingCodec] = React.useState(recorder.growing_codec === 'hevc_nvenc' ? 'hevc_nvenc' : 'avci100'); const _validGC = new Set(['avci50','avci100','avci200','hevc_nvenc']);
const [growingCodec, setGrowingCodec] = React.useState(_validGC.has(recorder.growing_codec) ? recorder.growing_codec : 'avci100');
const [projectId, setProjectId] = React.useState(recorder.project_id || PROJECTS[0]?.id || ''); const [projectId, setProjectId] = React.useState(recorder.project_id || PROJECTS[0]?.id || '');
const [saving, setSaving] = React.useState(false); const [saving, setSaving] = React.useState(false);
const [err, setErr] = React.useState(null); const [err, setErr] = React.useState(null);
@ -743,13 +744,19 @@ function RecorderConfigModal({ recorder, onClose, onSaved }) {
<select className="field-input" value={growingCodec} <select className="field-input" value={growingCodec}
onChange={e => setGrowingCodec(e.target.value)} disabled={isRec} onChange={e => setGrowingCodec(e.target.value)} disabled={isRec}
style={{ appearance: 'auto' }}> style={{ appearance: 'auto' }}>
<option value="avci100">AVC-Intra 100 (CPU, MXF · Premiere-native)</option> <option value="avci50">AVC-Intra 50 (CPU, MXF · ~100 Mbps @ 59.94)</option>
<option value="hevc_nvenc">HEVC all-intra (GPU/NVENC, frag-MOV)</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>
</select> </select>
<div className="mono" style={{ fontSize: 10.5, color: 'var(--text-3)', marginTop: 4 }}> <div className="mono" style={{ fontSize: 10.5, color: 'var(--text-3)', marginTop: 4 }}>
{growingCodec === 'hevc_nvenc' {growingCodec === 'hevc_nvenc'
? 'GPU-offloaded HEVC 10-bit 4:2:0 in fragmented MOV. Frees CPU. NOTE: not all editors import frag-MOV growing files.' ? 'GPU-offloaded HEVC 10-bit 4:2:0 in fragmented MOV. NOTE: Premiere does not import frag-MOV growing files.'
: 'CPU AVC-Intra 100, 4:2:2 10-bit, true 1080p59.94 in MXF OP1a. Premiere-native growing.'} : 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> </div>
<div className="field"> <div className="field">
@ -817,7 +824,7 @@ function _normRecorder(r) {
res: r.recording_resolution || '·', res: r.recording_resolution || '·',
framerate: r.recording_framerate || 'native', framerate: r.recording_framerate || 'native',
growing: r.growing_enabled === true, growing: r.growing_enabled === true,
growingCodec: r.growing_codec === 'hevc_nvenc' ? 'hevc_nvenc' : 'avci100', growingCodec: (['avci50','avci100','avci200','hevc_nvenc'].includes(r.growing_codec) ? r.growing_codec : 'avci100'),
nodeId: r.node_id || null, nodeId: r.node_id || null,
node: r.node_id ? r.node_id.slice(0, 8) : 'primary', node: r.node_id ? r.node_id.slice(0, 8) : 'primary',
deviceIndex: portIdx ?? null, deviceIndex: portIdx ?? null,
@ -1130,7 +1137,10 @@ function RecorderRow({ recorder: initialRecorder, onRefresh, onConfigure, nodeOn
<span className="badge accent" title={recorder.growingCodec === 'hevc_nvenc' <span className="badge accent" title={recorder.growingCodec === 'hevc_nvenc'
? 'Growing-file · HEVC all-intra (NVENC GPU, fragmented MOV)' ? 'Growing-file · HEVC all-intra (NVENC GPU, fragmented MOV)'
: 'Growing-file · AVC-Intra 100 (CPU, MXF OP1a)'}> : 'Growing-file · AVC-Intra 100 (CPU, MXF OP1a)'}>
GROWING · {recorder.growingCodec === 'hevc_nvenc' ? 'GPU/HEVC' : 'CPU/AVCI'} GROWING · {recorder.growingCodec === 'hevc_nvenc' ? 'GPU/HEVC'
: recorder.growingCodec === 'avci50' ? 'CPU/AVCI-50'
: recorder.growingCodec === 'avci200' ? 'CPU/AVCI-200'
: 'CPU/AVCI-100'}
</span> </span>
)} )}
</div> </div>