Four critical fixes: - Remove overflow:hidden on tlRef so Timeline.init's scroll survives re-renders - Don't call _renderClips() inside mousedown (was destroying event target mid-drag) - Use refs for undo history to eliminate stale closure in onClipsChanged callback - Change .tl-clip-area overflow:hidden to overflow:visible so pointer events reach clip edges
583 lines
21 KiB
JavaScript
583 lines
21 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:visible;';
|
|
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();
|
|
_initHandPan();
|
|
}
|
|
|
|
// ── 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;
|
|
e.preventDefault();
|
|
s.selectedId = clip._id;
|
|
// Update selection highlight without destroying the element we're dragging
|
|
s.tracksEl.querySelectorAll('.tl-clip').forEach(function (c) {
|
|
c.style.borderColor = c.dataset.clipId === clip._id ? 'var(--accent)' : 'var(--border-strong)';
|
|
});
|
|
_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 / ripple-delete selected clip
|
|
if ((e.key === 'Delete' || e.key === 'Backspace') && s.selectedId) {
|
|
e.preventDefault();
|
|
var clip = s.clips.find(function (c) { return c._id === s.selectedId; });
|
|
if (!clip) return;
|
|
|
|
if (e.shiftKey) {
|
|
// Shift+Delete: extract / lift — remove clip and leave the gap
|
|
s.clips = s.clips.filter(function (c) { return c._id !== s.selectedId; });
|
|
} else {
|
|
// Delete: ripple — close the gap by shifting later clips on the same track
|
|
var clipDur = clip.timeline_out_frames - clip.timeline_in_frames;
|
|
var cutPoint = clip.timeline_in_frames;
|
|
var trackId = clip.track;
|
|
s.clips = s.clips
|
|
.filter(function (c) { return c._id !== s.selectedId; })
|
|
.map(function (c) {
|
|
if (c.track === trackId && c.timeline_in_frames >= cutPoint) {
|
|
return Object.assign({}, c, {
|
|
timeline_in_frames: c.timeline_in_frames - clipDur,
|
|
timeline_out_frames: c.timeline_out_frames - clipDur,
|
|
});
|
|
}
|
|
return c;
|
|
});
|
|
}
|
|
|
|
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; }
|
|
|
|
// ── Scale (zoom) ─────────────────────────────────────────────────────────────
|
|
|
|
function setScale(px) {
|
|
s.scale = Math.min(500, Math.max(20, px));
|
|
_renderClips();
|
|
_renderRuler();
|
|
}
|
|
|
|
function getScale() { return s.scale; }
|
|
|
|
// ── Hand tool: pan by dragging ───────────────────────────────────────────────
|
|
|
|
function _initHandPan() {
|
|
var dragging = false;
|
|
var startX = 0;
|
|
var startScroll = 0;
|
|
|
|
s.tracksEl.addEventListener('mousedown', function (e) {
|
|
if (s.activeTool !== 'hand') return;
|
|
dragging = true;
|
|
startX = e.clientX;
|
|
startScroll = s.container.scrollLeft;
|
|
s.tracksEl.style.cursor = 'grabbing';
|
|
e.preventDefault();
|
|
});
|
|
|
|
document.addEventListener('mousemove', function (e) {
|
|
if (!dragging) return;
|
|
var dx = e.clientX - startX;
|
|
s.container.scrollLeft = Math.max(0, startScroll - dx);
|
|
});
|
|
|
|
document.addEventListener('mouseup', function () {
|
|
if (!dragging) return;
|
|
dragging = false;
|
|
if (s.activeTool === 'hand') s.tracksEl.style.cursor = 'grab';
|
|
});
|
|
}
|
|
|
|
// ── 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);
|
|
|
|
// Use s.fps for frame conversion so clips land on the correct frame grid
|
|
// regardless of sequence frame rate (not hardcoded to 59.94).
|
|
var srcInFr = Math.round(srcIn * s.fps);
|
|
var srcOutFr = Math.round(srcOut * s.fps);
|
|
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,
|
|
setScale, getScale,
|
|
addClip,
|
|
};
|
|
|
|
})(window);
|