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:
Claude
2026-04-06 23:34:32 +00:00
parent 454fddc35e
commit efa612380c
3 changed files with 519 additions and 13 deletions

View 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 };