// 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 (
onSelect(port.index, group.nodeId)}>
{portLabel} {port.index + 1}
{showTestBadge && port.present === false && (
TEST CARD
)}
{port.device ? port.device.split('/').pop() : `index ${port.index}`}
);
})}
))}
);
}
/**
* 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:`}
Capture node
onNodeChange(e.target.value)} style={{ appearance: 'auto' }}>
{nodes.length === 0
? No cluster nodes
: nodes.map(n => {
const id = n.id || n.hostname || n.name || '';
return {n.hostname || n.name || id} ;
})}
{portLabel} index
onIdxChange(Number(e.target.value))} style={{ appearance: 'auto' }}>
{Array.from({ length: portCount }, (_, i) =>
{portLabel} {i + 1} (index {i}) )}
);
}
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 [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 (
e.stopPropagation()}>
New recorder
Configure source, codec, and destination
Recorder name
setName(e.target.value)} />
Source type
{[
{ 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 => (
setSourceType(t.id)}>
))}
{sourceType === 'SRT' && (
Source URL
setSrtUrl(e.target.value)} style={{ flex: 1 }} />
{probing ? '…' : 'Probe'}
Recorder connects out to this URL (caller mode).
{probeResult &&
}
)}
{sourceType === 'RTMP' && (
Source URL
setRtmpUrl(e.target.value)} style={{ flex: 1 }} />
{probing ? '…' : 'Probe'}
Recorder pulls this RTMP stream.
{probeResult &&
}
)}
{sourceType === 'SDI' && (
Capture device
{sdiDevices === null && (
Detecting DeckLink devices…
)}
{sdiDevices !== null && sdiDevices.length > 0 && (
{ setSdiDeviceIdx(idx); setSdiNodeId(nodeId); }}
portLabel="SDI"
/>
)}
{sdiDevices !== null && sdiDevices.length === 0 && (
)}
)}
{sourceType === 'DELTACAST' && (
Capture device
{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 => (
setRecTab(t)}>
{t[0].toUpperCase() + t.slice(1)}
))}
{recTab === 'video' && (
Video codec
setRecCodec(e.target.value)} style={{ appearance: 'auto' }}>
All-Intra HEVC (NVENC) — GPU, growing
H.264 (NVENC) — GPU
ProRes 422 HQ — 4:2:2 CPU
ProRes 422
ProRes 422 LT
ProRes 422 Proxy
DNxHR HQ
H.264 (x264, CPU)
H.265 (x265, CPU)
{codecUsesBitrate ? (
Target bitrate (Mbps)
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' && (
)}
{proxyOn && (
Proxy
{['H.264', '2 Mbps', 'MP4', '1920×1080', 'AAC 128 kbps'].map(tag => (
{tag}
))}
Fixed proxy profile. Not configurable.
)}
Destination
Project
setProjectId(e.target.value)} style={{ appearance: 'auto' }}>
{PROJECTS.length === 0
? No projects
: PROJECTS.map(p => {p.name} )}
{submitErr && (
{submitErr}
)}
Cancel
{submitting ? 'Creating…' : 'Create recorder'}
);
}
window.NewRecorderModal = NewRecorderModal;