dragonflight/services/web-ui/public/modal-new-recorder.jsx
Zac Gaetano 9b47250388 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>
2026-05-29 17:04:00 -04:00

526 lines
25 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// modal-new-recorder.jsx - New Recorder dialog (SRT / RTMP / SDI / Deltacast)
/**
* DevicePortPicker - groups a flat per-port API response by node_id and
* renders one button per actual port. Replaces the old code that iterated
* over entries and synthesised port counts, which caused duplicate groups.
*
* props:
* ports - flat array from /cluster/devices/blackmagic or /deltacast
* each entry: { node_id, hostname, model, index, device, present? }
* selectedIdx - currently selected device_index
* selectedNode - currently selected node_id
* onSelect(idx, nodeId)
* portLabel - e.g. "SDI" or "Port"
* showTestBadge - show TEST CARD badge when present===false
*/
function DevicePortPicker({ ports, selectedIdx, selectedNode, onSelect, portLabel = 'Port', showTestBadge = false }) {
// Group by node_id (stable - one group per physical node)
const groups = React.useMemo(() => {
const map = new Map();
for (const p of ports) {
const key = p.node_id || p.hostname || 'unknown';
if (!map.has(key)) map.set(key, { nodeId: p.node_id || p.hostname || '', hostname: p.hostname || key, model: p.model || '', ports: [] });
map.get(key).ports.push(p);
}
// Sort ports within each group by index
for (const g of map.values()) g.ports.sort((a, b) => a.index - b.index);
return Array.from(map.values());
}, [ports]);
return (
<div className="sdi-port-mini">
{groups.map(group => (
<div key={group.nodeId} style={{ marginBottom: groups.length > 1 ? 12 : 4 }}>
{/* Node header: only show when multiple groups, or always for clarity */}
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.08em', color: 'var(--text-3)', textTransform: 'uppercase', padding: '0 0 6px' }}>
{group.model ? group.model.toUpperCase() + ' · ' : ''}{group.hostname}
</div>
{group.ports.map(port => {
const active = selectedIdx === port.index && selectedNode === group.nodeId;
return (
<button key={port.index}
className={`sdi-mini-port${active ? ' active' : ''}`}
onClick={() => onSelect(port.index, group.nodeId)}>
<span className="sdi-mini-radio"><span className="sdi-mini-radio-dot" /></span>
<span style={{ fontSize: 12, fontWeight: 600 }}>
{portLabel} {port.index + 1}
{showTestBadge && port.present === false && (
<span style={{ fontSize: 9, fontWeight: 700, letterSpacing: '0.06em', color: 'var(--accent)', background: 'var(--accent-soft)', borderRadius: 3, padding: '1px 5px', marginLeft: 7 }}>
TEST CARD
</span>
)}
</span>
<span style={{ fontSize: 11, color: 'var(--text-3)', marginLeft: 'auto', fontFamily: 'var(--font-mono)' }}>
{port.device ? port.device.split('/').pop() : `index ${port.index}`}
</span>
</button>
);
})}
</div>
))}
</div>
);
}
/**
* ManualDevicePicker - fallback when no devices detected. Lets the operator
* pick node + index from dropdowns.
*/
function ManualDevicePicker({ nodes, nodeId, deviceIdx, portLabel, portCount, onNodeChange, onIdxChange, emptyNote }) {
return (
<div style={{ padding: '4px 0' }}>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginBottom: 10 }}>
{emptyNote || `No ${portLabel} devices auto-detected. Configure manually:`}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<div className="field">
<label className="field-label">Capture node</label>
<select className="field-input" value={nodeId}
onChange={e => onNodeChange(e.target.value)} style={{ appearance: 'auto' }}>
{nodes.length === 0
? <option value="">No cluster nodes</option>
: nodes.map(n => {
const id = n.id || n.hostname || n.name || '';
return <option key={id} value={id}>{n.hostname || n.name || id}</option>;
})}
</select>
</div>
<div className="field">
<label className="field-label">{portLabel} index</label>
<select className="field-input" value={deviceIdx}
onChange={e => onIdxChange(Number(e.target.value))} style={{ appearance: 'auto' }}>
{Array.from({ length: portCount }, (_, i) =>
<option key={i} value={i}>{portLabel} {i + 1} (index {i})</option>)}
</select>
</div>
</div>
</div>
);
}
function ProbeResult({ result }) {
if (!result.ok) {
return (
<div style={{ marginTop: 6, padding: '8px 10px', background: 'var(--danger-soft)', border: '1px solid var(--danger)', borderRadius: 5, fontSize: 11.5, color: 'var(--danger)' }}>
Probe failed: {result.error}
</div>
);
}
const d = result.data || {};
const entries = Object.entries(d).filter(([, v]) => v !== null && typeof v !== 'object');
if (entries.length === 0) {
return (
<div style={{ marginTop: 6, padding: '8px 10px', background: 'var(--success-soft)', border: '1px solid var(--success)', borderRadius: 5, fontSize: 11.5, color: 'var(--success)' }}>
Source reachable
</div>
);
}
return (
<div style={{ marginTop: 6, padding: '10px 12px', background: 'var(--bg-2)', border: '1px solid var(--success)', borderRadius: 5, fontSize: 11.5 }}>
{entries.map(([k, v]) => (
<div key={k} style={{ display: 'flex', gap: 8, padding: '2px 0' }}>
<span style={{ color: 'var(--text-3)', minWidth: 100, flexShrink: 0 }}>{k}</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11 }}>{String(v)}</span>
</div>
))}
</div>
);
}
function NewRecorderModal({ open, onClose }) {
const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
const NODES = window.ZAMPP_DATA?.NODES || [];
const [name, setName] = React.useState('');
const [sourceType, setSourceType] = React.useState('SRT');
const [srtUrl, setSrtUrl] = React.useState('srt://10.0.4.18:4200');
const [rtmpUrl, setRtmpUrl] = React.useState('rtmp://stream.local/live/cam_a');
const [sdiDeviceIdx, setSdiDeviceIdx] = React.useState(0);
const [sdiNodeId, setSdiNodeId] = React.useState(() => {
const n = NODES[0];
return n ? (n.id || n.hostname || '') : '';
});
const [sdiDevices, setSdiDevices] = React.useState(null);
const [dcDeviceIdx, setDcDeviceIdx] = React.useState(0);
const [dcNodeId, setDcNodeId] = React.useState(() => {
const n = NODES[0];
return n ? (n.id || n.hostname || '') : '';
});
const [dcDevices, setDcDevices] = React.useState(null);
const [recTab, setRecTab] = React.useState('video');
// All-Intra HEVC (NVENC) is the default master — GPU-encoded, growing-file
// 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 [projectId, setProjectId] = React.useState(PROJECTS[0]?.id || '');
const [submitting, setSubmitting] = React.useState(false);
const [submitErr, setSubmitErr] = React.useState(null);
const [probing, setProbing] = React.useState(false);
const [probeResult, setProbeResult] = React.useState(null);
React.useEffect(() => {
if (sourceType !== 'SDI' || sdiDevices !== null) return;
window.ZAMPP_API.fetch('/cluster/devices/blackmagic')
.then(d => setSdiDevices(Array.isArray(d) ? d : []))
.catch(() => setSdiDevices([]));
}, [sourceType]);
React.useEffect(() => {
if (sourceType !== 'DELTACAST' || dcDevices !== null) return;
window.ZAMPP_API.fetch('/cluster/devices/deltacast')
.then(d => setDcDevices(Array.isArray(d) ? d : []))
.catch(() => setDcDevices([]));
}, [sourceType]);
React.useEffect(() => { setProbeResult(null); }, [sourceType, srtUrl, rtmpUrl]);
const handleProbe = () => {
setProbing(true);
setProbeResult(null);
const body = sourceType === 'SRT'
? { source_type: 'srt', url: srtUrl }
: { source_type: 'rtmp', url: rtmpUrl };
window.ZAMPP_API.fetch('/recorders/probe', { method: 'POST', body: JSON.stringify(body) })
.then(r => { setProbing(false); setProbeResult({ ok: true, data: r }); })
.catch(e => { setProbing(false); setProbeResult({ ok: false, error: e.message }); });
};
const handleCreate = () => {
if (!name.trim()) { setSubmitErr('Recorder name is required.'); return; }
if (sourceType === 'SDI' && !sdiNodeId) { setSubmitErr('Select a capture node for SDI.'); return; }
if (sourceType === 'DELTACAST' && !dcNodeId) { setSubmitErr('Select a capture node for Deltacast.'); return; }
setSubmitting(true);
setSubmitErr(null);
const body = {
name: name.trim(),
source_type: sourceType.toLowerCase(),
project_id: projectId || undefined,
generate_proxy: proxyOn,
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 only applies to bitrate-controlled codecs (ProRes ignores it).
if (codecUsesBitrate && recBitrate) {
body.recording_video_bitrate = `${recBitrate}M`;
}
if (sourceType === 'SRT') {
body.source_config = { url: srtUrl };
} else if (sourceType === 'RTMP') {
body.source_config = { url: rtmpUrl };
} else if (sourceType === 'DELTACAST') {
body.source_config = {};
body.device_index = dcDeviceIdx;
body.node_id = dcNodeId || undefined;
} else {
// SDI (DeckLink): device_index and node_id are top-level fields
body.source_config = {};
body.device_index = sdiDeviceIdx;
body.node_id = sdiNodeId || undefined;
}
window.ZAMPP_API.fetch('/recorders', { method: 'POST', body: JSON.stringify(body) })
.then(() => {
setSubmitting(false);
// Recorders list listens for this and re-fetches; otherwise the
// operator has to wait for the next 10s poll tick to see the new row.
window.dispatchEvent(new CustomEvent('df:recorders-changed'));
onClose();
})
.catch(e => { setSubmitting(false); setSubmitErr(e.message || 'Failed to create recorder'); });
};
if (!open) return null;
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div>
<div style={{ fontSize: 15, fontWeight: 600 }}>New recorder</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 2 }}>Configure source, codec, and destination</div>
</div>
<button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
</div>
<div className="modal-body">
<div className="field">
<label className="field-label">Recorder name</label>
<input className="field-input" placeholder="e.g. Studio A Stage Cam"
value={name} onChange={e => setName(e.target.value)} />
</div>
<div className="field">
<label className="field-label">Source type</label>
<div className="source-type-grid">
{[
{ id: 'SRT', label: 'SRT', desc: 'Secure Reliable Transport · pull caller', icon: 'signal' },
{ id: 'RTMP', label: 'RTMP', desc: 'Real-Time Messaging Protocol', icon: 'globe' },
{ id: 'SDI', label: 'SDI', desc: 'Blackmagic DeckLink hardware', icon: 'video' },
{ id: 'DELTACAST', label: 'Deltacast', desc: 'Deltacast VideoMaster SDI', icon: 'video' },
].map(t => (
<button key={t.id}
className={`source-type-card ${sourceType === t.id ? 'active' : ''}`}
onClick={() => setSourceType(t.id)}>
<div className="source-type-icon"><Icon name={t.icon} size={16} /></div>
<div>
<div style={{ fontWeight: 600, fontSize: 13 }}>{t.label}</div>
<div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 2 }}>{t.desc}</div>
</div>
</button>
))}
</div>
</div>
{sourceType === 'SRT' && (
<div className="field">
<label className="field-label">Source URL</label>
<div style={{ display: 'flex', gap: 6 }}>
<input className="field-input mono" placeholder="srt://192.168.1.100:4200"
value={srtUrl} onChange={e => setSrtUrl(e.target.value)} style={{ flex: 1 }} />
<button className="btn ghost sm" onClick={handleProbe} disabled={probing}
style={{ flexShrink: 0, minWidth: 64 }}>
{probing ? '…' : 'Probe'}
</button>
</div>
<div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 4 }}>
Recorder connects out to this URL (caller mode).
</div>
{probeResult && <ProbeResult result={probeResult} />}
</div>
)}
{sourceType === 'RTMP' && (
<div className="field">
<label className="field-label">Source URL</label>
<div style={{ display: 'flex', gap: 6 }}>
<input className="field-input mono" placeholder="rtmp://server/live/streamkey"
value={rtmpUrl} onChange={e => setRtmpUrl(e.target.value)} style={{ flex: 1 }} />
<button className="btn ghost sm" onClick={handleProbe} disabled={probing}
style={{ flexShrink: 0, minWidth: 64 }}>
{probing ? '…' : 'Probe'}
</button>
</div>
<div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 4 }}>
Recorder pulls this RTMP stream.
</div>
{probeResult && <ProbeResult result={probeResult} />}
</div>
)}
{sourceType === 'SDI' && (
<div className="field">
<label className="field-label">Capture device</label>
{sdiDevices === null && (
<div style={{ padding: '10px 0', color: 'var(--text-3)', fontSize: 12 }}>Detecting DeckLink devices</div>
)}
{sdiDevices !== null && sdiDevices.length > 0 && (
<DevicePortPicker
ports={sdiDevices}
selectedIdx={sdiDeviceIdx}
selectedNode={sdiNodeId}
onSelect={(idx, nodeId) => { setSdiDeviceIdx(idx); setSdiNodeId(nodeId); }}
portLabel="SDI"
/>
)}
{sdiDevices !== null && sdiDevices.length === 0 && (
<ManualDevicePicker
nodes={NODES}
nodeId={sdiNodeId}
deviceIdx={sdiDeviceIdx}
portLabel="SDI"
portCount={4}
onNodeChange={setSdiNodeId}
onIdxChange={setSdiDeviceIdx}
/>
)}
</div>
)}
{sourceType === 'DELTACAST' && (
<div className="field">
<label className="field-label">Capture device</label>
{dcDevices === null && (
<div style={{ padding: '10px 0', color: 'var(--text-3)', fontSize: 12 }}>Detecting Deltacast devices</div>
)}
{dcDevices !== null && dcDevices.length > 0 && (
<DevicePortPicker
ports={dcDevices}
selectedIdx={dcDeviceIdx}
selectedNode={dcNodeId}
onSelect={(idx, nodeId) => { setDcDeviceIdx(idx); setDcNodeId(nodeId); }}
portLabel="Port"
showTestBadge
/>
)}
{dcDevices !== null && dcDevices.length === 0 && (
<ManualDevicePicker
nodes={NODES}
nodeId={dcNodeId}
deviceIdx={dcDeviceIdx}
portLabel="Port"
portCount={8}
onNodeChange={setDcNodeId}
onIdxChange={setDcDeviceIdx}
emptyNote="No Deltacast devices detected. Configure manually (test-card mode):"
/>
)}
</div>
)}
<div className="modal-section">
<div className="modal-section-head">
<span>Master recording</span>
<span style={{ flex: 1 }} />
<div className="tab-group">
{['video', 'audio', 'container'].map(t => (
<button key={t} className={recTab === t ? 'active' : ''} onClick={() => setRecTab(t)}>
{t[0].toUpperCase() + t.slice(1)}
</button>
))}
</div>
</div>
<div className="modal-section-body">
{recTab === 'video' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<div className="field">
<label className="field-label">Video codec</label>
<select className="field-input" value={recCodec} onChange={e => setRecCodec(e.target.value)} style={{ appearance: 'auto' }}>
<option value="hevc_nvenc">All-Intra HEVC (NVENC) GPU, growing</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>
{codecUsesBitrate ? (
<div className="field">
<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>
)}
{recTab === 'audio' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<Field label="Audio codec" value="PCM" select />
<Field label="Sample rate" value="48 kHz" select />
<Field label="Channels" value="2.0 stereo" select />
<Field label="Bit depth" value="24-bit" select />
</div>
)}
{recTab === 'container' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<Field label="Container" value={recContainer === 'mp4' ? 'MP4 (auto)' : 'Fragmented MOV (auto)'} select />
<Field label="Growing-file" value={recContainer === 'mov' ? 'Supported (edit-while-record)' : 'No'} select />
</div>
)}
</div>
</div>
<div className="modal-toggle-row">
<label className="switch">
<input type="checkbox" checked={proxyOn} onChange={e => setProxyOn(e.target.checked)} />
<span className="switch-track"><span className="switch-knob" /></span>
</label>
<div>
<div style={{ fontWeight: 500, fontSize: 13 }}>Generate proxy</div>
<div style={{ fontSize: 11, color: 'var(--text-3)' }}>
SDI sources record proxy in parallel. Network sources generate proxy after stop.
</div>
</div>
</div>
{proxyOn && (
<div className="modal-section">
<div className="modal-section-head"><span>Proxy</span></div>
<div className="modal-section-body">
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
{['H.264', '2 Mbps', 'MP4', '1920×1080', 'AAC 128 kbps'].map(tag => (
<span key={tag} className="mono" style={{ background: 'var(--bg-3)', borderRadius: 4, padding: '2px 8px', fontSize: 12 }}>{tag}</span>
))}
</div>
<div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 6 }}>Fixed proxy profile. Not configurable.</div>
</div>
</div>
)}
<div className="modal-section">
<div className="modal-section-head"><span>Destination</span></div>
<div className="modal-section-body">
<div className="field">
<label className="field-label">Project</label>
<select className="field-input" value={projectId}
onChange={e => setProjectId(e.target.value)} style={{ appearance: 'auto' }}>
{PROJECTS.length === 0
? <option value="">No projects</option>
: PROJECTS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
</div>
</div>
{submitErr && (
<div style={{
padding: '10px 14px', background: 'var(--danger-soft)',
border: '1px solid var(--danger)', borderRadius: 6,
fontSize: 12.5, color: 'var(--danger)', marginTop: 4,
}}>{submitErr}</div>
)}
</div>
<div className="modal-foot">
<button className="btn ghost" onClick={onClose}>Cancel</button>
<span style={{ flex: 1 }} />
<button className="btn primary" onClick={handleCreate} disabled={submitting}>
{submitting ? 'Creating…' : 'Create recorder'}
</button>
</div>
</div>
</div>
);
}
window.NewRecorderModal = NewRecorderModal;