feat(04-01): TimelineCanvas class + CSS + app.js wiring
- Create lightsync/frontend/timeline/timeline.js with TimelineCanvas class - Per-device horizontal tracks with track headers and time axis - HiDPI canvas rendering with devicePixelRatio handling - Waveform peaks background layer, beat marks overlay, cue block rendering - Playback cursor synced to audio.currentTime via requestAnimationFrame - Auto-scroll when cursor approaches right edge - Mouse wheel scrolling on timeline canvas - loadTracks(), loadWaveform(), loadBeats() data loading methods - Empty state messages when no devices or audio loaded - Add timeline CSS to style.css (toolbar, canvas, inspector strip, snap button) - Wire timeline into app.js: import, initTimeline(), zoom/beat-offset/snap controls - Remove old animation-grid click listener (element no longer exists in main-area) - Populate animation palette dynamically in sidebar with 7 animation types
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
// LightSync frontend — controller UI
|
||||
// Browser <audio> handles playback; server receives position ticks and sends beat events
|
||||
|
||||
import { TimelineCanvas } from './timeline/timeline.js';
|
||||
|
||||
const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`;
|
||||
|
||||
// ── API helpers ──────────────────────────────────────────────────────────────
|
||||
@@ -196,19 +198,6 @@ document.getElementById('btn-load')?.addEventListener('click', async () => {
|
||||
fetch(`/api/audio/beats?path=${encodeURIComponent(path)}`).catch(() => {});
|
||||
});
|
||||
|
||||
// ── Animation tiles ──────────────────────────────────────────────────────────
|
||||
|
||||
let selectedAnim = 'beat_pulse';
|
||||
|
||||
document.getElementById('animation-grid')?.addEventListener('click', (e) => {
|
||||
const tile = e.target.closest('.anim-tile');
|
||||
if (!tile) return;
|
||||
document.querySelectorAll('.anim-tile').forEach(t => t.classList.remove('active'));
|
||||
tile.classList.add('active');
|
||||
selectedAnim = tile.dataset.anim;
|
||||
client.send({ type: 'select_animation', animation: selectedAnim });
|
||||
});
|
||||
|
||||
// ── Devices ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadDevices() {
|
||||
@@ -260,3 +249,62 @@ document.getElementById('add-device-form')?.addEventListener('submit', async (e)
|
||||
// ── Init ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
wireAudio();
|
||||
|
||||
// ── Timeline ─────────────────────────────────────────────────────────────────
|
||||
|
||||
let timeline = null;
|
||||
|
||||
function initTimeline() {
|
||||
const canvas = document.getElementById('timeline-canvas');
|
||||
const audio = document.getElementById('player');
|
||||
if (!canvas || !audio) return;
|
||||
|
||||
timeline = new TimelineCanvas(canvas, audio);
|
||||
|
||||
// Load device tracks
|
||||
fetch('/api/devices').then(r => r.json()).then(devices => {
|
||||
timeline.loadTracks(devices);
|
||||
}).catch(e => console.warn('[timeline]', e));
|
||||
|
||||
// When audio loads, fetch waveform + beats
|
||||
audio.addEventListener('loadedmetadata', () => {
|
||||
const src = audio.src;
|
||||
const filename = src.split('/').pop();
|
||||
const path = `/app/shows/${filename}`;
|
||||
timeline.audioDuration = audio.duration;
|
||||
timeline.loadWaveform(path);
|
||||
timeline.loadBeats(path);
|
||||
});
|
||||
|
||||
// Zoom slider
|
||||
document.getElementById('zoom-slider')?.addEventListener('input', (e) => {
|
||||
timeline.pixelsPerSecond = parseFloat(e.target.value);
|
||||
});
|
||||
|
||||
// Beat offset
|
||||
document.getElementById('beat-offset')?.addEventListener('input', (e) => {
|
||||
timeline.calibrationOffset = parseFloat(e.target.value) || 0.03;
|
||||
});
|
||||
|
||||
// Snap toggle
|
||||
document.getElementById('btn-snap')?.addEventListener('click', (e) => {
|
||||
timeline.snapEnabled = !timeline.snapEnabled;
|
||||
e.target.classList.toggle('active', timeline.snapEnabled);
|
||||
});
|
||||
|
||||
// Populate animation palette in sidebar
|
||||
const palette = document.getElementById('animation-palette');
|
||||
if (palette) {
|
||||
const anims = ['chase', 'pulse', 'rainbow', 'strobe', 'color_wipe', 'fire', 'solid'];
|
||||
palette.innerHTML = anims.map(name => `
|
||||
<button class="anim-tile" data-anim="${name}" draggable="true">
|
||||
<span class="anim-cursor">></span>
|
||||
<span class="anim-name">${name.toUpperCase().replace('_', ' ')}</span>
|
||||
</button>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
timeline.startRenderLoop();
|
||||
}
|
||||
|
||||
initTimeline();
|
||||
|
||||
@@ -354,3 +354,133 @@ button:hover {
|
||||
|
||||
.text-dim { color: var(--text-dim); }
|
||||
.text-accent { color: var(--text-accent); }
|
||||
|
||||
/* ── Timeline ──────────────────────────────────── */
|
||||
#timeline-canvas {
|
||||
flex: 1;
|
||||
display: block;
|
||||
min-height: 0;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.timeline-toolbar {
|
||||
height: 32px;
|
||||
background: var(--bg-panel-dark);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 0 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.toolbar-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.1em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbar-unit {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.toolbar-sep {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#zoom-slider {
|
||||
width: 100px;
|
||||
accent-color: var(--accent);
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
#beat-offset {
|
||||
width: 70px;
|
||||
font-size: 12px;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.snap-btn.active {
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Block Inspector Strip ─────────────────────── */
|
||||
.block-inspector {
|
||||
height: 40px;
|
||||
background: var(--bg-panel-dark);
|
||||
border-top: 1px solid var(--border-dim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 0 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.inspector-label {
|
||||
color: var(--text-accent);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.inspector-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.inspector-field input,
|
||||
.inspector-field select {
|
||||
font-size: 12px;
|
||||
padding: 2px 4px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-dim);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.inspector-field input[type="number"] {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.inspector-color-swatch {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1px solid var(--border-dim);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.inspector-remove {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.inspector-remove:hover {
|
||||
color: #ff3333;
|
||||
border: none;
|
||||
}
|
||||
|
||||
328
lightsync/frontend/timeline/timeline.js
Normal file
328
lightsync/frontend/timeline/timeline.js
Normal file
@@ -0,0 +1,328 @@
|
||||
// TimelineCanvas — DAW-style canvas timeline renderer for LightSync
|
||||
// Renders per-device tracks, waveform background, beat marks, cue blocks,
|
||||
// and a realtime playback cursor synced to an HTML5 audio element.
|
||||
|
||||
const PIXELS_PER_SECOND = 100; // default, adjustable via zoom
|
||||
const TRACK_HEIGHT = 48;
|
||||
const HEADER_WIDTH = 140;
|
||||
const TIME_AXIS_HEIGHT = 24;
|
||||
|
||||
class TimelineCanvas {
|
||||
constructor(canvasEl, audioEl) {
|
||||
this.canvas = canvasEl;
|
||||
this.ctx = canvasEl.getContext('2d');
|
||||
this.audio = audioEl;
|
||||
|
||||
this.pixelsPerSecond = PIXELS_PER_SECOND;
|
||||
this.scrollX = 0;
|
||||
this.tracks = []; // [{device_id, device_name, strip_type, cues: []}]
|
||||
this.waveformPeaks = [];
|
||||
this.audioDuration = 0;
|
||||
this.beatTimes = [];
|
||||
this.calibrationOffset = 0.03;
|
||||
this.snapEnabled = true;
|
||||
this.selectedBlock = null;
|
||||
this._dragState = null;
|
||||
this._onSelectBlock = null; // callback(cue, trackIndex) for inspector
|
||||
|
||||
this._resizeCanvas();
|
||||
window.addEventListener('resize', () => this._resizeCanvas());
|
||||
|
||||
// Scroll via wheel
|
||||
this.canvas.addEventListener('wheel', (e) => {
|
||||
e.preventDefault();
|
||||
this.scrollX = Math.max(0, this.scrollX + e.deltaX + e.deltaY);
|
||||
}, { passive: false });
|
||||
}
|
||||
|
||||
// ── Coordinate helpers ────────────────────────────────────────────────────
|
||||
|
||||
timeToX(t) {
|
||||
return HEADER_WIDTH + t * this.pixelsPerSecond - this.scrollX;
|
||||
}
|
||||
|
||||
xToTime(x) {
|
||||
return (x - HEADER_WIDTH + this.scrollX) / this.pixelsPerSecond;
|
||||
}
|
||||
|
||||
trackIndexToY(i) {
|
||||
return TIME_AXIS_HEIGHT + i * TRACK_HEIGHT;
|
||||
}
|
||||
|
||||
yToTrackIndex(y) {
|
||||
if (y < TIME_AXIS_HEIGHT) return -1;
|
||||
return Math.floor((y - TIME_AXIS_HEIGHT) / TRACK_HEIGHT);
|
||||
}
|
||||
|
||||
// ── Canvas resize (HiDPI) ─────────────────────────────────────────────────
|
||||
|
||||
_resizeCanvas() {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
this.canvas.width = rect.width * dpr;
|
||||
this.canvas.height = rect.height * dpr;
|
||||
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
}
|
||||
|
||||
// ── Render loop ───────────────────────────────────────────────────────────
|
||||
|
||||
startRenderLoop() {
|
||||
const loop = () => {
|
||||
this.render();
|
||||
requestAnimationFrame(loop);
|
||||
};
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
render() {
|
||||
const ctx = this.ctx;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const W = this.canvas.width / dpr;
|
||||
const H = this.canvas.height / dpr;
|
||||
|
||||
// 1. Background
|
||||
ctx.fillStyle = '#0a0a0a';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// Empty state
|
||||
if (this.tracks.length === 0) {
|
||||
ctx.fillStyle = '#555555';
|
||||
ctx.font = '12px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('NO DEVICES — add a device in the sidebar to create tracks', W / 2, H / 2);
|
||||
ctx.textAlign = 'left';
|
||||
this._drawTimeAxis(ctx, W, H);
|
||||
this._drawTrackHeaders(ctx, W, H);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.audioDuration === 0) {
|
||||
ctx.fillStyle = '#555555';
|
||||
ctx.font = '12px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('LOAD AUDIO — use the transport bar to load a file', W / 2, H / 2);
|
||||
ctx.textAlign = 'left';
|
||||
}
|
||||
|
||||
// 2. Track row banding
|
||||
this.tracks.forEach((_, i) => {
|
||||
const y = this.trackIndexToY(i);
|
||||
ctx.fillStyle = i % 2 === 0 ? '#0a0a0a' : '#0c0c0c';
|
||||
ctx.fillRect(HEADER_WIDTH, y, W - HEADER_WIDTH, TRACK_HEIGHT);
|
||||
});
|
||||
|
||||
// 3. Waveform background
|
||||
if (this.waveformPeaks.length > 0 && this.audioDuration > 0) {
|
||||
const totalH = this.tracks.length * TRACK_HEIGHT;
|
||||
const midY = TIME_AXIS_HEIGHT + totalH / 2;
|
||||
ctx.fillStyle = '#1a3a3a';
|
||||
for (let i = 0; i < this.waveformPeaks.length; i++) {
|
||||
const t = (i / this.waveformPeaks.length) * this.audioDuration;
|
||||
const x = this.timeToX(t);
|
||||
if (x < HEADER_WIDTH || x > W) continue;
|
||||
const peak = this.waveformPeaks[i];
|
||||
const h = peak * totalH * 0.4;
|
||||
ctx.fillRect(x, midY - h, 1, h * 2);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Beat marks
|
||||
if (this.beatTimes.length > 0) {
|
||||
ctx.strokeStyle = 'rgba(0, 255, 255, 0.4)';
|
||||
ctx.lineWidth = 1;
|
||||
for (const beatTime of this.beatTimes) {
|
||||
const displayTime = beatTime - this.calibrationOffset;
|
||||
const x = this.timeToX(displayTime);
|
||||
if (x < HEADER_WIDTH || x > W) continue;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, TIME_AXIS_HEIGHT);
|
||||
ctx.lineTo(x, H);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Block rendering
|
||||
this.tracks.forEach((track, trackIdx) => {
|
||||
const y = this.trackIndexToY(trackIdx);
|
||||
for (const cue of track.cues) {
|
||||
const x = this.timeToX(cue.timestamp);
|
||||
const w = (cue.duration || 4.0) * this.pixelsPerSecond;
|
||||
if (x + w < HEADER_WIDTH || x > W) continue;
|
||||
|
||||
const isSelected = this.selectedBlock === cue;
|
||||
ctx.fillStyle = isSelected ? '#0d3a3a' : '#0a2a2a';
|
||||
ctx.fillRect(x, y + 1, w, TRACK_HEIGHT - 2);
|
||||
|
||||
ctx.strokeStyle = isSelected ? '#00ffff' : '#1a4a4a';
|
||||
ctx.lineWidth = isSelected ? 1.5 : 1;
|
||||
ctx.strokeRect(x, y + 1, w, TRACK_HEIGHT - 2);
|
||||
|
||||
// Label
|
||||
if (w > 20) {
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(x + 2, y + 1, w - 4, TRACK_HEIGHT - 2);
|
||||
ctx.clip();
|
||||
ctx.fillStyle = '#00ffff';
|
||||
ctx.font = '10px monospace';
|
||||
ctx.textAlign = 'left';
|
||||
const label = (cue.animation || 'CUE').toUpperCase();
|
||||
ctx.fillText(label, x + 6, y + TRACK_HEIGHT / 2 + 4);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 6. Drag ghost
|
||||
if (this._dragState && this._dragState.ghost) {
|
||||
const g = this._dragState.ghost;
|
||||
ctx.fillStyle = 'rgba(0, 255, 255, 0.15)';
|
||||
ctx.fillRect(g.x, g.y, g.w, g.h);
|
||||
ctx.strokeStyle = '#00ffff';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(g.x, g.y, g.w, g.h);
|
||||
}
|
||||
|
||||
// 7. Track headers (drawn over track rows)
|
||||
this._drawTrackHeaders(ctx, W, H);
|
||||
|
||||
// 8. Time axis
|
||||
this._drawTimeAxis(ctx, W, H);
|
||||
|
||||
// 9. Playback cursor + auto-scroll
|
||||
if (!this.audio.paused) {
|
||||
const cursorX = this.timeToX(this.audio.currentTime);
|
||||
|
||||
// Auto-scroll: if cursor approaches right edge, shift viewport
|
||||
if (cursorX > W * 0.8) {
|
||||
this.scrollX = HEADER_WIDTH + this.audio.currentTime * this.pixelsPerSecond - W * 0.2;
|
||||
this.scrollX = Math.max(0, this.scrollX);
|
||||
}
|
||||
|
||||
const cursorXAfterScroll = this.timeToX(this.audio.currentTime);
|
||||
if (cursorXAfterScroll >= HEADER_WIDTH && cursorXAfterScroll <= W) {
|
||||
ctx.strokeStyle = '#00ffff';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cursorXAfterScroll, TIME_AXIS_HEIGHT);
|
||||
ctx.lineTo(cursorXAfterScroll, H);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_drawTrackHeaders(ctx, W, H) {
|
||||
// Header background
|
||||
ctx.fillStyle = '#0f0f0f';
|
||||
ctx.fillRect(0, TIME_AXIS_HEIGHT, HEADER_WIDTH, H - TIME_AXIS_HEIGHT);
|
||||
|
||||
// Separator lines + labels
|
||||
ctx.strokeStyle = '#1a4a4a';
|
||||
ctx.lineWidth = 1;
|
||||
this.tracks.forEach((track, i) => {
|
||||
const y = this.trackIndexToY(i);
|
||||
|
||||
// Bottom border of track row
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y + TRACK_HEIGHT);
|
||||
ctx.lineTo(HEADER_WIDTH, y + TRACK_HEIGHT);
|
||||
ctx.stroke();
|
||||
|
||||
// Device name label
|
||||
ctx.fillStyle = '#cccccc';
|
||||
ctx.font = '12px monospace';
|
||||
ctx.textAlign = 'left';
|
||||
const label = track.device_name || `Device ${i + 1}`;
|
||||
// Clip to header width
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(0, y, HEADER_WIDTH - 4, TRACK_HEIGHT);
|
||||
ctx.clip();
|
||||
ctx.fillText(label, 8, y + TRACK_HEIGHT / 2 + 4);
|
||||
ctx.restore();
|
||||
});
|
||||
|
||||
// Right border of header area
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(HEADER_WIDTH, TIME_AXIS_HEIGHT);
|
||||
ctx.lineTo(HEADER_WIDTH, H);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
_drawTimeAxis(ctx, W, H) {
|
||||
// Time axis background
|
||||
ctx.fillStyle = '#080808';
|
||||
ctx.fillRect(0, 0, W, TIME_AXIS_HEIGHT);
|
||||
|
||||
ctx.strokeStyle = '#1a4a4a';
|
||||
ctx.fillStyle = '#555555';
|
||||
ctx.font = '10px monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
const visibleStart = Math.max(0, this.xToTime(HEADER_WIDTH));
|
||||
const visibleEnd = this.xToTime(W);
|
||||
|
||||
// Draw second marks
|
||||
const firstSec = Math.ceil(visibleStart);
|
||||
for (let sec = firstSec; sec <= visibleEnd; sec++) {
|
||||
const x = this.timeToX(sec);
|
||||
const isMajor = sec % 10 === 0;
|
||||
const isMid = sec % 5 === 0;
|
||||
const tickH = isMajor ? 10 : (isMid ? 7 : 4);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, TIME_AXIS_HEIGHT - tickH);
|
||||
ctx.lineTo(x, TIME_AXIS_HEIGHT);
|
||||
ctx.stroke();
|
||||
|
||||
if (isMajor || (isMid && this.pixelsPerSecond >= 40)) {
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
const label = m > 0 ? `${m}:${String(s).padStart(2, '0')}` : `${s}s`;
|
||||
ctx.fillText(label, x + 2, TIME_AXIS_HEIGHT - 2);
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom border
|
||||
ctx.strokeStyle = '#1a4a4a';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, TIME_AXIS_HEIGHT);
|
||||
ctx.lineTo(W, TIME_AXIS_HEIGHT);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// ── Data loading ──────────────────────────────────────────────────────────
|
||||
|
||||
loadTracks(devices) {
|
||||
this.tracks = devices.map(d => ({
|
||||
device_id: d.id,
|
||||
device_name: d.name,
|
||||
strip_type: d.strip_type,
|
||||
cues: [],
|
||||
}));
|
||||
}
|
||||
|
||||
async loadWaveform(audioPath) {
|
||||
try {
|
||||
const res = await fetch(`/api/audio/waveform?path=${encodeURIComponent(audioPath)}&peaks=2000`);
|
||||
const data = await res.json();
|
||||
this.waveformPeaks = data.peaks || [];
|
||||
} catch (e) {
|
||||
console.warn('[waveform]', e);
|
||||
}
|
||||
}
|
||||
|
||||
async loadBeats(audioPath) {
|
||||
try {
|
||||
const res = await fetch(`/api/audio/beats?path=${encodeURIComponent(audioPath)}`);
|
||||
const data = await res.json();
|
||||
// API returns 'beats' field (list of timestamps in seconds)
|
||||
this.beatTimes = data.beats || data.beat_times || [];
|
||||
} catch (e) {
|
||||
console.warn('[beats]', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { TimelineCanvas };
|
||||
Reference in New Issue
Block a user