feat(recorder): default All-Intra HEVC (NVENC) + custom bitrate, auto fps/res, source-bitrate warning

#2 Recorder codec/bitrate:
- Default recorder codec → hevc_nvenc (All-Intra HEVC NVENC); ProRes/H.264/DNxHR
  still selectable. recorders.js default flips prores_hq → hevc_nvenc.
- Custom target bitrate (Mbps) input, shown only for bitrate-controlled codecs
  (NVENC/x264/x265/DNxHD); ProRes shows quality-based (no bitrate).
- Framerate + resolution are auto-detected from source (manual fields removed).
- Container derived from codec (HEVC/ProRes/DNxHR → fragmented MOV, H.264 → MP4);
  drops the stub container picker (closes #150 direction).

#3 SRT/RTMP customization + bitrate warning:
- Same codec/bitrate/auto controls apply to network recorders (shared form).
- Warns in the modal when the configured target bitrate exceeds the probed
  source stream bitrate (via /recorders/probe) — re-encoding above source adds
  storage, not quality.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Zac Gaetano 2026-05-29 17:04:00 -04:00
parent 35fd9c0253
commit 9b47250388
2 changed files with 58 additions and 23 deletions

View file

@ -197,7 +197,7 @@ router.post('/', async (req, res, next) => {
// Defaults — written on insert so the DB row is always self-contained. // Defaults — written on insert so the DB row is always self-contained.
const defaults = { const defaults = {
source_config: {}, source_config: {},
recording_codec: 'prores_hq', recording_codec: 'hevc_nvenc',
recording_resolution: 'native', recording_resolution: 'native',
recording_audio_codec: 'pcm_s24le', recording_audio_codec: 'pcm_s24le',
recording_audio_channels: 2, recording_audio_channels: 2,

View file

@ -148,8 +148,18 @@ function NewRecorderModal({ open, onClose }) {
}); });
const [dcDevices, setDcDevices] = React.useState(null); const [dcDevices, setDcDevices] = React.useState(null);
const [recTab, setRecTab] = React.useState('video'); const [recTab, setRecTab] = React.useState('video');
const [recCodec, setRecCodec] = React.useState('prores_hq'); // All-Intra HEVC (NVENC) is the default master GPU-encoded, growing-file
const [recContainer, setRecContainer] = React.useState('mov'); // capable in fragmented MOV. ProRes stays selectable for 4:2:2 mezzanine.
const [recCodec, setRecCodec] = React.useState('hevc_nvenc');
// Custom target bitrate in Mbps for bitrate-controlled codecs (NVENC / x264 /
// x265 / DNxHD). ProRes ignores bitrate (quality is profile-driven).
const [recBitrate, setRecBitrate] = React.useState('60');
// Container is derived from the codec, not manually chosen: HEVC/ProRes/DNxHR
// MOV (fragmented, growing-capable); H.264 MP4.
const recContainer = (recCodec === 'h264' || recCodec === 'h264_nvenc' || recCodec === 'libx264') ? 'mp4' : 'mov';
// Codecs whose bitrate is operator-controlled (everything except ProRes).
const BITRATE_CODECS = new Set(['hevc_nvenc', 'h264_nvenc', 'libx264', 'libx265', 'dnxhd', 'dnxhr_hq']);
const codecUsesBitrate = BITRATE_CODECS.has(recCodec);
const [proxyOn, setProxyOn] = React.useState(true); const [proxyOn, setProxyOn] = React.useState(true);
const [projectId, setProjectId] = React.useState(PROJECTS[0]?.id || ''); const [projectId, setProjectId] = React.useState(PROJECTS[0]?.id || '');
const [submitting, setSubmitting] = React.useState(false); const [submitting, setSubmitting] = React.useState(false);
@ -198,7 +208,14 @@ function NewRecorderModal({ open, onClose }) {
generate_proxy: proxyOn, generate_proxy: proxyOn,
recording_codec: recCodec, recording_codec: recCodec,
recording_container: recContainer, recording_container: recContainer,
// Framerate + resolution are auto-detected from the source signal/stream.
recording_framerate: '', // empty = match source
recording_resolution: 'native',
}; };
// Custom bitrate only applies to bitrate-controlled codecs (ProRes ignores it).
if (codecUsesBitrate && recBitrate) {
body.recording_video_bitrate = `${recBitrate}M`;
}
if (sourceType === 'SRT') { if (sourceType === 'SRT') {
body.source_config = { url: srtUrl }; body.source_config = { url: srtUrl };
@ -382,22 +399,48 @@ function NewRecorderModal({ open, onClose }) {
<div className="field"> <div className="field">
<label className="field-label">Video codec</label> <label className="field-label">Video codec</label>
<select className="field-input" value={recCodec} onChange={e => setRecCodec(e.target.value)} style={{ appearance: 'auto' }}> <select className="field-input" value={recCodec} onChange={e => setRecCodec(e.target.value)} style={{ appearance: 'auto' }}>
<option value="prores_4444xq">ProRes 4444 XQ</option> <option value="hevc_nvenc">All-Intra HEVC (NVENC) GPU, growing</option>
<option value="prores_4444">ProRes 4444</option> <option value="h264_nvenc">H.264 (NVENC) GPU</option>
<option value="prores_hq">ProRes 422 HQ</option> <option value="prores_hq">ProRes 422 HQ 4:2:2 CPU</option>
<option value="prores">ProRes 422</option> <option value="prores">ProRes 422</option>
<option value="prores_lt">ProRes 422 LT</option> <option value="prores_lt">ProRes 422 LT</option>
<option value="prores_proxy">ProRes 422 Proxy</option> <option value="prores_proxy">ProRes 422 Proxy</option>
<option value="libx264">H.264 (x264)</option>
<option value="libx265">H.265 / HEVC (x265)</option>
<option value="dnxhd">DNxHD 185x</option>
<option value="dnxhr_hq">DNxHR HQ</option> <option value="dnxhr_hq">DNxHR HQ</option>
<option value="xdcam_hd422">XDCAM HD422</option> <option value="libx264">H.264 (x264, CPU)</option>
<option value="libx265">H.265 (x265, CPU)</option>
</select> </select>
</div> </div>
<Field label="Resolution" value="Source (native)" select /> {codecUsesBitrate ? (
<Field label="Color space" value="Rec. 709" select /> <div className="field">
<Field label="Bit depth" value="10-bit" select /> <label className="field-label">Target bitrate (Mbps)</label>
<input
className="field-input"
type="number" min="1" max="400" step="1"
value={recBitrate}
onChange={e => setRecBitrate(e.target.value)}
/>
</div>
) : (
<Field label="Bitrate" value="Quality-based (profile)" select />
)}
<Field label="Resolution" value="Auto — from source" select />
<Field label="Framerate" value="Auto — from source" select />
{/* #3: warn when the configured bitrate exceeds the probed source
bitrate re-encoding above source adds storage, not quality. */}
{codecUsesBitrate && (() => {
const d = probeResult && probeResult.ok ? (probeResult.data || {}) : null;
const raw = d && (d.bitrate ?? d.bit_rate ?? d.video_bitrate ?? (d.video && d.video.bit_rate));
const srcMbps = raw ? (Number(raw) > 100000 ? Number(raw) / 1e6 : Number(raw)) : null;
const cfg = parseFloat(recBitrate);
if (srcMbps && cfg && cfg > srcMbps * 1.05) {
return (
<div style={{ gridColumn: '1 / -1', fontSize: 11.5, color: 'var(--warn, #d9a441)', border: '1px solid var(--warn, #d9a441)', borderRadius: 6, padding: '8px 10px', background: 'rgba(217,164,65,0.08)' }}>
Target {cfg} Mbps exceeds the source stream (~{srcMbps.toFixed(1)} Mbps). Encoding above the source bitrate increases file size without adding quality.
</div>
);
}
return null;
})()}
</div> </div>
)} )}
{recTab === 'audio' && ( {recTab === 'audio' && (
@ -410,16 +453,8 @@ function NewRecorderModal({ open, onClose }) {
)} )}
{recTab === 'container' && ( {recTab === 'container' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<div className="field"> <Field label="Container" value={recContainer === 'mp4' ? 'MP4 (auto)' : 'Fragmented MOV (auto)'} select />
<label className="field-label">Container</label> <Field label="Growing-file" value={recContainer === 'mov' ? 'Supported (edit-while-record)' : 'No'} select />
<select className="field-input" value={recContainer} onChange={e => setRecContainer(e.target.value)} style={{ appearance: 'auto' }}>
<option value="mov">MOV (QuickTime)</option>
<option value="mxf">MXF (SMPTE)</option>
<option value="mkv">MKV (Matroska)</option>
<option value="mp4">MP4</option>
</select>
</div>
<Field label="Segment" value="None (single file)" select />
</div> </div>
)} )}
</div> </div>