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,
'-framerate', fcFps,
'-i', 'pipe:0',
// Audio FIFO → ffmpeg input 1. The bridge writes EXACTLY the SDI-clock
// paced samples (group 0 is the reference, same slot clock as video),
// so we DERIVE audio PTS from the sample count at 48 kHz — NOT from
// wall-clock arrival. Wall-clock timestamping made the audio stream's
// length equal real elapsed time while video length = frame_count/fps;
// when the encoder ran a hair under realtime the audio ended up ~1%
// longer than video (heard as a pitch-up). Reading the raw stream at
// its natural rate keeps both in the same SDI clock domain; the
// master-output aresample=async=1 still soaks up any micro-jitter.
// Audio FIFO → ffmpeg input 1. Wall-clock timestamps on the audio
// input are REQUIRED for throughput: without them ffmpeg's audio
// reader has no rate reference on the raw s16le FIFO and the demux
// thread stalls the whole graph (NVENC sat idle at 9% while frames
// dropped). With wallclock, audio is paced by arrival and the master
// -af aresample=async=1 resamples it onto the video CFR timeline so
// A/V length stays locked. The residual ~1% drift that wallclock used
// to cause was actually the all-intra HEVC dropping frames (video
// 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
// SELECTION (keep first N) is applied as an output filter so the
// discrete broadcast channels are preserved, not downmixed.
'-use_wallclock_as_timestamps', '1',
'-thread_queue_size', '512',
'-f', 's16le',
'-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
// node lacks one; unknown capability is left as a soft pass.
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;
@ -253,7 +253,7 @@ function buildStandbyEnv(recorder) {
`SOURCE_TYPE=${recorder.source_type}`,
`SOURCE_CONFIG=${JSON.stringify(sourceConfig)}`,
`DEVICE_INDEX=${deviceIndex}`,
`RECORDING_CODEC=${recorder.recording_codec || 'prores_hq'}`,
`RECORDING_CODEC=${recorder.recording_codec || 'hevc_nvenc'}`,
`RECORDING_RESOLUTION=${recorder.recording_resolution || 'native'}`,
`RECORDING_VIDEO_BITRATE=${recorder.recording_video_bitrate || ''}`,
`RECORDING_FRAMERATE=${recorder.recording_framerate || ''}`,
@ -262,7 +262,7 @@ function buildStandbyEnv(recorder) {
`RECORDING_AUDIO_CHANNELS=${recorder.recording_audio_channels ?? 2}`,
`RECORDING_CONTAINER=${recorder.recording_container || 'mov'}`,
`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_VIDEO_BITRATE=${recorder.proxy_video_bitrate || '2M'}`,
`PROXY_FRAMERATE=${recorder.proxy_framerate || ''}`,
@ -468,9 +468,9 @@ router.post('/', async (req, res, next) => {
recording_audio_codec: 'pcm_s24le',
recording_audio_channels: 2,
recording_container: 'mov',
proxy_enabled: true,
proxy_codec: 'h264',
proxy_resolution: '1920x1080',
proxy_enabled: true,
proxy_codec: 'h264_nvenc',
proxy_resolution: '1920x1080',
proxy_video_bitrate: '2M',
proxy_audio_codec: 'aac',
proxy_audio_bitrate: '128k',
@ -793,7 +793,7 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
`DEVICE_INDEX=${deviceIndex}`,
// Recording codec controls
`RECORDING_CODEC=${recorder.recording_codec || 'prores_hq'}`,
`RECORDING_CODEC=${recorder.recording_codec || 'hevc_nvenc'}`,
`RECORDING_RESOLUTION=${recorder.recording_resolution || 'native'}`,
`RECORDING_VIDEO_BITRATE=${recorder.recording_video_bitrate || ''}`,
`RECORDING_FRAMERATE=${recorder.recording_framerate || ''}`,
@ -804,7 +804,7 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
// Proxy codec controls
`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_VIDEO_BITRATE=${recorder.proxy_video_bitrate || '2M'}`,
`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: '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 => (
<button key={p.id}
className={`btn ghost sm${recCodec === p.codec ? ' active' : ''}`}
@ -437,15 +436,8 @@ function NewRecorderModal({ open, onClose }) {
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">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="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>
</div>
{showBitrate ? (

View file

@ -688,15 +688,8 @@ function RecorderConfigModal({ recorder, onClose, onSaved }) {
onChange={e => setCodec(e.target.value)} disabled={growing || isRec}
style={{ appearance: 'auto', opacity: growing ? 0.6 : 1 }}>
{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="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>
</div>