web-ui: rewrite recorders.html with tabbed codec settings + BMD card picker

- Replace flat codec dropdowns with Master/Proxy blocks, each with
  Video/Audio/Container tabs.
- Replace BM1/BM2 device dropdown with cluster-node picker plus
  inline BMDCards.render(...) SVG -- click a port to set device_index.
- Wire full codec field set (video bitrate, framerate, audio codec/
  bitrate/channels, container) end-to-end to /api/v1/recorders.
- Auto-hide bitrate input for profile-driven codecs (ProRes, DNxHR,
  PCM, FLAC); show for H.264/265/NVENC, AAC, AC-3, Opus, DNxHD.
- Resolve SDI source display in cards via /cluster/devices/blackmagic
  (hostname + model + port) instead of raw device index.

Finishes the pending item from
docs/superpowers/plans/2026-05-21-cluster-codec-revamp.md.
This commit is contained in:
Zac Gaetano 2026-05-21 09:47:32 -04:00
parent 8403355ba9
commit f6c0594088

View file

@ -118,6 +118,138 @@
.slide-panel-header { border-bottom: 1px solid oklch(28% 0.04 260 / 0.4); padding: 18px 22px; }
.slide-panel-title { font-size: 15px; font-weight: 600; letter-spacing: -0.005em; }
.slide-panel-body { padding: 18px 22px; }
/* ── Codec tabs (Video / Audio / Container) ── */
.codec-block {
border: 1px solid oklch(28% 0.04 260 / 0.45);
border-radius: 10px;
background: oklch(12% 0.018 250 / 0.55);
margin-bottom: 14px;
overflow: hidden;
}
.codec-block-header {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 14px;
background: oklch(15% 0.025 260 / 0.55);
border-bottom: 1px solid oklch(28% 0.04 260 / 0.4);
}
.codec-block-title {
font-size: 12px; font-weight: 600;
letter-spacing: 0.14em; text-transform: uppercase;
color: var(--text-secondary);
}
.codec-tabs {
display: flex; gap: 0;
padding: 0 8px;
background: oklch(10% 0.015 250 / 0.5);
border-bottom: 1px solid oklch(28% 0.04 260 / 0.4);
}
.codec-tab {
flex: 1;
padding: 10px 10px 9px;
background: transparent;
border: 0;
border-bottom: 2px solid transparent;
font-size: 12px; font-weight: 500;
letter-spacing: 0.08em; text-transform: uppercase;
color: var(--text-tertiary);
cursor: pointer;
transition: color 120ms ease, border-color 120ms ease;
}
.codec-tab:hover { color: var(--text-secondary); }
.codec-tab.active {
color: oklch(78% 0.14 266);
border-bottom-color: oklch(55% 0.20 266 / 0.85);
}
.codec-tab-panel { display: none; padding: 14px; }
.codec-tab-panel.active { display: block; }
/* ── SDI picker (node + visual port card) ── */
.sdi-picker { display: flex; flex-direction: column; gap: 12px; }
.sdi-card-wrap {
border: 1px solid oklch(28% 0.04 260 / 0.45);
border-radius: 10px;
background:
radial-gradient(ellipse 60% 40% at 50% 0%, oklch(28% 0.10 266 / 0.18), transparent 70%),
oklch(11% 0.018 250 / 0.6);
padding: 14px 16px 12px;
display: flex; flex-direction: column; gap: 8px;
}
.sdi-card-empty {
padding: 18px;
text-align: center;
font-size: 12px;
color: var(--text-tertiary);
font-family: var(--font-mono);
letter-spacing: 0.02em;
}
.sdi-card-meta {
display: flex; flex-wrap: wrap; gap: 14px;
font-size: 11px; color: var(--text-tertiary);
font-family: var(--font-mono); letter-spacing: 0.02em;
}
.sdi-card-meta strong { color: var(--text-secondary); font-weight: 500; }
.bmd-card-svg {
display: block; width: 100%;
max-width: 360px; margin: 0 auto;
height: auto;
}
.bmd-card-body {
fill: oklch(18% 0.04 260 / 0.85);
stroke: oklch(30% 0.05 260 / 0.7);
stroke-width: 1;
}
.bmd-card-bracket {
fill: oklch(60% 0.02 260 / 0.65);
stroke: oklch(40% 0.03 260 / 0.8);
stroke-width: 1;
}
.bmd-card-trace {
fill: none;
stroke: oklch(35% 0.08 266 / 0.4);
stroke-width: 0.8;
stroke-dasharray: 2 3;
}
.bmd-card-model {
fill: oklch(55% 0.04 260);
font: 600 9px/1 var(--font-mono, ui-monospace);
letter-spacing: 0.14em;
}
.bmd-port-ring {
fill: oklch(20% 0.03 260);
stroke: oklch(45% 0.04 260);
stroke-width: 1.4;
transition: stroke 120ms ease, fill 120ms ease;
}
.bmd-port-pin {
fill: oklch(70% 0.03 260);
}
.bmd-port-label {
fill: var(--text-secondary, oklch(75% 0.02 260));
font: 600 11px/1 ui-sans-serif, system-ui, sans-serif;
}
.bmd-port-sublabel {
fill: var(--text-tertiary, oklch(55% 0.02 260));
font: 500 9px/1 var(--font-mono, ui-monospace);
letter-spacing: 0.04em;
}
.bmd-port-group { transition: filter 120ms ease; }
.bmd-port-group:hover .bmd-port-ring {
stroke: oklch(60% 0.18 266 / 0.8);
}
.bmd-port-group.is-selected .bmd-port-ring {
fill: oklch(30% 0.14 266 / 0.55);
stroke: oklch(75% 0.18 266);
stroke-width: 2;
filter: drop-shadow(0 0 6px oklch(60% 0.20 266 / 0.7));
}
.bmd-port-group.is-selected .bmd-port-pin {
fill: oklch(85% 0.10 266);
}
.bmd-port-group.is-selected .bmd-port-label {
fill: oklch(82% 0.14 266);
}
</style>
</head>
<body>
@ -255,57 +387,194 @@
<!-- Dynamic source config -->
<div id="sourceConfigFields" class="conditional-fields"></div>
<!-- Recording settings -->
<div class="form-group">
<div class="form-section-label">Recording settings</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" for="recCodec">Codec</label>
<select id="recCodec">
<option value="prores_hq">ProRes HQ</option>
<option value="prores_422">ProRes 422</option>
<option value="prores_lt">ProRes LT</option>
<option value="h264">H.264</option>
<option value="dnxhd">DNxHD</option>
</select>
<!-- Master recording codec block -->
<div class="codec-block">
<div class="codec-block-header">
<span class="codec-block-title">Master recording</span>
</div>
<div class="form-group">
<label class="form-label" for="recResolution">Resolution</label>
<select id="recResolution">
<option value="native">Native (source)</option>
<option value="1920x1080">1920×1080</option>
<option value="1280x720">1280×720</option>
<option value="3840x2160">3840×2160</option>
</select>
<div class="codec-tabs" role="tablist" data-target="master">
<button class="codec-tab active" data-tab="video">Video</button>
<button class="codec-tab" data-tab="audio">Audio</button>
<button class="codec-tab" data-tab="container">Container</button>
</div>
<!-- Video -->
<div class="codec-tab-panel active" data-panel="master:video">
<div class="form-row">
<div class="form-group">
<label class="form-label" for="recCodec">Video codec</label>
<select id="recCodec"></select>
</div>
<div class="form-group">
<label class="form-label" for="recResolution">Resolution</label>
<select id="recResolution">
<option value="native">Native (source)</option>
<option value="3840x2160">3840×2160 (UHD)</option>
<option value="1920x1080">1920×1080 (HD)</option>
<option value="1280x720">1280×720</option>
</select>
</div>
</div>
<div class="form-row" id="recVideoBitrateRow" style="display:none;">
<div class="form-group">
<label class="form-label" for="recVideoBitrate">Video bitrate</label>
<input type="text" id="recVideoBitrate" placeholder="e.g. 50M, 100M">
<div class="form-hint">ffmpeg <code>-b:v</code> value. Leave empty for codec default.</div>
</div>
<div class="form-group">
<label class="form-label" for="recFramerate">Framerate</label>
<select id="recFramerate">
<option value="">Source</option>
<option value="23.976">23.976</option>
<option value="24">24</option>
<option value="25">25</option>
<option value="29.97">29.97</option>
<option value="30">30</option>
<option value="50">50</option>
<option value="59.94">59.94</option>
<option value="60">60</option>
</select>
</div>
</div>
<div class="form-row" id="recFramerateOnlyRow">
<div class="form-group">
<label class="form-label" for="recFramerateAlt">Framerate</label>
<select id="recFramerateAlt">
<option value="">Source</option>
<option value="23.976">23.976</option>
<option value="24">24</option>
<option value="25">25</option>
<option value="29.97">29.97</option>
<option value="30">30</option>
<option value="50">50</option>
<option value="59.94">59.94</option>
<option value="60">60</option>
</select>
<div class="form-hint">ProRes / DNxHR pick bitrate from profile + resolution, so only framerate is configurable.</div>
</div>
</div>
</div>
<!-- Audio -->
<div class="codec-tab-panel" data-panel="master:audio">
<div class="form-row">
<div class="form-group">
<label class="form-label" for="recAudioCodec">Audio codec</label>
<select id="recAudioCodec"></select>
</div>
<div class="form-group">
<label class="form-label" for="recAudioChannels">Channels</label>
<select id="recAudioChannels">
<option value="1">1 (mono)</option>
<option value="2" selected>2 (stereo)</option>
<option value="4">4</option>
<option value="6">6 (5.1)</option>
<option value="8">8 (7.1)</option>
</select>
</div>
</div>
<div class="form-row" id="recAudioBitrateRow" style="display:none;">
<div class="form-group">
<label class="form-label" for="recAudioBitrate">Audio bitrate</label>
<input type="text" id="recAudioBitrate" placeholder="e.g. 192k, 320k">
<div class="form-hint">Only applies to compressed audio codecs (AAC, Opus, AC-3).</div>
</div>
</div>
</div>
<!-- Container -->
<div class="codec-tab-panel" data-panel="master:container">
<div class="form-group">
<label class="form-label" for="recContainer">Container format</label>
<select id="recContainer"></select>
<div class="form-hint">MOV is recommended for ProRes / DNxHR masters. MXF for broadcast workflows.</div>
</div>
</div>
</div>
<!-- Proxy toggle -->
<div class="form-group">
<label class="toggle">
<input type="checkbox" id="proxyToggle">
<input type="checkbox" id="proxyToggle" checked>
<div class="toggle-track"></div>
<span class="toggle-label">Generate proxy on stop</span>
<span class="toggle-label">Generate proxy</span>
</label>
<div class="form-hint">SDI sources record proxy in parallel. Network sources (SRT/RTMP) generate proxy after stop.</div>
</div>
<!-- Proxy settings (shown when proxy enabled) -->
<div id="proxyFields" style="display:none;" class="form-row">
<div class="form-group">
<label class="form-label" for="proxyCodec">Proxy codec</label>
<select id="proxyCodec">
<option value="h264">H.264</option>
<option value="h265">H.265</option>
</select>
<!-- Proxy codec block (mirrors master) -->
<div class="codec-block" id="proxyBlock">
<div class="codec-block-header">
<span class="codec-block-title">Proxy</span>
</div>
<div class="form-group">
<label class="form-label" for="proxyBitrate">Proxy bitrate</label>
<select id="proxyBitrate">
<option value="2000k">2 Mbps</option>
<option value="4000k">4 Mbps</option>
<option value="8000k">8 Mbps</option>
</select>
<div class="codec-tabs" role="tablist" data-target="proxy">
<button class="codec-tab active" data-tab="video">Video</button>
<button class="codec-tab" data-tab="audio">Audio</button>
<button class="codec-tab" data-tab="container">Container</button>
</div>
<div class="codec-tab-panel active" data-panel="proxy:video">
<div class="form-row">
<div class="form-group">
<label class="form-label" for="proxyCodec">Video codec</label>
<select id="proxyCodec"></select>
</div>
<div class="form-group">
<label class="form-label" for="proxyResolution">Resolution</label>
<select id="proxyResolution">
<option value="1920x1080">1920×1080</option>
<option value="1280x720">1280×720</option>
<option value="960x540">960×540</option>
<option value="640x360">640×360</option>
</select>
</div>
</div>
<div class="form-row" id="proxyVideoBitrateRow">
<div class="form-group">
<label class="form-label" for="proxyVideoBitrate">Video bitrate</label>
<input type="text" id="proxyVideoBitrate" value="8M" placeholder="e.g. 8M">
</div>
<div class="form-group">
<label class="form-label" for="proxyFramerate">Framerate</label>
<select id="proxyFramerate">
<option value="">Source</option>
<option value="23.976">23.976</option>
<option value="24">24</option>
<option value="25">25</option>
<option value="29.97">29.97</option>
<option value="30">30</option>
</select>
</div>
</div>
</div>
<div class="codec-tab-panel" data-panel="proxy:audio">
<div class="form-row">
<div class="form-group">
<label class="form-label" for="proxyAudioCodec">Audio codec</label>
<select id="proxyAudioCodec"></select>
</div>
<div class="form-group">
<label class="form-label" for="proxyAudioChannels">Channels</label>
<select id="proxyAudioChannels">
<option value="1">1 (mono)</option>
<option value="2" selected>2 (stereo)</option>
</select>
</div>
</div>
<div class="form-row" id="proxyAudioBitrateRow">
<div class="form-group">
<label class="form-label" for="proxyAudioBitrate">Audio bitrate</label>
<input type="text" id="proxyAudioBitrate" value="192k" placeholder="e.g. 192k">
</div>
</div>
</div>
<div class="codec-tab-panel" data-panel="proxy:container">
<div class="form-group">
<label class="form-label" for="proxyContainer">Container format</label>
<select id="proxyContainer"></select>
</div>
</div>
</div>
@ -339,11 +608,67 @@
<script src="js/api.js?v=6"></script>
<script src="js/topbar-strip.js?v=1"></script>
<script src="js/bmd-card.js?v=1"></script>
<script>
const pState = { recorders: [], timers: {}, sourceType: 'srt', mode: 'caller', projects: [], signals: {}, editingId: null };
// ── Codec catalogues (must match capture-manager.js) ──────────────
// Keys are exactly the values VIDEO_CODECS / AUDIO_CODECS / CONTAINER_FMT
// accept. `bitrateControl` mirrors the capture-manager flag — when false,
// the bitrate input is hidden (ProRes profile drives the bitrate; DNxHR
// profile drives the bitrate; PCM/FLAC are constant-bitrate by definition).
const VIDEO_CODECS = {
prores_hq: { label: 'ProRes 422 HQ', bitrateControl: false },
prores_422: { label: 'ProRes 422', bitrateControl: false },
prores_lt: { label: 'ProRes 422 LT', bitrateControl: false },
prores_proxy: { label: 'ProRes 422 Proxy', bitrateControl: false },
dnxhr_hq: { label: 'DNxHR HQ', bitrateControl: false },
dnxhr_sq: { label: 'DNxHR SQ', bitrateControl: false },
dnxhd: { label: 'DNxHD', bitrateControl: true },
h264: { label: 'H.264 (libx264)', bitrateControl: true },
h264_nvenc: { label: 'H.264 NVENC', bitrateControl: true },
h265: { label: 'H.265 (libx265)', bitrateControl: true },
hevc_nvenc: { label: 'HEVC NVENC', bitrateControl: true },
};
const AUDIO_CODECS = {
pcm_s16le: { label: 'PCM 16-bit', bitrateControl: false },
pcm_s24le: { label: 'PCM 24-bit', bitrateControl: false },
pcm_s32le: { label: 'PCM 32-bit', bitrateControl: false },
flac: { label: 'FLAC', bitrateControl: false },
aac: { label: 'AAC', bitrateControl: true },
ac3: { label: 'AC-3', bitrateControl: true },
opus: { label: 'Opus', bitrateControl: true },
};
const CONTAINER_FMT = {
mov: 'MOV (QuickTime)',
mp4: 'MP4',
mkv: 'MKV (Matroska)',
mxf: 'MXF',
ts: 'TS (MPEG-TS)',
};
// Default recommended codecs per role
const MASTER_DEFAULT_VIDEO = 'prores_hq';
const MASTER_DEFAULT_AUDIO = 'pcm_s24le';
const MASTER_DEFAULT_CONTAINER = 'mov';
const PROXY_DEFAULT_VIDEO = 'h264';
const PROXY_DEFAULT_AUDIO = 'aac';
const PROXY_DEFAULT_CONTAINER = 'mp4';
const pState = {
recorders: [], timers: {}, sourceType: 'srt', mode: 'caller',
projects: [], signals: {}, editingId: null,
// SDI picker state
bmdDevices: [], // flat list from /cluster/devices/blackmagic
sdiNodes: [], // grouped by node_id
selectedNodeId: null,
selectedDeviceIndex: null,
};
document.addEventListener('DOMContentLoaded', async () => {
await Promise.all([loadRecorders(), loadProjects()]);
populateCodecDropdowns();
wireCodecTabs();
wireCodecChangeHandlers();
await Promise.all([loadRecorders(), loadProjects(), loadBmdDevices()]);
setInterval(loadRecorders, 5000);
setInterval(pollRecordingSignals, 2000);
@ -368,12 +693,99 @@
document.getElementById('saveRecorderBtn').onclick = handleSaveRecorder;
document.getElementById('probeBtn').onclick = handleProbe;
document.getElementById('proxyToggle').onchange = e => {
document.getElementById('proxyFields').style.display = e.target.checked ? 'grid' : 'none';
document.getElementById('proxyBlock').style.display = e.target.checked ? '' : 'none';
};
document.getElementById('recProject').onchange = handleProjectChange;
updateSourceFields();
});
// ── Codec dropdown population ─────────────
function populateCodecDropdowns() {
const fill = (selId, dict, defaultKey) => {
const sel = document.getElementById(selId);
if (!sel) return;
sel.innerHTML = Object.entries(dict).map(([k, v]) => {
const label = typeof v === 'string' ? v : v.label;
return `<option value="${k}">${label}</option>`;
}).join('');
if (defaultKey) sel.value = defaultKey;
};
fill('recCodec', VIDEO_CODECS, MASTER_DEFAULT_VIDEO);
fill('recAudioCodec', AUDIO_CODECS, MASTER_DEFAULT_AUDIO);
fill('recContainer', CONTAINER_FMT, MASTER_DEFAULT_CONTAINER);
fill('proxyCodec', VIDEO_CODECS, PROXY_DEFAULT_VIDEO);
fill('proxyAudioCodec', AUDIO_CODECS, PROXY_DEFAULT_AUDIO);
fill('proxyContainer', CONTAINER_FMT, PROXY_DEFAULT_CONTAINER);
}
// ── Codec tabs: simple per-block accordion of panels ──
function wireCodecTabs() {
document.querySelectorAll('.codec-tabs').forEach(tabs => {
const target = tabs.dataset.target;
tabs.querySelectorAll('.codec-tab').forEach(btn => {
btn.addEventListener('click', () => {
tabs.querySelectorAll('.codec-tab').forEach(b => b.classList.toggle('active', b === btn));
const want = `${target}:${btn.dataset.tab}`;
document.querySelectorAll(`.codec-tab-panel[data-panel^="${target}:"]`).forEach(p => {
p.classList.toggle('active', p.dataset.panel === want);
});
});
});
});
}
// Show/hide bitrate inputs based on whether the selected codec supports
// bitrate control. ProRes & DNxHR (profile-driven) hide it; H.264/265 show it.
function wireCodecChangeHandlers() {
const sync = () => {
const masterV = VIDEO_CODECS[document.getElementById('recCodec').value] || {};
const masterA = AUDIO_CODECS[document.getElementById('recAudioCodec').value] || {};
document.getElementById('recVideoBitrateRow').style.display = masterV.bitrateControl ? 'grid' : 'none';
document.getElementById('recFramerateOnlyRow').style.display = masterV.bitrateControl ? 'none' : 'grid';
document.getElementById('recAudioBitrateRow').style.display = masterA.bitrateControl ? 'grid' : 'none';
const proxyV = VIDEO_CODECS[document.getElementById('proxyCodec').value] || {};
const proxyA = AUDIO_CODECS[document.getElementById('proxyAudioCodec').value] || {};
document.getElementById('proxyVideoBitrateRow').style.display = proxyV.bitrateControl ? 'grid' : 'none';
document.getElementById('proxyAudioBitrateRow').style.display = proxyA.bitrateControl ? 'grid' : 'none';
};
['recCodec', 'recAudioCodec', 'proxyCodec', 'proxyAudioCodec'].forEach(id => {
const el = document.getElementById(id);
if (el) el.addEventListener('change', sync);
});
sync();
}
// ── Cluster: BMD devices ──────────────────
async function loadBmdDevices() {
try {
const resp = await fetch('/api/v1/cluster/devices/blackmagic', { credentials: 'include' });
if (!resp.ok) return;
pState.bmdDevices = await resp.json();
// Group by node so we can render the per-node card
const byNode = new Map();
for (const d of pState.bmdDevices) {
if (!byNode.has(d.node_id)) {
byNode.set(d.node_id, {
node_id: d.node_id,
hostname: d.hostname,
ip_address: d.ip_address,
role: d.role,
online: d.online,
model: d.model,
devices: [],
});
}
byNode.get(d.node_id).devices.push(d);
}
pState.sdiNodes = Array.from(byNode.values());
// Re-render SDI fields if currently visible
if (pState.sourceType === 'sdi') updateSourceFields();
} catch (err) {
console.error('[bmd] load failed', err);
}
}
// ── Load / render ─────────────────────────
async function loadRecorders() {
const r = await getRecorders();
@ -411,10 +823,16 @@
let sourceDisplay = '';
if (cfg.url) {
sourceDisplay = cfg.url;
} else if (cfg.device !== undefined) {
sourceDisplay = `DeckLink ${cfg.device}`;
} else if (sourceTypeKey === 'sdi') {
const idx = rec.device_index ?? cfg.device ?? 0;
// Resolve node hostname + model from the cluster list if we have it
const dev = pState.bmdDevices.find(d => d.node_id === rec.node_id && d.index === idx);
if (dev) {
sourceDisplay = `${dev.hostname} · ${dev.model || 'DeckLink'} · port ${idx + 1}`;
} else {
sourceDisplay = `DeckLink port ${idx + 1}`;
}
} else if (cfg.mode === 'listener') {
// legacy/listener data - display read-only but no longer creatable from UI
const port = cfg.listen_port || (sourceTypeKey === 'srt' ? 49001 : 41936);
sourceDisplay = `(legacy listener :${port})`;
}
@ -447,6 +865,7 @@
<div class="recorder-badges">
<span class="badge ${badgeClass}">${sourceTypeKey.toUpperCase()}</span>
${rec.recording_codec ? `<span class="badge badge-idle">${esc(rec.recording_codec)}</span>` : ''}
${rec.recording_container ? `<span class="badge badge-idle">${esc(rec.recording_container.toUpperCase())}</span>` : ''}
</div>
</div>
<div class="recorder-actions">
@ -612,20 +1031,48 @@
document.getElementById('panelTitle').textContent = 'New recorder';
document.getElementById('saveRecorderBtn').textContent = 'Create recorder';
document.getElementById('probeBtn').style.display = '';
// Reset form to defaults
document.getElementById('recName').value = '';
document.getElementById('recCodec').value = 'prores_hq';
document.getElementById('recCodec').value = MASTER_DEFAULT_VIDEO;
document.getElementById('recResolution').value = 'native';
document.getElementById('proxyToggle').checked = false;
document.getElementById('proxyFields').style.display = 'none';
document.getElementById('recVideoBitrate').value = '';
document.getElementById('recFramerate').value = '';
document.getElementById('recFramerateAlt').value = '';
document.getElementById('recAudioCodec').value = MASTER_DEFAULT_AUDIO;
document.getElementById('recAudioBitrate').value = '';
document.getElementById('recAudioChannels').value = '2';
document.getElementById('recContainer').value = MASTER_DEFAULT_CONTAINER;
document.getElementById('proxyToggle').checked = true;
document.getElementById('proxyBlock').style.display = '';
document.getElementById('proxyCodec').value = PROXY_DEFAULT_VIDEO;
document.getElementById('proxyResolution').value = '1920x1080';
document.getElementById('proxyVideoBitrate').value = '8M';
document.getElementById('proxyFramerate').value = '';
document.getElementById('proxyAudioCodec').value = PROXY_DEFAULT_AUDIO;
document.getElementById('proxyAudioBitrate').value = '192k';
document.getElementById('proxyAudioChannels').value = '2';
document.getElementById('proxyContainer').value = PROXY_DEFAULT_CONTAINER;
document.getElementById('recProject').value = '';
document.getElementById('recBin').innerHTML = '<option value="">Project root</option>';
const pr = document.getElementById('probeResult');
if (pr) pr.remove();
pState.sourceType = 'srt';
pState.selectedNodeId = null;
pState.selectedDeviceIndex = null;
document.querySelectorAll('.source-type-btn').forEach(b => b.classList.toggle('active', b.dataset.type === 'srt'));
// Make sure tabs are on Video
document.querySelectorAll('.codec-tabs').forEach(tabs => {
tabs.querySelectorAll('.codec-tab').forEach(b => b.classList.toggle('active', b.dataset.tab === 'video'));
});
document.querySelectorAll('.codec-tab-panel').forEach(p => p.classList.toggle('active', p.dataset.panel.endsWith(':video')));
document.getElementById('recorderPanel').classList.add('open');
document.getElementById('panelOverlay').classList.add('open');
wireCodecChangeHandlers(); // re-sync visibility
updateSourceFields();
}
@ -646,34 +1093,52 @@
// Basic fields
document.getElementById('recName').value = rec.name || '';
document.getElementById('recCodec').value = rec.recording_codec || 'prores_hq';
document.getElementById('recResolution').value = rec.recording_resolution || 'native';
// Master codec
document.getElementById('recCodec').value = rec.recording_codec || MASTER_DEFAULT_VIDEO;
document.getElementById('recResolution').value = rec.recording_resolution || 'native';
document.getElementById('recVideoBitrate').value = rec.recording_video_bitrate || '';
document.getElementById('recFramerate').value = rec.recording_framerate || '';
document.getElementById('recFramerateAlt').value = rec.recording_framerate || '';
document.getElementById('recAudioCodec').value = rec.recording_audio_codec || MASTER_DEFAULT_AUDIO;
document.getElementById('recAudioBitrate').value = rec.recording_audio_bitrate || '';
document.getElementById('recAudioChannels').value = String(rec.recording_audio_channels ?? 2);
document.getElementById('recContainer').value = rec.recording_container || MASTER_DEFAULT_CONTAINER;
// Proxy
const proxyEnabled = !!rec.proxy_enabled;
const proxyEnabled = rec.proxy_enabled !== false;
document.getElementById('proxyToggle').checked = proxyEnabled;
document.getElementById('proxyFields').style.display = proxyEnabled ? 'grid' : 'none';
if (proxyEnabled) {
const pc = document.getElementById('proxyCodec');
if (pc) pc.value = rec.proxy_codec || 'h264';
const pb = document.getElementById('proxyBitrate');
// proxy_resolution stores a value like "4000k" (set from proxy bitrate select on create)
if (pb) pb.value = rec.proxy_resolution || '4000k';
}
document.getElementById('proxyBlock').style.display = proxyEnabled ? '' : 'none';
document.getElementById('proxyCodec').value = rec.proxy_codec || PROXY_DEFAULT_VIDEO;
document.getElementById('proxyResolution').value = rec.proxy_resolution || '1920x1080';
document.getElementById('proxyVideoBitrate').value = rec.proxy_video_bitrate || '8M';
document.getElementById('proxyFramerate').value = rec.proxy_framerate || '';
document.getElementById('proxyAudioCodec').value = rec.proxy_audio_codec || PROXY_DEFAULT_AUDIO;
document.getElementById('proxyAudioBitrate').value = rec.proxy_audio_bitrate || '192k';
document.getElementById('proxyAudioChannels').value = String(rec.proxy_audio_channels ?? 2);
document.getElementById('proxyContainer').value = rec.proxy_container || PROXY_DEFAULT_CONTAINER;
wireCodecChangeHandlers();
// Source type
const srcType = (rec.source_type || 'srt').toLowerCase();
pState.sourceType = srcType;
document.querySelectorAll('.source-type-btn').forEach(b => b.classList.toggle('active', b.dataset.type === srcType));
// Restore SDI selection if this is an SDI recorder
if (srcType === 'sdi') {
pState.selectedNodeId = rec.node_id || null;
pState.selectedDeviceIndex = rec.device_index ?? (rec.source_config?.device ?? 0);
} else {
pState.selectedNodeId = null;
pState.selectedDeviceIndex = null;
}
updateSourceFields();
// Populate source-specific fields after updateSourceFields injects DOM nodes
setTimeout(() => {
const cfg = rec.source_config || {};
if (srcType === 'sdi') {
const d = document.getElementById('sdiDevice');
if (d) d.value = String(cfg.device ?? 0);
} else if (srcType === 'srt') {
if (srcType === 'srt') {
const u = document.getElementById('srtUrl');
if (u) u.value = cfg.url || '';
} else if (srcType === 'rtmp') {
@ -718,15 +1183,7 @@
container.innerHTML = '';
if (type === 'sdi') {
container.innerHTML = `
<div class="form-group">
<label class="form-label" for="sdiDevice">DeckLink device</label>
<select id="sdiDevice">
<option value="0">DeckLink Card 1</option>
<option value="1">DeckLink Card 2</option>
</select>
</div>`;
renderSdiPicker(container);
} else if (type === 'srt') {
container.innerHTML = `
<div id="srtCallerFields">
@ -736,16 +1193,6 @@
<div class="form-hint">The recorder will connect out to this URL (caller mode). <code>?mode=caller</code> is appended automatically.</div>
</div>
</div>`;
// Wire port input to update banner
setTimeout(() => {
const portIn = document.getElementById('srtPort');
if (portIn) portIn.addEventListener('input', () => {
const el = document.getElementById('srtConnectStr');
if (el) el.textContent = `srt://${location.hostname || '10.0.0.25'}:${portIn.value}?mode=caller`;
});
}, 0);
} else if (type === 'rtmp') {
container.innerHTML = `
<div id="rtmpCallerFields">
@ -755,20 +1202,112 @@
<div class="form-hint">The recorder will pull this RTMP stream. Must be an existing published stream on an RTMP server.</div>
</div>
</div>`;
setTimeout(() => {
const portIn = document.getElementById('rtmpPort');
const keyIn = document.getElementById('rtmpKey');
const update = () => {
const el = document.getElementById('rtmpConnectStr');
if (el) el.textContent = `rtmp://${location.hostname || '10.0.0.25'}:${portIn?.value || 41936}/live/${keyIn?.value || 'stream'}`;
};
portIn?.addEventListener('input', update);
keyIn?.addEventListener('input', update);
}, 0);
}
}
// SDI picker = node dropdown + inline BMD card SVG
function renderSdiPicker(container) {
const wrap = document.createElement('div');
wrap.className = 'sdi-picker';
// Node selector
const nodeGroup = document.createElement('div');
nodeGroup.className = 'form-group';
nodeGroup.innerHTML = `
<label class="form-label" for="sdiNode">Capture node</label>
<select id="sdiNode"></select>
<div class="form-hint">Pick the cluster node hosting the DeckLink card, then click a port on the diagram below.</div>
`;
wrap.appendChild(nodeGroup);
// Card host
const cardWrap = document.createElement('div');
cardWrap.className = 'sdi-card-wrap';
cardWrap.id = 'sdiCardWrap';
wrap.appendChild(cardWrap);
container.appendChild(wrap);
// Populate node options
const nodeSel = document.getElementById('sdiNode');
if (!pState.sdiNodes.length) {
nodeSel.innerHTML = '<option value="">No DeckLink-capable nodes detected</option>';
cardWrap.innerHTML = `<div class="sdi-card-empty">
No nodes in the cluster reported a DeckLink card.<br>
Connect a worker with BMD hardware and refresh.
</div>`;
return;
}
nodeSel.innerHTML = pState.sdiNodes.map(n => {
const onlineMark = n.online ? '' : ' (offline)';
const modelStr = n.model ? ` · ${n.model}` : '';
const portCount = n.devices.length;
return `<option value="${n.node_id}">${esc(n.hostname)}${modelStr} · ${portCount} port${portCount === 1 ? '' : 's'}${onlineMark}</option>`;
}).join('');
// Restore selection or pick first
if (pState.selectedNodeId && pState.sdiNodes.find(n => n.node_id === pState.selectedNodeId)) {
nodeSel.value = pState.selectedNodeId;
} else {
pState.selectedNodeId = pState.sdiNodes[0].node_id;
nodeSel.value = pState.selectedNodeId;
}
if (pState.selectedDeviceIndex == null) {
pState.selectedDeviceIndex = 0;
}
nodeSel.addEventListener('change', () => {
pState.selectedNodeId = nodeSel.value;
pState.selectedDeviceIndex = 0;
drawBmdCardForSelectedNode();
});
drawBmdCardForSelectedNode();
}
function drawBmdCardForSelectedNode() {
const wrap = document.getElementById('sdiCardWrap');
if (!wrap) return;
wrap.innerHTML = '';
const node = pState.sdiNodes.find(n => n.node_id === pState.selectedNodeId);
if (!node) {
wrap.innerHTML = `<div class="sdi-card-empty">Select a node to see its DeckLink card.</div>`;
return;
}
// Meta strip above the card
const meta = document.createElement('div');
meta.className = 'sdi-card-meta';
meta.innerHTML = `
<span><strong>Host:</strong> ${esc(node.hostname)}</span>
${node.ip_address ? `<span><strong>IP:</strong> ${esc(node.ip_address)}</span>` : ''}
${node.model ? `<span><strong>Model:</strong> ${esc(node.model)}</span>` : ''}
<span><strong>Ports:</strong> ${node.devices.length}</span>
<span><strong>Status:</strong> ${node.online ? 'online' : 'offline'}</span>
`;
wrap.appendChild(meta);
// SVG render
if (!window.BMDCards) {
wrap.appendChild(Object.assign(document.createElement('div'), {
className: 'sdi-card-empty',
textContent: 'BMD card renderer failed to load.',
}));
return;
}
const svg = window.BMDCards.render({
model: node.model,
deviceCount: node.devices.length,
selectedIndex: pState.selectedDeviceIndex,
onSelect: (i) => {
pState.selectedDeviceIndex = i;
drawBmdCardForSelectedNode();
},
});
wrap.appendChild(svg);
}
function setMode(_mode) {
// Listener mode UI was removed - all recorders are caller (pull) mode now.
pState.mode = 'caller';
@ -793,12 +1332,10 @@
if (r.success) r.data.forEach(b => binSel.innerHTML += `<option value="${b.id}">${esc(b.name)}</option>`);
}
// ── Save recorder (create or update) ──────
// ── Probe ─────────────────────────────────
async function handleProbe() {
const btn = document.getElementById('probeBtn');
btn.disabled = true; btn.textContent = 'Probing...';
// Build payload from current form state
const type = pState.sourceType;
const payload = { source_type: type };
if (type === 'srt' && document.getElementById('srtUrl')) {
@ -806,8 +1343,8 @@
} else if (type === 'rtmp' && document.getElementById('rtmpUrl')) {
payload.source_url = document.getElementById('rtmpUrl').value.trim();
} else if (type === 'sdi') {
const d = document.getElementById('sdiDevice');
if (d) payload.device = parseInt(d.value || '0', 10);
payload.device = pState.selectedDeviceIndex ?? 0;
if (pState.selectedNodeId) payload.node_id = pState.selectedNodeId;
}
try {
const r = await fetch('/api/v1/recorders/probe', {
@ -857,37 +1394,84 @@
host.innerHTML = html;
}
// ── Save recorder (create or update) ──────
async function handleSaveRecorder() {
const name = document.getElementById('recName').value.trim();
if (!name) { toast('Enter a recorder name', '', 'warning'); return; }
const type = pState.sourceType;
const recording_codec = document.getElementById('recCodec').value;
const recording_resolution = document.getElementById('recResolution').value;
const projectId = document.getElementById('recProject').value || null;
const masterCodec = document.getElementById('recCodec').value;
const masterCodecMeta = VIDEO_CODECS[masterCodec] || {};
const proxyCodec = document.getElementById('proxyCodec').value;
const proxyCodecMeta = VIDEO_CODECS[proxyCodec] || {};
const masterAudioMeta = AUDIO_CODECS[document.getElementById('recAudioCodec').value] || {};
const proxyAudioMeta = AUDIO_CODECS[document.getElementById('proxyAudioCodec').value] || {};
// SDI requires a node + device index
let nodeId = null;
let deviceIndex = null;
let sourceConfig = {};
if (type === 'sdi') {
sourceConfig.device = parseInt(document.getElementById('sdiDevice')?.value || '0');
if (!pState.selectedNodeId) {
toast('Pick an SDI capture node', '', 'warning');
return;
}
nodeId = pState.selectedNodeId;
deviceIndex = pState.selectedDeviceIndex ?? 0;
sourceConfig.device = deviceIndex;
} else if (type === 'srt') {
sourceConfig.mode = 'caller';
sourceConfig.url = document.getElementById('srtUrl')?.value;
sourceConfig.url = (document.getElementById('srtUrl')?.value || '').trim();
if (!sourceConfig.url) { toast('Enter an SRT source URL', '', 'warning'); return; }
} else if (type === 'rtmp') {
sourceConfig.mode = 'caller';
sourceConfig.url = document.getElementById('rtmpUrl')?.value;
sourceConfig.url = (document.getElementById('rtmpUrl')?.value || '').trim();
if (!sourceConfig.url) { toast('Enter an RTMP source URL', '', 'warning'); return; }
}
const proxy_enabled = document.getElementById('proxyToggle').checked;
const proxyEnabled = document.getElementById('proxyToggle').checked;
// Choose the right framerate field — bitrate-controlled codecs use the
// "main" framerate (in the same row as bitrate); profile-driven codecs
// use the alt framerate in its own row.
const masterFramerate = masterCodecMeta.bitrateControl
? document.getElementById('recFramerate').value
: document.getElementById('recFramerateAlt').value;
const payload = {
name,
source_type: type,
source_config: sourceConfig,
recording_codec,
recording_resolution,
project_id: projectId,
proxy_enabled,
proxy_codec: proxy_enabled ? document.getElementById('proxyCodec').value : undefined,
proxy_resolution: proxy_enabled ? document.getElementById('proxyBitrate').value : undefined,
// Master codec
recording_codec: masterCodec,
recording_resolution: document.getElementById('recResolution').value,
recording_video_bitrate: masterCodecMeta.bitrateControl ? (document.getElementById('recVideoBitrate').value.trim() || null) : null,
recording_framerate: masterFramerate || null,
recording_audio_codec: document.getElementById('recAudioCodec').value,
recording_audio_bitrate: masterAudioMeta.bitrateControl ? (document.getElementById('recAudioBitrate').value.trim() || null) : null,
recording_audio_channels: parseInt(document.getElementById('recAudioChannels').value, 10),
recording_container: document.getElementById('recContainer').value,
// Proxy
proxy_enabled: proxyEnabled,
proxy_codec: proxyEnabled ? proxyCodec : undefined,
proxy_resolution: proxyEnabled ? document.getElementById('proxyResolution').value : undefined,
proxy_video_bitrate: proxyEnabled && proxyCodecMeta.bitrateControl
? (document.getElementById('proxyVideoBitrate').value.trim() || null)
: undefined,
proxy_framerate: proxyEnabled ? (document.getElementById('proxyFramerate').value || null) : undefined,
proxy_audio_codec: proxyEnabled ? document.getElementById('proxyAudioCodec').value : undefined,
proxy_audio_bitrate: proxyEnabled && proxyAudioMeta.bitrateControl
? (document.getElementById('proxyAudioBitrate').value.trim() || null)
: undefined,
proxy_audio_channels: proxyEnabled ? parseInt(document.getElementById('proxyAudioChannels').value, 10) : undefined,
proxy_container: proxyEnabled ? document.getElementById('proxyContainer').value : undefined,
// Pinning
project_id: document.getElementById('recProject').value || null,
node_id: nodeId,
device_index: deviceIndex,
};
if (pState.editingId) {
@ -916,7 +1500,7 @@
}
function esc(s) {
if (!s) return '';
if (s === null || s === undefined) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
</script>