dragonflight/services/web-ui/public/editor.html
Zac Gaetano 07ded22f8e feat: video proxy streaming endpoint + editor drag-and-drop to timeline
- mam-api: add GET /api/v1/assets/:id/video streaming proxy that fetches
  from RustFS/S3 and pipes to browser with range-request support, bypassing
  direct S3 access from Chrome
- mam-api: fix /stream route to return /video proxy URL for both proxy and
  original-mp4 assets; return null cleanly for non-playable sources
- s3/client: set requestChecksumCalculation/responseChecksumValidation to
  WHEN_REQUIRED to suppress x-amz-checksum-mode header on signed URLs
- editor: fix loadSourceAsset to set state.sourceAsset even when no proxy
  exists (info toast instead of bail-out) so Insert/Overwrite still work
- editor: add drag-and-drop from media panel to timeline — items are now
  draggable, timeline container accepts drops and calls Timeline.addClip
  with the asset at playhead position
- editor: add tl-drag-over CSS highlight on timeline during drag
2026-05-19 22:47:33 -04:00

984 lines
38 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">
<title>Editor — Z-AMPP</title>
<link rel="stylesheet" href="css/common.css">
<style>
/* ── Editor layout ─────────────────────────────────────────── */
.editor-shell {
display: flex;
flex: 1;
overflow: hidden;
height: 100%;
}
/* 2-column × 2-row grid */
.editor-body {
flex: 1;
display: grid;
grid-template-columns: 300px 1fr;
grid-template-rows: 40vh 1fr;
grid-template-areas:
"source program"
"media timeline-panel";
overflow: hidden;
}
/* ── Monitor shared styles ─────────────────────────────────── */
.monitor {
display: flex;
flex-direction: column;
border-right: 1px solid var(--border);
border-bottom: 1px solid var(--border);
overflow: hidden;
background: var(--bg-base);
}
.monitor-source { grid-area: source; }
.monitor-program { grid-area: program; border-right: none; }
.monitor-video-wrap {
flex: 1;
background: #000;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.monitor-video-wrap video {
max-width: 100%;
max-height: 100%;
display: block;
}
.monitor-bar {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
background: var(--bg-panel);
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.monitor-tc {
font-size: 11px;
font-weight: 500;
font-variant-numeric: tabular-nums;
font-family: 'Courier New', monospace;
color: var(--accent);
letter-spacing: .04em;
min-width: 96px;
}
.monitor-scrub {
flex: 1;
height: 3px;
cursor: pointer;
accent-color: var(--accent);
}
.monitor-inout {
display: flex;
gap: var(--sp-1);
}
/* ── Media panel ───────────────────────────────────────────── */
.media-panel {
grid-area: media;
display: flex;
flex-direction: column;
border-right: 1px solid var(--border);
overflow: hidden;
background: var(--bg-panel);
}
.media-panel-header {
padding: var(--sp-2) var(--sp-3);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.media-panel-title {
font-size: var(--text-xs);
font-weight: 500;
text-transform: uppercase;
letter-spacing: .08em;
color: var(--text-tertiary);
}
.media-asset-list {
flex: 1;
overflow-y: auto;
padding: var(--sp-1) 0;
}
.media-asset-item {
display: flex;
align-items: flex-start;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
font-size: var(--text-xs);
color: var(--text-secondary);
cursor: pointer;
transition: background var(--t-fast), color var(--t-fast);
overflow: hidden;
}
.media-asset-item svg { flex-shrink: 0; margin-top: 2px; }
.media-asset-item:hover { background: var(--bg-hover); color: var(--text-primary); }
.media-asset-item.active { background: var(--accent-subtle); color: var(--accent); }
.media-asset-info {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
gap: 2px;
}
.media-asset-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-asset-meta {
font-size: 10px;
color: var(--text-tertiary);
font-family: var(--font-mono);
letter-spacing: 0.02em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.media-asset-item.active .media-asset-meta { color: var(--accent); opacity: 0.7; }
/* ── Timeline panel ────────────────────────────────────────── */
.timeline-panel {
grid-area: timeline-panel;
display: flex;
flex-direction: column;
overflow: hidden;
}
.timeline-toolbar {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
background: var(--bg-panel);
}
.tl-tool-btn {
height: 26px;
padding: 0 var(--sp-2);
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--r-sm);
color: var(--text-secondary);
font-size: var(--text-xs);
font-weight: 500;
cursor: pointer;
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
}
.tl-tool-btn:hover { border-color: var(--accent-border); color: var(--text-primary); }
.tl-tool-btn.active { background: var(--accent-subtle); border-color: var(--accent-border); color: var(--accent); }
.tl-sep { width: 1px; height: 18px; background: var(--border); }
.tl-save-status {
margin-left: auto;
font-size: var(--text-xs);
color: var(--text-tertiary);
}
.timeline-container.tl-drag-over {
outline: 2px dashed var(--accent, #0fa3ff);
background: rgba(15,163,255,0.06);
}
.timeline-container {
flex: 1;
overflow: hidden;
}
/* ── Topbar: sequence info ─────────────────────────────────── */
.topbar-seq-name {
font-size: var(--text-sm);
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
padding: 0 var(--sp-2);
border-radius: var(--r-sm);
transition: background var(--t-fast);
}
.topbar-seq-name:hover { background: var(--bg-hover); }
</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="Wild Dragon" 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">
<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 active">
<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>
</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">&#8212;</div>
<div class="sidebar-user-role" id="userRole"></div>
</div>
<button class="btn btn-ghost" id="logoutBtn" style="padding:0;width:28px;height:28px;flex-shrink:0;" title="Sign out">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M6 2H3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3M11 11l3-3-3-3M6 8h8"/></svg>
</button>
</div>
</div>
</nav>
<!-- Main area -->
<div class="main">
<!-- Topbar -->
<header class="topbar">
<div class="topbar-left">
<span class="page-title">Editor</span>
<span class="topbar-sep">/</span>
<select id="seqSelect" class="project-select" style="min-width:160px;" aria-label="Select sequence"></select>
<button class="btn btn-ghost btn-sm" id="newSeqBtn" title="New sequence">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"><path d="M8 2v12M2 8h12"/></svg>
</button>
</div>
<div class="topbar-right">
<button class="btn btn-ghost btn-sm" id="exportEdlBtn">Export EDL</button>
<button class="btn btn-primary btn-sm" id="saveBtn">Save</button>
</div>
</header>
<!-- Editor body -->
<div class="editor-shell">
<div class="editor-body">
<!-- Source Monitor -->
<div class="monitor monitor-source">
<div class="monitor-video-wrap">
<video id="srcVideo" preload="metadata"></video>
</div>
<div class="monitor-bar">
<span class="monitor-tc" id="srcTC">00:00:00;00</span>
<input type="range" class="monitor-scrub" id="srcScrub" min="0" max="1000" value="0" step="1">
<div class="monitor-inout">
<button class="btn btn-ghost btn-sm tl-tool-btn" id="srcInBtn" title="Mark In (I)">In</button>
<button class="btn btn-ghost btn-sm tl-tool-btn" id="srcOutBtn" title="Mark Out (O)">Out</button>
</div>
<button class="btn btn-primary btn-sm" id="insertBtn" title="Insert at playhead">Insert</button>
<button class="btn btn-ghost btn-sm" id="overwriteBtn" title="Overwrite at playhead">OW</button>
</div>
</div>
<!-- Program Monitor -->
<div class="monitor monitor-program">
<div class="monitor-video-wrap">
<video id="pgmVideo" preload="auto"></video>
</div>
<div class="monitor-bar">
<button class="tl-tool-btn" id="pgmPlayBtn" style="min-width:32px;">&#9654;</button>
<span class="monitor-tc" id="pgmTC">00:00:00;00</span>
<input type="range" class="monitor-scrub" id="pgmScrub" min="0" max="1000" value="0" step="1">
</div>
</div>
<!-- Media Panel -->
<div class="media-panel">
<div class="media-panel-header">
<span class="media-panel-title">Media</span>
<select id="mediaBinSel" style="font-size:var(--text-xs);height:22px;" aria-label="Filter by bin">
<option value="">All assets</option>
</select>
</div>
<div class="media-asset-list" id="mediaAssetList"></div>
</div>
<!-- Timeline Panel -->
<div class="timeline-panel">
<div class="timeline-toolbar">
<button class="tl-tool-btn active" id="toolSelect" title="Select (V)">V</button>
<button class="tl-tool-btn" id="toolRazor" title="Razor (C)">C</button>
<button class="tl-tool-btn" id="toolHand" title="Hand (H)">H</button>
<div class="tl-sep"></div>
<button class="tl-tool-btn" id="undoBtn" title="Undo (Ctrl+Z)">&#8617;</button>
<button class="tl-tool-btn" id="redoBtn" title="Redo (Ctrl+Shift+Z)">&#8618;</button>
<span class="tl-save-status" id="saveStatus"></span>
</div>
<div class="timeline-container" id="timelineContainer"></div>
</div>
</div><!-- .editor-body -->
</div><!-- .editor-shell -->
</div><!-- .main -->
</div><!-- .shell -->
<div class="toast-container" id="toastContainer" aria-live="polite"></div>
<!-- New sequence dialog -->
<div class="slide-overlay" id="seqOverlay"></div>
<div class="slide-panel" id="seqPanel">
<div class="slide-panel-header">
<span class="slide-panel-title">New sequence</span>
<button class="btn btn-ghost btn-sm" id="closeSeqPanel" 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">
<div class="form-group">
<label class="form-label" for="newSeqName">Sequence name</label>
<input type="text" id="newSeqName" placeholder="e.g. Rough Cut v1">
</div>
</div>
<div class="slide-panel-footer">
<button class="btn btn-ghost" id="cancelSeqBtn">Cancel</button>
<button class="btn btn-primary" id="saveSeqBtn">Create</button>
</div>
</div>
<script src="js/api.js?v=6"></script>
<script src="js/timecode.js"></script>
<script src="js/timeline.js"></script>
<script>
// ════════════════════════════════════════════════════════════════
// APP STATE
// ════════════════════════════════════════════════════════════════
const state = {
projectId: null,
sequences: [],
seq: null,
bins: [],
assets: [],
sourceAsset: null,
srcIn: null,
srcOut: null,
pgmPlaying: false,
pgmClipIdx: -1,
pgmClips: [],
streamCache: {},
saveTimer: null,
history: [],
historyIdx: -1,
isDirty: false,
};
// ════════════════════════════════════════════════════════════════
// INIT
// ════════════════════════════════════════════════════════════════
document.addEventListener('DOMContentLoaded', async () => {
const params = new URLSearchParams(location.search);
state.projectId = params.get('project');
const openAsset = params.get('asset');
if (!state.projectId) {
const pr = await getProjects();
if (pr.success && pr.data.length) state.projectId = pr.data[0].id;
}
if (!state.projectId) { toast('No project selected', 'Go to Library first', 'warning'); return; }
setupToolbar();
setupSourceMonitor();
setupProgramMonitor();
setupKeyboard();
setupNewSeqPanel();
Timeline.init(document.getElementById('timelineContainer'), {
fps: 59.94,
scale: 100,
onClipsChanged: onClipsChanged,
onPlayheadMoved: onPlayheadMoved,
});
(function setupTimelineDrop() {
var tlc = document.getElementById('timelineContainer');
tlc.addEventListener('dragover', function(e) {
e.preventDefault(); e.dataTransfer.dropEffect = 'copy';
tlc.classList.add('tl-drag-over');
});
tlc.addEventListener('dragleave', function(e) {
if (!tlc.contains(e.relatedTarget)) tlc.classList.remove('tl-drag-over');
});
tlc.addEventListener('drop', async function(e) {
e.preventDefault(); tlc.classList.remove('tl-drag-over');
var raw = e.dataTransfer.getData('application/x-zampp-asset');
if (!raw) return;
var asset; try { asset = JSON.parse(raw); } catch(err) { return; }
if (!state.seq) { toast('No sequence open', '', 'warning'); return; }
var r = await getAssetStreamUrl(asset.id);
if (r.success && r.data && r.data.url) state.streamCache[asset.id] = r.data.url;
Timeline.addClip(Object.assign({}, asset, { streamUrl: state.streamCache[asset.id]||null }), 0, (asset.duration_ms||10000)/1000, 0);
});
})();
await loadProject();
await loadSequences();
await loadMediaAssets();
if (openAsset) {
const asset = state.assets.find(a => a.id === openAsset);
if (asset) loadSourceAsset(asset);
}
});
// ════════════════════════════════════════════════════════════════
// PROJECT + SEQUENCES
// ════════════════════════════════════════════════════════════════
async function loadProject() {
const r = await getBins(state.projectId);
state.bins = r.success ? r.data : [];
const sel = document.getElementById('mediaBinSel');
sel.innerHTML = '<option value="">All assets</option>' +
state.bins.map(b => '<option value="' + b.id + '">' + esc(b.name) + '</option>').join('');
sel.onchange = () => loadMediaAssets();
}
async function loadSequences() {
const r = await getSequences(state.projectId);
state.sequences = r.success ? r.data : [];
renderSeqSelect();
if (state.sequences.length) await openSequence(state.sequences[0].id);
}
function renderSeqSelect() {
const sel = document.getElementById('seqSelect');
if (!state.sequences.length) {
sel.innerHTML = '<option value="">No sequences &#8212; create one</option>';
return;
}
sel.innerHTML = state.sequences.map(s =>
'<option value="' + s.id + '">' + esc(s.name) + '</option>'
).join('');
sel.onchange = () => openSequence(sel.value);
}
async function openSequence(id) {
const r = await getSequence(id);
if (!r.success) { toast('Failed to load sequence', r.error, 'error'); return; }
state.seq = r.data;
state.history = [cloneClips(state.seq.clips)];
state.historyIdx = 0;
document.getElementById('seqSelect').value = id;
Timeline.render(state.seq.clips, { fps: 59.94 });
updateProgramScrub();
}
// ════════════════════════════════════════════════════════════════
// MEDIA PANEL
// ════════════════════════════════════════════════════════════════
async function loadMediaAssets() {
const filters = { project_id: state.projectId };
const binId = document.getElementById('mediaBinSel').value;
if (binId) filters.bin_id = binId;
const r = await getAssets(filters);
state.assets = r.success ? r.data : [];
renderMediaList();
}
function fmtMs(ms) {
if (!ms || ms <= 0) return null;
const s = Math.floor(ms / 1000);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sc = s % 60;
if (h > 0) return [h, m, sc].map(v => String(v).padStart(2,'0')).join(':');
return [m, sc].map(v => String(v).padStart(2,'0')).join(':');
}
function renderMediaList() {
const list = document.getElementById('mediaAssetList');
list.innerHTML = '';
state.assets.forEach(function(asset) {
const el = document.createElement('div');
el.className = 'media-asset-item';
const metaParts = [
asset.resolution || null,
asset.codec || null,
asset.fps ? asset.fps + ' fps' : null,
fmtMs(asset.duration_ms),
].filter(Boolean);
const metaStr = metaParts.join(' · ');
el.innerHTML =
'<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.3" width="12" height="12">' +
'<rect x="1" y="3" width="8" height="8" rx="1"/>' +
'<path d="M9 6l4-2v6l-4-2"/>' +
'</svg>' +
'<div class="media-asset-info">' +
'<span class="media-asset-name" title="' + esc(asset.display_name || asset.filename) + '">' +
esc(asset.display_name || asset.filename) +
'</span>' +
(metaStr ? '<span class="media-asset-meta">' + esc(metaStr) + '</span>' : '') +
'</div>';
el.draggable = true;
el.ondragstart = function(e) {
e.dataTransfer.setData('application/x-zampp-asset', JSON.stringify({ id: asset.id, display_name: asset.display_name || asset.filename, duration_ms: asset.duration_ms }));
e.dataTransfer.effectAllowed = 'copy';
};
el.ondblclick = function() { loadSourceAsset(asset); };
el.onclick = function() {
list.querySelectorAll('.media-asset-item').forEach(function(e) { e.classList.remove('active'); });
el.classList.add('active');
};
list.appendChild(el);
});
}
// ════════════════════════════════════════════════════════════════
// SOURCE MONITOR
// ════════════════════════════════════════════════════════════════
function setupSourceMonitor() {
const vid = document.getElementById('srcVideo');
const scrub = document.getElementById('srcScrub');
vid.addEventListener('timeupdate', function() {
document.getElementById('srcTC').textContent =
TC.framesToTC(TC.secondsToFrames(vid.currentTime));
if (!vid.duration) return;
scrub.value = Math.round((vid.currentTime / vid.duration) * 1000);
updateSrcInOutMarkers();
});
scrub.addEventListener('input', function() {
if (!vid.duration) return;
vid.currentTime = (scrub.value / 1000) * vid.duration;
});
document.getElementById('srcInBtn').onclick = markSrcIn;
document.getElementById('srcOutBtn').onclick = markSrcOut;
document.getElementById('insertBtn').onclick = doInsert;
document.getElementById('overwriteBtn').onclick = doOverwrite;
}
async function loadSourceAsset(asset) {
state.sourceAsset = asset;
state.srcIn = null;
state.srcOut = null;
const vid = document.getElementById('srcVideo');
vid.src = '';
let url = state.streamCache[asset.id];
if (!url) {
const r = await getAssetStreamUrl(asset.id);
if (r.success && r.data && r.data.url) {
url = r.data.url;
state.streamCache[asset.id] = url;
} else {
toast('No proxy — clip usable in timeline only', '', 'info');
}
}
if (url) { vid.src = url; vid.load(); }
updateSrcInOutMarkers();
}
function markSrcIn() {
const vid = document.getElementById('srcVideo');
state.srcIn = vid.currentTime;
updateSrcInOutMarkers();
}
function markSrcOut() {
const vid = document.getElementById('srcVideo');
state.srcOut = vid.currentTime;
updateSrcInOutMarkers();
}
function updateSrcInOutMarkers() {
const inBtn = document.getElementById('srcInBtn');
const outBtn = document.getElementById('srcOutBtn');
inBtn.title = state.srcIn != null ? 'In: ' + TC.framesToTC(TC.secondsToFrames(state.srcIn)) : 'Mark In (I)';
outBtn.title = state.srcOut != null ? 'Out: ' + TC.framesToTC(TC.secondsToFrames(state.srcOut)) : 'Mark Out (O)';
}
function doInsert() { _addToTimeline(false); }
function doOverwrite() { _addToTimeline(true); }
function _addToTimeline(overwrite) {
if (!state.sourceAsset) { toast('Load a clip first', '', 'warning'); return; }
if (!state.seq) { toast('No sequence open', '', 'warning'); return; }
const vid = document.getElementById('srcVideo');
const srcIn = state.srcIn != null ? state.srcIn : 0;
const srcOut = state.srcOut != null ? state.srcOut : (vid.duration || (state.sourceAsset.duration_ms || 10000) / 1000);
Timeline.addClip(
Object.assign({}, state.sourceAsset, { streamUrl: state.streamCache[state.sourceAsset.id] }),
srcIn, srcOut, 0
);
}
// ════════════════════════════════════════════════════════════════
// PROGRAM MONITOR
// ════════════════════════════════════════════════════════════════
function setupProgramMonitor() {
const vid = document.getElementById('pgmVideo');
const scrub = document.getElementById('pgmScrub');
const btn = document.getElementById('pgmPlayBtn');
vid.addEventListener('timeupdate', onPgmTimeUpdate);
vid.addEventListener('ended', onPgmEnded);
scrub.addEventListener('input', function() {
if (!state.pgmClips.length) return;
const totalDuration = pgmTotalDuration();
if (totalDuration <= 0) return;
const targetSecs = (scrub.value / 1000) * totalDuration;
seekPgmToSeconds(targetSecs);
});
btn.onclick = togglePgmPlay;
}
function pgmTotalDuration() {
if (!state.pgmClips.length) return 0;
const last = state.pgmClips[state.pgmClips.length - 1];
return TC.framesToSeconds(last.timeline_out_frames);
}
function onPgmTimeUpdate() {
if (!state.pgmPlaying) return;
const vid = document.getElementById('pgmVideo');
const clip = state.pgmClips[state.pgmClipIdx];
if (!clip) return;
const srcOutSecs = TC.framesToSeconds(clip.source_out_frames);
if (vid.currentTime >= srcOutSecs) {
loadNextPgmClip();
return;
}
const elapsed = vid.currentTime - TC.framesToSeconds(clip.source_in_frames);
const tlFrames = clip.timeline_in_frames + TC.secondsToFrames(elapsed);
Timeline.setPlayhead(tlFrames);
document.getElementById('pgmTC').textContent = TC.framesToTC(tlFrames);
const total = pgmTotalDuration();
if (total > 0)
document.getElementById('pgmScrub').value =
Math.round((TC.framesToSeconds(tlFrames) / total) * 1000);
}
function onPgmEnded() {
loadNextPgmClip();
}
async function loadNextPgmClip() {
state.pgmClipIdx++;
const clip = state.pgmClips[state.pgmClipIdx];
if (!clip) { stopPgm(); return; }
await _loadClipToPgm(clip);
document.getElementById('pgmVideo').play().catch(function() {});
}
async function _loadClipToPgm(clip) {
let url = state.streamCache[clip.asset_id];
if (!url) {
const r = await getAssetStreamUrl(clip.asset_id);
if (!r.success || !r.data || !r.data.url) return;
url = state.streamCache[clip.asset_id] = r.data.url;
}
const vid = document.getElementById('pgmVideo');
if (vid.src !== url) { vid.src = url; vid.load(); }
await new Promise(function(res) {
if (vid.readyState >= 1) { res(); return; }
vid.addEventListener('loadedmetadata', res, { once: true });
});
vid.currentTime = TC.framesToSeconds(clip.source_in_frames);
}
async function togglePgmPlay() {
if (state.pgmPlaying) { stopPgm(); return; }
if (!state.seq) return;
state.pgmClips = (state.seq.clips || [])
.filter(function(c) { return c.track === 0; })
.sort(function(a, b) { return a.timeline_in_frames - b.timeline_in_frames; });
if (!state.pgmClips.length) return;
const ph = Timeline.getPlayhead();
state.pgmClipIdx = state.pgmClips.findIndex(function(c) {
return ph >= c.timeline_in_frames && ph < c.timeline_out_frames;
});
if (state.pgmClipIdx < 0) state.pgmClipIdx = 0;
state.pgmPlaying = true;
document.getElementById('pgmPlayBtn').textContent = '⏸';
const clip = state.pgmClips[state.pgmClipIdx];
await _loadClipToPgm(clip);
document.getElementById('pgmVideo').play().catch(function() {});
}
function stopPgm() {
state.pgmPlaying = false;
state.pgmClipIdx = -1;
document.getElementById('pgmPlayBtn').textContent = '▶';
document.getElementById('pgmVideo').pause();
}
function seekPgmToSeconds(targetSecs) {
stopPgm();
const targetFrames = TC.secondsToFrames(targetSecs);
Timeline.setPlayhead(targetFrames);
document.getElementById('pgmTC').textContent = TC.framesToTC(targetFrames);
}
function updateProgramScrub() {
document.getElementById('pgmScrub').value = 0;
document.getElementById('pgmTC').textContent = '00:00:00;00';
}
// ════════════════════════════════════════════════════════════════
// TIMELINE CALLBACKS
// ════════════════════════════════════════════════════════════════
function onClipsChanged(clips) {
if (!state.seq) return;
state.history = state.history.slice(0, state.historyIdx + 1);
state.history.push(cloneClips(clips));
state.historyIdx = state.history.length - 1;
state.seq.clips = clips;
markDirty();
}
function onPlayheadMoved(frames) {
document.getElementById('pgmTC').textContent = TC.framesToTC(frames);
const total = pgmTotalDuration();
if (total > 0)
document.getElementById('pgmScrub').value =
Math.round((TC.framesToSeconds(frames) / total) * 1000);
}
// ════════════════════════════════════════════════════════════════
// AUTO-SAVE
// ════════════════════════════════════════════════════════════════
function markDirty() {
state.isDirty = true;
setSaveStatus('Unsaved');
clearTimeout(state.saveTimer);
state.saveTimer = setTimeout(saveSequence, 2000);
}
async function saveSequence() {
if (!state.seq || !state.isDirty) return;
clearTimeout(state.saveTimer);
setSaveStatus('Saving…');
const clips = (state.seq.clips || []).map(function(c) {
return {
asset_id: c.asset_id,
track: c.track,
timeline_in_frames: c.timeline_in_frames,
timeline_out_frames: c.timeline_out_frames,
source_in_frames: c.source_in_frames,
source_out_frames: c.source_out_frames,
};
});
const r = await syncSequenceClips(state.seq.id, clips);
if (r.success) {
state.isDirty = false;
setSaveStatus('Saved');
setTimeout(function() { setSaveStatus(''); }, 2000);
} else {
setSaveStatus('Save failed');
toast('Save failed', r.error, 'error');
}
}
function setSaveStatus(msg) {
document.getElementById('saveStatus').textContent = msg;
}
// ════════════════════════════════════════════════════════════════
// UNDO / REDO
// ════════════════════════════════════════════════════════════════
function cloneClips(clips) {
return clips.map(function(c) { return Object.assign({}, c); });
}
function undo() {
if (state.historyIdx <= 0) return;
state.historyIdx--;
applyHistory();
}
function redo() {
if (state.historyIdx >= state.history.length - 1) return;
state.historyIdx++;
applyHistory();
}
function applyHistory() {
const clips = cloneClips(state.history[state.historyIdx]);
state.seq.clips = clips;
Timeline.render(clips, { fps: 59.94 });
markDirty();
}
// ════════════════════════════════════════════════════════════════
// TOOLBAR + KEYBOARD
// ════════════════════════════════════════════════════════════════
function setupToolbar() {
const btns = {
select: document.getElementById('toolSelect'),
razor: document.getElementById('toolRazor'),
hand: document.getElementById('toolHand'),
};
Object.entries(btns).forEach(function(entry) {
var tool = entry[0];
var btn = entry[1];
btn.onclick = function() {
Object.values(btns).forEach(function(b) { b.classList.remove('active'); });
btn.classList.add('active');
Timeline.setTool(tool);
};
});
document.getElementById('undoBtn').onclick = undo;
document.getElementById('redoBtn').onclick = redo;
document.getElementById('saveBtn').onclick = saveSequence;
document.getElementById('exportEdlBtn').onclick = function() {
if (!state.seq) { toast('No sequence open', '', 'warning'); return; }
exportSequenceEDL(state.seq.id, (state.seq.name || 'sequence') + '.edl');
};
}
function setupKeyboard() {
document.addEventListener('keydown', function(e) {
const tag = document.activeElement.tagName;
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(tag)) return;
if (e.code === 'Space') { e.preventDefault(); togglePgmPlay(); return; }
if (e.key === 'j' || e.key === 'J') { stopPgm(); return; }
if (e.key === 'k' || e.key === 'K') { stopPgm(); return; }
if (e.key === 'l' || e.key === 'L') { togglePgmPlay(); return; }
if (e.key === 'i' || e.key === 'I') { markSrcIn(); return; }
if (e.key === 'o' || e.key === 'O') { markSrcOut(); return; }
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'z') { e.preventDefault(); redo(); return; }
if ((e.ctrlKey || e.metaKey) && e.key === 'z') { e.preventDefault(); undo(); return; }
if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); saveSequence(); return; }
if (e.key === 'v' || e.key === 'V') updateToolbarActive('select');
if (e.key === 'c' || e.key === 'C') updateToolbarActive('razor');
if (e.key === 'h' || e.key === 'H') updateToolbarActive('hand');
});
}
function updateToolbarActive(tool) {
const map = { select: 'toolSelect', razor: 'toolRazor', hand: 'toolHand' };
Object.keys(map).forEach(function(k) {
document.getElementById(map[k]).classList.remove('active');
});
if (map[tool]) document.getElementById(map[tool]).classList.add('active');
Timeline.setTool(tool);
}
// ════════════════════════════════════════════════════════════════
// NEW SEQUENCE PANEL
// ════════════════════════════════════════════════════════════════
function setupNewSeqPanel() {
document.getElementById('newSeqBtn').onclick = function() { openPanel('seq'); };
document.getElementById('closeSeqPanel').onclick = function() { closePanel('seq'); };
document.getElementById('cancelSeqBtn').onclick = function() { closePanel('seq'); };
document.getElementById('seqOverlay').onclick = function() { closePanel('seq'); };
document.getElementById('saveSeqBtn').onclick = createNewSequence;
}
async function createNewSequence() {
const name = document.getElementById('newSeqName').value.trim() || 'Sequence ' + (state.sequences.length + 1);
const r = await createSequence({ project_id: state.projectId, name: name });
if (!r.success) { toast('Failed to create sequence', r.error, 'error'); return; }
closePanel('seq');
document.getElementById('newSeqName').value = '';
state.sequences.unshift(r.data);
renderSeqSelect();
await openSequence(r.data.id);
toast('Sequence created', name, 'success');
}
function openPanel(name) {
document.getElementById(name + 'Panel').classList.add('open');
document.getElementById(name + 'Overlay').classList.add('open');
}
function closePanel(name) {
document.getElementById(name + 'Panel').classList.remove('open');
document.getElementById(name + 'Overlay').classList.remove('open');
}
// ════════════════════════════════════════════════════════════════
// UTILITIES
// ════════════════════════════════════════════════════════════════
function toast(title, msg, type) {
type = 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(function() { el.remove(); }, 4000);
}
function esc(s) {
if (!s) 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>