feat(ui): add DOM-based timeline engine (select, razor, playhead)
This commit is contained in:
parent
ad6e836345
commit
10152b5ad7
1 changed files with 505 additions and 0 deletions
505
services/web-ui/public/js/timeline.js
Normal file
505
services/web-ui/public/js/timeline.js
Normal file
|
|
@ -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);
|
||||
Loading…
Reference in a new issue