merge: bring NLE editor pages (editor.html, timeline.js, timecode.js) from main

This commit is contained in:
Zac Gaetano 2026-05-18 23:02:51 -04:00
commit 910bbf8d3f
5 changed files with 1556 additions and 0 deletions

View file

@ -0,0 +1,897 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Editor — 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>
/* ── 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: center;
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);
white-space: nowrap;
overflow: hidden;
}
.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-item span { overflow: hidden; text-overflow: ellipsis; }
/* ── 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 {
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">
<div class="sidebar-brand-mark">
<svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12"><path d="M8 1L2 5v6l6 4 6-4V5L8 1zm0 2.2L12 6v4l-4 2.7L4 10V6l4-2.8z"/></svg>
</div>
<span class="sidebar-brand-name">Wild Dragon</span>
</div>
<nav class="sidebar-nav">
<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="editor.html" class="nav-item active">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="3" width="14" height="10" rx="1"/><path d="M1 7h14M5 3v10M5 7l3-2v4l-3-2z"/></svg>
Editor
</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>
<div class="sidebar-section-label">Admin</div>
<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 8H15M3.2 3.2l1 1M11.8 11.8l1 1M3.2 12.8l1-1M11.8 4.2l1-1"/></svg>
Settings
</a>
<a href="users.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="5" r="3"/><path d="M2 14c0-3.3 2.7-6 6-6s6 2.7 6 6"/></svg>
Users
</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"></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,
});
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 renderMediaList() {
const list = document.getElementById('mediaAssetList');
list.innerHTML = '';
state.assets.forEach(function(asset) {
const el = document.createElement('div');
el.className = 'media-asset-item';
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>' +
'<span title="' + esc(asset.display_name || asset.filename) + '">' + esc(asset.display_name || asset.filename) + '</span>';
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) { toast('No proxy available', '', 'warning'); return; }
url = r.data.url;
state.streamCache[asset.id] = 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');
}
// ════════════════════════════════════════════════════════════════
// 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>

View file

@ -702,6 +702,9 @@
${asset.duration ? `<span class="asset-duration">${formatDuration(asset.duration)}</span>` : ''}
</div>
<div class="asset-actions">
<button class="asset-action-btn" onclick="openInEditor('${asset.id}', event)" title="Open in Editor">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="3" width="14" height="10" rx="1"/><path d="M1 7h14M5 3v10M5 7l3-2v4l-3-2z"/></svg>
</button>
<button class="asset-action-btn" onclick="deleteAssetPrompt('${asset.id}', event)" title="Delete">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 4h10M6 4V2h4v2M5 4v9h6V4"/></svg>
</button>
@ -749,6 +752,13 @@
else toast('Delete failed', r.error, 'error');
}
function openInEditor(assetId, e) {
e.stopPropagation();
const projectId = state.currentProjectId;
if (!projectId) { toast('Select a project first', '', 'warning'); return; }
location.href = 'editor.html?project=' + projectId + '&asset=' + assetId;
}
// ── Search ────────────────────────────────
function setupSearch() {
const inp = document.getElementById('searchInput');

View file

@ -487,3 +487,69 @@ async function createToken(data) {
async function revokeToken(id) {
return api(`/tokens/${id}`, { method: 'DELETE' });
}
// ============================================================
// SEQUENCE API CALLS
// ============================================================
async function getSequences(projectId) {
return api(`/sequences?project_id=${projectId}`);
}
async function createSequence(data) {
return api('/sequences', {
method: 'POST',
body: JSON.stringify(data),
});
}
async function getSequence(sequenceId) {
return api(`/sequences/${sequenceId}`);
}
async function updateSequence(sequenceId, data) {
return api(`/sequences/${sequenceId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async function deleteSequence(sequenceId) {
return api(`/sequences/${sequenceId}`, { method: 'DELETE' });
}
/**
* Replace all clips in a sequence.
* @param {string} sequenceId
* @param {Array<{asset_id, track, timeline_in_frames, timeline_out_frames, source_in_frames, source_out_frames}>} clips
*/
async function syncSequenceClips(sequenceId, clips) {
return api(`/sequences/${sequenceId}/clips`, {
method: 'PUT',
body: JSON.stringify(clips),
});
}
/**
* Download EDL for the V1 track of a sequence.
* Triggers a file download in the browser.
*/
async function exportSequenceEDL(sequenceId, filename) {
try {
const response = await fetch(`${API_BASE}/sequences/${sequenceId}/export/edl`, {
method: 'POST',
credentials: 'include',
});
if (!response.ok) throw new Error(`EDL export failed: ${response.status}`);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename || 'sequence.edl';
a.click();
URL.revokeObjectURL(url);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
}

View file

@ -0,0 +1,77 @@
// services/web-ui/public/js/timecode.js
// 59.94 fps drop-frame timecode utilities.
// Exposes: window.TC
(function (global) {
'use strict';
// 59.94 = 60000/1001
const FPS_EXACT = 60000 / 1001;
const NOM = 60; // nominal integer fps
const DROP = 4; // frames dropped per minute (except every 10th)
const FRAMES_PER_MIN = NOM * 60 - DROP; // 3596
const FRAMES_PER_10MIN = FRAMES_PER_MIN * 10 + DROP; // 35964
const FRAMES_PER_HOUR = FRAMES_PER_10MIN * 6; // 215784
function pad2(n) { return String(Math.floor(n)).padStart(2, '0'); }
/**
* Convert a frame count to a 59.94 DF timecode string: HH:MM:SS;FF
*/
function framesToTC(totalFrames) {
const fc = Math.max(0, Math.round(totalFrames));
const h = Math.floor(fc / FRAMES_PER_HOUR);
let rem = fc % FRAMES_PER_HOUR;
const tm = Math.floor(rem / FRAMES_PER_10MIN); // tens of minutes (05)
rem = rem % FRAMES_PER_10MIN;
let m = 0;
if (rem >= DROP) {
m = Math.floor((rem - DROP) / FRAMES_PER_MIN) + 1;
rem = (rem - DROP) % FRAMES_PER_MIN;
}
const M = tm * 10 + m;
const s = Math.floor(rem / NOM);
const ff = rem % NOM;
return `${pad2(h)}:${pad2(M)}:${pad2(s)};${pad2(ff)}`;
}
/**
* Convert a 59.94 DF timecode string (HH:MM:SS;FF or HH:MM:SS:FF) to frame count.
*/
function tcToFrames(tc) {
if (!tc) return 0;
const clean = String(tc).replace(';', ':');
const parts = clean.split(':').map(Number);
if (parts.length !== 4) return 0;
const [h, m, s, f] = parts;
const totalMinutes = h * 60 + m;
return (NOM * 3600 * h)
+ (NOM * 60 * m)
+ (NOM * s)
+ f
- DROP * (totalMinutes - Math.floor(totalMinutes / 10));
}
/**
* Convert seconds (float) frame count at 59.94.
*/
function secondsToFrames(seconds) {
return Math.round(seconds * FPS_EXACT);
}
/**
* Convert frame count seconds (float) at 59.94.
*/
function framesToSeconds(frames) {
return frames / FPS_EXACT;
}
global.TC = {
framesToTC,
tcToFrames,
secondsToFrames,
framesToSeconds,
FPS: FPS_EXACT,
};
})(window);

View file

@ -0,0 +1,506 @@
// services/web-ui/public/js/timeline.js
// DOM-based NLE timeline engine. Exposes: window.Timeline
// Depends on: window.TC (timecode.js must be loaded first)
(function (global) {
'use strict';
const TRACK_HEIGHT = 48; // px, each track row
const HEADER_W = 40; // px, track label column width
const MIN_CLIP_PX = 4; // minimum rendered clip width
const TRACKS = [
{ id: 0, label: 'V1', type: 'video' },
{ id: 1, label: 'V2', type: 'video' },
{ id: 100, label: 'A1', type: 'audio' },
{ id: 101, label: 'A2', type: 'audio' },
];
// ── Internal state ──────────────────────────────────────────────────────────
const s = {
container: null,
rulerEl: null,
tracksEl: null,
playheadEl: null,
clips: [], // working copy; each has a local .id
scale: 100, // px per second
fps: 59.94,
playheadFrames: 0,
activeTool: 'select', // 'select' | 'razor' | 'hand'
selectedId: null,
onClipsChanged: null, // callback(clips[])
onPlayheadMoved: null, // callback(frames)
};
let _uid = 0;
function uid() { return 'tl_' + (++_uid); }
function framesToPx(f) { return (f / s.fps) * s.scale; }
function pxToFrames(px) { return Math.round((px / s.scale) * s.fps); }
// ── init ────────────────────────────────────────────────────────────────────
function init(container, options) {
options = options || {};
s.container = container;
s.fps = options.fps || 59.94;
s.scale = options.scale || 100;
s.onClipsChanged = options.onClipsChanged || null;
s.onPlayheadMoved = options.onPlayheadMoved || null;
container.innerHTML = '';
container.style.cssText = [
'position:relative', 'overflow-x:auto', 'overflow-y:hidden',
'user-select:none', 'display:flex', 'flex-direction:column',
].join(';');
// Ruler row
s.rulerEl = document.createElement('div');
s.rulerEl.className = 'tl-ruler';
s.rulerEl.style.cssText = [
'position:sticky', 'top:0', 'z-index:10',
'height:24px', 'flex-shrink:0',
'background:var(--bg-base)', 'border-bottom:1px solid var(--border)',
'cursor:pointer',
].join(';');
s.rulerEl.addEventListener('click', _onRulerClick);
container.appendChild(s.rulerEl);
// Tracks wrapper (clips + playhead live here)
s.tracksEl = document.createElement('div');
s.tracksEl.style.cssText = 'position:relative;flex:1;';
TRACKS.forEach(function (t) {
var row = document.createElement('div');
row.className = 'tl-track-row';
row.style.cssText = [
'display:flex', 'height:' + TRACK_HEIGHT + 'px',
'border-bottom:1px solid var(--border)',
].join(';');
// Sticky label
var hdr = document.createElement('div');
hdr.className = 'tl-track-hdr';
hdr.textContent = t.label;
hdr.style.cssText = [
'width:' + HEADER_W + 'px', 'flex-shrink:0',
'display:flex', 'align-items:center', 'justify-content:center',
'font-size:10px', 'font-weight:600', 'letter-spacing:.05em',
'color:var(--text-tertiary)', 'background:var(--bg-panel)',
'border-right:1px solid var(--border)',
'position:sticky', 'left:0', 'z-index:5',
].join(';');
row.appendChild(hdr);
// Clip area
var area = document.createElement('div');
area.className = 'tl-clip-area';
area.dataset.trackId = t.id;
area.style.cssText = 'flex:1;position:relative;overflow:hidden;';
area.addEventListener('click', _onAreaClick);
row.appendChild(area);
s.tracksEl.appendChild(row);
});
// Playhead
s.playheadEl = document.createElement('div');
s.playheadEl.className = 'tl-playhead';
s.playheadEl.style.cssText = [
'position:absolute', 'top:0', 'bottom:0',
'width:2px', 'background:var(--accent)',
'pointer-events:none', 'z-index:20',
].join(';');
s.tracksEl.appendChild(s.playheadEl);
container.appendChild(s.tracksEl);
// Zoom: Ctrl+wheel
container.addEventListener('wheel', function (e) {
if (!e.ctrlKey) return;
e.preventDefault();
var delta = e.deltaY < 0 ? 1.15 : 0.87;
s.scale = Math.min(500, Math.max(20, s.scale * delta));
_renderClips();
_renderRuler();
}, { passive: false });
// Keyboard shortcuts (Delete, tool keys)
document.removeEventListener('keydown', _onKeyDown);
document.addEventListener('keydown', _onKeyDown);
_renderRuler();
}
// ── Ruler ───────────────────────────────────────────────────────────────────
function _renderRuler() {
s.rulerEl.innerHTML = '';
var totalSecs = 600; // 10 minutes
var totalPx = HEADER_W + totalSecs * s.scale;
var canvas = document.createElement('canvas');
canvas.width = totalPx;
canvas.height = 24;
canvas.style.cssText = 'display:block;width:' + totalPx + 'px;height:24px;';
var ctx = canvas.getContext('2d');
var bgColor = getComputedStyle(document.documentElement)
.getPropertyValue('--bg-base').trim() || '#0d0f14';
var borderColor = getComputedStyle(document.documentElement)
.getPropertyValue('--border').trim() || '#2a2d35';
var textColor = getComputedStyle(document.documentElement)
.getPropertyValue('--text-tertiary').trim() || '#5a6070';
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, totalPx, 24);
// Tick interval: coarser when zoomed out
var tickSecs = s.scale < 30 ? 10 : s.scale < 60 ? 5 : s.scale < 120 ? 2 : 1;
ctx.strokeStyle = borderColor;
ctx.fillStyle = textColor;
ctx.font = '9px Inter, sans-serif';
ctx.textAlign = 'left';
for (var t = 0; t <= totalSecs; t += tickSecs) {
var x = HEADER_W + t * s.scale;
ctx.beginPath();
ctx.moveTo(x, 18);
ctx.lineTo(x, 24);
ctx.stroke();
if (t % (tickSecs * 5) === 0) {
var mm = String(Math.floor(t / 60)).padStart(2, '0');
var ss = String(t % 60).padStart(2, '0');
ctx.fillText(mm + ':' + ss, x + 2, 14);
}
}
s.rulerEl.appendChild(canvas);
}
function _onRulerClick(e) {
var rect = s.rulerEl.getBoundingClientRect();
var x = e.clientX - rect.left - HEADER_W + s.container.scrollLeft;
if (x < 0) return;
var frames = pxToFrames(x);
setPlayhead(frames);
if (s.onPlayheadMoved) s.onPlayheadMoved(frames);
}
// ── Render clips ────────────────────────────────────────────────────────────
function render(clips, options) {
options = options || {};
s.clips = clips.map(function (c) { return Object.assign({}, c, { _id: c._id || uid() }); });
if (options.fps) s.fps = options.fps;
_renderClips();
}
function _renderClips() {
TRACKS.forEach(function (t) {
var area = s.tracksEl.querySelector('.tl-clip-area[data-track-id="' + t.id + '"]');
if (!area) return;
area.innerHTML = '';
s.clips
.filter(function (c) { return c.track === t.id; })
.sort(function (a, b) { return a.timeline_in_frames - b.timeline_in_frames; })
.forEach(function (clip) {
area.appendChild(_makeClipEl(clip, t));
});
});
_positionPlayhead();
}
function _makeClipEl(clip, track) {
var left = framesToPx(clip.timeline_in_frames);
var width = Math.max(MIN_CLIP_PX, framesToPx(clip.timeline_out_frames - clip.timeline_in_frames));
var isSelected = clip._id === s.selectedId;
var el = document.createElement('div');
el.className = 'tl-clip';
el.dataset.clipId = clip._id;
el.style.cssText = [
'position:absolute',
'left:' + left + 'px',
'width:' + width + 'px',
'height:' + (TRACK_HEIGHT - 2) + 'px',
'top:1px',
'border-radius:3px',
'box-sizing:border-box',
'overflow:hidden',
'display:flex',
'align-items:center',
'padding:0 6px',
'background:' + (track.type === 'video'
? 'oklch(55% 0.15 52 / 0.22)'
: 'oklch(55% 0.14 280 / 0.22)'),
'border:1px solid ' + (isSelected
? 'var(--accent)'
: 'var(--border-strong)'),
'cursor:' + (s.activeTool === 'razor' ? 'crosshair' : 'default'),
].join(';');
// Clip label
var lbl = document.createElement('span');
lbl.textContent = clip.display_name || 'Clip';
lbl.style.cssText = [
'font-size:9px', 'font-weight:500',
'color:var(--text-secondary)',
'overflow:hidden', 'text-overflow:ellipsis', 'white-space:nowrap',
'pointer-events:none', 'flex:1',
].join(';');
el.appendChild(lbl);
if (s.activeTool === 'select') {
// Trim handles (shown on hover)
var lh = _makeHandle('left');
var rh = _makeHandle('right');
el.appendChild(lh);
el.appendChild(rh);
el.addEventListener('mouseenter', function () {
lh.style.opacity = '0.8';
rh.style.opacity = '0.8';
});
el.addEventListener('mouseleave', function () {
lh.style.opacity = '0';
rh.style.opacity = '0';
});
// Drag to move (body only)
el.addEventListener('mousedown', function (e) {
if (e.target === lh || e.target === rh) return;
s.selectedId = clip._id;
_renderClips();
_onMoveStart(e, clip);
});
lh.addEventListener('mousedown', function (e) {
e.stopPropagation();
_onTrimStart(e, clip, 'left');
});
rh.addEventListener('mousedown', function (e) {
e.stopPropagation();
_onTrimStart(e, clip, 'right');
});
}
if (s.activeTool === 'razor') {
el.addEventListener('click', function (e) { _onRazorClick(e, clip); });
}
return el;
}
function _makeHandle(side) {
var h = document.createElement('div');
h.style.cssText = [
'position:absolute',
side + ':0',
'top:0', 'bottom:0',
'width:6px',
'cursor:' + (side === 'left' ? 'w-resize' : 'e-resize'),
'background:var(--accent)',
'opacity:0',
'transition:opacity 0.12s',
].join(';');
return h;
}
// ── Select tool: move ───────────────────────────────────────────────────────
function _onMoveStart(e, clip) {
var startX = e.clientX;
var origIn = clip.timeline_in_frames;
var origOut = clip.timeline_out_frames;
var duration = origOut - origIn;
function onMove(ev) {
var dx = ev.clientX - startX;
var dFrames = pxToFrames(dx);
clip.timeline_in_frames = Math.max(0, origIn + dFrames);
clip.timeline_out_frames = clip.timeline_in_frames + duration;
_renderClips();
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
if (s.onClipsChanged) s.onClipsChanged(s.clips.slice());
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
// ── Select tool: trim ───────────────────────────────────────────────────────
function _onTrimStart(e, clip, side) {
var startX = e.clientX;
var origTlIn = clip.timeline_in_frames;
var origTlOut = clip.timeline_out_frames;
var origSrcIn = clip.source_in_frames;
var origSrcOut = clip.source_out_frames;
// Maximum source out is the asset's full duration in frames
var assetFrames = clip.duration_ms
? Math.round((clip.duration_ms / 1000) * s.fps)
: origSrcOut;
function onMove(ev) {
var dx = ev.clientX - startX;
var dFr = pxToFrames(dx);
if (side === 'left') {
var newTlIn = Math.max(0, Math.min(origTlIn + dFr, origTlOut - 1));
var dIn = newTlIn - origTlIn;
clip.timeline_in_frames = newTlIn;
clip.source_in_frames = Math.max(0, origSrcIn + dIn);
} else {
var newTlOut = Math.max(origTlIn + 1, origTlOut + dFr);
var dOut = newTlOut - origTlOut;
clip.timeline_out_frames = newTlOut;
clip.source_out_frames = Math.min(assetFrames, origSrcOut + dOut);
}
_renderClips();
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
if (s.onClipsChanged) s.onClipsChanged(s.clips.slice());
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
// ── Razor tool ──────────────────────────────────────────────────────────────
function _onRazorClick(e, clip) {
// Frame at the click position within the clip element
var clipRect = e.currentTarget.getBoundingClientRect();
var xWithinClip = e.clientX - clipRect.left;
var framesInClip = pxToFrames(xWithinClip);
var clipDuration = clip.timeline_out_frames - clip.timeline_in_frames;
// Guard: don't split at the very edge
if (framesInClip <= 0 || framesInClip >= clipDuration) return;
var splitTlFrame = clip.timeline_in_frames + framesInClip;
var splitSrcFrame = clip.source_in_frames + framesInClip;
var left = Object.assign({}, clip, {
_id: uid(),
timeline_out_frames: splitTlFrame,
source_out_frames: splitSrcFrame,
});
var right = Object.assign({}, clip, {
_id: uid(),
timeline_in_frames: splitTlFrame,
source_in_frames: splitSrcFrame,
});
var idx = s.clips.findIndex(function (c) { return c._id === clip._id; });
s.clips.splice(idx, 1, left, right);
_renderClips();
if (s.onClipsChanged) s.onClipsChanged(s.clips.slice());
}
// ── Area click (deselect) ───────────────────────────────────────────────────
function _onAreaClick(e) {
if (e.target !== e.currentTarget) return; // hit a clip, not empty space
if (s.activeTool === 'select') {
s.selectedId = null;
_renderClips();
}
}
// ── Keyboard ────────────────────────────────────────────────────────────────
function _onKeyDown(e) {
if (!s.container) return;
// Don't steal keys from input fields
if (['INPUT','TEXTAREA','SELECT'].includes(document.activeElement.tagName)) return;
// Tool shortcuts
if (!e.ctrlKey && !e.metaKey && !e.altKey) {
if (e.key === 'v' || e.key === 'V') { setTool('select'); return; }
if (e.key === 'c' || e.key === 'C') { setTool('razor'); return; }
if (e.key === 'h' || e.key === 'H') { setTool('hand'); return; }
}
// Delete selected clip
if ((e.key === 'Delete' || e.key === 'Backspace') && s.selectedId) {
e.preventDefault();
s.clips = s.clips.filter(function (c) { return c._id !== s.selectedId; });
s.selectedId = null;
_renderClips();
if (s.onClipsChanged) s.onClipsChanged(s.clips.slice());
}
}
// ── Playhead ────────────────────────────────────────────────────────────────
function _positionPlayhead() {
if (!s.playheadEl) return;
s.playheadEl.style.left = (HEADER_W + framesToPx(s.playheadFrames)) + 'px';
}
function setPlayhead(frames) {
s.playheadFrames = Math.max(0, Math.round(frames));
_positionPlayhead();
}
function getPlayhead() { return s.playheadFrames; }
// ── Tool ────────────────────────────────────────────────────────────────────
function setTool(name) {
s.activeTool = name;
if (s.tracksEl) {
s.tracksEl.style.cursor =
name === 'razor' ? 'crosshair' :
name === 'hand' ? 'grab' : 'default';
}
_renderClips();
}
function getTool() { return s.activeTool; }
// ── Add clip at playhead ─────────────────────────────────────────────────────
function addClip(asset, srcIn, srcOut, track) {
track = track !== undefined ? track : 0;
srcIn = srcIn || 0;
srcOut = srcOut || (asset.duration_ms ? asset.duration_ms / 1000 : 10);
var srcInFr = TC.secondsToFrames(srcIn);
var srcOutFr = TC.secondsToFrames(srcOut);
var tlInFr = s.playheadFrames;
var tlOutFr = tlInFr + (srcOutFr - srcInFr);
var clip = {
_id: uid(),
asset_id: asset.id || asset.asset_id,
display_name: asset.display_name || asset.filename || 'Clip',
duration_ms: asset.duration_ms || null,
streamUrl: asset.streamUrl || null,
track: track,
timeline_in_frames: tlInFr,
timeline_out_frames: tlOutFr,
source_in_frames: srcInFr,
source_out_frames: srcOutFr,
};
s.clips.push(clip);
_renderClips();
// Advance playhead to end of new clip
setPlayhead(tlOutFr);
if (s.onPlayheadMoved) s.onPlayheadMoved(tlOutFr);
if (s.onClipsChanged) s.onClipsChanged(s.clips.slice());
return clip;
}
// ── Public API ───────────────────────────────────────────────────────────────
global.Timeline = {
init, render, setTool, getTool,
setPlayhead, getPlayhead,
addClip,
};
})(window);