diff --git a/services/web-ui/public/editor.html b/services/web-ui/public/editor.html
new file mode 100644
index 0000000..b15ab02
--- /dev/null
+++ b/services/web-ui/public/editor.html
@@ -0,0 +1,897 @@
+
+
+
+
+
@@ -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');
diff --git a/services/web-ui/public/js/api.js b/services/web-ui/public/js/api.js
index d32a8c5..9013068 100644
--- a/services/web-ui/public/js/api.js
+++ b/services/web-ui/public/js/api.js
@@ -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 };
+ }
+}
diff --git a/services/web-ui/public/js/timecode.js b/services/web-ui/public/js/timecode.js
new file mode 100644
index 0000000..e7bebe4
--- /dev/null
+++ b/services/web-ui/public/js/timecode.js
@@ -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 (0–5)
+ 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);
diff --git a/services/web-ui/public/js/timeline.js b/services/web-ui/public/js/timeline.js
new file mode 100644
index 0000000..61d30f1
--- /dev/null
+++ b/services/web-ui/public/js/timeline.js
@@ -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);