From 10152b5ad7d54abb2997336bbc4bd90835f21759 Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Mon, 18 May 2026 20:02:41 -0400 Subject: [PATCH] feat(ui): add DOM-based timeline engine (select, razor, playhead) --- services/web-ui/public/js/timeline.js | 505 ++++++++++++++++++++++++++ 1 file changed, 505 insertions(+) create mode 100644 services/web-ui/public/js/timeline.js diff --git a/services/web-ui/public/js/timeline.js b/services/web-ui/public/js/timeline.js new file mode 100644 index 0000000..bdc8bf8 --- /dev/null +++ b/services/web-ui/public/js/timeline.js @@ -0,0 +1,505 @@ +// 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.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);