// 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 (
{groups.map(group => (
1 ? 12 : 4 }}> {/* Node header: only show when multiple groups, or always for clarity */}
{group.model ? group.model.toUpperCase() + ' · ' : ''}{group.hostname}
{group.ports.map(port => { const active = selectedIdx === port.index && selectedNode === group.nodeId; return ( ); })}
))}
); } /** * 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 (
{emptyNote || `No ${portLabel} devices auto-detected. Configure manually:`}
); } function ProbeResult({ result }) { if (!result.ok) { return (
Probe failed: {result.error}
); } const d = result.data || {}; const entries = Object.entries(d).filter(([, v]) => v !== null && typeof v !== 'object'); if (entries.length === 0) { return (
✓ Source reachable
); } return (
{entries.map(([k, v]) => (
{k} {String(v)}
))}
); } 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('25'); // 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 [growingOn, setGrowingOn] = React.useState(false); // Growing-files mode forces the master to XDCAM HD422 / MXF OP1a in the capture // backend (the only growing format Premiere can import live), but the target // bitrate is still operator-controlled and applied via -b:v. Keep the bitrate // input visible/editable whenever growing is on, even if the selected (and // soon-to-be-overridden) codec would normally be quality-driven (ProRes). const showBitrate = codecUsesBitrate || growingOn; 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, growing_enabled: growingOn, 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 applies to bitrate-controlled codecs AND to growing-files // mode (which forces H.264/TS in capture but still honors -b:v). ProRes // without growing ignores bitrate, so we omit it there. if ((codecUsesBitrate || growingOn) && 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') { // One Deltacast board (index 0) exposes 8 channels. The picker's selected // index IS the capture channel, so persist it as source_config.port; the // capture sidecar maps that to the bridge's --port. device_index is kept // for backward-compatible display/fallback. body.source_config = { port: dcDeviceIdx }; 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 (
e.stopPropagation()}>
New recorder
Configure source, codec, and destination
setName(e.target.value)} />
{[ { 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 => ( ))}
{sourceType === 'SRT' && (
setSrtUrl(e.target.value)} style={{ flex: 1 }} />
Recorder connects out to this URL (caller mode).
{probeResult && }
)} {sourceType === 'RTMP' && (
setRtmpUrl(e.target.value)} style={{ flex: 1 }} />
Recorder pulls this RTMP stream.
{probeResult && }
)} {sourceType === 'SDI' && (
{sdiDevices === null && (
Detecting DeckLink devices…
)} {sdiDevices !== null && sdiDevices.length > 0 && ( { setSdiDeviceIdx(idx); setSdiNodeId(nodeId); }} portLabel="SDI" /> )} {sdiDevices !== null && sdiDevices.length === 0 && ( )}
)} {sourceType === 'DELTACAST' && (
{dcDevices === null && (
Detecting Deltacast devices…
)} {dcDevices !== null && dcDevices.length > 0 && ( { setDcDeviceIdx(idx); setDcNodeId(nodeId); }} portLabel="Port" showTestBadge /> )} {dcDevices !== null && dcDevices.length === 0 && ( )}
)}
Master recording
{['video', 'audio', 'container'].map(t => ( ))}
{recTab === 'video' && ( <> {/* Codec presets — one click fills codec + bitrate with a known-good combo that passes the server-side validateRecorderConfig guard. Container is derived from the codec (HEVC/ProRes/DNxHR → MOV, H.264 → MP4), and master audio is always PCM (valid in MOV). */}
{[ { 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 => ( ))}
{showBitrate ? (
setRecBitrate(e.target.value)} />
) : ( )} {/* #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 (
⚠ Target {cfg} Mbps exceeds the source stream (~{srcMbps.toFixed(1)} Mbps). Encoding above the source bitrate increases file size without adding quality.
); } return null; })()}
)} {recTab === 'audio' && (
)} {recTab === 'container' && (
)}
Generate proxy
SDI sources record proxy in parallel. Network sources generate proxy after stop.
Growing-files mode
Write the live master to the SMB share so editors can cut while it's still recording. Requires the SMB share to be configured in Settings → Storage.
{growingOn && (
Growing-files mode records XDCAM HD422 (MPEG-2 4:2:2 CBR) in MXF OP1a — the format Premiere supports for edit-while-record growing files. Bitrate below still applies. Premiere can import while it's still being written. The codec and container above are overridden for this recorder (the target bitrate still applies). Turn growing off to record your selected master codec/container.
)}
{proxyOn && (
Proxy
{['H.264', '2 Mbps', 'MP4', '1920×1080', 'AAC 128 kbps'].map(tag => ( {tag} ))}
Fixed proxy profile. Not configurable.
)}
Destination
{submitErr && (
{submitErr}
)}
); } window.NewRecorderModal = NewRecorderModal;