dragonflight/services/web-ui/public/js/timeline.js
ZGaetano 0ea8d7ce33 fix(timeline): cap right-trim at source asset boundary
When duration_ms is known, dragging the right-trim handle past the end
of the source clip could push timeline_out_frames beyond what the source
material covers.  Cap the delta so neither timeline_out_frames nor
source_out_frames can extend past the available source frames.

Also changed assetFrames fallback from origSrcOut (prevents any extension
when duration is unknown) to null, so the guard is simply skipped when
we don't have duration metadata.
2026-05-19 00:02:34 -04:00

513 lines
19 KiB
JavaScript

// 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;
// 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 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);