// 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:visible;'; 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(); _initHandPan(); } // ── 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; e.preventDefault(); s.selectedId = clip._id; // Update selection highlight without destroying the element we're dragging s.tracksEl.querySelectorAll('.tl-clip').forEach(function (c) { c.style.borderColor = c.dataset.clipId === clip._id ? 'var(--accent)' : 'var(--border-strong)'; }); _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; // Full asset duration in frames (null when unknown) var assetFrames = clip.duration_ms ? Math.round((clip.duration_ms / 1000) * s.fps) : null; 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; // When we know the asset duration, don't extend past the source boundary if (assetFrames !== null) { var maxOut = assetFrames - origSrcOut; if (dOut > maxOut) dOut = maxOut; } clip.timeline_out_frames = origTlOut + dOut; clip.source_out_frames = assetFrames !== null ? Math.min(assetFrames, origSrcOut + dOut) : 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 / ripple-delete selected clip if ((e.key === 'Delete' || e.key === 'Backspace') && s.selectedId) { e.preventDefault(); var clip = s.clips.find(function (c) { return c._id === s.selectedId; }); if (!clip) return; if (e.shiftKey) { // Shift+Delete: extract / lift — remove clip and leave the gap s.clips = s.clips.filter(function (c) { return c._id !== s.selectedId; }); } else { // Delete: ripple — close the gap by shifting later clips on the same track var clipDur = clip.timeline_out_frames - clip.timeline_in_frames; var cutPoint = clip.timeline_in_frames; var trackId = clip.track; s.clips = s.clips .filter(function (c) { return c._id !== s.selectedId; }) .map(function (c) { if (c.track === trackId && c.timeline_in_frames >= cutPoint) { return Object.assign({}, c, { timeline_in_frames: c.timeline_in_frames - clipDur, timeline_out_frames: c.timeline_out_frames - clipDur, }); } return c; }); } 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; } // ── Scale (zoom) ───────────────────────────────────────────────────────────── function setScale(px) { s.scale = Math.min(500, Math.max(20, px)); _renderClips(); _renderRuler(); } function getScale() { return s.scale; } // ── Hand tool: pan by dragging ─────────────────────────────────────────────── function _initHandPan() { var dragging = false; var startX = 0; var startScroll = 0; s.tracksEl.addEventListener('mousedown', function (e) { if (s.activeTool !== 'hand') return; dragging = true; startX = e.clientX; startScroll = s.container.scrollLeft; s.tracksEl.style.cursor = 'grabbing'; e.preventDefault(); }); document.addEventListener('mousemove', function (e) { if (!dragging) return; var dx = e.clientX - startX; s.container.scrollLeft = Math.max(0, startScroll - dx); }); document.addEventListener('mouseup', function () { if (!dragging) return; dragging = false; if (s.activeTool === 'hand') s.tracksEl.style.cursor = 'grab'; }); } // ── 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); // Use s.fps for frame conversion so clips land on the correct frame grid // regardless of sequence frame rate (not hardcoded to 59.94). var srcInFr = Math.round(srcIn * s.fps); var srcOutFr = Math.round(srcOut * s.fps); 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, setScale, getScale, addClip, }; })(window);