2026-04-07 22:05:43 -04:00
<!DOCTYPE html>
< html lang = "en" >
< head >
2026-05-16 13:57:20 -04:00
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
2026-05-18 10:13:08 -04:00
< link rel = "icon" type = "image/x-icon" href = "favicon.ico" >
2026-05-17 07:39:19 -04:00
< title > Recorders — Z-AMPP< / title >
< link rel = "stylesheet" href = "css/common.css?v=3" >
2026-05-16 13:57:20 -04:00
< style >
2026-05-18 10:49:46 -04:00
/* ── Recorders page · AMPP-aligned theme ── */
.main {
background:
radial-gradient(ellipse 70% 50% at 50% 0%, oklch(28% 0.10 266 / 0.35), transparent 60%),
radial-gradient(ellipse 60% 40% at 90% 100%, oklch(35% 0.16 266 / 0.18), transparent 65%),
var(--bg-base);
}
.page-content { padding: 24px 32px 48px; }
2026-05-16 13:57:20 -04:00
.recorder-grid {
display: grid;
2026-05-18 10:49:46 -04:00
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
gap: 16px;
2026-05-16 13:57:20 -04:00
}
.recorder-card {
2026-05-18 10:49:46 -04:00
position: relative;
background: oklch(13% 0.018 250 / 0.7);
border: 1px solid oklch(28% 0.04 260 / 0.5);
border-radius: 12px;
padding: 16px 18px 14px;
display: flex; flex-direction: column;
gap: 12px;
backdrop-filter: blur(6px);
transition: border-color 160ms ease, background 160ms ease, transform 160ms ease;
}
.recorder-card:hover { border-color: oklch(45% 0.20 266 / 0.4); }
2026-05-16 13:57:20 -04:00
.recorder-card.recording {
2026-05-18 10:49:46 -04:00
border-color: oklch(62% 0.22 25 / 0.55);
background: oklch(15% 0.025 260 / 0.8);
box-shadow: 0 16px 40px -16px oklch(62% 0.22 25 / 0.25);
2026-05-16 13:57:20 -04:00
}
2026-05-18 10:49:46 -04:00
.recorder-card.error { border-color: oklch(62% 0.22 25 / 0.5); }
2026-05-16 13:57:20 -04:00
.recorder-header {
2026-05-18 10:49:46 -04:00
display: flex; align-items: flex-start; justify-content: space-between;
gap: 12px;
2026-05-16 13:57:20 -04:00
}
2026-05-18 10:49:46 -04:00
.recorder-id { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
2026-05-16 13:57:20 -04:00
.recorder-name {
2026-05-18 10:49:46 -04:00
font-size: 15px; font-weight: 600;
letter-spacing: -0.005em;
color: var(--text-primary);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.recorder-badges { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.recorder-actions { display: flex; gap: 4px; flex-shrink: 0; }
.recorder-status-row { display: flex; align-items: center; gap: 10px; padding: 2px 0; }
.recorder-status-row .text-sm { font-size: 13px; font-weight: 500; }
.recorder-timer { margin-left: auto; font-family: var(--font-mono); font-size: 14px; font-weight: 600; color: oklch(70% 0.18 266); letter-spacing: 0.02em; font-variant-numeric: tabular-nums; }
.recorder-card.recording .recorder-timer { color: oklch(62% 0.22 25); }
.signal-strip { position: relative; height: 4px; background: oklch(20% 0.04 260); border-radius: 2px; overflow: hidden; }
.signal-strip-fill { position: absolute; top: 0; bottom: 0; left: 0; width: 100%; background: linear-gradient(90deg, oklch(55% 0.20 266), oklch(70% 0.18 266)); animation: signalSlide 2.4s linear infinite; }
.signal-strip--warn .signal-strip-fill { background: oklch(82% 0.15 90); }
.signal-strip--bad .signal-strip-fill { background: oklch(62% 0.22 25); animation: none; opacity: 0.4; }
.signal-strip--idle .signal-strip-fill { background: oklch(35% 0.04 260); animation: none; opacity: 0.4; }
@keyframes signalSlide { 0% { transform: translateX(-100%) } 100% { transform: translateX(100%) } }
.recorder-preview { position: relative; width: 100%; aspect-ratio: 16/9; background: #000; border-radius: 8px; overflow: hidden; border: 1px solid oklch(28% 0.04 260 / 0.5); }
.recorder-preview video { width: 100%; height: 100%; object-fit: contain; display: block; }
.recorder-preview-stamp { position: absolute; top: 10px; left: 10px; display: inline-flex; align-items: center; gap: 6px; background: oklch(10% 0.015 250 / 0.7); backdrop-filter: blur(6px); padding: 4px 10px; border-radius: 999px; border: 1px solid oklch(62% 0.22 25 / 0.5); font-size: 10px; font-weight: 700; letter-spacing: 0.14em; color: #fff; }
.recorder-preview-dot { width: 6px; height: 6px; background: oklch(62% 0.22 25); border-radius: 50%; animation: fsPulse 1.4s ease-in-out infinite; box-shadow: 0 0 8px oklch(62% 0.22 25); }
@keyframes fsPulse { 0%,100% { opacity: 0.7 } 50% { opacity: 1 } }
.recorder-source { display: flex; align-items: center; gap: 8px; padding: 8px 10px; background: oklch(11% 0.018 250 / 0.6); border: 1px solid oklch(28% 0.04 260 / 0.4); border-radius: 6px; font-size: 12px; color: var(--text-secondary); font-family: var(--font-mono); letter-spacing: 0.01em; }
.recorder-source svg { color: var(--text-tertiary); }
.recorder-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-top: 8px; border-top: 1px solid oklch(28% 0.04 260 / 0.4); }
.recorder-footer-meta { font-size: 11px; color: var(--text-tertiary); font-family: var(--font-mono); letter-spacing: 0.02em; }
.recorder-controls { display: flex; gap: 6px; }
/* Source-type + mode selectors — brand-blue tile feel */
.source-type-row, .mode-row { display: flex; gap: 8px; }
.source-type-btn, .mode-btn {
flex: 1; padding: 10px 12px;
background: oklch(13% 0.018 250 / 0.6);
border: 1px solid oklch(28% 0.04 260 / 0.45);
border-radius: 8px;
font-size: 13px; font-weight: 500;
2026-05-16 13:57:20 -04:00
color: var(--text-secondary);
2026-05-18 10:49:46 -04:00
cursor: pointer; text-align: center;
letter-spacing: 0.04em;
transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
2026-05-16 13:57:20 -04:00
}
2026-05-18 10:49:46 -04:00
.source-type-btn:hover, .mode-btn:hover {
border-color: oklch(45% 0.20 266 / 0.5);
color: var(--text-primary);
2026-05-16 13:57:20 -04:00
}
2026-05-18 10:49:46 -04:00
.source-type-btn.active, .mode-btn.active {
border-color: oklch(55% 0.20 266 / 0.7);
background: oklch(20% 0.08 266 / 0.45);
color: oklch(78% 0.14 266);
font-weight: 600;
2026-05-16 13:57:20 -04:00
}
.form-section-label {
2026-05-18 10:49:46 -04:00
font-size: 11px; font-weight: 600;
letter-spacing: 0.18em; text-transform: uppercase;
2026-05-16 13:57:20 -04:00
color: var(--text-tertiary);
2026-05-18 10:49:46 -04:00
padding: 14px 0 8px;
2026-05-16 13:57:20 -04:00
}
2026-05-18 10:49:46 -04:00
.recorder-connect-info { margin-top: 4px; }
2026-05-16 13:57:20 -04:00
2026-05-18 10:49:46 -04:00
/* Slide panel polish */
.slide-panel { background: oklch(11% 0.018 250 / 0.98); border-left: 1px solid oklch(28% 0.04 260 / 0.6); }
.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; }
2026-05-21 10:10:24 -04:00
/* Flexbox overflow footgun fix: a flex child won't honour overflow:auto
unless we give it min-height:0. Without this, the new codec blocks
overflow past the panel bottom and the footer is unreachable. */
.slide-panel { height: 100vh; max-height: 100vh; overflow: hidden; }
.slide-panel-body {
padding: 18px 22px;
min-height: 0;
flex: 1;
overflow-y: auto;
}
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.
2026-05-21 09:47:32 -04:00
/* ── 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);
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);
}
2026-05-16 13:57:20 -04:00
< / style >
2026-04-07 22:05:43 -04:00
< / head >
< body >
2026-05-16 13:57:20 -04:00
< div class = "shell" >
<!-- Sidebar -->
< nav class = "sidebar" aria-label = "Main navigation" >
< div class = "sidebar-brand" >
2026-05-18 10:13:08 -04:00
< img src = "img/dragon-logo.png?v=1" alt = "Wild Dragon" class = "sidebar-logo" >
2026-05-17 07:39:19 -04:00
< span class = "sidebar-brand-name" > Z-AMPP< / span >
2026-04-07 22:05:43 -04:00
< / div >
2026-05-18 22:56:51 -04:00
< nav class = "sidebar-nav" >
< a href = "home.html" class = "nav-item" >
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < path d = "M2 7l6-5 6 5v7a1 1 0 0 1-1 1h-3v-5H6v5H3a1 1 0 0 1-1-1z" / > < / svg >
Home
< / a >
< a href = "index.html" class = "nav-item" >
2026-05-16 13:57:20 -04:00
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < rect x = "1" y = "1" width = "6" height = "6" rx = "1" / > < rect x = "9" y = "1" width = "6" height = "6" rx = "1" / > < rect x = "1" y = "9" width = "6" height = "6" rx = "1" / > < rect x = "9" y = "9" width = "6" height = "6" rx = "1" / > < / svg >
Library
< / a >
2026-05-18 22:56:51 -04:00
< a href = "projects.html" class = "nav-item" >
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < path d = "M1 4a1 1 0 0 1 1-1h4l2 2h5a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z" / > < / svg >
Projects
< / a >
2026-05-16 13:57:20 -04:00
< a href = "upload.html" class = "nav-item" >
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < path d = "M8 11V3M5 6l3-3 3 3" / > < path d = "M2 13h12" / > < / svg >
Ingest
< / a >
< a href = "recorders.html" class = "nav-item active" >
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < rect x = "1" y = "4" width = "10" height = "8" rx = "1" / > < path d = "M11 7l4-2v6l-4-2" / > < / svg >
Recorders
< / a >
< a href = "capture.html" class = "nav-item" >
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < circle cx = "8" cy = "8" r = "3" / > < circle cx = "8" cy = "8" r = "6.5" / > < / svg >
Capture
< / a >
< a href = "jobs.html" class = "nav-item" >
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < path d = "M2 4h12M2 8h8M2 12h5" / > < / svg >
Jobs
< / a >
2026-05-18 23:11:53 -04:00
< a href = "editor.html" class = "nav-item" >
2026-05-17 21:44:15 -04:00
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < path d = "M2 13l1.5-3.5L11 2l3 3-7.5 7.5L3 14zM10 3l3 3" / > < / svg >
Editor
< / a >
2026-05-18 22:56:51 -04:00
< div class = "sidebar-section-label" > Admin< / div >
< a href = "users.html" class = "nav-item" >
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < circle cx = "6" cy = "5" r = "2.5" / > < path d = "M1 13c0-2.8 2.2-5 5-5s5 2.2 5 5" / > < circle cx = "12" cy = "5" r = "2" / > < path d = "M15 12c0-1.9-1.3-3.5-3-4" / > < / svg >
Users
< / a >
< a href = "tokens.html" class = "nav-item" >
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < circle cx = "6" cy = "10" r = "3.5" / > < path d = "M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1" / > < / svg >
Tokens
< / a >
2026-05-20 00:22:57 -04:00
< a href = "containers.html" class = "nav-item" >
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < rect x = "1" y = "5" width = "14" height = "4" rx = "1" / > < rect x = "1" y = "10" width = "14" height = "4" rx = "1" / > < path d = "M4 7h1M4 12h1" / > < / svg >
Containers
< / a >
< a href = "cluster.html" class = "nav-item" >
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < circle cx = "8" cy = "8" r = "2" / > < circle cx = "2" cy = "3" r = "1.5" / > < circle cx = "14" cy = "3" r = "1.5" / > < circle cx = "2" cy = "13" r = "1.5" / > < circle cx = "14" cy = "13" r = "1.5" / > < path d = "M3.1 4.1L6.5 6.5M12.9 4.1L9.5 6.5M3.1 11.9L6.5 9.5M12.9 11.9L9.5 9.5" / > < / svg >
Cluster
< / a >
2026-05-20 14:42:46 -04:00
< a href = "settings.html" class = "nav-item" >
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < circle cx = "8" cy = "8" r = "2.5" / > < path d = "M8 1v1.5M8 13.5V15M1 8h1.5M13.5 8H15M2.9 2.9l1.1 1.1M12 12l1.1 1.1M2.9 13.1L4 12M12 4l1.1-1.1" / > < / svg >
Settings
< / a >
2026-05-16 13:57:20 -04:00
< / nav >
2026-05-18 22:56:51 -04:00
< div class = "sidebar-footer" >
< div class = "sidebar-user" >
< div class = "sidebar-user-avatar" id = "userAvatar" > ?< / div >
< div class = "sidebar-user-info" >
< div class = "sidebar-user-name" id = "userName" > —< / div >
< div class = "sidebar-user-role" id = "userRole" > < / div >
< / div >
< button class = "btn btn-ghost" id = "logoutBtn" title = "Sign out" style = "padding:0;width:24px;height:24px;flex-shrink:0;" >
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" width = "14" height = "14" > < path d = "M10 8H3M6 5l-3 3 3 3" / > < path d = "M7 3h5a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H7" / > < / svg >
< / button >
< / div >
< / div >
2026-05-16 13:57:20 -04:00
< / nav >
< div class = "main" >
< header class = "topbar" >
< div class = "topbar-left" >
< span class = "page-title" > Recorders< / span >
< / div >
< div class = "topbar-right" >
< button class = "btn btn-primary btn-sm" id = "newRecorderBtn" >
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < path d = "M8 2v12M2 8h12" / > < / svg >
New recorder
< / button >
< / div >
< / header >
< div class = "page-content" >
< div id = "recorderGrid" class = "recorder-grid" > < / div >
< div id = "recorderEmpty" class = "empty-state" style = "display:none;" >
< div class = "empty-state-icon" >
< svg viewBox = "0 0 40 40" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < rect x = "2" y = "9" width = "26" height = "22" rx = "2" / > < path d = "M28 16l10-5v18l-10-5" / > < circle cx = "15" cy = "20" r = "4" / > < / svg >
2026-04-07 22:05:43 -04:00
< / div >
2026-05-16 13:57:20 -04:00
< div class = "empty-state-title" > No recorders yet< / div >
< div class = "empty-state-body" > Create a recorder to ingest live streams via SRT, RTMP, or SDI.< / div >
< div class = "empty-state-actions" >
< button class = "btn btn-primary btn-sm" onclick = "openPanel()" > New recorder< / button >
< / div >
< / div >
< / div >
< / div >
< / div >
<!-- Slide panel: new/edit recorder -->
< div class = "slide-overlay" id = "panelOverlay" > < / div >
< div class = "slide-panel" id = "recorderPanel" >
< div class = "slide-panel-header" >
2026-05-18 23:35:16 -04:00
< span class = "slide-panel-title" id = "panelTitle" > New recorder< / span >
2026-05-16 13:57:20 -04:00
< button class = "btn btn-ghost btn-sm" id = "closePanelBtn" style = "padding:0;width:28px;height:28px;" >
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < path d = "M3 3l10 10M13 3L3 13" / > < / svg >
< / button >
< / div >
< div class = "slide-panel-body" >
<!-- Name -->
< div class = "form-group" >
< label class = "form-label" for = "recName" > Recorder name< / label >
< input type = "text" id = "recName" placeholder = "e.g. Studio A SRT" >
2026-04-07 22:05:43 -04:00
< / div >
2026-05-16 13:57:20 -04:00
<!-- Source type -->
< div class = "form-group" >
< label class = "form-label" > Source type< / label >
< div class = "source-type-row" >
< button class = "source-type-btn active" data-type = "srt" onclick = "setSourceType('srt')" > SRT< / button >
< button class = "source-type-btn" data-type = "rtmp" onclick = "setSourceType('rtmp')" > RTMP< / button >
< button class = "source-type-btn" data-type = "sdi" onclick = "setSourceType('sdi')" > SDI< / button >
< / div >
< / div >
2026-04-07 22:05:43 -04:00
2026-05-16 13:57:20 -04:00
<!-- Dynamic source config -->
< div id = "sourceConfigFields" class = "conditional-fields" > < / div >
2026-04-07 22:05:43 -04:00
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.
2026-05-21 09:47:32 -04:00
<!-- Master recording codec block -->
< div class = "codec-block" >
< div class = "codec-block-header" >
< span class = "codec-block-title" > Master recording< / span >
< / div >
< 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 >
2026-04-07 22:05:43 -04:00
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.
2026-05-21 09:47:32 -04:00
<!-- 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 >
2026-05-16 13:57:20 -04:00
< / div >
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.
2026-05-21 09:47:32 -04:00
<!-- 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 >
2026-05-16 13:57:20 -04:00
< / div >
< / div >
2026-04-07 22:05:43 -04:00
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.
2026-05-21 09:47:32 -04:00
<!-- Proxy toggle -->
2026-05-16 13:57:20 -04:00
< div class = "form-group" >
< label class = "toggle" >
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.
2026-05-21 09:47:32 -04:00
< input type = "checkbox" id = "proxyToggle" checked >
2026-05-16 13:57:20 -04:00
< div class = "toggle-track" > < / div >
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.
2026-05-21 09:47:32 -04:00
< span class = "toggle-label" > Generate proxy< / span >
2026-05-16 13:57:20 -04:00
< / label >
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.
2026-05-21 09:47:32 -04:00
< div class = "form-hint" > SDI sources record proxy in parallel. Network sources (SRT/RTMP) generate proxy after stop.< / div >
2026-05-16 13:57:20 -04:00
< / div >
2026-04-07 22:05:43 -04:00
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.
2026-05-21 09:47:32 -04:00
<!-- Proxy codec block (mirrors master) -->
< div class = "codec-block" id = "proxyBlock" >
< div class = "codec-block-header" >
< span class = "codec-block-title" > Proxy< / span >
2026-05-16 13:57:20 -04:00
< / div >
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.
2026-05-21 09:47:32 -04:00
< 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 >
2026-05-16 13:57:20 -04:00
< / div >
< / div >
2026-04-07 22:05:43 -04:00
2026-05-16 13:57:20 -04:00
<!-- Project / bin -->
< div class = "form-group" >
< div class = "form-section-label" > Destination< / div >
< / div >
< div class = "form-row" >
< div class = "form-group" >
< label class = "form-label" for = "recProject" > Project< / label >
< select id = "recProject" >
< option value = "" > None (manual assignment)< / option >
< / select >
< / div >
< div class = "form-group" >
< label class = "form-label" for = "recBin" > Bin< / label >
< select id = "recBin" >
< option value = "" > Project root< / option >
< / select >
< / div >
< / div >
< / div >
< div class = "slide-panel-footer" >
< button class = "btn btn-ghost" onclick = "closePanel()" > Cancel< / button >
2026-05-17 18:39:09 -04:00
< button class = "btn btn-secondary" id = "probeBtn" > Probe source< / button >
2026-05-16 13:57:20 -04:00
< button class = "btn btn-primary" id = "saveRecorderBtn" > Create recorder< / button >
< / div >
< / div >
< div class = "toast-container" id = "toastContainer" aria-live = "polite" > < / div >
2026-05-18 23:35:16 -04:00
< script src = "js/api.js?v=6" > < / script >
feat(design): broadcast ops console redesign sweep
Confirmed shape brief: ops-console direction, balanced density, blue committed accent, readability emphasized.
Design system: surface scale to 5 steps tinted toward hue 266; text contrast lifted (secondary 62->72%, tertiary 44->52, added text-disabled); borders gained faint variant; status tokens renamed semantically (signal-good/warn/bad/idle); typography upgraded (Inter 400/500/600, JetBrains Mono for numerics, new 2xl/3xl/tc scale steps).
New components: tc-display timecode classes with blue glow; tally-word with good/warn/bad/rec variants; signal-strip flutter bar with modifiers; chip dense monospaced pill; manifest table styles.
Topbar status strip: new js/topbar-strip.js auto-injects a 28px strip on every page with wall clock, page name, live API latency, and system-pulse dot.
Per-page: recorders gain live signal-strip above fps line; capture timecode bumped 38->64px with blue glow; ingest drop zone slimmed 200->88px side-by-side layout so manifest gets the real estate.
2026-05-17 19:04:38 -04:00
< script src = "js/topbar-strip.js?v=1" > < / script >
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.
2026-05-21 09:47:32 -04:00
< script src = "js/bmd-card.js?v=1" > < / script >
2026-05-16 13:57:20 -04:00
< script >
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.
2026-05-21 09:47:32 -04:00
// ── 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,
};
2026-05-16 13:57:20 -04:00
document.addEventListener('DOMContentLoaded', async () => {
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.
2026-05-21 09:47:32 -04:00
populateCodecDropdowns();
wireCodecTabs();
wireCodecChangeHandlers();
await Promise.all([loadRecorders(), loadProjects(), loadBmdDevices()]);
2026-05-16 13:57:20 -04:00
setInterval(loadRecorders, 5000);
2026-05-17 07:39:19 -04:00
setInterval(pollRecordingSignals, 2000);
// Poll live signal info for every recorder currently in `recording` state
async function pollRecordingSignals() {
const active = pState.recorders.filter(r => r.status === "recording");
await Promise.all(active.map(async (rec) => {
try {
const resp = await fetch(`/api/v1/recorders/${rec.id}/status`, { credentials: "include" });
if (resp.ok) {
const j = await resp.json();
pState.signals[rec.id] = j;
updateSignalBadge(rec.id, j);
}
} catch (_) {}
}));
}
2026-05-16 13:57:20 -04:00
document.getElementById('newRecorderBtn').onclick = openPanel;
document.getElementById('closePanelBtn').onclick = closePanel;
document.getElementById('panelOverlay').onclick = closePanel;
document.getElementById('saveRecorderBtn').onclick = handleSaveRecorder;
2026-05-17 18:39:09 -04:00
document.getElementById('probeBtn').onclick = handleProbe;
2026-05-16 13:57:20 -04:00
document.getElementById('proxyToggle').onchange = e => {
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.
2026-05-21 09:47:32 -04:00
document.getElementById('proxyBlock').style.display = e.target.checked ? '' : 'none';
2026-05-16 13:57:20 -04:00
};
document.getElementById('recProject').onchange = handleProjectChange;
updateSourceFields();
});
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.
2026-05-21 09:47:32 -04:00
// ── 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);
}
}
2026-05-17 07:39:19 -04:00
// ── Load / render ─────────────────────────
2026-05-16 13:57:20 -04:00
async function loadRecorders() {
const r = await getRecorders();
if (!r.success) return;
pState.recorders = r.data;
renderRecorders();
}
function renderRecorders() {
const grid = document.getElementById('recorderGrid');
const empty = document.getElementById('recorderEmpty');
if (!pState.recorders.length) {
grid.innerHTML = ''; empty.style.display = 'flex'; return;
}
empty.style.display = 'none';
2026-05-18 09:47:03 -04:00
// Skip full DOM rebuild if structure is unchanged — the per-second timer
// and the signal poll will update the dynamic fields in place. Rebuilding
// every 5s was tearing down the live < video > element and the timer span.
const sig = pState.recorders.map(r => r.id + ':' + r.status + ':' + (r.live_asset_id || '') + ':' + (r.started_at || '')).join('|');
if (sig === pState._lastRenderSig & & grid.children.length === pState.recorders.length) {
return;
}
pState._lastRenderSig = sig;
2026-05-16 13:57:20 -04:00
grid.innerHTML = pState.recorders.map(rec => {
const isRecording = rec.status === 'recording';
const cfg = rec.source_config || {};
const sourceTypeKey = (rec.source_type || 'sdi').toLowerCase();
const badgeClass = { sdi:'badge-sdi', srt:'badge-srt', rtmp:'badge-rtmp' }[sourceTypeKey] || 'badge-idle';
const statusClass = isRecording ? 'recording' : rec.status === 'error' ? 'error' : '';
const statusDotClass = isRecording ? 'status-dot--recording' : rec.status === 'error' ? 'status-dot--error' : 'status-dot--idle';
let sourceDisplay = '';
2026-05-17 07:39:19 -04:00
if (cfg.url) {
2026-05-16 13:57:20 -04:00
sourceDisplay = cfg.url;
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.
2026-05-21 09:47:32 -04:00
} 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}`;
}
2026-05-17 07:39:19 -04:00
} else if (cfg.mode === 'listener') {
const port = cfg.listen_port || (sourceTypeKey === 'srt' ? 49001 : 41936);
sourceDisplay = `(legacy listener :${port})`;
2026-05-16 13:57:20 -04:00
}
let connectBanner = '';
if (!isRecording & & cfg.mode === 'listener') {
const serverIp = location.hostname || '10.0.0.25';
if (sourceTypeKey === 'srt') {
2026-05-17 07:39:19 -04:00
const port = cfg.listen_port || 49001;
2026-05-16 13:57:20 -04:00
connectBanner = `< div class = "info-banner recorder-connect-info" >
< svg viewBox = "0 0 14 14" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < circle cx = "7" cy = "7" r = "6" / > < path d = "M7 4v4M7 9.5v.5" / > < / svg >
< span > Push to < code > srt://${serverIp}:${port}?mode=caller< / code > < / span >
< / div > `;
} else if (sourceTypeKey === 'rtmp') {
2026-05-17 07:39:19 -04:00
const port = cfg.listen_port || 41936;
2026-05-16 13:57:20 -04:00
const key = cfg.stream_key || 'stream';
connectBanner = `< div class = "info-banner recorder-connect-info" >
< svg viewBox = "0 0 14 14" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < circle cx = "7" cy = "7" r = "6" / > < path d = "M7 4v4M7 9.5v.5" / > < / svg >
< span > Push to < code > rtmp://${serverIp}:${port}/live/${key}< / code > < / span >
< / div > `;
}
}
const lastRec = rec.last_recording_at ? new Date(rec.last_recording_at).toLocaleString() : 'Never';
return `< div class = "recorder-card ${statusClass}" data-id = "${rec.id}" >
< div class = "recorder-header" >
< div class = "recorder-id" >
< div class = "recorder-name" > ${esc(rec.name)}< / div >
< div class = "recorder-badges" >
< span class = "badge ${badgeClass}" > ${sourceTypeKey.toUpperCase()}< / span >
2026-05-18 23:35:16 -04:00
${rec.recording_codec ? `< span class = "badge badge-idle" > ${esc(rec.recording_codec)}< / span > ` : ''}
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.
2026-05-21 09:47:32 -04:00
${rec.recording_container ? `< span class = "badge badge-idle" > ${esc(rec.recording_container.toUpperCase())}< / span > ` : ''}
2026-05-16 13:57:20 -04:00
< / div >
< / div >
< div class = "recorder-actions" >
2026-05-18 23:35:16 -04:00
${!isRecording ? `< button class = "btn btn-ghost btn-sm" onclick = "openEditPanel('${rec.id}')" title = "Edit recorder" style = "padding:0;width:28px;height:28px;" >
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < path d = "M2 13l1.5-3.5L11 2l3 3-7.5 7.5L3 14zM10 3l3 3" / > < / svg >
< / button > ` : ''}
2026-05-16 13:57:20 -04:00
< button class = "btn btn-ghost btn-sm" onclick = "handleDeleteRecorder('${rec.id}')" title = "Delete recorder" style = "padding:0;width:28px;height:28px;" >
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < path d = "M3 4h10M6 4V2h4v2M5 4v9h6V4" / > < / svg >
< / button >
< / div >
< / div >
2026-04-07 22:05:43 -04:00
2026-05-16 13:57:20 -04:00
< div class = "recorder-status-row" >
2026-05-17 18:39:09 -04:00
< span class = "status-dot ${statusDotClass}" id = "statusDot-${rec.id}" > < / span >
< span class = "text-sm" id = "statusText-${rec.id}" style = "color:${isRecording ? 'var(--accent)' : rec.status === 'error' ? 'var(--status-red)' : 'var(--text-secondary)'}" >
2026-05-18 09:47:03 -04:00
${(() => { if (rec.status === 'error') return 'Error'; if (!isRecording) return 'Idle'; const sg = (pState.signals[rec.id]||{}).signal; if (sg === 'lost') return 'Signal lost'; if (sg === 'error') return 'Connection error'; if (sg === 'connecting') return 'Connecting...'; return 'Recording'; })()}
2026-05-16 13:57:20 -04:00
< / span >
2026-05-18 09:47:03 -04:00
${isRecording ? `< span class = "recorder-timer" id = "timer-${rec.id}" > ${rec.started_at ? formatDur(Math.max(0, Math.floor((Date.now() - new Date(rec.started_at).getTime())/1000))) : "00:00:00"}< / span > ` : ''}
2026-05-16 13:57:20 -04:00
< / div >
2026-05-18 09:47:03 -04:00
${isRecording ? `< div class = "signal-strip" id = "signalStrip-${rec.id}" > < div class = "signal-strip-fill" > < / div > < / div > < div class = "recorder-status-row" style = "font-size:var(--text-xs);" > < span id = "signal-${rec.id}" style = "color:var(--text-tertiary);font-family:var(--font-mono);letter-spacing:0.02em" > ${(pState.signals[rec.id]||{}).signal === "lost" ? "No signal — stream dropped" : (pState.signals[rec.id]||{}).signal === "error" ? "Capture error" : "Receiving stream"}< / span > < / div > ` : ''}
2026-05-18 09:40:42 -04:00
${isRecording & & rec.live_asset_id ? `< div class = "recorder-preview" > < video id = "livevideo-${rec.id}" data-live-id = "${rec.live_asset_id}" muted playsinline autoplay > < / video > < div class = "recorder-preview-stamp" > < span class = "recorder-preview-dot" > < / span > LIVE< / div > < / div > ` : ''}
2026-04-07 22:05:43 -04:00
2026-05-16 13:57:20 -04:00
< div class = "recorder-source" >
< svg viewBox = "0 0 14 14" fill = "none" stroke = "currentColor" stroke-width = "1.5" width = "12" height = "12" > < path d = "M2 4h10M2 8h7M2 12h5" / > < / svg >
< span > ${esc(sourceDisplay)}< / span >
< / div >
2026-04-07 22:05:43 -04:00
2026-05-16 13:57:20 -04:00
${connectBanner}
< div class = "recorder-footer" >
< span class = "recorder-footer-meta" > Last: ${lastRec}< / span >
< div class = "recorder-controls" >
${isRecording
? `< button class = "btn btn-danger btn-sm" onclick = "handleStop('${rec.id}')" > Stop< / button > `
: `< button class = "btn btn-record btn-sm" onclick = "handleStart('${rec.id}')" >
< svg viewBox = "0 0 14 14" fill = "currentColor" width = "10" height = "10" > < circle cx = "7" cy = "7" r = "5" / > < / svg >
Record
< / button > `
2026-04-07 22:05:43 -04:00
}
2026-05-16 13:57:20 -04:00
< / div >
< / div >
< / div > `;
}).join('');
2026-05-17 07:39:19 -04:00
// Start timers for recording recorders
2026-05-16 13:57:20 -04:00
pState.recorders.filter(r => r.status === 'recording').forEach(rec => {
if (!pState.timers[rec.id]) {
const startedAt = rec.started_at ? new Date(rec.started_at) : new Date();
pState.timers[rec.id] = setInterval(() => {
const el = document.getElementById(`timer-${rec.id}`);
if (!el) { clearInterval(pState.timers[rec.id]); delete pState.timers[rec.id]; return; }
const elapsed = Math.floor((Date.now() - startedAt) / 1000);
el.textContent = formatDur(elapsed);
}, 500);
}
});
Object.keys(pState.timers).forEach(id => {
if (!pState.recorders.find(r => r.id === id & & r.status === 'recording')) {
clearInterval(pState.timers[id]); delete pState.timers[id];
}
});
2026-05-18 09:40:42 -04:00
// Attach an HLS source to each live-preview < video > on the page.
pState.recorders.filter(r => r.status === 'recording' & & r.live_asset_id).forEach(rec => {
const v = document.getElementById('livevideo-' + rec.id);
if (!v || v.dataset.attached === '1') return;
const url = '/live/' + rec.live_asset_id + '/index.m3u8';
const attach = () => {
if (v.canPlayType('application/vnd.apple.mpegurl')) {
v.src = url; v.play().catch(() => {});
} else if (window.Hls & & window.Hls.isSupported()) {
const hls = new Hls({ lowLatencyMode: true, liveSyncDuration: 2, liveMaxLatencyDuration: 6 });
hls.loadSource(url); hls.attachMedia(v);
hls.on(Hls.Events.MANIFEST_PARSED, () => v.play().catch(() => {}));
v._hls = hls;
}
v.dataset.attached = '1';
};
if (window.Hls) attach();
else {
const sc = document.createElement('script');
sc.src = 'https://cdn.jsdelivr.net/npm/hls.js@1.5.0/dist/hls.min.js';
sc.onload = attach;
document.head.appendChild(sc);
}
});
document.querySelectorAll('video[data-live-id]').forEach(v => {
const id = v.id.replace('livevideo-', '');
const rec = pState.recorders.find(r => r.id === id);
if (!rec || rec.status !== 'recording') {
try { if (v._hls) { v._hls.destroy(); delete v._hls; } } catch (_) {}
v.removeAttribute('src');
}
});
2026-05-16 13:57:20 -04:00
}
2026-05-17 07:39:19 -04:00
function updateSignalBadge(rid, st) {
2026-05-17 18:39:09 -04:00
const el = document.getElementById('signal-' + rid);
const sig = st.signal || 'connecting';
if (el) {
const detail = {
connecting: 'Waiting for stream...',
receiving: 'Receiving • ' + (st.framesReceived || 0) + ' fr • ' + Math.round(st.currentFps || 0) + ' fps',
lost: 'No signal — stream dropped',
error: st.lastError ? st.lastError : 'Connection error',
stopped: 'Stopped',
};
const col = {
connecting: 'var(--status-yellow, oklch(82% 0.15 90))',
receiving: 'var(--status-green, oklch(68% 0.18 148))',
lost: 'var(--status-red, oklch(62% 0.22 25))',
error: 'var(--status-red, oklch(62% 0.22 25))',
stopped: 'var(--text-tertiary)',
};
el.textContent = detail[sig] || sig;
el.style.color = col[sig] || 'var(--text-tertiary)';
el.title = st.lastError || '';
}
const mainTxt = document.getElementById('statusText-' + rid);
const mainDot = document.getElementById('statusDot-' + rid);
if (mainTxt & & mainDot) {
const mainLabel = { connecting: 'Connecting...', receiving: 'Recording', lost: 'Signal lost', error: 'Connection error' }[sig] || 'Recording';
const mainCol = { connecting: 'var(--status-yellow, oklch(82% 0.15 90))', receiving: 'var(--accent)', lost: 'var(--status-red, oklch(62% 0.22 25))', error: 'var(--status-red, oklch(62% 0.22 25))' }[sig] || 'var(--accent)';
mainTxt.textContent = mainLabel;
mainTxt.style.color = mainCol;
mainDot.style.background = mainCol;
}
feat(design): broadcast ops console redesign sweep
Confirmed shape brief: ops-console direction, balanced density, blue committed accent, readability emphasized.
Design system: surface scale to 5 steps tinted toward hue 266; text contrast lifted (secondary 62->72%, tertiary 44->52, added text-disabled); borders gained faint variant; status tokens renamed semantically (signal-good/warn/bad/idle); typography upgraded (Inter 400/500/600, JetBrains Mono for numerics, new 2xl/3xl/tc scale steps).
New components: tc-display timecode classes with blue glow; tally-word with good/warn/bad/rec variants; signal-strip flutter bar with modifiers; chip dense monospaced pill; manifest table styles.
Topbar status strip: new js/topbar-strip.js auto-injects a 28px strip on every page with wall clock, page name, live API latency, and system-pulse dot.
Per-page: recorders gain live signal-strip above fps line; capture timecode bumped 38->64px with blue glow; ingest drop zone slimmed 200->88px side-by-side layout so manifest gets the real estate.
2026-05-17 19:04:38 -04:00
const strip = document.getElementById('signalStrip-' + rid);
if (strip) {
strip.classList.remove('signal-strip--warn', 'signal-strip--bad', 'signal-strip--idle');
if (sig === 'connecting') strip.classList.add('signal-strip--warn');
else if (sig === 'lost' || sig === 'error') strip.classList.add('signal-strip--bad');
}
2026-05-17 07:39:19 -04:00
}
2026-05-16 13:57:20 -04:00
function formatDur(s) {
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
return [h, m, sec].map(v => String(v).padStart(2,'0')).join(':');
}
2026-05-17 07:39:19 -04:00
// ── Controls ──────────────────────────────
2026-05-16 13:57:20 -04:00
async function handleStart(id) {
const r = await startRecorder(id);
if (r.success) { toast('Recording started', '', 'success'); loadRecorders(); }
else toast('Failed to start', r.error, 'error');
}
async function handleStop(id) {
const r = await stopRecorder(id);
if (r.success) { toast('Recording stopped', '', 'success'); loadRecorders(); }
else toast('Failed to stop', r.error, 'error');
}
async function handleDeleteRecorder(id) {
if (!confirm('Delete this recorder?')) return;
const r = await deleteRecorder(id);
if (r.success) { toast('Recorder deleted', '', 'success'); loadRecorders(); }
else toast('Delete failed', r.error, 'error');
}
2026-05-17 07:39:19 -04:00
// ── Panel ─────────────────────────────────
2026-05-16 13:57:20 -04:00
function openPanel() {
2026-05-18 23:35:16 -04:00
pState.editingId = null;
document.getElementById('panelTitle').textContent = 'New recorder';
document.getElementById('saveRecorderBtn').textContent = 'Create recorder';
document.getElementById('probeBtn').style.display = '';
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.
2026-05-21 09:47:32 -04:00
2026-05-18 23:35:16 -04:00
// Reset form to defaults
document.getElementById('recName').value = '';
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.
2026-05-21 09:47:32 -04:00
document.getElementById('recCodec').value = MASTER_DEFAULT_VIDEO;
2026-05-18 23:35:16 -04:00
document.getElementById('recResolution').value = 'native';
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.
2026-05-21 09:47:32 -04:00
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;
2026-05-18 23:35:16 -04:00
document.getElementById('recProject').value = '';
document.getElementById('recBin').innerHTML = '< option value = "" > Project root< / option > ';
const pr = document.getElementById('probeResult');
if (pr) pr.remove();
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.
2026-05-21 09:47:32 -04:00
2026-05-18 23:35:16 -04:00
pState.sourceType = 'srt';
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.
2026-05-21 09:47:32 -04:00
pState.selectedNodeId = null;
pState.selectedDeviceIndex = null;
2026-05-18 23:35:16 -04:00
document.querySelectorAll('.source-type-btn').forEach(b => b.classList.toggle('active', b.dataset.type === 'srt'));
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.
2026-05-21 09:47:32 -04:00
// 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')));
2026-05-16 13:57:20 -04:00
document.getElementById('recorderPanel').classList.add('open');
document.getElementById('panelOverlay').classList.add('open');
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.
2026-05-21 09:47:32 -04:00
wireCodecChangeHandlers(); // re-sync visibility
2026-05-16 13:57:20 -04:00
updateSourceFields();
}
2026-05-18 23:35:16 -04:00
function openEditPanel(recId) {
const rec = pState.recorders.find(r => r.id === recId);
if (!rec) return;
if (rec.status === 'recording') {
toast('Cannot edit while recording', 'Stop the recorder first', 'warning');
return;
}
pState.editingId = recId;
document.getElementById('panelTitle').textContent = 'Edit recorder';
document.getElementById('saveRecorderBtn').textContent = 'Save changes';
document.getElementById('probeBtn').style.display = '';
const pr = document.getElementById('probeResult');
if (pr) pr.remove();
// Basic fields
document.getElementById('recName').value = rec.name || '';
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.
2026-05-21 09:47:32 -04:00
// 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;
2026-05-18 23:35:16 -04:00
// Proxy
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.
2026-05-21 09:47:32 -04:00
const proxyEnabled = rec.proxy_enabled !== false;
2026-05-18 23:35:16 -04:00
document.getElementById('proxyToggle').checked = proxyEnabled;
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.
2026-05-21 09:47:32 -04:00
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();
2026-05-18 23:35:16 -04:00
// 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));
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.
2026-05-21 09:47:32 -04:00
// 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;
}
2026-05-18 23:35:16 -04:00
updateSourceFields();
// Populate source-specific fields after updateSourceFields injects DOM nodes
setTimeout(() => {
const cfg = rec.source_config || {};
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.
2026-05-21 09:47:32 -04:00
if (srcType === 'srt') {
2026-05-18 23:35:16 -04:00
const u = document.getElementById('srtUrl');
if (u) u.value = cfg.url || '';
} else if (srcType === 'rtmp') {
const u = document.getElementById('rtmpUrl');
if (u) u.value = cfg.url || '';
}
}, 0);
// Project / bin
const projSel = document.getElementById('recProject');
projSel.value = rec.project_id || '';
document.getElementById('recBin').innerHTML = '< option value = "" > Project root< / option > ';
if (rec.project_id) {
handleProjectChange().then(() => {
if (rec.bin_id) document.getElementById('recBin').value = rec.bin_id;
});
}
document.getElementById('recorderPanel').classList.add('open');
document.getElementById('panelOverlay').classList.add('open');
}
2026-05-16 13:57:20 -04:00
function closePanel() {
2026-05-18 23:35:16 -04:00
pState.editingId = null;
2026-05-16 13:57:20 -04:00
document.getElementById('recorderPanel').classList.remove('open');
document.getElementById('panelOverlay').classList.remove('open');
2026-05-18 23:35:16 -04:00
const pr = document.getElementById('probeResult');
if (pr) pr.remove();
2026-05-16 13:57:20 -04:00
}
2026-05-17 07:39:19 -04:00
// ── Source type ───────────────────────────
2026-05-16 13:57:20 -04:00
function setSourceType(type) {
pState.sourceType = type;
2026-05-17 07:39:19 -04:00
pState.mode = 'caller';
2026-05-16 13:57:20 -04:00
document.querySelectorAll('.source-type-btn').forEach(b => b.classList.toggle('active', b.dataset.type === type));
updateSourceFields();
}
function updateSourceFields() {
const container = document.getElementById('sourceConfigFields');
const type = pState.sourceType;
container.innerHTML = '';
if (type === 'sdi') {
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.
2026-05-21 09:47:32 -04:00
renderSdiPicker(container);
2026-05-16 13:57:20 -04:00
} else if (type === 'srt') {
container.innerHTML = `
2026-05-17 07:39:19 -04:00
< div id = "srtCallerFields" >
2026-05-16 13:57:20 -04:00
< div class = "form-group" >
< label class = "form-label" for = "srtUrl" > Source URL< / label >
2026-05-17 07:39:19 -04:00
< input type = "url" id = "srtUrl" placeholder = "srt://192.168.1.100:4200" >
< div class = "form-hint" > The recorder will connect out to this URL (caller mode). < code > ?mode=caller< / code > is appended automatically.< / div >
2026-05-16 13:57:20 -04:00
< / div >
< / div > `;
} else if (type === 'rtmp') {
container.innerHTML = `
2026-05-17 07:39:19 -04:00
< div id = "rtmpCallerFields" >
2026-05-16 13:57:20 -04:00
< div class = "form-group" >
< label class = "form-label" for = "rtmpUrl" > Source URL< / label >
2026-05-17 07:39:19 -04:00
< input type = "url" id = "rtmpUrl" placeholder = "rtmp://server/live/streamkey" >
< div class = "form-hint" > The recorder will pull this RTMP stream. Must be an existing published stream on an RTMP server.< / div >
2026-05-16 13:57:20 -04:00
< / div >
< / div > `;
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.
2026-05-21 09:47:32 -04:00
}
}
// 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('');
2026-05-16 13:57:20 -04:00
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.
2026-05-21 09:47:32 -04:00
// 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;
2026-05-16 13:57:20 -04:00
}
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.
2026-05-21 09:47:32 -04:00
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);
2026-05-16 13:57:20 -04:00
}
2026-05-17 07:39:19 -04:00
function setMode(_mode) {
// Listener mode UI was removed - all recorders are caller (pull) mode now.
pState.mode = 'caller';
2026-05-16 13:57:20 -04:00
}
2026-05-17 07:39:19 -04:00
// ── Projects for recorder destination ─────
2026-05-16 13:57:20 -04:00
async function loadProjects() {
const r = await getProjects();
if (!r.success) return;
pState.projects = r.data;
const sel = document.getElementById('recProject');
sel.innerHTML = '< option value = "" > None (manual assignment)< / option > ' +
r.data.map(p => `< option value = "${p.id}" > ${esc(p.name)}< / option > `).join('');
}
async function handleProjectChange() {
const projectId = document.getElementById('recProject').value;
const binSel = document.getElementById('recBin');
binSel.innerHTML = '< option value = "" > Project root< / option > ';
if (!projectId) return;
const r = await getBins(projectId);
if (r.success) r.data.forEach(b => binSel.innerHTML += `< option value = "${b.id}" > ${esc(b.name)}< / option > `);
}
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.
2026-05-21 09:47:32 -04:00
// ── Probe ─────────────────────────────────
2026-05-17 18:39:09 -04:00
async function handleProbe() {
const btn = document.getElementById('probeBtn');
btn.disabled = true; btn.textContent = 'Probing...';
const type = pState.sourceType;
const payload = { source_type: type };
if (type === 'srt' & & document.getElementById('srtUrl')) {
payload.source_url = document.getElementById('srtUrl').value.trim();
} else if (type === 'rtmp' & & document.getElementById('rtmpUrl')) {
payload.source_url = document.getElementById('rtmpUrl').value.trim();
} else if (type === 'sdi') {
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.
2026-05-21 09:47:32 -04:00
payload.device = pState.selectedDeviceIndex ?? 0;
if (pState.selectedNodeId) payload.node_id = pState.selectedNodeId;
2026-05-17 18:39:09 -04:00
}
try {
const r = await fetch('/api/v1/recorders/probe', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify(payload),
});
const data = await r.json();
renderProbeResult(data);
} catch (err) {
renderProbeResult({ ok: false, error: 'Network error: ' + err.message });
} finally {
btn.disabled = false; btn.textContent = 'Probe source';
}
}
function renderProbeResult(d) {
let host = document.getElementById('probeResult');
if (!host) {
host = document.createElement('div');
host.id = 'probeResult';
host.style.cssText = 'margin-top:var(--sp-3);padding:var(--sp-3);border-radius:var(--r-md);border:1px solid var(--border);background:var(--bg-surface);font-size:var(--text-xs)';
const footer = document.querySelector('.slide-panel-footer');
footer.parentElement.insertBefore(host, footer);
}
if (!d.ok) {
host.style.borderColor = 'oklch(62% 0.22 25 / 0.5)';
host.style.background = 'oklch(62% 0.22 25 / 0.08)';
2026-05-19 00:46:12 -04:00
host.innerHTML = '< div style = "color:var(--status-red);font-weight:500;margin-bottom:4px" > No signal detected< / div > < div style = "color:var(--text-secondary);white-space:pre-wrap" > ' + esc(d.error || 'Unknown error') + '< / div > ';
2026-05-17 18:39:09 -04:00
return;
}
host.style.borderColor = 'oklch(68% 0.18 148 / 0.5)';
host.style.background = 'oklch(68% 0.18 148 / 0.08)';
renderProbeOk(host, d);
}
function renderProbeOk(host, d) {
if (d.source_type === 'sdi') {
host.innerHTML = '< div style = "color:var(--status-green);font-weight:500;margin-bottom:6px" > DeckLink devices found< / div > < ul style = "margin:0;padding-left:18px;color:var(--text-primary)" > ' + (d.devices || []).map(n => '< li > ' + esc(n) + '< / li > ').join('') + '< / ul > ';
return;
}
const v = (d.streams || []).find(s => s.codec_type === 'video');
const a = (d.streams || []).find(s => s.codec_type === 'audio');
let html = '< div style = "color:var(--status-green);font-weight:500;margin-bottom:6px" > Signal detected< / div > < div style = "color:var(--text-primary);line-height:1.6" > ';
if (v) html += '< div > < strong > Video:< / strong > ' + esc(v.codec_name || '?') + ' ' + (v.width || '?') + 'x' + (v.height || '?') + ' • ' + esc(v.r_frame_rate || v.avg_frame_rate || '?') + ' fps • ' + esc(v.pix_fmt || '?') + '< / div > ';
if (a) html += '< div > < strong > Audio:< / strong > ' + esc(a.codec_name || '?') + ' • ' + (a.sample_rate || '?') + ' Hz • ' + (a.channels || '?') + ' ch< / div > ';
html += '< / div > ';
host.innerHTML = html;
}
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.
2026-05-21 09:47:32 -04:00
// ── Save recorder (create or update) ──────
2026-05-18 23:35:16 -04:00
async function handleSaveRecorder() {
2026-05-16 13:57:20 -04:00
const name = document.getElementById('recName').value.trim();
if (!name) { toast('Enter a recorder name', '', 'warning'); return; }
const type = pState.sourceType;
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.
2026-05-21 09:47:32 -04:00
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;
2026-05-16 13:57:20 -04:00
let sourceConfig = {};
if (type === 'sdi') {
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.
2026-05-21 09:47:32 -04:00
if (!pState.selectedNodeId) {
toast('Pick an SDI capture node', '', 'warning');
return;
}
nodeId = pState.selectedNodeId;
deviceIndex = pState.selectedDeviceIndex ?? 0;
sourceConfig.device = deviceIndex;
2026-05-16 13:57:20 -04:00
} else if (type === 'srt') {
2026-05-17 07:39:19 -04:00
sourceConfig.mode = 'caller';
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.
2026-05-21 09:47:32 -04:00
sourceConfig.url = (document.getElementById('srtUrl')?.value || '').trim();
if (!sourceConfig.url) { toast('Enter an SRT source URL', '', 'warning'); return; }
2026-05-16 13:57:20 -04:00
} else if (type === 'rtmp') {
2026-05-17 07:39:19 -04:00
sourceConfig.mode = 'caller';
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.
2026-05-21 09:47:32 -04:00
sourceConfig.url = (document.getElementById('rtmpUrl')?.value || '').trim();
if (!sourceConfig.url) { toast('Enter an RTMP source URL', '', 'warning'); return; }
2026-05-16 13:57:20 -04:00
}
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.
2026-05-21 09:47:32 -04:00
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;
2026-05-16 13:57:20 -04:00
const payload = {
2026-05-18 23:35:16 -04:00
name,
source_type: type,
source_config: sourceConfig,
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.
2026-05-21 09:47:32 -04:00
// 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,
2026-05-16 13:57:20 -04:00
};
2026-05-18 23:35:16 -04:00
if (pState.editingId) {
const r = await patchRecorder(pState.editingId, payload);
if (r.success) {
toast('Recorder updated', name, 'success');
closePanel();
await loadRecorders();
} else toast('Failed to update recorder', r.error, 'error');
} else {
const r = await createRecorder(payload);
if (r.success) {
toast('Recorder created', name, 'success');
closePanel();
await loadRecorders();
} else toast('Failed to create recorder', r.error, 'error');
}
2026-05-16 13:57:20 -04:00
}
function toast(title, msg, type = 'info') {
const el = document.createElement('div');
el.className = `toast toast--${type}`;
el.innerHTML = `< div class = "toast-body" > < div class = "toast-title" > ${esc(title)}< / div > ${msg ? `< div class = "toast-msg" > ${esc(msg)}< / div > ` : ''}< / div > `;
document.getElementById('toastContainer').appendChild(el);
setTimeout(() => el.remove(), 4000);
}
function esc(s) {
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.
2026-05-21 09:47:32 -04:00
if (s === null || s === undefined) return '';
2026-05-16 13:57:20 -04:00
return String(s).replace(/&/g,'& ').replace(/< /g,'< ').replace(/>/g,'> ').replace(/"/g,'" ');
}
< / script >
2026-05-18 13:41:52 -04:00
< script src = "js/auth-guard.js" > < / script >
2026-04-07 22:05:43 -04:00
< / body >
< / html >