dragonflight/services/web-ui/public/recorders.html

1517 lines
68 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<title>Recorders — Dragonflight</title>
<link rel="stylesheet" href="css/common.css?v=3">
<style>
/* ── Recorders page · AMPP-aligned theme ── */
.main {
background:
radial-gradient(ellipse 70% 50% at 50% 0%, oklch(28% 0.10 32 / 0.35), transparent 60%),
radial-gradient(ellipse 60% 40% at 90% 100%, oklch(35% 0.16 32 / 0.18), transparent 65%),
var(--bg-base);
}
.page-content { padding: 24px 32px 48px; }
.recorder-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
gap: 16px;
}
.recorder-card {
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 32 / 0.4); }
.recorder-card.recording {
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);
}
.recorder-card.error { border-color: oklch(62% 0.22 25 / 0.5); }
.recorder-header {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 12px;
}
.recorder-id { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.recorder-name {
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 32); 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 32), oklch(70% 0.18 32)); 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;
color: var(--text-secondary);
cursor: pointer; text-align: center;
letter-spacing: 0.04em;
transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
}
.source-type-btn:hover, .mode-btn:hover {
border-color: oklch(45% 0.20 32 / 0.5);
color: var(--text-primary);
}
.source-type-btn.active, .mode-btn.active {
border-color: oklch(55% 0.20 32 / 0.7);
background: oklch(20% 0.08 32 / 0.45);
color: oklch(78% 0.14 32);
font-weight: 600;
}
.form-section-label {
font-size: 11px; font-weight: 600;
letter-spacing: 0.18em; text-transform: uppercase;
color: var(--text-tertiary);
padding: 14px 0 8px;
}
.recorder-connect-info { margin-top: 4px; }
/* 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; }
/* 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;
}
/* ── 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 32);
border-bottom-color: oklch(55% 0.20 32 / 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 32 / 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 32 / 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 32 / 0.8);
}
.bmd-port-group.is-selected .bmd-port-ring {
fill: oklch(30% 0.14 32 / 0.55);
stroke: oklch(75% 0.18 32);
stroke-width: 2;
filter: drop-shadow(0 0 6px oklch(60% 0.20 32 / 0.7));
}
.bmd-port-group.is-selected .bmd-port-pin {
fill: oklch(85% 0.10 32);
}
.bmd-port-group.is-selected .bmd-port-label {
fill: oklch(82% 0.14 32);
}
</style>
</head>
<body>
<div class="shell">
<!-- Sidebar -->
<nav class="sidebar" aria-label="Main navigation">
<div class="sidebar-brand">
<img src="img/dragon-logo.png?v=1" alt="Dragonflight" class="sidebar-logo">
<span class="sidebar-brand-name">Dragonflight</span>
</div>
<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">
<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>
<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>
<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>
<a href="editor.html" class="nav-item">
<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>
<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>
<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>
<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>
</nav>
<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>
</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>
</div>
<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">
<span class="slide-panel-title" id="panelTitle">New recorder</span>
<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">
</div>
<!-- Source type -->
<div class="form-group">
<label class="form-label">Source type</label>
<div class="source-type-row">
<button class="source-type-btn" 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 active" data-type="sdi" onclick="setSourceType('sdi')">SDI</button>
</div>
</div>
<!-- Dynamic source config -->
<div id="sourceConfigFields" class="conditional-fields"></div>
<!-- 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>
<!-- Video -->
<div class="codec-tab-panel active" data-panel="master:video">
<div class="form-row">
<div class="form-group">
<label class="form-label" for="recCodec">Video codec</label>
<select id="recCodec"></select>
</div>
<div class="form-group">
<label class="form-label" for="recResolution">Resolution</label>
<select id="recResolution">
<option value="native">Native (source)</option>
<option value="3840x2160">3840×2160 (UHD)</option>
<option value="1920x1080">1920×1080 (HD)</option>
<option value="1280x720">1280×720</option>
</select>
</div>
</div>
<div class="form-row" id="recVideoBitrateRow" style="display:none;">
<div class="form-group">
<label class="form-label" for="recVideoBitrate">Video bitrate</label>
<input type="text" id="recVideoBitrate" placeholder="e.g. 50M, 100M">
<div class="form-hint">ffmpeg <code>-b:v</code> value. Leave empty for codec default.</div>
</div>
<div class="form-group">
<label class="form-label" for="recFramerate">Framerate</label>
<select id="recFramerate">
<option value="">Source</option>
<option value="23.976">23.976</option>
<option value="24">24</option>
<option value="25">25</option>
<option value="29.97">29.97</option>
<option value="30">30</option>
<option value="50">50</option>
<option value="59.94">59.94</option>
<option value="60">60</option>
</select>
</div>
</div>
<div class="form-row" id="recFramerateOnlyRow">
<div class="form-group">
<label class="form-label" for="recFramerateAlt">Framerate</label>
<select id="recFramerateAlt">
<option value="">Source</option>
<option value="23.976">23.976</option>
<option value="24">24</option>
<option value="25">25</option>
<option value="29.97">29.97</option>
<option value="30">30</option>
<option value="50">50</option>
<option value="59.94">59.94</option>
<option value="60">60</option>
</select>
<div class="form-hint">ProRes / DNxHR pick bitrate from profile + resolution, so only framerate is configurable.</div>
</div>
</div>
</div>
<!-- Audio -->
<div class="codec-tab-panel" data-panel="master:audio">
<div class="form-row">
<div class="form-group">
<label class="form-label" for="recAudioCodec">Audio codec</label>
<select id="recAudioCodec"></select>
</div>
<div class="form-group">
<label class="form-label" for="recAudioChannels">Channels</label>
<select id="recAudioChannels">
<option value="1">1 (mono)</option>
<option value="2" selected>2 (stereo)</option>
<option value="4">4</option>
<option value="6">6 (5.1)</option>
<option value="8">8 (7.1)</option>
</select>
</div>
</div>
<div class="form-row" id="recAudioBitrateRow" style="display:none;">
<div class="form-group">
<label class="form-label" for="recAudioBitrate">Audio bitrate</label>
<input type="text" id="recAudioBitrate" placeholder="e.g. 192k, 320k">
<div class="form-hint">Only applies to compressed audio codecs (AAC, Opus, AC-3).</div>
</div>
</div>
</div>
<!-- Container -->
<div class="codec-tab-panel" data-panel="master:container">
<div class="form-group">
<label class="form-label" for="recContainer">Container format</label>
<select id="recContainer"></select>
<div class="form-hint">MOV is recommended for ProRes / DNxHR masters. MXF for broadcast workflows.</div>
</div>
</div>
</div>
<!-- Proxy toggle -->
<div class="form-group">
<label class="toggle">
<input type="checkbox" id="proxyToggle" checked>
<div class="toggle-track"></div>
<span class="toggle-label">Generate proxy</span>
</label>
<div class="form-hint">SDI sources record proxy in parallel. Network sources (SRT/RTMP) generate proxy after stop.</div>
</div>
<!-- Proxy codec block (mirrors master) -->
<div class="codec-block" id="proxyBlock">
<div class="codec-block-header">
<span class="codec-block-title">Proxy</span>
</div>
<div class="codec-tabs" role="tablist" data-target="proxy">
<button class="codec-tab active" data-tab="video">Video</button>
<button class="codec-tab" data-tab="audio">Audio</button>
<button class="codec-tab" data-tab="container">Container</button>
</div>
<div class="codec-tab-panel active" data-panel="proxy:video">
<div class="form-row">
<div class="form-group">
<label class="form-label" for="proxyCodec">Video codec</label>
<select id="proxyCodec"></select>
</div>
<div class="form-group">
<label class="form-label" for="proxyResolution">Resolution</label>
<select id="proxyResolution">
<option value="1920x1080">1920×1080</option>
<option value="1280x720">1280×720</option>
<option value="960x540">960×540</option>
<option value="640x360">640×360</option>
</select>
</div>
</div>
<div class="form-row" id="proxyVideoBitrateRow">
<div class="form-group">
<label class="form-label" for="proxyVideoBitrate">Video bitrate</label>
<input type="text" id="proxyVideoBitrate" value="8M" placeholder="e.g. 8M">
</div>
<div class="form-group">
<label class="form-label" for="proxyFramerate">Framerate</label>
<select id="proxyFramerate">
<option value="">Source</option>
<option value="23.976">23.976</option>
<option value="24">24</option>
<option value="25">25</option>
<option value="29.97">29.97</option>
<option value="30">30</option>
</select>
</div>
</div>
</div>
<div class="codec-tab-panel" data-panel="proxy:audio">
<div class="form-row">
<div class="form-group">
<label class="form-label" for="proxyAudioCodec">Audio codec</label>
<select id="proxyAudioCodec"></select>
</div>
<div class="form-group">
<label class="form-label" for="proxyAudioChannels">Channels</label>
<select id="proxyAudioChannels">
<option value="1">1 (mono)</option>
<option value="2" selected>2 (stereo)</option>
</select>
</div>
</div>
<div class="form-row" id="proxyAudioBitrateRow">
<div class="form-group">
<label class="form-label" for="proxyAudioBitrate">Audio bitrate</label>
<input type="text" id="proxyAudioBitrate" value="192k" placeholder="e.g. 192k">
</div>
</div>
</div>
<div class="codec-tab-panel" data-panel="proxy:container">
<div class="form-group">
<label class="form-label" for="proxyContainer">Container format</label>
<select id="proxyContainer"></select>
</div>
</div>
</div>
<!-- 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>
<button class="btn btn-secondary" id="probeBtn">Probe source</button>
<button class="btn btn-primary" id="saveRecorderBtn">Create recorder</button>
</div>
</div>
<div class="toast-container" id="toastContainer" aria-live="polite"></div>
<script src="js/api.js?v=6"></script>
<script src="js/topbar-strip.js?v=1"></script>
<script src="js/bmd-card.js?v=1"></script>
<script>
// ── 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: 'sdi', mode: 'caller',
projects: [], signals: {}, editingId: null,
// SDI picker state
bmdDevices: [], // flat list from /cluster/devices/blackmagic
sdiNodes: [], // grouped by node_id
selectedNodeId: null,
selectedDeviceIndex: null,
};
document.addEventListener('DOMContentLoaded', async () => {
populateCodecDropdowns();
wireCodecTabs();
wireCodecChangeHandlers();
await Promise.all([loadRecorders(), loadProjects(), loadBmdDevices()]);
setInterval(loadRecorders, 5000);
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 (_) {}
}));
}
document.getElementById('newRecorderBtn').onclick = openPanel;
document.getElementById('closePanelBtn').onclick = closePanel;
document.getElementById('panelOverlay').onclick = closePanel;
document.getElementById('saveRecorderBtn').onclick = handleSaveRecorder;
document.getElementById('probeBtn').onclick = handleProbe;
document.getElementById('proxyToggle').onchange = e => {
document.getElementById('proxyBlock').style.display = e.target.checked ? '' : 'none';
};
document.getElementById('recProject').onchange = handleProjectChange;
updateSourceFields();
});
// ── Codec dropdown population ─────────────
function populateCodecDropdowns() {
const fill = (selId, dict, defaultKey) => {
const sel = document.getElementById(selId);
if (!sel) return;
sel.innerHTML = Object.entries(dict).map(([k, v]) => {
const label = typeof v === 'string' ? v : v.label;
return `<option value="${k}">${label}</option>`;
}).join('');
if (defaultKey) sel.value = defaultKey;
};
fill('recCodec', VIDEO_CODECS, MASTER_DEFAULT_VIDEO);
fill('recAudioCodec', AUDIO_CODECS, MASTER_DEFAULT_AUDIO);
fill('recContainer', CONTAINER_FMT, MASTER_DEFAULT_CONTAINER);
fill('proxyCodec', VIDEO_CODECS, PROXY_DEFAULT_VIDEO);
fill('proxyAudioCodec', AUDIO_CODECS, PROXY_DEFAULT_AUDIO);
fill('proxyContainer', CONTAINER_FMT, PROXY_DEFAULT_CONTAINER);
}
// ── Codec tabs: simple per-block accordion of panels ──
function wireCodecTabs() {
document.querySelectorAll('.codec-tabs').forEach(tabs => {
const target = tabs.dataset.target;
tabs.querySelectorAll('.codec-tab').forEach(btn => {
btn.addEventListener('click', () => {
tabs.querySelectorAll('.codec-tab').forEach(b => b.classList.toggle('active', b === btn));
const want = `${target}:${btn.dataset.tab}`;
document.querySelectorAll(`.codec-tab-panel[data-panel^="${target}:"]`).forEach(p => {
p.classList.toggle('active', p.dataset.panel === want);
});
});
});
});
}
// Show/hide bitrate inputs based on whether the selected codec supports
// bitrate control. ProRes & DNxHR (profile-driven) hide it; H.264/265 show it.
function wireCodecChangeHandlers() {
const sync = () => {
const masterV = VIDEO_CODECS[document.getElementById('recCodec').value] || {};
const masterA = AUDIO_CODECS[document.getElementById('recAudioCodec').value] || {};
document.getElementById('recVideoBitrateRow').style.display = masterV.bitrateControl ? 'grid' : 'none';
document.getElementById('recFramerateOnlyRow').style.display = masterV.bitrateControl ? 'none' : 'grid';
document.getElementById('recAudioBitrateRow').style.display = masterA.bitrateControl ? 'grid' : 'none';
const proxyV = VIDEO_CODECS[document.getElementById('proxyCodec').value] || {};
const proxyA = AUDIO_CODECS[document.getElementById('proxyAudioCodec').value] || {};
document.getElementById('proxyVideoBitrateRow').style.display = proxyV.bitrateControl ? 'grid' : 'none';
document.getElementById('proxyAudioBitrateRow').style.display = proxyA.bitrateControl ? 'grid' : 'none';
};
['recCodec', 'recAudioCodec', 'proxyCodec', 'proxyAudioCodec'].forEach(id => {
const el = document.getElementById(id);
if (el) el.addEventListener('change', sync);
});
sync();
}
// ── Cluster: BMD devices ──────────────────
async function loadBmdDevices() {
try {
const resp = await fetch('/api/v1/cluster/devices/blackmagic', { credentials: 'include' });
if (!resp.ok) return;
pState.bmdDevices = await resp.json();
// Group by node so we can render the per-node card
const byNode = new Map();
for (const d of pState.bmdDevices) {
if (!byNode.has(d.node_id)) {
byNode.set(d.node_id, {
node_id: d.node_id,
hostname: d.hostname,
ip_address: d.ip_address,
role: d.role,
online: d.online,
model: d.model,
devices: [],
});
}
byNode.get(d.node_id).devices.push(d);
}
pState.sdiNodes = Array.from(byNode.values());
// Re-render SDI fields if currently visible
if (pState.sourceType === 'sdi') updateSourceFields();
} catch (err) {
console.error('[bmd] load failed', err);
}
}
// ── Load / render ─────────────────────────
async function loadRecorders() {
const r = await getRecorders();
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';
// 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;
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 = '';
if (cfg.url) {
sourceDisplay = cfg.url;
} else if (sourceTypeKey === 'sdi') {
const idx = rec.device_index ?? cfg.device ?? 0;
// Resolve node hostname + model from the cluster list if we have it
const dev = pState.bmdDevices.find(d => d.node_id === rec.node_id && d.index === idx);
if (dev) {
sourceDisplay = `${dev.hostname} · ${dev.model || 'DeckLink'} · port ${idx + 1}`;
} else {
sourceDisplay = `DeckLink port ${idx + 1}`;
}
} else if (cfg.mode === 'listener') {
const port = cfg.listen_port || (sourceTypeKey === 'srt' ? 49001 : 41936);
sourceDisplay = `(legacy listener :${port})`;
}
let connectBanner = '';
if (!isRecording && cfg.mode === 'listener') {
const serverIp = location.hostname || '10.0.0.25';
if (sourceTypeKey === 'srt') {
const port = cfg.listen_port || 49001;
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') {
const port = cfg.listen_port || 41936;
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>
${rec.recording_codec ? `<span class="badge badge-idle">${esc(rec.recording_codec)}</span>` : ''}
${rec.recording_container ? `<span class="badge badge-idle">${esc(rec.recording_container.toUpperCase())}</span>` : ''}
</div>
</div>
<div class="recorder-actions">
${!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>` : ''}
<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>
<div class="recorder-status-row">
<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)'}">
${(() => { 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'; })()}
</span>
${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>` : ''}
</div>
${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>` : ''}
${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>` : ''}
<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>
${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>`
}
</div>
</div>
</div>`;
}).join('');
// Start timers for recording recorders
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];
}
});
// 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');
}
});
}
function updateSignalBadge(rid, st) {
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;
}
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');
}
}
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(':');
}
// ── Controls ──────────────────────────────
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');
}
// ── Panel ─────────────────────────────────
function openPanel() {
pState.editingId = null;
document.getElementById('panelTitle').textContent = 'New recorder';
document.getElementById('saveRecorderBtn').textContent = 'Create recorder';
document.getElementById('probeBtn').style.display = '';
// Reset form to defaults
document.getElementById('recName').value = '';
document.getElementById('recCodec').value = MASTER_DEFAULT_VIDEO;
document.getElementById('recResolution').value = 'native';
document.getElementById('recVideoBitrate').value = '';
document.getElementById('recFramerate').value = '';
document.getElementById('recFramerateAlt').value = '';
document.getElementById('recAudioCodec').value = MASTER_DEFAULT_AUDIO;
document.getElementById('recAudioBitrate').value = '';
document.getElementById('recAudioChannels').value = '2';
document.getElementById('recContainer').value = MASTER_DEFAULT_CONTAINER;
document.getElementById('proxyToggle').checked = true;
document.getElementById('proxyBlock').style.display = '';
document.getElementById('proxyCodec').value = PROXY_DEFAULT_VIDEO;
document.getElementById('proxyResolution').value = '1920x1080';
document.getElementById('proxyVideoBitrate').value = '8M';
document.getElementById('proxyFramerate').value = '';
document.getElementById('proxyAudioCodec').value = PROXY_DEFAULT_AUDIO;
document.getElementById('proxyAudioBitrate').value = '192k';
document.getElementById('proxyAudioChannels').value = '2';
document.getElementById('proxyContainer').value = PROXY_DEFAULT_CONTAINER;
document.getElementById('recProject').value = '';
document.getElementById('recBin').innerHTML = '<option value="">Project root</option>';
const pr = document.getElementById('probeResult');
if (pr) pr.remove();
pState.sourceType = 'sdi';
pState.selectedNodeId = null;
pState.selectedDeviceIndex = null;
document.querySelectorAll('.source-type-btn').forEach(b => b.classList.toggle('active', b.dataset.type === 'sdi'));
// Make sure tabs are on Video
document.querySelectorAll('.codec-tabs').forEach(tabs => {
tabs.querySelectorAll('.codec-tab').forEach(b => b.classList.toggle('active', b.dataset.tab === 'video'));
});
document.querySelectorAll('.codec-tab-panel').forEach(p => p.classList.toggle('active', p.dataset.panel.endsWith(':video')));
document.getElementById('recorderPanel').classList.add('open');
document.getElementById('panelOverlay').classList.add('open');
wireCodecChangeHandlers(); // re-sync visibility
updateSourceFields();
}
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 || '';
// Master codec
document.getElementById('recCodec').value = rec.recording_codec || MASTER_DEFAULT_VIDEO;
document.getElementById('recResolution').value = rec.recording_resolution || 'native';
document.getElementById('recVideoBitrate').value = rec.recording_video_bitrate || '';
document.getElementById('recFramerate').value = rec.recording_framerate || '';
document.getElementById('recFramerateAlt').value = rec.recording_framerate || '';
document.getElementById('recAudioCodec').value = rec.recording_audio_codec || MASTER_DEFAULT_AUDIO;
document.getElementById('recAudioBitrate').value = rec.recording_audio_bitrate || '';
document.getElementById('recAudioChannels').value = String(rec.recording_audio_channels ?? 2);
document.getElementById('recContainer').value = rec.recording_container || MASTER_DEFAULT_CONTAINER;
// Proxy
const proxyEnabled = rec.proxy_enabled !== false;
document.getElementById('proxyToggle').checked = proxyEnabled;
document.getElementById('proxyBlock').style.display = proxyEnabled ? '' : 'none';
document.getElementById('proxyCodec').value = rec.proxy_codec || PROXY_DEFAULT_VIDEO;
document.getElementById('proxyResolution').value = rec.proxy_resolution || '1920x1080';
document.getElementById('proxyVideoBitrate').value = rec.proxy_video_bitrate || '8M';
document.getElementById('proxyFramerate').value = rec.proxy_framerate || '';
document.getElementById('proxyAudioCodec').value = rec.proxy_audio_codec || PROXY_DEFAULT_AUDIO;
document.getElementById('proxyAudioBitrate').value = rec.proxy_audio_bitrate || '192k';
document.getElementById('proxyAudioChannels').value = String(rec.proxy_audio_channels ?? 2);
document.getElementById('proxyContainer').value = rec.proxy_container || PROXY_DEFAULT_CONTAINER;
wireCodecChangeHandlers();
// Source type
const srcType = (rec.source_type || 'srt').toLowerCase();
pState.sourceType = srcType;
document.querySelectorAll('.source-type-btn').forEach(b => b.classList.toggle('active', b.dataset.type === srcType));
// Restore SDI selection if this is an SDI recorder
if (srcType === 'sdi') {
pState.selectedNodeId = rec.node_id || null;
pState.selectedDeviceIndex = rec.device_index ?? (rec.source_config?.device ?? 0);
} else {
pState.selectedNodeId = null;
pState.selectedDeviceIndex = null;
}
updateSourceFields();
// Populate source-specific fields after updateSourceFields injects DOM nodes
setTimeout(() => {
const cfg = rec.source_config || {};
if (srcType === 'srt') {
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');
}
function closePanel() {
pState.editingId = null;
document.getElementById('recorderPanel').classList.remove('open');
document.getElementById('panelOverlay').classList.remove('open');
const pr = document.getElementById('probeResult');
if (pr) pr.remove();
}
// ── Source type ───────────────────────────
function setSourceType(type) {
pState.sourceType = type;
pState.mode = 'caller';
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') {
renderSdiPicker(container);
} else if (type === 'srt') {
container.innerHTML = `
<div id="srtCallerFields">
<div class="form-group">
<label class="form-label" for="srtUrl">Source URL</label>
<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>
</div>
</div>`;
} else if (type === 'rtmp') {
container.innerHTML = `
<div id="rtmpCallerFields">
<div class="form-group">
<label class="form-label" for="rtmpUrl">Source URL</label>
<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>
</div>
</div>`;
}
}
// SDI picker = node dropdown + inline BMD card SVG
function renderSdiPicker(container) {
const wrap = document.createElement('div');
wrap.className = 'sdi-picker';
// Node selector
const nodeGroup = document.createElement('div');
nodeGroup.className = 'form-group';
nodeGroup.innerHTML = `
<label class="form-label" for="sdiNode">Capture node</label>
<select id="sdiNode"></select>
<div class="form-hint">Pick the cluster node hosting the DeckLink card, then click a port on the diagram below.</div>
`;
wrap.appendChild(nodeGroup);
// Card host
const cardWrap = document.createElement('div');
cardWrap.className = 'sdi-card-wrap';
cardWrap.id = 'sdiCardWrap';
wrap.appendChild(cardWrap);
container.appendChild(wrap);
// Populate node options
const nodeSel = document.getElementById('sdiNode');
if (!pState.sdiNodes.length) {
nodeSel.innerHTML = '<option value="">No DeckLink-capable nodes detected</option>';
cardWrap.innerHTML = `<div class="sdi-card-empty">
No nodes in the cluster reported a DeckLink card.<br>
Connect a worker with BMD hardware and refresh.
</div>`;
return;
}
nodeSel.innerHTML = pState.sdiNodes.map(n => {
const onlineMark = n.online ? '' : ' (offline)';
const modelStr = n.model ? ` · ${n.model}` : '';
const portCount = n.devices.length;
return `<option value="${n.node_id}">${esc(n.hostname)}${modelStr} · ${portCount} port${portCount === 1 ? '' : 's'}${onlineMark}</option>`;
}).join('');
// Restore selection or pick first
if (pState.selectedNodeId && pState.sdiNodes.find(n => n.node_id === pState.selectedNodeId)) {
nodeSel.value = pState.selectedNodeId;
} else {
pState.selectedNodeId = pState.sdiNodes[0].node_id;
nodeSel.value = pState.selectedNodeId;
}
if (pState.selectedDeviceIndex == null) {
pState.selectedDeviceIndex = 0;
}
nodeSel.addEventListener('change', () => {
pState.selectedNodeId = nodeSel.value;
pState.selectedDeviceIndex = 0;
drawBmdCardForSelectedNode();
});
drawBmdCardForSelectedNode();
}
function drawBmdCardForSelectedNode() {
const wrap = document.getElementById('sdiCardWrap');
if (!wrap) return;
wrap.innerHTML = '';
const node = pState.sdiNodes.find(n => n.node_id === pState.selectedNodeId);
if (!node) {
wrap.innerHTML = `<div class="sdi-card-empty">Select a node to see its DeckLink card.</div>`;
return;
}
// Meta strip above the card
const meta = document.createElement('div');
meta.className = 'sdi-card-meta';
meta.innerHTML = `
<span><strong>Host:</strong> ${esc(node.hostname)}</span>
${node.ip_address ? `<span><strong>IP:</strong> ${esc(node.ip_address)}</span>` : ''}
${node.model ? `<span><strong>Model:</strong> ${esc(node.model)}</span>` : ''}
<span><strong>Ports:</strong> ${node.devices.length}</span>
<span><strong>Status:</strong> ${node.online ? 'online' : 'offline'}</span>
`;
wrap.appendChild(meta);
// SVG render
if (!window.BMDCards) {
wrap.appendChild(Object.assign(document.createElement('div'), {
className: 'sdi-card-empty',
textContent: 'BMD card renderer failed to load.',
}));
return;
}
const svg = window.BMDCards.render({
model: node.model,
deviceCount: node.devices.length,
selectedIndex: pState.selectedDeviceIndex,
onSelect: (i) => {
pState.selectedDeviceIndex = i;
drawBmdCardForSelectedNode();
},
});
wrap.appendChild(svg);
}
function setMode(_mode) {
// Listener mode UI was removed - all recorders are caller (pull) mode now.
pState.mode = 'caller';
}
// ── Projects for recorder destination ─────
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>`);
}
// ── Probe ─────────────────────────────────
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') {
payload.device = pState.selectedDeviceIndex ?? 0;
if (pState.selectedNodeId) payload.node_id = pState.selectedNodeId;
}
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)';
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>';
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;
}
// ── Save recorder (create or update) ──────
async function handleSaveRecorder() {
const name = document.getElementById('recName').value.trim();
if (!name) { toast('Enter a recorder name', '', 'warning'); return; }
const type = pState.sourceType;
const masterCodec = document.getElementById('recCodec').value;
const masterCodecMeta = VIDEO_CODECS[masterCodec] || {};
const proxyCodec = document.getElementById('proxyCodec').value;
const proxyCodecMeta = VIDEO_CODECS[proxyCodec] || {};
const masterAudioMeta = AUDIO_CODECS[document.getElementById('recAudioCodec').value] || {};
const proxyAudioMeta = AUDIO_CODECS[document.getElementById('proxyAudioCodec').value] || {};
// SDI requires a node + device index
let nodeId = null;
let deviceIndex = null;
let sourceConfig = {};
if (type === 'sdi') {
if (!pState.selectedNodeId) {
toast('Pick an SDI capture node', '', 'warning');
return;
}
nodeId = pState.selectedNodeId;
deviceIndex = pState.selectedDeviceIndex ?? 0;
sourceConfig.device = deviceIndex;
} else if (type === 'srt') {
sourceConfig.mode = 'caller';
sourceConfig.url = (document.getElementById('srtUrl')?.value || '').trim();
if (!sourceConfig.url) { toast('Enter an SRT source URL', '', 'warning'); return; }
} else if (type === 'rtmp') {
sourceConfig.mode = 'caller';
sourceConfig.url = (document.getElementById('rtmpUrl')?.value || '').trim();
if (!sourceConfig.url) { toast('Enter an RTMP source URL', '', 'warning'); return; }
}
const proxyEnabled = document.getElementById('proxyToggle').checked;
// Choose the right framerate field — bitrate-controlled codecs use the
// "main" framerate (in the same row as bitrate); profile-driven codecs
// use the alt framerate in its own row.
const masterFramerate = masterCodecMeta.bitrateControl
? document.getElementById('recFramerate').value
: document.getElementById('recFramerateAlt').value;
const payload = {
name,
source_type: type,
source_config: sourceConfig,
// Master codec
recording_codec: masterCodec,
recording_resolution: document.getElementById('recResolution').value,
recording_video_bitrate: masterCodecMeta.bitrateControl ? (document.getElementById('recVideoBitrate').value.trim() || null) : null,
recording_framerate: masterFramerate || null,
recording_audio_codec: document.getElementById('recAudioCodec').value,
recording_audio_bitrate: masterAudioMeta.bitrateControl ? (document.getElementById('recAudioBitrate').value.trim() || null) : null,
recording_audio_channels: parseInt(document.getElementById('recAudioChannels').value, 10),
recording_container: document.getElementById('recContainer').value,
// Proxy
proxy_enabled: proxyEnabled,
proxy_codec: proxyEnabled ? proxyCodec : undefined,
proxy_resolution: proxyEnabled ? document.getElementById('proxyResolution').value : undefined,
proxy_video_bitrate: proxyEnabled && proxyCodecMeta.bitrateControl
? (document.getElementById('proxyVideoBitrate').value.trim() || null)
: undefined,
proxy_framerate: proxyEnabled ? (document.getElementById('proxyFramerate').value || null) : undefined,
proxy_audio_codec: proxyEnabled ? document.getElementById('proxyAudioCodec').value : undefined,
proxy_audio_bitrate: proxyEnabled && proxyAudioMeta.bitrateControl
? (document.getElementById('proxyAudioBitrate').value.trim() || null)
: undefined,
proxy_audio_channels: proxyEnabled ? parseInt(document.getElementById('proxyAudioChannels').value, 10) : undefined,
proxy_container: proxyEnabled ? document.getElementById('proxyContainer').value : undefined,
// Pinning
project_id: document.getElementById('recProject').value || null,
node_id: nodeId,
device_index: deviceIndex,
};
if (pState.editingId) {
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');
}
}
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) {
if (s === null || s === undefined) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
</script>
<script src="js/auth-guard.js"></script>
</body>
</html>