dragonflight/services/web-ui/public/js/timeline.js

506 lines
18 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;
// 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);