// 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('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 [growingOn, setGrowingOn] = React.useState(false); 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 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 (
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' && (
{codecUsesBitrate ? (
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.
{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;