fix(capture): restore audio wallclock (throughput) + remove CPU codec options

- restore -use_wallclock_as_timestamps on audio input: without it ffmpeg's raw
  s16le reader stalled the graph (NVENC idle at 9%, ~half frames dropped). With
  it + long-GOP HEVC the encoder runs realtime and A/V length stays locked.
- remove all CPU codec options (prores*, dnxh*, libx264/265) from recorder UI;
  GPU NVENC only (hevc_nvenc / h264_nvenc). 3x L4 cluster, no reason for CPU.
- GPU codec defaults in env builders + proxy default h264_nvenc.
This commit is contained in:
Zac Gaetano 2026-06-04 04:14:59 +00:00
parent 0ea22e1e53
commit 07eea02109
4 changed files with 21 additions and 34 deletions

View file

@ -737,18 +737,20 @@ class CaptureManager {
'-video_size', fcSize, '-video_size', fcSize,
'-framerate', fcFps, '-framerate', fcFps,
'-i', 'pipe:0', '-i', 'pipe:0',
// Audio FIFO → ffmpeg input 1. The bridge writes EXACTLY the SDI-clock // Audio FIFO → ffmpeg input 1. Wall-clock timestamps on the audio
// paced samples (group 0 is the reference, same slot clock as video), // input are REQUIRED for throughput: without them ffmpeg's audio
// so we DERIVE audio PTS from the sample count at 48 kHz — NOT from // reader has no rate reference on the raw s16le FIFO and the demux
// wall-clock arrival. Wall-clock timestamping made the audio stream's // thread stalls the whole graph (NVENC sat idle at 9% while frames
// length equal real elapsed time while video length = frame_count/fps; // dropped). With wallclock, audio is paced by arrival and the master
// when the encoder ran a hair under realtime the audio ended up ~1% // -af aresample=async=1 resamples it onto the video CFR timeline so
// longer than video (heard as a pitch-up). Reading the raw stream at // A/V length stays locked. The residual ~1% drift that wallclock used
// its natural rate keeps both in the same SDI clock domain; the // to cause was actually the all-intra HEVC dropping frames (video
// master-output aresample=async=1 still soaks up any micro-jitter. // short); that's fixed by long-GOP HEVC for non-growing records, so
// wallclock is safe again and necessary.
// The FIFO carries the full 16ch the bridge publishes; channel // The FIFO carries the full 16ch the bridge publishes; channel
// SELECTION (keep first N) is applied as an output filter so the // SELECTION (keep first N) is applied as an output filter so the
// discrete broadcast channels are preserved, not downmixed. // discrete broadcast channels are preserved, not downmixed.
'-use_wallclock_as_timestamps', '1',
'-thread_queue_size', '512', '-thread_queue_size', '512',
'-f', 's16le', '-f', 's16le',
'-ar', '48000', '-ar', '48000',

View file

@ -198,7 +198,7 @@ function validateRecorderConfig(cfg, nodeHasGpu = null) {
// NVENC requires a GPU on the target node. Only a hard error when we know the // NVENC requires a GPU on the target node. Only a hard error when we know the
// node lacks one; unknown capability is left as a soft pass. // node lacks one; unknown capability is left as a soft pass.
if (GPU_CODECS.includes(codec) && nodeHasGpu === false) { if (GPU_CODECS.includes(codec) && nodeHasGpu === false) {
return `Invalid combo: codec ${cfg.recording_codec} requires an NVIDIA GPU, but the target node reports no GPU. Choose a software codec (e.g. prores_hq, dnxhr_hq, h264) or assign a GPU node.`; return `Invalid combo: codec ${cfg.recording_codec} requires an NVIDIA GPU, but the target node reports no GPU. Assign this recorder to a GPU node.`;
} }
return null; return null;
@ -253,7 +253,7 @@ function buildStandbyEnv(recorder) {
`SOURCE_TYPE=${recorder.source_type}`, `SOURCE_TYPE=${recorder.source_type}`,
`SOURCE_CONFIG=${JSON.stringify(sourceConfig)}`, `SOURCE_CONFIG=${JSON.stringify(sourceConfig)}`,
`DEVICE_INDEX=${deviceIndex}`, `DEVICE_INDEX=${deviceIndex}`,
`RECORDING_CODEC=${recorder.recording_codec || 'prores_hq'}`, `RECORDING_CODEC=${recorder.recording_codec || 'hevc_nvenc'}`,
`RECORDING_RESOLUTION=${recorder.recording_resolution || 'native'}`, `RECORDING_RESOLUTION=${recorder.recording_resolution || 'native'}`,
`RECORDING_VIDEO_BITRATE=${recorder.recording_video_bitrate || ''}`, `RECORDING_VIDEO_BITRATE=${recorder.recording_video_bitrate || ''}`,
`RECORDING_FRAMERATE=${recorder.recording_framerate || ''}`, `RECORDING_FRAMERATE=${recorder.recording_framerate || ''}`,
@ -262,7 +262,7 @@ function buildStandbyEnv(recorder) {
`RECORDING_AUDIO_CHANNELS=${recorder.recording_audio_channels ?? 2}`, `RECORDING_AUDIO_CHANNELS=${recorder.recording_audio_channels ?? 2}`,
`RECORDING_CONTAINER=${recorder.recording_container || 'mov'}`, `RECORDING_CONTAINER=${recorder.recording_container || 'mov'}`,
`PROXY_ENABLED=${recorder.proxy_enabled !== false ? 'true' : 'false'}`, `PROXY_ENABLED=${recorder.proxy_enabled !== false ? 'true' : 'false'}`,
`PROXY_CODEC=${recorder.proxy_codec || 'h264'}`, `PROXY_CODEC=${recorder.proxy_codec || 'h264_nvenc'}`,
`PROXY_RESOLUTION=${recorder.proxy_resolution || '1920x1080'}`, `PROXY_RESOLUTION=${recorder.proxy_resolution || '1920x1080'}`,
`PROXY_VIDEO_BITRATE=${recorder.proxy_video_bitrate || '2M'}`, `PROXY_VIDEO_BITRATE=${recorder.proxy_video_bitrate || '2M'}`,
`PROXY_FRAMERATE=${recorder.proxy_framerate || ''}`, `PROXY_FRAMERATE=${recorder.proxy_framerate || ''}`,
@ -468,9 +468,9 @@ router.post('/', async (req, res, next) => {
recording_audio_codec: 'pcm_s24le', recording_audio_codec: 'pcm_s24le',
recording_audio_channels: 2, recording_audio_channels: 2,
recording_container: 'mov', recording_container: 'mov',
proxy_enabled: true, proxy_enabled: true,
proxy_codec: 'h264', proxy_codec: 'h264_nvenc',
proxy_resolution: '1920x1080', proxy_resolution: '1920x1080',
proxy_video_bitrate: '2M', proxy_video_bitrate: '2M',
proxy_audio_codec: 'aac', proxy_audio_codec: 'aac',
proxy_audio_bitrate: '128k', proxy_audio_bitrate: '128k',
@ -793,7 +793,7 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
`DEVICE_INDEX=${deviceIndex}`, `DEVICE_INDEX=${deviceIndex}`,
// Recording codec controls // Recording codec controls
`RECORDING_CODEC=${recorder.recording_codec || 'prores_hq'}`, `RECORDING_CODEC=${recorder.recording_codec || 'hevc_nvenc'}`,
`RECORDING_RESOLUTION=${recorder.recording_resolution || 'native'}`, `RECORDING_RESOLUTION=${recorder.recording_resolution || 'native'}`,
`RECORDING_VIDEO_BITRATE=${recorder.recording_video_bitrate || ''}`, `RECORDING_VIDEO_BITRATE=${recorder.recording_video_bitrate || ''}`,
`RECORDING_FRAMERATE=${recorder.recording_framerate || ''}`, `RECORDING_FRAMERATE=${recorder.recording_framerate || ''}`,
@ -804,7 +804,7 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
// Proxy codec controls // Proxy codec controls
`PROXY_ENABLED=${recorder.proxy_enabled !== false ? 'true' : 'false'}`, `PROXY_ENABLED=${recorder.proxy_enabled !== false ? 'true' : 'false'}`,
`PROXY_CODEC=${recorder.proxy_codec || 'h264'}`, `PROXY_CODEC=${recorder.proxy_codec || 'h264_nvenc'}`,
`PROXY_RESOLUTION=${recorder.proxy_resolution || '1920x1080'}`, `PROXY_RESOLUTION=${recorder.proxy_resolution || '1920x1080'}`,
`PROXY_VIDEO_BITRATE=${recorder.proxy_video_bitrate || '2M'}`, `PROXY_VIDEO_BITRATE=${recorder.proxy_video_bitrate || '2M'}`,
`PROXY_FRAMERATE=${recorder.proxy_framerate || ''}`, `PROXY_FRAMERATE=${recorder.proxy_framerate || ''}`,

View file

@ -418,7 +418,6 @@ function NewRecorderModal({ open, onClose }) {
{[ {[
{ id: 'hevc', label: 'HEVC Master (MOV)', codec: 'hevc_nvenc', bitrate: '25' }, { id: 'hevc', label: 'HEVC Master (MOV)', codec: 'hevc_nvenc', bitrate: '25' },
{ id: 'h264', label: 'H.264 Proxy-friendly (MP4)', codec: 'h264_nvenc', bitrate: '25' }, { id: 'h264', label: 'H.264 Proxy-friendly (MP4)', codec: 'h264_nvenc', bitrate: '25' },
{ id: 'dnxhr', label: 'DNxHR HQ (MOV)', codec: 'dnxhr_hq', bitrate: '145' },
].map(p => ( ].map(p => (
<button key={p.id} <button key={p.id}
className={`btn ghost sm${recCodec === p.codec ? ' active' : ''}`} className={`btn ghost sm${recCodec === p.codec ? ' active' : ''}`}
@ -437,15 +436,8 @@ function NewRecorderModal({ open, onClose }) {
onChange={e => setRecCodec(e.target.value)} disabled={growingOn} onChange={e => setRecCodec(e.target.value)} disabled={growingOn}
style={{ appearance: 'auto', opacity: growingOn ? 0.6 : 1 }}> style={{ appearance: 'auto', opacity: growingOn ? 0.6 : 1 }}>
{growingOn && <option value="h264_growing">XDCAM HD422 (MXF OP1a, growing)</option>} {growingOn && <option value="h264_growing">XDCAM HD422 (MXF OP1a, growing)</option>}
<option value="hevc_nvenc">All-Intra HEVC (NVENC, GPU, growing)</option> <option value="hevc_nvenc">HEVC (NVENC, GPU)</option>
<option value="h264_nvenc">H.264 (NVENC, GPU)</option> <option value="h264_nvenc">H.264 (NVENC, GPU)</option>
<option value="prores_hq">ProRes 422 HQ (4:2:2, CPU)</option>
<option value="prores">ProRes 422</option>
<option value="prores_lt">ProRes 422 LT</option>
<option value="prores_proxy">ProRes 422 Proxy</option>
<option value="dnxhr_hq">DNxHR HQ</option>
<option value="libx264">H.264 (x264, CPU)</option>
<option value="libx265">H.265 (x265, CPU)</option>
</select> </select>
</div> </div>
{showBitrate ? ( {showBitrate ? (

View file

@ -688,15 +688,8 @@ function RecorderConfigModal({ recorder, onClose, onSaved }) {
onChange={e => setCodec(e.target.value)} disabled={growing || isRec} onChange={e => setCodec(e.target.value)} disabled={growing || isRec}
style={{ appearance: 'auto', opacity: growing ? 0.6 : 1 }}> style={{ appearance: 'auto', opacity: growing ? 0.6 : 1 }}>
{growing && <option value="h264_growing">XDCAM HD422 (MXF OP1a, growing)</option>} {growing && <option value="h264_growing">XDCAM HD422 (MXF OP1a, growing)</option>}
<option value="hevc_nvenc">All-Intra HEVC (NVENC, GPU, growing)</option> <option value="hevc_nvenc">HEVC (NVENC, GPU)</option>
<option value="h264_nvenc">H.264 (NVENC, GPU)</option> <option value="h264_nvenc">H.264 (NVENC, GPU)</option>
<option value="prores_hq">ProRes 422 HQ (4:2:2, CPU)</option>
<option value="prores">ProRes 422</option>
<option value="prores_lt">ProRes 422 LT</option>
<option value="prores_proxy">ProRes 422 Proxy</option>
<option value="dnxhr_hq">DNxHR HQ</option>
<option value="libx264">H.264 (x264, CPU)</option>
<option value="libx265">H.265 (x265, CPU)</option>
</select> </select>
</div> </div>