557 lines
22 KiB
Markdown
557 lines
22 KiB
Markdown
---
|
|
phase: 04-timeline-editor
|
|
plan: 01
|
|
type: execute
|
|
wave: 1
|
|
depends_on: []
|
|
files_modified:
|
|
- lightsync/models/show.py
|
|
- lightsync/frontend/index.html
|
|
- lightsync/frontend/style.css
|
|
- lightsync/frontend/timeline/timeline.js
|
|
autonomous: true
|
|
requirements: [TL-01, TL-05]
|
|
|
|
must_haves:
|
|
truths:
|
|
- "Per-device horizontal tracks render on a time axis with device name labels"
|
|
- "Playback cursor moves across the timeline in realtime as audio plays"
|
|
- "Waveform peaks render as a background layer behind tracks"
|
|
- "CueModel has a duration field that defaults to 4.0"
|
|
artifacts:
|
|
- path: "lightsync/models/show.py"
|
|
provides: "CueModel with duration field, AnalysisBlock with calibration_offset"
|
|
contains: "duration: float = 4.0"
|
|
- path: "lightsync/frontend/timeline/timeline.js"
|
|
provides: "TimelineCanvas class with rendering loop"
|
|
exports: ["TimelineCanvas"]
|
|
min_lines: 150
|
|
- path: "lightsync/frontend/index.html"
|
|
provides: "Timeline canvas element, toolbar, inspector placeholder"
|
|
contains: "timeline-canvas"
|
|
- path: "lightsync/frontend/style.css"
|
|
provides: "Timeline layout styles, toolbar, inspector strip"
|
|
contains: "#timeline-canvas"
|
|
key_links:
|
|
- from: "lightsync/frontend/timeline/timeline.js"
|
|
to: "HTML5 audio element"
|
|
via: "requestAnimationFrame reads audio.currentTime for cursor position"
|
|
pattern: "audio\\.currentTime"
|
|
- from: "lightsync/frontend/timeline/timeline.js"
|
|
to: "/api/audio/waveform"
|
|
via: "fetch waveform peaks on audio load"
|
|
pattern: "api/audio/waveform"
|
|
- from: "lightsync/frontend/timeline/timeline.js"
|
|
to: "/api/devices"
|
|
via: "fetch device list to build tracks"
|
|
pattern: "api/devices"
|
|
---
|
|
|
|
<objective>
|
|
Build the timeline canvas foundation — data model changes, HTML layout restructure, canvas rendering with per-device tracks, time axis, waveform background, and realtime playback cursor.
|
|
|
|
Purpose: This is the visual backbone of the DAW UI. Everything else (block interactions, beat overlay, inspector) builds on top of this canvas and coordinate system.
|
|
Output: A visible, scrollable timeline with device tracks and a moving playback cursor synced to the HTML5 audio element.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/STATE.md
|
|
@.planning/phases/04-timeline-editor/04-CONTEXT.md
|
|
@.planning/phases/04-timeline-editor/04-RESEARCH.md
|
|
@.planning/phases/04-timeline-editor/04-UI-SPEC.md
|
|
|
|
@lightsync/models/show.py
|
|
@lightsync/frontend/index.html
|
|
@lightsync/frontend/style.css
|
|
@lightsync/frontend/app.js
|
|
|
|
<interfaces>
|
|
<!-- From lightsync/models/show.py -->
|
|
class CueModel(BaseModel):
|
|
id: UUID = Field(default_factory=uuid4)
|
|
timestamp: float
|
|
mode: Literal["animation", "frame_sequence"] = "animation"
|
|
animation: str | None = None
|
|
params: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
class AnalysisBlock(BaseModel):
|
|
analysed_at: datetime | None = None
|
|
tempo_bpm: float | None = None
|
|
beat_times: list[float] = Field(default_factory=list)
|
|
onset_times: list[float] = Field(default_factory=list)
|
|
|
|
<!-- From lightsync/frontend/app.js -->
|
|
// WebSocket client: client.send({ type, ... })
|
|
// Audio: document.getElementById('player') — HTML5 audio element
|
|
// API: apiPost(path, data), apiDelete(path)
|
|
// Devices: loadDevices() populates #device-list
|
|
|
|
<!-- CSS variables from style.css -->
|
|
--bg-primary: #0a0a0a; --bg-panel: #0f0f0f; --bg-panel-dark: #080808;
|
|
--border-dim: #1a4a4a; --accent: #00ffff; --text-primary: #cccccc;
|
|
--text-dim: #555555; --text-accent: var(--accent); --font-mono: JetBrains Mono chain
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Data model changes + HTML layout restructure</name>
|
|
<files>lightsync/models/show.py, lightsync/frontend/index.html</files>
|
|
<read_first>lightsync/models/show.py, lightsync/frontend/index.html, lightsync/frontend/style.css</read_first>
|
|
<action>
|
|
**1. lightsync/models/show.py — Add duration field to CueModel and calibration_offset to AnalysisBlock:**
|
|
|
|
```python
|
|
class CueModel(BaseModel):
|
|
id: UUID = Field(default_factory=uuid4)
|
|
timestamp: float
|
|
duration: float = 4.0 # block length in seconds; defaults for old files
|
|
mode: Literal["animation", "frame_sequence"] = "animation"
|
|
animation: str | None = None
|
|
params: dict[str, Any] = Field(default_factory=dict)
|
|
```
|
|
|
|
```python
|
|
class AnalysisBlock(BaseModel):
|
|
analysed_at: datetime | None = None
|
|
tempo_bpm: float | None = None
|
|
beat_times: list[float] = Field(default_factory=list)
|
|
onset_times: list[float] = Field(default_factory=list)
|
|
calibration_offset: float = 0.03 # seconds; compensates librosa latency bias
|
|
```
|
|
|
|
Do NOT change schema_version (stays at 1). Duration defaults gracefully for old files via Pydantic.
|
|
|
|
**2. lightsync/frontend/index.html — Replace the animation grid in main-area with timeline layout:**
|
|
|
|
Replace the entire content of `<main class="main-area">` (the `.section-header` ANIMATIONS div and the `#animation-grid` div) with this structure:
|
|
|
|
```html
|
|
<main class="main-area">
|
|
<div id="timeline-toolbar" class="timeline-toolbar">
|
|
<label class="toolbar-field">
|
|
<span class="toolbar-label">ZOOM</span>
|
|
<input type="range" id="zoom-slider" min="20" max="400" step="10" value="100">
|
|
</label>
|
|
<div class="toolbar-sep"></div>
|
|
<label class="toolbar-field">
|
|
<span class="toolbar-label">BEAT OFFSET</span>
|
|
<input type="number" id="beat-offset" step="0.005" min="-0.2" max="0.2" value="0.030">
|
|
<span class="toolbar-unit">s</span>
|
|
</label>
|
|
<div class="toolbar-sep"></div>
|
|
<button id="btn-snap" class="transport-btn snap-btn active" title="Snap to beat">SNAP</button>
|
|
</div>
|
|
<canvas id="timeline-canvas"></canvas>
|
|
<div id="block-inspector" class="block-inspector" style="display:none;"></div>
|
|
</main>
|
|
```
|
|
|
|
Move the ANIMATIONS section into the sidebar. Add a second panel BELOW the existing DEVICES panel (inside `<aside class="sidebar">`), after the closing `</div>` of the first `.panel`:
|
|
|
|
```html
|
|
<div class="panel" style="flex: 0 0 auto;">
|
|
<div class="panel-header">ANIMATIONS</div>
|
|
<div class="panel-content animation-list" id="animation-palette">
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
The animation palette will be populated dynamically by JS (Task 2 wires this). For now the empty container is sufficient. Remove the old hardcoded `.anim-tile` buttons from `main-area`.
|
|
</action>
|
|
<verify>
|
|
<automated>cd /home/claude/led2 && python -c "from lightsync.models.show import CueModel, AnalysisBlock; c = CueModel(timestamp=1.0); assert c.duration == 4.0; a = AnalysisBlock(); assert a.calibration_offset == 0.03; print('OK')" && grep -q 'timeline-canvas' lightsync/frontend/index.html && grep -q 'timeline-toolbar' lightsync/frontend/index.html && grep -q 'block-inspector' lightsync/frontend/index.html && grep -q 'animation-palette' lightsync/frontend/index.html && echo "ALL PASS"</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- lightsync/models/show.py contains `duration: float = 4.0` in CueModel
|
|
- lightsync/models/show.py contains `calibration_offset: float = 0.03` in AnalysisBlock
|
|
- lightsync/frontend/index.html contains `id="timeline-canvas"` (canvas element)
|
|
- lightsync/frontend/index.html contains `id="timeline-toolbar"` with zoom-slider, beat-offset, btn-snap
|
|
- lightsync/frontend/index.html contains `id="block-inspector"` with style="display:none;"
|
|
- lightsync/frontend/index.html contains `id="animation-palette"` inside the sidebar
|
|
- The old animation-grid div is removed from main-area
|
|
- CueModel(timestamp=1.0).duration == 4.0 (Python import succeeds)
|
|
</acceptance_criteria>
|
|
<done>CueModel has duration field with default 4.0, AnalysisBlock has calibration_offset with default 0.03, index.html has timeline canvas layout with toolbar/canvas/inspector structure, animation palette moved to sidebar</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: TimelineCanvas class + CSS + app.js wiring</name>
|
|
<files>lightsync/frontend/timeline/timeline.js, lightsync/frontend/style.css, lightsync/frontend/app.js</files>
|
|
<read_first>lightsync/frontend/index.html, lightsync/frontend/style.css, lightsync/frontend/app.js, lightsync/api/audio.py</read_first>
|
|
<action>
|
|
**1. Create `lightsync/frontend/timeline/timeline.js` — the TimelineCanvas class.**
|
|
|
|
This is the core canvas rendering engine. It must export a `TimelineCanvas` class with:
|
|
|
|
**Constants:**
|
|
```javascript
|
|
const PIXELS_PER_SECOND = 100; // default, adjustable via zoom
|
|
const TRACK_HEIGHT = 48;
|
|
const HEADER_WIDTH = 140;
|
|
const TIME_AXIS_HEIGHT = 24;
|
|
```
|
|
|
|
**Constructor:** `constructor(canvasEl, audioEl)` — stores references to the canvas element and audio element. Initializes:
|
|
- `this.canvas = canvasEl`
|
|
- `this.ctx = canvasEl.getContext('2d')`
|
|
- `this.audio = audioEl`
|
|
- `this.pixelsPerSecond = PIXELS_PER_SECOND`
|
|
- `this.scrollX = 0`
|
|
- `this.tracks = []` — array of `{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 for inspector
|
|
- Calls `this._resizeCanvas()` and adds `window resize` listener to call `_resizeCanvas()`
|
|
|
|
**Coordinate helpers (exposed as methods):**
|
|
- `timeToX(t)` — returns `HEADER_WIDTH + t * this.pixelsPerSecond - this.scrollX`
|
|
- `xToTime(x)` — returns `(x - HEADER_WIDTH + this.scrollX) / this.pixelsPerSecond`
|
|
- `trackIndexToY(i)` — returns `TIME_AXIS_HEIGHT + i * TRACK_HEIGHT`
|
|
- `yToTrackIndex(y)` — returns `Math.floor((y - TIME_AXIS_HEIGHT) / TRACK_HEIGHT)`, clamped to -1 if above time axis
|
|
|
|
**`_resizeCanvas()` method** — handles devicePixelRatio:
|
|
```javascript
|
|
_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()` method** — clears canvas, draws layers in order:
|
|
1. Background fill `#0a0a0a` (full canvas)
|
|
2. Track row banding: even tracks `#0a0a0a`, odd tracks `#0c0c0c` — filled rects from HEADER_WIDTH to canvas width, each TRACK_HEIGHT tall starting at TIME_AXIS_HEIGHT
|
|
3. Waveform: if `this.waveformPeaks.length > 0`, draw vertical bars in `#1a3a3a`. For each peak, compute x via `timeToX((i / peaks.length) * audioDuration)`, draw from midY-h to midY+h where h is `peak * TRACK_HEIGHT * tracks.length * 0.4`. Only draw peaks where x is within visible range (0 to canvasWidth).
|
|
4. Beat marks: for each `beatTime` in `this.beatTimes`, compute `displayTime = beatTime - this.calibrationOffset`, draw 1px wide vertical line in `rgba(0, 255, 255, 0.4)` from TIME_AXIS_HEIGHT to canvas bottom
|
|
5. Block rendering: for each track, for each cue in track.cues, compute x = `timeToX(cue.timestamp)`, w = `cue.duration * this.pixelsPerSecond`, y = `trackIndexToY(trackIdx)`. Fill rect with `#0a2a2a` (or `#0d3a3a` if selected). Border 1px `#1a4a4a` (or `#00ffff` if selected). Clip and draw animation name label in `#00ffff` at 10px uppercase inside block.
|
|
6. Drag ghost: if `this._dragState && this._dragState.ghost`, draw ghost rect with `rgba(0,255,255,0.15)` fill and 1px `#00ffff` border
|
|
7. Track headers: fill `#0f0f0f` rect from x=0 to HEADER_WIDTH, full height. Draw track separator lines. For each track draw device_name in `#cccccc` at 12px, vertically centered in track row.
|
|
8. Time axis: fill `#080808` rect from x=0 to canvasWidth, height TIME_AXIS_HEIGHT. Draw second/minute markers as tick marks in `#1a4a4a` with time labels in `#555555` at 10px. Show marks at every second, with longer ticks and labels at 5s and 10s intervals.
|
|
9. Playback cursor: if `!this.audio.paused`, compute x = `timeToX(this.audio.currentTime)`. Draw 1px vertical line in `#00ffff` from TIME_AXIS_HEIGHT to canvas bottom.
|
|
|
|
**`startRenderLoop()` method:**
|
|
```javascript
|
|
startRenderLoop() {
|
|
const loop = () => { this.render(); requestAnimationFrame(loop); };
|
|
requestAnimationFrame(loop);
|
|
}
|
|
```
|
|
|
|
**`loadTracks(devices)` method** — takes array of device objects from `/api/devices`, builds `this.tracks`:
|
|
```javascript
|
|
loadTracks(devices) {
|
|
this.tracks = devices.map(d => ({
|
|
device_id: d.id,
|
|
device_name: d.name,
|
|
strip_type: d.strip_type,
|
|
cues: []
|
|
}));
|
|
}
|
|
```
|
|
|
|
**`loadWaveform(audioPath)` async method:**
|
|
```javascript
|
|
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); }
|
|
}
|
|
```
|
|
|
|
**`loadBeats(audioPath)` async method:**
|
|
```javascript
|
|
async loadBeats(audioPath) {
|
|
try {
|
|
const res = await fetch(`/api/audio/beats?path=${encodeURIComponent(audioPath)}`);
|
|
const data = await res.json();
|
|
this.beatTimes = data.beat_times || [];
|
|
} catch (e) { console.warn('[beats]', e); }
|
|
}
|
|
```
|
|
|
|
**Scroll handling** — add wheel event on canvas:
|
|
```javascript
|
|
this.canvas.addEventListener('wheel', (e) => {
|
|
e.preventDefault();
|
|
this.scrollX = Math.max(0, this.scrollX + e.deltaX + e.deltaY);
|
|
}, { passive: false });
|
|
```
|
|
|
|
**Auto-scroll during playback** — in `render()`, after computing cursor x: if cursor x > canvasWidth * 0.8, set `this.scrollX` so cursor is at 20% from left.
|
|
|
|
Export the class as `export { TimelineCanvas }`.
|
|
|
|
**Empty state handling:** In `render()`, if `this.tracks.length === 0` and no audio loaded, draw centered dim text "NO DEVICES -- add a device in the sidebar to create tracks" in `#555555`. If tracks exist but `this.audioDuration === 0`, draw "LOAD AUDIO -- use the transport bar to load a file".
|
|
|
|
**2. Add CSS to style.css:**
|
|
|
|
Add these rules AFTER the existing `.anim-desc` rule block (replacing the existing `.main-area` animation grid styles is NOT needed — those stay for sidebar now):
|
|
|
|
```css
|
|
/* ── 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;
|
|
}
|
|
```
|
|
|
|
**3. Wire timeline into app.js:**
|
|
|
|
At the TOP of app.js, add the import:
|
|
```javascript
|
|
import { TimelineCanvas } from './timeline/timeline.js';
|
|
```
|
|
|
|
At the BOTTOM of app.js (after `wireAudio();`), add initialization:
|
|
```javascript
|
|
// ── 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();
|
|
```
|
|
|
|
Remove or comment out the old `selectedAnim` variable and the `animation-grid` click listener (lines that reference `#animation-grid`), since that element no longer exists in main-area.
|
|
</action>
|
|
<verify>
|
|
<automated>cd /home/claude/led2 && test -f lightsync/frontend/timeline/timeline.js && grep -q "export.*TimelineCanvas" lightsync/frontend/timeline/timeline.js && grep -q "timeToX" lightsync/frontend/timeline/timeline.js && grep -q "requestAnimationFrame" lightsync/frontend/timeline/timeline.js && grep -q "devicePixelRatio" lightsync/frontend/timeline/timeline.js && grep -q "import.*TimelineCanvas" lightsync/frontend/app.js && grep -q "#timeline-canvas" lightsync/frontend/style.css && grep -q ".block-inspector" lightsync/frontend/style.css && echo "ALL PASS"</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- lightsync/frontend/timeline/timeline.js exists and exports TimelineCanvas
|
|
- timeline.js contains timeToX, xToTime, trackIndexToY, yToTrackIndex methods
|
|
- timeline.js contains _resizeCanvas with devicePixelRatio handling
|
|
- timeline.js contains render() method with layer drawing (background, waveform, beat marks, blocks, headers, time axis, cursor)
|
|
- timeline.js contains startRenderLoop() using requestAnimationFrame
|
|
- timeline.js contains loadTracks(), loadWaveform(), loadBeats() methods
|
|
- timeline.js contains wheel scroll handler updating scrollX
|
|
- app.js imports TimelineCanvas from './timeline/timeline.js'
|
|
- app.js has initTimeline() function that creates TimelineCanvas, wires zoom/beat-offset/snap
|
|
- app.js populates #animation-palette with 7 animation types
|
|
- style.css contains #timeline-canvas, .timeline-toolbar, .block-inspector, .inspector-label rules
|
|
- Old animation-grid click listener removed from app.js
|
|
</acceptance_criteria>
|
|
<done>Timeline canvas renders per-device tracks with headers, time axis, waveform background, beat marks, and a playback cursor synced to audio.currentTime via requestAnimationFrame. Toolbar controls zoom, beat offset, and snap toggle. Animation palette rendered dynamically in sidebar.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
1. Docker rebuild: `cd /home/claude/led2 && docker compose build && docker compose up -d`
|
|
2. Open browser: navigate to the app URL
|
|
3. Verify: per-device tracks visible (if devices exist), time axis with second markers, toolbar with ZOOM/BEAT OFFSET/SNAP controls
|
|
4. Load audio file: waveform peaks appear as dim background, beat marks appear as cyan lines
|
|
5. Press play: cursor moves across timeline in sync with audio
|
|
6. Scroll: mouse wheel scrolls timeline horizontally
|
|
7. Sidebar: ANIMATIONS panel visible below DEVICES with 7 animation types
|
|
8. Python model: `python -c "from lightsync.models.show import CueModel; c = CueModel(timestamp=0); print(c.duration)"` prints 4.0
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- Timeline canvas fills the main area with per-device horizontal tracks
|
|
- Playback cursor moves in realtime synced to audio.currentTime
|
|
- Waveform peaks render behind tracks
|
|
- Beat marks overlay on timeline at correct time positions
|
|
- Toolbar zoom slider adjusts pixelsPerSecond
|
|
- CueModel.duration defaults to 4.0
|
|
- AnalysisBlock.calibration_offset defaults to 0.03
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/04-timeline-editor/04-01-SUMMARY.md`
|
|
</output>
|