// 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' && ( <> {/* 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: '60' }, { 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 => ( ))}
{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.
{growingOn && (
Growing-files mode records H.264 All-Intra (4:2:0) in MPEG-TS — the only format 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;