524 lines
19 KiB
HTML
524 lines
19 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Capture — Wild Dragon</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap" rel="stylesheet">
|
|
<link rel="stylesheet" href="css/common.css">
|
|
<style>
|
|
.capture-layout {
|
|
display: grid;
|
|
grid-template-columns: 340px 1fr;
|
|
gap: var(--sp-6);
|
|
max-width: 960px;
|
|
}
|
|
|
|
/* Control panel */
|
|
.capture-controls {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--sp-5);
|
|
}
|
|
|
|
/* Timecode display */
|
|
.timecode-block {
|
|
background: var(--bg-surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--r-lg);
|
|
padding: var(--sp-5) var(--sp-6);
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: var(--sp-3);
|
|
}
|
|
|
|
.timecode-label {
|
|
font-size: var(--text-xs);
|
|
font-weight: 500;
|
|
letter-spacing: 0.12em;
|
|
text-transform: uppercase;
|
|
color: var(--text-tertiary);
|
|
}
|
|
|
|
.timecode-display {
|
|
font-family: var(--font-mono);
|
|
font-size: 64px;
|
|
font-weight: 500;
|
|
font-variant-numeric: tabular-nums;
|
|
letter-spacing: 0.05em;
|
|
color: var(--accent);
|
|
text-shadow: 0 0 18px oklch(55% 0.20 266 / 0.30);
|
|
line-height: 1;
|
|
}
|
|
|
|
.timecode-display.inactive {
|
|
color: var(--text-tertiary);
|
|
text-shadow: none;
|
|
}
|
|
|
|
.timecode-status {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--sp-2);
|
|
font-size: var(--text-xs);
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
/* Record button */
|
|
.record-btn-wrap {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: var(--sp-3);
|
|
}
|
|
|
|
.record-btn {
|
|
width: 80px;
|
|
height: 80px;
|
|
border-radius: 50%;
|
|
background: oklch(20% 0.010 25);
|
|
border: 3px solid oklch(30% 0.010 25);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: pointer;
|
|
transition: background var(--t-fast), border-color var(--t-fast), box-shadow var(--t-fast);
|
|
}
|
|
|
|
.record-btn:hover {
|
|
background: oklch(25% 0.012 25);
|
|
border-color: var(--status-red);
|
|
}
|
|
|
|
.record-btn.recording {
|
|
background: var(--status-red);
|
|
border-color: var(--status-red);
|
|
box-shadow: 0 0 0 4px oklch(62% 0.22 25 / 0.20);
|
|
animation: rec-pulse 2s ease-out infinite;
|
|
}
|
|
|
|
.record-btn-inner {
|
|
width: 28px;
|
|
height: 28px;
|
|
border-radius: 50%;
|
|
background: var(--status-red);
|
|
transition: border-radius var(--t-fast), width var(--t-fast), height var(--t-fast);
|
|
}
|
|
|
|
.record-btn.recording .record-btn-inner {
|
|
border-radius: var(--r-sm);
|
|
width: 22px;
|
|
height: 22px;
|
|
background: oklch(98% 0.005 25);
|
|
}
|
|
|
|
@keyframes rec-pulse {
|
|
0% { box-shadow: 0 0 0 4px oklch(62% 0.22 25 / 0.25); }
|
|
50% { box-shadow: 0 0 0 10px oklch(62% 0.22 25 / 0.08); }
|
|
100% { box-shadow: 0 0 0 4px oklch(62% 0.22 25 / 0.25); }
|
|
}
|
|
|
|
.record-btn-label {
|
|
font-size: var(--text-sm);
|
|
color: var(--text-secondary);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.record-btn-label.recording { color: var(--status-red); }
|
|
|
|
/* Settings panel */
|
|
.capture-settings {
|
|
background: var(--bg-surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--r-lg);
|
|
padding: var(--sp-4);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--sp-4);
|
|
}
|
|
|
|
.settings-title {
|
|
font-size: var(--text-xs);
|
|
font-weight: 500;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
color: var(--text-tertiary);
|
|
padding-bottom: var(--sp-3);
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
/* Status bar */
|
|
.status-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--sp-3);
|
|
padding: var(--sp-3) var(--sp-4);
|
|
background: var(--bg-surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--r-lg);
|
|
font-size: var(--text-xs);
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.status-bar-dot { flex-shrink: 0; }
|
|
.status-bar-text { flex: 1; }
|
|
.status-bar-file { font-family: 'SF Mono', monospace; font-size: 11px; color: var(--text-tertiary); }
|
|
|
|
/* Recent captures table */
|
|
.recent-section { display: flex; flex-direction: column; gap: var(--sp-3); }
|
|
.recent-title {
|
|
font-size: var(--text-xs);
|
|
font-weight: 500;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
color: var(--text-tertiary);
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="shell">
|
|
<nav class="sidebar" aria-label="Main navigation">
|
|
<div class="sidebar-brand">
|
|
<img src="img/ampp-safe.png?v=hardhat3" alt="Z-AMPP" class="sidebar-logo">
|
|
<span class="sidebar-brand-name">Z-AMPP</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">
|
|
<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 active">
|
|
<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="#" class="nav-item" target="_blank" onclick="window.open(location.protocol + '//' + location.hostname + ':47435/', '_blank'); return false;" rel="noopener">
|
|
<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>
|
|
</nav>
|
|
</nav>
|
|
|
|
<div class="main">
|
|
<header class="topbar">
|
|
<div class="topbar-left">
|
|
<span class="page-title">Capture</span>
|
|
<span class="topbar-sep">/</span>
|
|
<span class="text-sm text-secondary">Direct SDI</span>
|
|
</div>
|
|
<div class="topbar-right">
|
|
<span class="text-xs text-tertiary" id="deviceStatus">Loading devices…</span>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="page-content">
|
|
<!-- No-device empty state (shown when no SDI cards found) -->
|
|
<div id="noDeviceState" 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="3" y="10" width="34" height="22" rx="2"/><path d="M13 32v4M27 32v4M9 36h22"/><path d="M16 19l4-3 4 3-1.5 5h-5L16 19z" stroke-opacity="0.5"/></svg>
|
|
</div>
|
|
<div class="empty-state-title">No SDI devices found</div>
|
|
<div class="empty-state-body">This machine has no DeckLink cards installed. To ingest live streams via SRT or RTMP, use the Recorders page instead.</div>
|
|
<div class="empty-state-actions">
|
|
<a href="recorders.html" class="btn btn-primary btn-sm">Go to Recorders</a>
|
|
<button class="btn btn-ghost btn-sm" onclick="initCapture()">Retry</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Main capture layout (shown when devices found) -->
|
|
<div class="capture-layout" id="captureLayout" style="display:none;">
|
|
<!-- Left: controls -->
|
|
<div class="capture-controls">
|
|
<!-- Timecode -->
|
|
<div class="timecode-block">
|
|
<div class="timecode-label">Elapsed</div>
|
|
<div class="timecode-display inactive" id="timecodeDisplay">00:00:00:00</div>
|
|
<div class="timecode-status">
|
|
<span class="status-dot status-dot--idle" id="recStatusDot"></span>
|
|
<span id="recStatusLabel">Ready</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Record button -->
|
|
<div class="record-btn-wrap">
|
|
<button class="record-btn" id="recordBtn" onclick="toggleRecord()" aria-label="Start recording" title="Start/stop recording">
|
|
<div class="record-btn-inner"></div>
|
|
</button>
|
|
<div class="record-btn-label" id="recordBtnLabel">Record</div>
|
|
</div>
|
|
|
|
<!-- Status bar -->
|
|
<div class="status-bar" id="statusBar">
|
|
<span class="status-bar-dot status-dot status-dot--idle" id="statusDot"></span>
|
|
<span class="status-bar-text" id="statusText">Select device and project to begin</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right: settings + recent -->
|
|
<div style="display:flex;flex-direction:column;gap:var(--sp-5);">
|
|
<!-- Settings -->
|
|
<div class="capture-settings">
|
|
<div class="settings-title">Source</div>
|
|
<div class="form-group">
|
|
<label class="form-label" for="deviceSelect">Device</label>
|
|
<select id="deviceSelect"></select>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="capture-settings">
|
|
<div class="settings-title">Destination</div>
|
|
<div class="form-group">
|
|
<label class="form-label" for="projectSelect">Project</label>
|
|
<select id="projectSelect">
|
|
<option value="">Select project…</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label" for="binSelect">Bin</label>
|
|
<select id="binSelect">
|
|
<option value="">Project root</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label" for="clipName">Clip name</label>
|
|
<input type="text" id="clipName" placeholder="Auto-generated if blank">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Recent captures -->
|
|
<div class="recent-section">
|
|
<div class="recent-title">Recent captures</div>
|
|
<div id="recentList">
|
|
<div class="empty-state" style="padding:var(--sp-8) 0;">
|
|
<div class="empty-state-body">No recent captures</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="toast-container" id="toastContainer" aria-live="polite"></div>
|
|
|
|
<script src="js/api.js?v=5"></script>
|
|
<script src="js/topbar-strip.js?v=1"></script>
|
|
<script>
|
|
const cState = {
|
|
devices: [],
|
|
projects: [],
|
|
currentDevice: null,
|
|
isRecording: false,
|
|
startedAt: null,
|
|
tcInterval: null,
|
|
};
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
initCapture();
|
|
document.getElementById('projectSelect').onchange = handleProjectChange;
|
|
});
|
|
|
|
async function initCapture() {
|
|
document.getElementById('deviceStatus').textContent = 'Loading devices…';
|
|
const [devRes, projRes, recRes] = await Promise.all([
|
|
getCaptureDevices(),
|
|
getProjects(),
|
|
getRecentCaptures(8),
|
|
]);
|
|
|
|
if (devRes.success) {
|
|
cState.devices = devRes.data;
|
|
renderDevices();
|
|
}
|
|
|
|
if (projRes.success) {
|
|
cState.projects = projRes.data;
|
|
const sel = document.getElementById('projectSelect');
|
|
sel.innerHTML = '<option value="">Select project…</option>' +
|
|
projRes.data.map(p => `<option value="${p.id}">${esc(p.name)}</option>`).join('');
|
|
}
|
|
|
|
if (recRes.success) renderRecent(recRes.data);
|
|
|
|
if (!cState.devices.length) {
|
|
document.getElementById('noDeviceState').style.display = 'flex';
|
|
document.getElementById('captureLayout').style.display = 'none';
|
|
document.getElementById('deviceStatus').textContent = 'No devices';
|
|
} else {
|
|
document.getElementById('noDeviceState').style.display = 'none';
|
|
document.getElementById('captureLayout').style.display = 'grid';
|
|
}
|
|
|
|
// Check if already recording
|
|
const statusRes = await getRecordingStatus();
|
|
if (statusRes.success && statusRes.data?.recording) {
|
|
cState.isRecording = true;
|
|
cState.startedAt = new Date(statusRes.data.started_at || Date.now());
|
|
updateRecordingUI();
|
|
startTimecodeUpdate();
|
|
}
|
|
}
|
|
|
|
function renderDevices() {
|
|
const sel = document.getElementById('deviceSelect');
|
|
const status = document.getElementById('deviceStatus');
|
|
if (!cState.devices.length) { status.textContent = 'No devices'; return; }
|
|
sel.innerHTML = cState.devices.map(d => `<option value="${d.id}">${esc(d.name)}</option>`).join('');
|
|
cState.currentDevice = cState.devices[0].id;
|
|
status.textContent = `${cState.devices.length} device${cState.devices.length > 1 ? 's' : ''} available`;
|
|
}
|
|
|
|
async function handleProjectChange() {
|
|
const pid = document.getElementById('projectSelect').value;
|
|
const binSel = document.getElementById('binSelect');
|
|
binSel.innerHTML = '<option value="">Project root</option>';
|
|
if (!pid) return;
|
|
const r = await getBins(pid);
|
|
if (r.success) r.data.forEach(b => binSel.innerHTML += `<option value="${b.id}">${esc(b.name)}</option>`);
|
|
}
|
|
|
|
async function toggleRecord() {
|
|
if (cState.isRecording) {
|
|
await stopCap();
|
|
} else {
|
|
await startCap();
|
|
}
|
|
}
|
|
|
|
async function startCap() {
|
|
const device = document.getElementById('deviceSelect').value;
|
|
const projectId = document.getElementById('projectSelect').value;
|
|
if (!device) { toast('Select a device', '', 'warning'); return; }
|
|
|
|
setStatus('Starting…', 'processing');
|
|
const r = await startRecording(
|
|
device,
|
|
projectId || null,
|
|
document.getElementById('binSelect').value || null,
|
|
document.getElementById('clipName').value || null
|
|
);
|
|
|
|
if (r.success) {
|
|
cState.isRecording = true;
|
|
cState.startedAt = new Date();
|
|
updateRecordingUI();
|
|
startTimecodeUpdate();
|
|
setStatus('Recording', 'recording');
|
|
toast('Recording started', '', 'success');
|
|
} else {
|
|
setStatus('Error: ' + (r.error || 'failed to start'), 'error');
|
|
toast('Failed to start', r.error, 'error');
|
|
}
|
|
}
|
|
|
|
async function stopCap() {
|
|
setStatus('Stopping…', 'processing');
|
|
const r = await stopRecording();
|
|
if (r.success) {
|
|
cState.isRecording = false;
|
|
clearInterval(cState.tcInterval);
|
|
cState.tcInterval = null;
|
|
updateRecordingUI();
|
|
setStatus('Stopped — file saved', 'idle');
|
|
toast('Recording stopped', r.data?.filename || '', 'success');
|
|
setTimeout(() => initCapture(), 1500);
|
|
} else {
|
|
setStatus('Stop failed: ' + r.error, 'error');
|
|
toast('Failed to stop', r.error, 'error');
|
|
}
|
|
}
|
|
|
|
function updateRecordingUI() {
|
|
const btn = document.getElementById('recordBtn');
|
|
const label = document.getElementById('recordBtnLabel');
|
|
const tc = document.getElementById('timecodeDisplay');
|
|
const dot = document.getElementById('recStatusDot');
|
|
const statusLabel = document.getElementById('recStatusLabel');
|
|
|
|
if (cState.isRecording) {
|
|
btn.classList.add('recording');
|
|
btn.setAttribute('aria-label', 'Stop recording');
|
|
label.textContent = 'Stop';
|
|
label.classList.add('recording');
|
|
tc.classList.remove('inactive');
|
|
dot.className = 'status-dot status-dot--recording';
|
|
statusLabel.textContent = 'Recording';
|
|
} else {
|
|
btn.classList.remove('recording');
|
|
btn.setAttribute('aria-label', 'Start recording');
|
|
label.textContent = 'Record';
|
|
label.classList.remove('recording');
|
|
tc.classList.add('inactive');
|
|
tc.textContent = '00:00:00:00';
|
|
dot.className = 'status-dot status-dot--idle';
|
|
statusLabel.textContent = 'Ready';
|
|
}
|
|
}
|
|
|
|
function startTimecodeUpdate() {
|
|
if (cState.tcInterval) clearInterval(cState.tcInterval);
|
|
cState.tcInterval = setInterval(() => {
|
|
const elapsed = Math.floor((Date.now() - cState.startedAt) / 1000);
|
|
const h = Math.floor(elapsed / 3600);
|
|
const m = Math.floor((elapsed % 3600) / 60);
|
|
const s = elapsed % 60;
|
|
const f = Math.floor((Date.now() - cState.startedAt) % 1000 / (1000 / 30));
|
|
document.getElementById('timecodeDisplay').textContent =
|
|
[h, m, s, f].map(v => String(v).padStart(2,'0')).join(':');
|
|
}, 33);
|
|
}
|
|
|
|
function setStatus(text, type) {
|
|
document.getElementById('statusText').textContent = text;
|
|
const dot = document.getElementById('statusDot');
|
|
const dotClass = { recording:'status-dot--recording', processing:'status-dot--processing', error:'status-dot--error', idle:'status-dot--idle' }[type] || 'status-dot--idle';
|
|
dot.className = `status-bar-dot status-dot ${dotClass}`;
|
|
}
|
|
|
|
function renderRecent(captures) {
|
|
const list = document.getElementById('recentList');
|
|
if (!captures?.length) return;
|
|
list.innerHTML = `<table class="data-table">
|
|
<thead><tr><th>File</th><th>Duration</th><th>Date</th></tr></thead>
|
|
<tbody>${captures.map(c => `
|
|
<tr>
|
|
<td class="truncate" style="max-width:200px">${esc(c.filename || c.clip_name || 'untitled')}</td>
|
|
<td>${c.duration ? formatDuration(c.duration) : '—'}</td>
|
|
<td>${c.created_at ? new Date(c.created_at).toLocaleString() : '—'}</td>
|
|
</tr>`).join('')}
|
|
</tbody>
|
|
</table>`;
|
|
}
|
|
|
|
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) return '';
|
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|