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.
513 lines
19 KiB
JavaScript
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);
|