docs(04): create phase plan
This commit is contained in:
@@ -87,7 +87,7 @@ Plans:
|
|||||||
4. Each block has a color picker and editable parameters (speed, colors, direction, length); changes persist in the show file
|
4. Each block has a color picker and editable parameters (speed, colors, direction, length); changes persist in the show file
|
||||||
5. The playback cursor moves in real time across the timeline as audio plays, reflecting audio position accurately
|
5. The playback cursor moves in real time across the timeline as audio plays, reflecting audio position accurately
|
||||||
6. A beat calibration offset control shifts all beat marks backward by a configurable amount to compensate for librosa's systematic latency bias
|
6. A beat calibration offset control shifts all beat marks backward by a configurable amount to compensate for librosa's systematic latency bias
|
||||||
**Plans**: 3 plans
|
**Plans**: 4 plans
|
||||||
|
|
||||||
Plans:
|
Plans:
|
||||||
- [ ] 04-01: Timeline canvas — per-device tracks, time axis, block rendering, playback cursor
|
- [ ] 04-01: Timeline canvas — per-device tracks, time axis, block rendering, playback cursor
|
||||||
|
|||||||
556
.planning/phases/04-timeline-editor/04-01-PLAN.md
Normal file
556
.planning/phases/04-timeline-editor/04-01-PLAN.md
Normal file
@@ -0,0 +1,556 @@
|
|||||||
|
---
|
||||||
|
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>
|
||||||
737
.planning/phases/04-timeline-editor/04-02-PLAN.md
Normal file
737
.planning/phases/04-timeline-editor/04-02-PLAN.md
Normal file
@@ -0,0 +1,737 @@
|
|||||||
|
---
|
||||||
|
phase: 04-timeline-editor
|
||||||
|
plan: 02
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on: [04-01]
|
||||||
|
files_modified:
|
||||||
|
- lightsync/frontend/timeline/commands.js
|
||||||
|
- lightsync/frontend/timeline/history.js
|
||||||
|
- lightsync/frontend/timeline/timeline.js
|
||||||
|
- lightsync/frontend/app.js
|
||||||
|
- lightsync/api/timeline.py
|
||||||
|
- lightsync/main.py
|
||||||
|
autonomous: true
|
||||||
|
requirements: [TL-02, TL-03, TL-06]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Animation blocks can be dragged from the sidebar palette and placed on a track"
|
||||||
|
- "Blocks can be moved by dragging the center region"
|
||||||
|
- "Blocks can be resized by dragging left or right edge handles (8px hit zone)"
|
||||||
|
- "Blocks can be deleted with the Delete key"
|
||||||
|
- "Undo (Ctrl+Z) and Redo (Ctrl+Shift+Z) work for all mutations with at least 50 steps"
|
||||||
|
- "Block overlap on the same track is rejected"
|
||||||
|
artifacts:
|
||||||
|
- path: "lightsync/frontend/timeline/commands.js"
|
||||||
|
provides: "Command classes: PlaceBlockCommand, MoveBlockCommand, ResizeBlockCommand, DeleteBlockCommand"
|
||||||
|
exports: ["PlaceBlockCommand", "MoveBlockCommand", "ResizeBlockCommand", "DeleteBlockCommand"]
|
||||||
|
min_lines: 80
|
||||||
|
- path: "lightsync/frontend/timeline/history.js"
|
||||||
|
provides: "CommandHistory with undoStack, redoStack, execute(), undo(), redo()"
|
||||||
|
exports: ["CommandHistory"]
|
||||||
|
min_lines: 30
|
||||||
|
- path: "lightsync/api/timeline.py"
|
||||||
|
provides: "Block CRUD REST endpoints"
|
||||||
|
contains: "router = APIRouter"
|
||||||
|
key_links:
|
||||||
|
- from: "lightsync/frontend/timeline/commands.js"
|
||||||
|
to: "timeline.js tracks state"
|
||||||
|
via: "Commands mutate track.cues arrays directly"
|
||||||
|
pattern: "track\\.cues"
|
||||||
|
- from: "lightsync/frontend/timeline/timeline.js"
|
||||||
|
to: "lightsync/frontend/timeline/history.js"
|
||||||
|
via: "timeline calls history.execute(cmd) for all mutations"
|
||||||
|
pattern: "history\\.execute"
|
||||||
|
- from: "lightsync/frontend/timeline/commands.js"
|
||||||
|
to: "/api/shows/{show_id}/tracks/{device_id}/blocks"
|
||||||
|
via: "async fetch calls on execute/undo for backend sync"
|
||||||
|
pattern: "api/shows"
|
||||||
|
- from: "lightsync/api/timeline.py"
|
||||||
|
to: "lightsync/models/show.py"
|
||||||
|
via: "CueModel creation and mutation"
|
||||||
|
pattern: "CueModel"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Add block interaction capabilities — drag-and-place from palette, move, resize, delete — all wrapped in a command pattern with undo/redo support (min 50 steps). Includes backend block CRUD endpoints.
|
||||||
|
|
||||||
|
Purpose: This is the core editing capability. Without it, the timeline is a static display. The command pattern ensures all mutations are reversible.
|
||||||
|
Output: Fully interactive block manipulation with undo/redo, backed by REST API persistence.
|
||||||
|
</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/phases/04-timeline-editor/04-CONTEXT.md
|
||||||
|
@.planning/phases/04-timeline-editor/04-RESEARCH.md
|
||||||
|
@.planning/phases/04-timeline-editor/04-UI-SPEC.md
|
||||||
|
@.planning/phases/04-timeline-editor/04-01-SUMMARY.md
|
||||||
|
|
||||||
|
@lightsync/models/show.py
|
||||||
|
@lightsync/frontend/timeline/timeline.js
|
||||||
|
@lightsync/frontend/app.js
|
||||||
|
@lightsync/api/shows.py
|
||||||
|
@lightsync/main.py
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- From timeline.js (Plan 01 output) -->
|
||||||
|
export class TimelineCanvas {
|
||||||
|
constructor(canvasEl, audioEl)
|
||||||
|
timeToX(t), xToTime(x), trackIndexToY(i), yToTrackIndex(y)
|
||||||
|
render()
|
||||||
|
startRenderLoop()
|
||||||
|
loadTracks(devices)
|
||||||
|
tracks: [{device_id, device_name, strip_type, cues: []}]
|
||||||
|
selectedBlock: CueModel | null
|
||||||
|
_dragState: object | null
|
||||||
|
_onSelectBlock: function | null
|
||||||
|
snapEnabled: boolean
|
||||||
|
beatTimes: float[]
|
||||||
|
calibrationOffset: float
|
||||||
|
pixelsPerSecond: number
|
||||||
|
scrollX: number
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- From show.py -->
|
||||||
|
class CueModel(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
timestamp: float
|
||||||
|
duration: float = 4.0
|
||||||
|
mode: Literal["animation", "frame_sequence"] = "animation"
|
||||||
|
animation: str | None = None
|
||||||
|
params: dict[str, Any] = {}
|
||||||
|
|
||||||
|
class ShowModel(BaseModel):
|
||||||
|
tracks: list[TrackModel] # TrackModel has device_id: UUID, cues: list[CueModel]
|
||||||
|
|
||||||
|
<!-- From shows.py -->
|
||||||
|
router = APIRouter(tags=["shows"])
|
||||||
|
# Endpoints: GET/POST /api/shows, GET /api/shows/{show_id}
|
||||||
|
# state.show_store.save(show), state.show_store.load(show_id)
|
||||||
|
|
||||||
|
<!-- From main.py -->
|
||||||
|
# app.include_router(shows.router, prefix="/api/shows")
|
||||||
|
# Module-level: show_store, registry
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Command pattern + history + backend block CRUD</name>
|
||||||
|
<files>lightsync/frontend/timeline/commands.js, lightsync/frontend/timeline/history.js, lightsync/api/timeline.py, lightsync/main.py</files>
|
||||||
|
<read_first>lightsync/frontend/timeline/timeline.js, lightsync/models/show.py, lightsync/api/shows.py, lightsync/main.py, lightsync/frontend/app.js</read_first>
|
||||||
|
<action>
|
||||||
|
**1. Create `lightsync/frontend/timeline/history.js` — CommandHistory class:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
export class CommandHistory {
|
||||||
|
constructor(maxSteps = 50) {
|
||||||
|
this.undoStack = [];
|
||||||
|
this.redoStack = [];
|
||||||
|
this.maxSteps = maxSteps;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(cmd) {
|
||||||
|
cmd.execute();
|
||||||
|
this.undoStack.push(cmd);
|
||||||
|
if (this.undoStack.length > this.maxSteps) this.undoStack.shift();
|
||||||
|
this.redoStack = []; // clear redo on new action (prevents branching corruption)
|
||||||
|
}
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
const cmd = this.undoStack.pop();
|
||||||
|
if (!cmd) return false;
|
||||||
|
cmd.undo();
|
||||||
|
this.redoStack.push(cmd);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
redo() {
|
||||||
|
const cmd = this.redoStack.pop();
|
||||||
|
if (!cmd) return false;
|
||||||
|
cmd.execute();
|
||||||
|
this.undoStack.push(cmd);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
get canUndo() { return this.undoStack.length > 0; }
|
||||||
|
get canRedo() { return this.redoStack.length > 0; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Create `lightsync/frontend/timeline/commands.js` — Command classes:**
|
||||||
|
|
||||||
|
Each command stores the data needed for execute and undo. Commands mutate the `tracks` array (passed by reference from TimelineCanvas). Backend sync is fire-and-forget fetch (no await during rapid undo/redo).
|
||||||
|
|
||||||
|
Generate a UUID for new blocks using `crypto.randomUUID()`.
|
||||||
|
|
||||||
|
Helper function for backend sync (top of file):
|
||||||
|
```javascript
|
||||||
|
function syncBlock(showId, deviceId, block, method) {
|
||||||
|
if (!showId) return; // no show loaded yet — skip sync
|
||||||
|
const base = `/api/shows/${showId}/tracks/${deviceId}/blocks`;
|
||||||
|
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
||||||
|
if (method === 'POST' || method === 'PATCH') {
|
||||||
|
opts.body = JSON.stringify(block);
|
||||||
|
}
|
||||||
|
const url = method === 'POST' ? base : `${base}/${block.id}`;
|
||||||
|
fetch(url, opts).catch(e => console.warn('[sync]', e));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**PlaceBlockCommand:**
|
||||||
|
```javascript
|
||||||
|
export class PlaceBlockCommand {
|
||||||
|
constructor(tracks, block) {
|
||||||
|
this.tracks = tracks;
|
||||||
|
this.block = block; // {id, timestamp, duration, animation, params, device_id}
|
||||||
|
}
|
||||||
|
execute() {
|
||||||
|
const track = this.tracks.find(t => t.device_id === this.block.device_id);
|
||||||
|
if (track) track.cues.push(this.block);
|
||||||
|
syncBlock(this._showId, this.block.device_id, this.block, 'POST');
|
||||||
|
}
|
||||||
|
undo() {
|
||||||
|
const track = this.tracks.find(t => t.device_id === this.block.device_id);
|
||||||
|
if (track) track.cues = track.cues.filter(c => c.id !== this.block.id);
|
||||||
|
syncBlock(this._showId, this.block.device_id, this.block, 'DELETE');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**MoveBlockCommand:** Stores `oldTimestamp` and `newTimestamp`.
|
||||||
|
```javascript
|
||||||
|
export class MoveBlockCommand {
|
||||||
|
constructor(tracks, block, oldTimestamp, newTimestamp) {
|
||||||
|
this.tracks = tracks;
|
||||||
|
this.block = block;
|
||||||
|
this.oldTimestamp = oldTimestamp;
|
||||||
|
this.newTimestamp = newTimestamp;
|
||||||
|
}
|
||||||
|
execute() {
|
||||||
|
this.block.timestamp = this.newTimestamp;
|
||||||
|
syncBlock(this._showId, this.block.device_id, this.block, 'PATCH');
|
||||||
|
}
|
||||||
|
undo() {
|
||||||
|
this.block.timestamp = this.oldTimestamp;
|
||||||
|
syncBlock(this._showId, this.block.device_id, this.block, 'PATCH');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**ResizeBlockCommand:** Stores old/new values for both `timestamp` and `duration` (left-edge resize changes both, right-edge resize changes only duration).
|
||||||
|
```javascript
|
||||||
|
export class ResizeBlockCommand {
|
||||||
|
constructor(tracks, block, oldTimestamp, oldDuration, newTimestamp, newDuration) {
|
||||||
|
this.tracks = tracks;
|
||||||
|
this.block = block;
|
||||||
|
this.oldTimestamp = oldTimestamp;
|
||||||
|
this.oldDuration = oldDuration;
|
||||||
|
this.newTimestamp = newTimestamp;
|
||||||
|
this.newDuration = newDuration;
|
||||||
|
}
|
||||||
|
execute() {
|
||||||
|
this.block.timestamp = this.newTimestamp;
|
||||||
|
this.block.duration = this.newDuration;
|
||||||
|
syncBlock(this._showId, this.block.device_id, this.block, 'PATCH');
|
||||||
|
}
|
||||||
|
undo() {
|
||||||
|
this.block.timestamp = this.oldTimestamp;
|
||||||
|
this.block.duration = this.oldDuration;
|
||||||
|
syncBlock(this._showId, this.block.device_id, this.block, 'PATCH');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**DeleteBlockCommand:**
|
||||||
|
```javascript
|
||||||
|
export class DeleteBlockCommand {
|
||||||
|
constructor(tracks, block) {
|
||||||
|
this.tracks = tracks;
|
||||||
|
this.block = block;
|
||||||
|
}
|
||||||
|
execute() {
|
||||||
|
const track = this.tracks.find(t => t.device_id === this.block.device_id);
|
||||||
|
if (track) track.cues = track.cues.filter(c => c.id !== this.block.id);
|
||||||
|
syncBlock(this._showId, this.block.device_id, this.block, 'DELETE');
|
||||||
|
}
|
||||||
|
undo() {
|
||||||
|
const track = this.tracks.find(t => t.device_id === this.block.device_id);
|
||||||
|
if (track) track.cues.push(this.block);
|
||||||
|
syncBlock(this._showId, this.block.device_id, this.block, 'POST');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
All command classes must also export a `setShowId(id)` module-level function that sets `_showId` on future commands (or store it as module-level variable used by `syncBlock`). Alternatively, make `_showId` a module-level `let currentShowId = null;` and export `setCurrentShowId(id)`.
|
||||||
|
|
||||||
|
**3. Create `lightsync/api/timeline.py` — Block CRUD router:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from uuid import UUID
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Any, Optional
|
||||||
|
import lightsync.main as state
|
||||||
|
|
||||||
|
router = APIRouter(tags=["timeline"])
|
||||||
|
|
||||||
|
|
||||||
|
class BlockCreate(BaseModel):
|
||||||
|
id: str # UUID as string from frontend crypto.randomUUID()
|
||||||
|
timestamp: float
|
||||||
|
duration: float = 4.0
|
||||||
|
animation: str | None = None
|
||||||
|
params: dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class BlockUpdate(BaseModel):
|
||||||
|
timestamp: float | None = None
|
||||||
|
duration: float | None = None
|
||||||
|
animation: str | None = None
|
||||||
|
params: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/shows/{show_id}/tracks/{device_id}/blocks", status_code=201)
|
||||||
|
async def create_block(show_id: str, device_id: str, block: BlockCreate):
|
||||||
|
show = await state.show_store.load(show_id)
|
||||||
|
if show is None:
|
||||||
|
raise HTTPException(404, "Show not found")
|
||||||
|
track = next((t for t in show.tracks if str(t.device_id) == device_id), None)
|
||||||
|
if track is None:
|
||||||
|
# Auto-create track for this device
|
||||||
|
from lightsync.models.show import TrackModel, CueModel
|
||||||
|
cue = CueModel(
|
||||||
|
id=UUID(block.id), timestamp=block.timestamp,
|
||||||
|
duration=block.duration, animation=block.animation,
|
||||||
|
params=block.params
|
||||||
|
)
|
||||||
|
track = TrackModel(device_id=UUID(device_id), cues=[cue])
|
||||||
|
show.tracks.append(track)
|
||||||
|
else:
|
||||||
|
from lightsync.models.show import CueModel
|
||||||
|
cue = CueModel(
|
||||||
|
id=UUID(block.id), timestamp=block.timestamp,
|
||||||
|
duration=block.duration, animation=block.animation,
|
||||||
|
params=block.params
|
||||||
|
)
|
||||||
|
track.cues.append(cue)
|
||||||
|
await state.show_store.save(show)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/shows/{show_id}/tracks/{device_id}/blocks/{block_id}")
|
||||||
|
async def update_block(show_id: str, device_id: str, block_id: str, update: BlockUpdate):
|
||||||
|
show = await state.show_store.load(show_id)
|
||||||
|
if show is None:
|
||||||
|
raise HTTPException(404, "Show not found")
|
||||||
|
track = next((t for t in show.tracks if str(t.device_id) == device_id), None)
|
||||||
|
if track is None:
|
||||||
|
raise HTTPException(404, "Track not found")
|
||||||
|
cue = next((c for c in track.cues if str(c.id) == block_id), None)
|
||||||
|
if cue is None:
|
||||||
|
raise HTTPException(404, "Block not found")
|
||||||
|
if update.timestamp is not None:
|
||||||
|
cue.timestamp = update.timestamp
|
||||||
|
if update.duration is not None:
|
||||||
|
cue.duration = update.duration
|
||||||
|
if update.animation is not None:
|
||||||
|
cue.animation = update.animation
|
||||||
|
if update.params is not None:
|
||||||
|
cue.params = update.params
|
||||||
|
await state.show_store.save(show)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/shows/{show_id}/tracks/{device_id}/blocks/{block_id}")
|
||||||
|
async def delete_block(show_id: str, device_id: str, block_id: str):
|
||||||
|
show = await state.show_store.load(show_id)
|
||||||
|
if show is None:
|
||||||
|
raise HTTPException(404, "Show not found")
|
||||||
|
track = next((t for t in show.tracks if str(t.device_id) == device_id), None)
|
||||||
|
if track is None:
|
||||||
|
raise HTTPException(404, "Track not found")
|
||||||
|
track.cues = [c for c in track.cues if str(c.id) != block_id]
|
||||||
|
await state.show_store.save(show)
|
||||||
|
return {"status": "ok"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. Register router in `lightsync/main.py`:**
|
||||||
|
|
||||||
|
Add import: `from lightsync.api import timeline`
|
||||||
|
Add router: `app.include_router(timeline.router, prefix="/api")`
|
||||||
|
|
||||||
|
Place the import alongside existing `from lightsync.api import devices, shows, audio, ws` and the `include_router` alongside existing router registrations.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/claude/led2 && test -f lightsync/frontend/timeline/commands.js && test -f lightsync/frontend/timeline/history.js && test -f lightsync/api/timeline.py && grep -q "PlaceBlockCommand" lightsync/frontend/timeline/commands.js && grep -q "MoveBlockCommand" lightsync/frontend/timeline/commands.js && grep -q "ResizeBlockCommand" lightsync/frontend/timeline/commands.js && grep -q "DeleteBlockCommand" lightsync/frontend/timeline/commands.js && grep -q "class CommandHistory" lightsync/frontend/timeline/history.js && grep -q "undoStack" lightsync/frontend/timeline/history.js && grep -q "redoStack" lightsync/frontend/timeline/history.js && grep -q "timeline" lightsync/main.py && python -c "from lightsync.api.timeline import router; print('router OK')" && echo "ALL PASS"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- lightsync/frontend/timeline/history.js exports CommandHistory with execute(), undo(), redo(), canUndo, canRedo
|
||||||
|
- history.js constructor takes maxSteps=50 and enforces it via shift()
|
||||||
|
- history.js redo clears redoStack on new execute (branching protection)
|
||||||
|
- lightsync/frontend/timeline/commands.js exports PlaceBlockCommand, MoveBlockCommand, ResizeBlockCommand, DeleteBlockCommand
|
||||||
|
- Each command has execute() and undo() methods
|
||||||
|
- commands.js contains syncBlock helper that does fire-and-forget fetch to /api/shows/{showId}/tracks/{deviceId}/blocks
|
||||||
|
- lightsync/api/timeline.py contains POST /shows/{show_id}/tracks/{device_id}/blocks endpoint
|
||||||
|
- lightsync/api/timeline.py contains PATCH /shows/{show_id}/tracks/{device_id}/blocks/{block_id} endpoint
|
||||||
|
- lightsync/api/timeline.py contains DELETE /shows/{show_id}/tracks/{device_id}/blocks/{block_id} endpoint
|
||||||
|
- lightsync/main.py imports timeline router and includes it with prefix="/api"
|
||||||
|
- `python -c "from lightsync.api.timeline import router"` succeeds
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Command pattern (4 command classes) + CommandHistory + backend block CRUD endpoints registered and importable</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Wire mouse interactions into TimelineCanvas</name>
|
||||||
|
<files>lightsync/frontend/timeline/timeline.js, lightsync/frontend/app.js</files>
|
||||||
|
<read_first>lightsync/frontend/timeline/timeline.js, lightsync/frontend/timeline/commands.js, lightsync/frontend/timeline/history.js, lightsync/frontend/app.js, lightsync/frontend/index.html</read_first>
|
||||||
|
<action>
|
||||||
|
**1. Add interaction methods to `timeline.js`:**
|
||||||
|
|
||||||
|
Import at top of timeline.js:
|
||||||
|
```javascript
|
||||||
|
import { CommandHistory } from './history.js';
|
||||||
|
import { PlaceBlockCommand, MoveBlockCommand, ResizeBlockCommand, DeleteBlockCommand, setCurrentShowId } from './commands.js';
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to constructor:
|
||||||
|
```javascript
|
||||||
|
this.history = new CommandHistory(50);
|
||||||
|
this.showId = null; // set when a show is loaded
|
||||||
|
```
|
||||||
|
|
||||||
|
**Add `hitTest(mouseX, mouseY)` method:**
|
||||||
|
```javascript
|
||||||
|
hitTest(mouseX, mouseY) {
|
||||||
|
const EDGE = 8;
|
||||||
|
for (const track of this.tracks) {
|
||||||
|
const trackIdx = this.tracks.indexOf(track);
|
||||||
|
const y = this.trackIndexToY(trackIdx);
|
||||||
|
if (mouseY < y || mouseY > y + TRACK_HEIGHT) continue;
|
||||||
|
// Iterate cues in reverse (last placed = on top)
|
||||||
|
for (let i = track.cues.length - 1; i >= 0; i--) {
|
||||||
|
const cue = track.cues[i];
|
||||||
|
const x = this.timeToX(cue.timestamp);
|
||||||
|
const w = cue.duration * this.pixelsPerSecond;
|
||||||
|
if (mouseX < x || mouseX > x + w) continue;
|
||||||
|
if (mouseX < x + EDGE) return { block: cue, handle: 'resize-left', trackIdx };
|
||||||
|
if (mouseX > x + w - EDGE) return { block: cue, handle: 'resize-right', trackIdx };
|
||||||
|
return { block: cue, handle: 'move', trackIdx };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Add overlap check method:**
|
||||||
|
```javascript
|
||||||
|
_hasOverlap(track, block, startTime, duration) {
|
||||||
|
const end = startTime + duration;
|
||||||
|
return track.cues.some(c => {
|
||||||
|
if (c.id === block.id) return false; // skip self
|
||||||
|
const cEnd = c.timestamp + c.duration;
|
||||||
|
return startTime < cEnd && end > c.timestamp;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Add snap helper:**
|
||||||
|
```javascript
|
||||||
|
snapToBeat(t) {
|
||||||
|
if (!this.snapEnabled || this.beatTimes.length === 0) return t;
|
||||||
|
const SNAP_THRESHOLD = 0.1; // 100ms
|
||||||
|
let nearest = null, minDist = Infinity;
|
||||||
|
for (const beat of this.beatTimes) {
|
||||||
|
const adjusted = beat - this.calibrationOffset;
|
||||||
|
const d = Math.abs(t - adjusted);
|
||||||
|
if (d < minDist) { minDist = d; nearest = adjusted; }
|
||||||
|
}
|
||||||
|
return (nearest !== null && minDist < SNAP_THRESHOLD) ? nearest : t;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Add `_setupInteractions()` method** — call from constructor AFTER _resizeCanvas():
|
||||||
|
```javascript
|
||||||
|
_setupInteractions() {
|
||||||
|
const canvas = this.canvas;
|
||||||
|
|
||||||
|
canvas.addEventListener('mousedown', (e) => {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const mx = e.clientX - rect.left;
|
||||||
|
const my = e.clientY - rect.top;
|
||||||
|
|
||||||
|
// Check if in header or time axis area — ignore
|
||||||
|
if (mx < HEADER_WIDTH || my < TIME_AXIS_HEIGHT) return;
|
||||||
|
|
||||||
|
const hit = this.hitTest(mx, my);
|
||||||
|
|
||||||
|
if (!hit) {
|
||||||
|
// Clicked on empty canvas — deselect
|
||||||
|
this.selectedBlock = null;
|
||||||
|
if (this._onSelectBlock) this._onSelectBlock(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select the clicked block
|
||||||
|
this.selectedBlock = hit.block;
|
||||||
|
if (this._onSelectBlock) this._onSelectBlock(hit.block);
|
||||||
|
|
||||||
|
if (hit.handle === 'move') {
|
||||||
|
this._startMove(e, hit.block, mx);
|
||||||
|
} else if (hit.handle === 'resize-left' || hit.handle === 'resize-right') {
|
||||||
|
this._startResize(e, hit.block, hit.handle, mx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update cursor on hover
|
||||||
|
canvas.addEventListener('mousemove', (e) => {
|
||||||
|
if (this._dragState) return; // during drag, document handles mousemove
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const mx = e.clientX - rect.left;
|
||||||
|
const my = e.clientY - rect.top;
|
||||||
|
const hit = this.hitTest(mx, my);
|
||||||
|
if (hit && (hit.handle === 'resize-left' || hit.handle === 'resize-right')) {
|
||||||
|
canvas.style.cursor = 'ew-resize';
|
||||||
|
} else if (hit && hit.handle === 'move') {
|
||||||
|
canvas.style.cursor = 'grab';
|
||||||
|
} else {
|
||||||
|
canvas.style.cursor = 'default';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete key
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Delete' && this.selectedBlock) {
|
||||||
|
const cmd = new DeleteBlockCommand(this.tracks, this.selectedBlock);
|
||||||
|
this.history.execute(cmd);
|
||||||
|
this.selectedBlock = null;
|
||||||
|
if (this._onSelectBlock) this._onSelectBlock(null);
|
||||||
|
}
|
||||||
|
// Undo: Ctrl+Z
|
||||||
|
if (e.ctrlKey && !e.shiftKey && e.key === 'z') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.history.undo();
|
||||||
|
}
|
||||||
|
// Redo: Ctrl+Shift+Z or Ctrl+Y
|
||||||
|
if ((e.ctrlKey && e.shiftKey && e.key === 'Z') || (e.ctrlKey && e.key === 'y')) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.history.redo();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Add `_startMove(e, block, startMX)` method:**
|
||||||
|
```javascript
|
||||||
|
_startMove(e, block, startMX) {
|
||||||
|
const oldTimestamp = block.timestamp;
|
||||||
|
const startTime = block.timestamp;
|
||||||
|
const startScrollX = this.scrollX;
|
||||||
|
this.canvas.style.cursor = 'grabbing';
|
||||||
|
|
||||||
|
const onDrag = (ev) => {
|
||||||
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
const mx = ev.clientX - rect.left;
|
||||||
|
const deltaX = mx - startMX;
|
||||||
|
const deltaTime = deltaX / this.pixelsPerSecond;
|
||||||
|
block.timestamp = Math.max(0, startTime + deltaTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUp = () => {
|
||||||
|
document.removeEventListener('mousemove', onDrag);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
this.canvas.style.cursor = 'default';
|
||||||
|
// Snap
|
||||||
|
block.timestamp = this.snapToBeat(block.timestamp);
|
||||||
|
block.timestamp = Math.max(0, block.timestamp);
|
||||||
|
// Check overlap
|
||||||
|
const track = this.tracks.find(t => t.device_id === block.device_id);
|
||||||
|
if (track && this._hasOverlap(track, block, block.timestamp, block.duration)) {
|
||||||
|
block.timestamp = oldTimestamp; // revert
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (block.timestamp !== oldTimestamp) {
|
||||||
|
// Reset to old for command to execute properly
|
||||||
|
block.timestamp = oldTimestamp;
|
||||||
|
const cmd = new MoveBlockCommand(this.tracks, block, oldTimestamp, this.snapToBeat(Math.max(0, startTime + (block.timestamp - oldTimestamp) || (this.snapToBeat(Math.max(0, startTime + (0))) ))));
|
||||||
|
// Simpler: just compute final time
|
||||||
|
const finalTime = this.snapToBeat(Math.max(0, startTime + ((e.clientX - e.clientX) / this.pixelsPerSecond)));
|
||||||
|
// Actually: just record the snapped value before reverting
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simpler approach — store snapped result:
|
||||||
|
let finalTimestamp = oldTimestamp;
|
||||||
|
|
||||||
|
const onDrag2 = (ev) => {
|
||||||
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
const mx = ev.clientX - rect.left;
|
||||||
|
const deltaX = mx - startMX;
|
||||||
|
const deltaTime = deltaX / this.pixelsPerSecond;
|
||||||
|
block.timestamp = Math.max(0, startTime + deltaTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUp2 = () => {
|
||||||
|
document.removeEventListener('mousemove', onDrag2);
|
||||||
|
document.removeEventListener('mouseup', onUp2);
|
||||||
|
this.canvas.style.cursor = 'default';
|
||||||
|
const snapped = this.snapToBeat(Math.max(0, block.timestamp));
|
||||||
|
const track = this.tracks.find(t => t.device_id === block.device_id);
|
||||||
|
if (track && this._hasOverlap(track, block, snapped, block.duration)) {
|
||||||
|
block.timestamp = oldTimestamp;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
block.timestamp = oldTimestamp; // revert for command
|
||||||
|
if (snapped !== oldTimestamp) {
|
||||||
|
const cmd = new MoveBlockCommand(this.tracks, block, oldTimestamp, snapped);
|
||||||
|
this.history.execute(cmd);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', onDrag2);
|
||||||
|
document.addEventListener('mouseup', onUp2, { once: true });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
IMPORTANT: The above `_startMove` has redundant code from drafting. The executor MUST implement it cleanly as a single version. The logic is:
|
||||||
|
1. Record `oldTimestamp = block.timestamp`
|
||||||
|
2. On mousemove: update `block.timestamp` live (visual feedback only)
|
||||||
|
3. On mouseup: compute `snapped = snapToBeat(max(0, block.timestamp))`, check overlap, revert `block.timestamp = oldTimestamp`, then if snapped !== oldTimestamp execute `MoveBlockCommand(tracks, block, oldTimestamp, snapped)`
|
||||||
|
|
||||||
|
**Add `_startResize(e, block, handle, startMX)` method:**
|
||||||
|
Same pattern. Record `oldTimestamp`, `oldDuration`. On mousemove: if handle is 'resize-right', adjust duration = max(0.5, (mx - blockStartX) / pixelsPerSecond). If handle is 'resize-left', adjust both timestamp and duration so the right edge stays fixed. On mouseup: snap, check overlap, revert, execute `ResizeBlockCommand`. Minimum duration is 0.5 seconds.
|
||||||
|
|
||||||
|
**Add `handleDrop(animationType, mouseX, mouseY)` method** — called when an animation palette item is dropped on canvas:
|
||||||
|
```javascript
|
||||||
|
handleDrop(animationType, mouseX, mouseY) {
|
||||||
|
const trackIdx = this.yToTrackIndex(mouseY);
|
||||||
|
if (trackIdx < 0 || trackIdx >= this.tracks.length) return;
|
||||||
|
const track = this.tracks[trackIdx];
|
||||||
|
const rawTime = Math.max(0, this.xToTime(mouseX));
|
||||||
|
const time = this.snapToBeat(rawTime);
|
||||||
|
const duration = 4.0; // default duration
|
||||||
|
|
||||||
|
if (this._hasOverlap(track, {id: null}, time, duration)) return; // reject overlap
|
||||||
|
|
||||||
|
const block = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
device_id: track.device_id,
|
||||||
|
timestamp: time,
|
||||||
|
duration: duration,
|
||||||
|
animation: animationType,
|
||||||
|
params: { color: '#00ffff', speed: 1.0, direction: 'forward', length: 0.3 }
|
||||||
|
};
|
||||||
|
const cmd = new PlaceBlockCommand(this.tracks, block);
|
||||||
|
this.history.execute(cmd);
|
||||||
|
this.selectedBlock = block;
|
||||||
|
if (this._onSelectBlock) this._onSelectBlock(block);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Wire drag-from-palette in app.js:**
|
||||||
|
|
||||||
|
In the `initTimeline()` function (after palette creation), add drag handling for the animation palette items:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Drag from palette to canvas
|
||||||
|
const palette = document.getElementById('animation-palette');
|
||||||
|
const canvasEl = document.getElementById('timeline-canvas');
|
||||||
|
|
||||||
|
palette?.addEventListener('dragstart', (e) => {
|
||||||
|
const tile = e.target.closest('.anim-tile');
|
||||||
|
if (!tile) return;
|
||||||
|
e.dataTransfer.setData('text/plain', tile.dataset.anim);
|
||||||
|
e.dataTransfer.effectAllowed = 'copy';
|
||||||
|
});
|
||||||
|
|
||||||
|
canvasEl?.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
|
// Draw ghost preview — update drag state for rendering
|
||||||
|
const rect = canvasEl.getBoundingClientRect();
|
||||||
|
const mx = e.clientX - rect.left;
|
||||||
|
const my = e.clientY - rect.top;
|
||||||
|
const trackIdx = timeline.yToTrackIndex(my);
|
||||||
|
const time = timeline.xToTime(mx);
|
||||||
|
timeline._dragState = {
|
||||||
|
ghost: true,
|
||||||
|
trackIdx: trackIdx,
|
||||||
|
time: Math.max(0, time),
|
||||||
|
duration: 4.0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
canvasEl?.addEventListener('dragleave', () => {
|
||||||
|
timeline._dragState = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
canvasEl?.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const animType = e.dataTransfer.getData('text/plain');
|
||||||
|
if (!animType) return;
|
||||||
|
const rect = canvasEl.getBoundingClientRect();
|
||||||
|
const mx = e.clientX - rect.left;
|
||||||
|
const my = e.clientY - rect.top;
|
||||||
|
timeline.handleDrop(animType, mx, my);
|
||||||
|
timeline._dragState = null;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Also set `draggable="true"` on palette items (already done in Plan 01 HTML generation).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/claude/led2 && grep -q "hitTest" lightsync/frontend/timeline/timeline.js && grep -q "handleDrop" lightsync/frontend/timeline/timeline.js && grep -q "_startMove" lightsync/frontend/timeline/timeline.js && grep -q "_startResize" lightsync/frontend/timeline/timeline.js && grep -q "snapToBeat" lightsync/frontend/timeline/timeline.js && grep -q "_hasOverlap" lightsync/frontend/timeline/timeline.js && grep -q "_setupInteractions" lightsync/frontend/timeline/timeline.js && grep -q "Ctrl.*Z\|ctrlKey" lightsync/frontend/timeline/timeline.js && grep -q "dragstart\|dragover\|drop" lightsync/frontend/app.js && echo "ALL PASS"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- timeline.js imports CommandHistory from history.js
|
||||||
|
- timeline.js imports all 4 command classes from commands.js
|
||||||
|
- timeline.js constructor creates `this.history = new CommandHistory(50)`
|
||||||
|
- timeline.js has hitTest() method with 8px EDGE for resize handles
|
||||||
|
- timeline.js has _hasOverlap() method checking time range collisions
|
||||||
|
- timeline.js has snapToBeat() with 0.1s threshold and calibrationOffset
|
||||||
|
- timeline.js has _setupInteractions() with mousedown, mousemove, keydown listeners
|
||||||
|
- timeline.js has _startMove() attaching mousemove/mouseup to document (not canvas)
|
||||||
|
- timeline.js has _startResize() with minimum 0.5s duration
|
||||||
|
- timeline.js has handleDrop() creating new block with crypto.randomUUID()
|
||||||
|
- Keydown listener handles Delete key, Ctrl+Z (undo), Ctrl+Shift+Z (redo), Ctrl+Y (redo)
|
||||||
|
- app.js has dragstart listener on palette, dragover/dragleave/drop on canvas
|
||||||
|
- Ghost block state set during dragover, cleared on dragleave/drop
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Full block interaction: drag-and-place from palette, move by dragging center, resize by dragging edges, delete with Delete key, undo/redo with Ctrl+Z/Ctrl+Shift+Z. Overlap prevention on same track. All mutations go through CommandHistory.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
1. Docker rebuild and restart
|
||||||
|
2. Add a device via sidebar, load audio
|
||||||
|
3. Drag animation from ANIMATIONS palette onto a track — block appears
|
||||||
|
4. Click block center and drag — block moves, snaps to nearest beat on release
|
||||||
|
5. Drag block right edge — block resizes
|
||||||
|
6. Press Delete with block selected — block disappears
|
||||||
|
7. Press Ctrl+Z — deleted block reappears (undo)
|
||||||
|
8. Press Ctrl+Shift+Z — block disappears again (redo)
|
||||||
|
9. Attempt to place block overlapping existing — placement rejected
|
||||||
|
10. Rapidly perform 50+ operations, then undo all — verify all revert correctly
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Blocks can be placed on tracks via drag-and-drop from palette
|
||||||
|
- Blocks can be moved and resized with snap-to-beat
|
||||||
|
- Delete key removes selected block
|
||||||
|
- Undo/redo works for all operations with at least 50 steps
|
||||||
|
- Block overlap is prevented
|
||||||
|
- Backend block CRUD endpoints accept requests
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/04-timeline-editor/04-02-SUMMARY.md`
|
||||||
|
</output>
|
||||||
290
.planning/phases/04-timeline-editor/04-03-PLAN.md
Normal file
290
.planning/phases/04-timeline-editor/04-03-PLAN.md
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
---
|
||||||
|
phase: 04-timeline-editor
|
||||||
|
plan: 03
|
||||||
|
type: execute
|
||||||
|
wave: 3
|
||||||
|
depends_on: [04-01, 04-02]
|
||||||
|
files_modified:
|
||||||
|
- lightsync/frontend/timeline/beats.js
|
||||||
|
- lightsync/frontend/timeline/timeline.js
|
||||||
|
- lightsync/frontend/app.js
|
||||||
|
autonomous: true
|
||||||
|
requirements: [TL-07, TL-08, SYNC-01, SYNC-02]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Beat marks from librosa appear as thin cyan lines on the timeline canvas"
|
||||||
|
- "Blocks snap to calibrated beat positions when placed, moved, or resized"
|
||||||
|
- "Beat calibration offset slider adjusts beat mark display in realtime"
|
||||||
|
- "Snap toggle enables/disables snapping behavior"
|
||||||
|
- "DETECTING BEATS... indicator shows while beat analysis is loading"
|
||||||
|
artifacts:
|
||||||
|
- path: "lightsync/frontend/timeline/beats.js"
|
||||||
|
provides: "BeatGrid class for beat data management, calibration, and snap logic"
|
||||||
|
exports: ["BeatGrid"]
|
||||||
|
min_lines: 40
|
||||||
|
key_links:
|
||||||
|
- from: "lightsync/frontend/timeline/beats.js"
|
||||||
|
to: "/api/audio/beats"
|
||||||
|
via: "fetch beat_times and onset_times from backend"
|
||||||
|
pattern: "api/audio/beats"
|
||||||
|
- from: "lightsync/frontend/timeline/timeline.js"
|
||||||
|
to: "lightsync/frontend/timeline/beats.js"
|
||||||
|
via: "timeline.beatGrid.snap() called during block placement/move/resize"
|
||||||
|
pattern: "beatGrid\\.snap"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Extract beat management into a dedicated BeatGrid module, enhance beat mark rendering with loading states and onset marks, and ensure snap-to-beat is properly integrated with all block interactions.
|
||||||
|
|
||||||
|
Purpose: Beat detection and snap are core requirements (TL-07, TL-08, SYNC-01, SYNC-02). Extracting into a module makes calibration offset management clean and testable.
|
||||||
|
Output: BeatGrid module with snap logic, enhanced beat rendering, loading indicator.
|
||||||
|
</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/phases/04-timeline-editor/04-CONTEXT.md
|
||||||
|
@.planning/phases/04-timeline-editor/04-RESEARCH.md
|
||||||
|
@.planning/phases/04-timeline-editor/04-UI-SPEC.md
|
||||||
|
@.planning/phases/04-timeline-editor/04-01-SUMMARY.md
|
||||||
|
@.planning/phases/04-timeline-editor/04-02-SUMMARY.md
|
||||||
|
|
||||||
|
@lightsync/frontend/timeline/timeline.js
|
||||||
|
@lightsync/frontend/app.js
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- From timeline.js (after Plan 01+02) -->
|
||||||
|
export class TimelineCanvas {
|
||||||
|
beatTimes: float[]
|
||||||
|
calibrationOffset: float
|
||||||
|
snapEnabled: boolean
|
||||||
|
snapToBeat(t): float // basic snap method from Plan 02
|
||||||
|
loadBeats(audioPath): Promise<void>
|
||||||
|
render(): void // draws beat marks in layer 4
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- From /api/audio/beats endpoint (Phase 2) -->
|
||||||
|
GET /api/audio/beats?path=/app/shows/song.mp3
|
||||||
|
Response: { beat_times: float[], onset_times: float[], tempo_bpm: float }
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: BeatGrid module + enhanced timeline integration</name>
|
||||||
|
<files>lightsync/frontend/timeline/beats.js, lightsync/frontend/timeline/timeline.js, lightsync/frontend/app.js</files>
|
||||||
|
<read_first>lightsync/frontend/timeline/timeline.js, lightsync/frontend/app.js, lightsync/api/audio.py</read_first>
|
||||||
|
<action>
|
||||||
|
**1. Create `lightsync/frontend/timeline/beats.js` — BeatGrid class:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
export class BeatGrid {
|
||||||
|
constructor() {
|
||||||
|
this.beatTimes = []; // raw beat times from librosa (never mutated)
|
||||||
|
this.onsetTimes = []; // raw onset times
|
||||||
|
this.tempoBpm = null;
|
||||||
|
this.calibrationOffset = 0.03; // seconds, default 30ms
|
||||||
|
this.snapEnabled = true;
|
||||||
|
this.loading = false;
|
||||||
|
this.error = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load beat data from backend API */
|
||||||
|
async load(audioPath) {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
this.beatTimes = [];
|
||||||
|
this.onsetTimes = [];
|
||||||
|
this.tempoBpm = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/audio/beats?path=${encodeURIComponent(audioPath)}`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
this.beatTimes = data.beat_times || [];
|
||||||
|
this.onsetTimes = data.onset_times || [];
|
||||||
|
this.tempoBpm = data.tempo_bpm || null;
|
||||||
|
} catch (e) {
|
||||||
|
this.error = 'BEAT DETECTION FAILED';
|
||||||
|
console.warn('[beats]', e);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get calibrated beat times for display */
|
||||||
|
getCalibratedBeats() {
|
||||||
|
return this.beatTimes.map(t => t - this.calibrationOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get calibrated onset times for display */
|
||||||
|
getCalibratedOnsets() {
|
||||||
|
return this.onsetTimes.map(t => t - this.calibrationOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Snap a time value to nearest calibrated beat within threshold */
|
||||||
|
snap(t) {
|
||||||
|
if (!this.snapEnabled || this.beatTimes.length === 0) return t;
|
||||||
|
const SNAP_THRESHOLD = 0.1; // 100ms
|
||||||
|
const calibrated = this.getCalibratedBeats();
|
||||||
|
let nearest = null;
|
||||||
|
let minDist = Infinity;
|
||||||
|
for (const beat of calibrated) {
|
||||||
|
const d = Math.abs(t - beat);
|
||||||
|
if (d < minDist) { minDist = d; nearest = beat; }
|
||||||
|
}
|
||||||
|
return (nearest !== null && minDist < SNAP_THRESHOLD) ? nearest : t;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if beats are loaded and available */
|
||||||
|
get hasBeats() {
|
||||||
|
return this.beatTimes.length > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Refactor timeline.js to use BeatGrid:**
|
||||||
|
|
||||||
|
At the top of timeline.js, add import:
|
||||||
|
```javascript
|
||||||
|
import { BeatGrid } from './beats.js';
|
||||||
|
```
|
||||||
|
|
||||||
|
In the constructor, replace the individual beat-related properties:
|
||||||
|
- Remove: `this.beatTimes = []`, `this.calibrationOffset = 0.03`, `this.snapEnabled = true`
|
||||||
|
- Add: `this.beatGrid = new BeatGrid();`
|
||||||
|
|
||||||
|
Update `snapToBeat(t)` method to delegate to beatGrid:
|
||||||
|
```javascript
|
||||||
|
snapToBeat(t) {
|
||||||
|
return this.beatGrid.snap(t);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `loadBeats(audioPath)` to delegate:
|
||||||
|
```javascript
|
||||||
|
async loadBeats(audioPath) {
|
||||||
|
await this.beatGrid.load(audioPath);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Update beat mark rendering in `render()` (layer 4) to use `this.beatGrid.getCalibratedBeats()` instead of `this.beatTimes`:
|
||||||
|
```javascript
|
||||||
|
// Beat marks
|
||||||
|
if (this.beatGrid.hasBeats) {
|
||||||
|
const calibrated = this.beatGrid.getCalibratedBeats();
|
||||||
|
ctx.strokeStyle = 'rgba(0, 255, 255, 0.4)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (const bt of calibrated) {
|
||||||
|
const x = this.timeToX(bt);
|
||||||
|
if (x < HEADER_WIDTH || x > width) continue;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, TIME_AXIS_HEIGHT);
|
||||||
|
ctx.lineTo(x, height);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
// Also draw onset marks as dimmer, shorter ticks
|
||||||
|
const onsets = this.beatGrid.getCalibratedOnsets();
|
||||||
|
ctx.strokeStyle = 'rgba(0, 255, 255, 0.15)';
|
||||||
|
for (const ot of onsets) {
|
||||||
|
const x = this.timeToX(ot);
|
||||||
|
if (x < HEADER_WIDTH || x > width) continue;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, TIME_AXIS_HEIGHT);
|
||||||
|
ctx.lineTo(x, TIME_AXIS_HEIGHT + 8); // short tick at top
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
} else if (this.beatGrid.loading) {
|
||||||
|
// Loading indicator
|
||||||
|
ctx.fillStyle = '#555555';
|
||||||
|
ctx.font = '10px monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('DETECTING BEATS...', HEADER_WIDTH + (width - HEADER_WIDTH) / 2, TIME_AXIS_HEIGHT + 14);
|
||||||
|
} else if (this.beatGrid.error) {
|
||||||
|
ctx.fillStyle = '#555555';
|
||||||
|
ctx.font = '10px monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(this.beatGrid.error, HEADER_WIDTH + (width - HEADER_WIDTH) / 2, TIME_AXIS_HEIGHT + 14);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add getters/setters to TimelineCanvas for backward compatibility with app.js toolbar wiring:
|
||||||
|
```javascript
|
||||||
|
get snapEnabled() { return this.beatGrid.snapEnabled; }
|
||||||
|
set snapEnabled(v) { this.beatGrid.snapEnabled = v; }
|
||||||
|
get calibrationOffset() { return this.beatGrid.calibrationOffset; }
|
||||||
|
set calibrationOffset(v) { this.beatGrid.calibrationOffset = v; }
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Update app.js:**
|
||||||
|
|
||||||
|
The existing toolbar wiring (zoom-slider, beat-offset, btn-snap) from Plan 01 should continue to work through the getters/setters. No changes needed for those.
|
||||||
|
|
||||||
|
If tempo is available after beat loading, show it in the header or toolbar. Add after `timeline.loadBeats(path)`:
|
||||||
|
```javascript
|
||||||
|
// After beats load, update tempo display if available
|
||||||
|
timeline.loadBeats(path).then(() => {
|
||||||
|
if (timeline.beatGrid.tempoBpm) {
|
||||||
|
const label = document.getElementById('beat-label');
|
||||||
|
if (label) label.textContent = `${Math.round(timeline.beatGrid.tempoBpm)} BPM`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the direct `timeline.loadBeats(path)` call in the audio loadedmetadata handler with the above pattern (call loadBeats and chain the tempo display update).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/claude/led2 && test -f lightsync/frontend/timeline/beats.js && grep -q "export class BeatGrid" lightsync/frontend/timeline/beats.js && grep -q "getCalibratedBeats" lightsync/frontend/timeline/beats.js && grep -q "snap(" lightsync/frontend/timeline/beats.js && grep -q "SNAP_THRESHOLD" lightsync/frontend/timeline/beats.js && grep -q "calibrationOffset" lightsync/frontend/timeline/beats.js && grep -q "import.*BeatGrid" lightsync/frontend/timeline/timeline.js && grep -q "beatGrid" lightsync/frontend/timeline/timeline.js && grep -q "DETECTING BEATS" lightsync/frontend/timeline/timeline.js && grep -q "getCalibratedBeats\|getCalibratedOnsets" lightsync/frontend/timeline/timeline.js && echo "ALL PASS"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- lightsync/frontend/timeline/beats.js exports BeatGrid class
|
||||||
|
- BeatGrid has load(audioPath) async method fetching from /api/audio/beats
|
||||||
|
- BeatGrid has getCalibratedBeats() returning beat_times adjusted by calibrationOffset
|
||||||
|
- BeatGrid has getCalibratedOnsets() returning onset_times adjusted by calibrationOffset
|
||||||
|
- BeatGrid has snap(t) method with 0.1s threshold using calibrated beats
|
||||||
|
- BeatGrid has loading, error state properties
|
||||||
|
- BeatGrid stores raw beatTimes unmodified (calibration applied only at display/snap time)
|
||||||
|
- timeline.js imports BeatGrid and creates this.beatGrid in constructor
|
||||||
|
- timeline.js render() draws calibrated beat marks in rgba(0,255,255,0.4)
|
||||||
|
- timeline.js render() draws onset marks in rgba(0,255,255,0.15) as short ticks
|
||||||
|
- timeline.js render() shows "DETECTING BEATS..." when beatGrid.loading is true
|
||||||
|
- timeline.js render() shows beatGrid.error message when error is set
|
||||||
|
- timeline.js snapToBeat() delegates to this.beatGrid.snap()
|
||||||
|
- timeline.js has snapEnabled and calibrationOffset getters/setters proxying to beatGrid
|
||||||
|
- BPM display updates in header after beat analysis completes
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>BeatGrid module manages all beat data with calibration offset. Beat marks and onset marks render on timeline. Snap-to-beat works through BeatGrid. Loading/error states displayed. BPM shown in header.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
1. Docker rebuild and restart
|
||||||
|
2. Load an audio file
|
||||||
|
3. Verify "DETECTING BEATS..." appears briefly during analysis
|
||||||
|
4. After analysis: cyan beat marks appear as vertical lines on timeline
|
||||||
|
5. Shorter/dimmer onset ticks appear at top of tracks
|
||||||
|
6. BPM label updates in header (e.g. "120 BPM")
|
||||||
|
7. Adjust BEAT OFFSET slider: beat marks shift left/right in realtime
|
||||||
|
8. Place a block near a beat: block snaps to beat position
|
||||||
|
9. Toggle SNAP off: block places at exact drop position without snapping
|
||||||
|
10. Toggle SNAP on: snapping resumes
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Beat marks render on timeline from librosa data
|
||||||
|
- Calibration offset shifts beat display without modifying raw data
|
||||||
|
- Snap-to-beat works for place, move, and resize operations
|
||||||
|
- Snap can be toggled on/off
|
||||||
|
- Loading and error states shown for beat detection
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/04-timeline-editor/04-03-SUMMARY.md`
|
||||||
|
</output>
|
||||||
423
.planning/phases/04-timeline-editor/04-04-PLAN.md
Normal file
423
.planning/phases/04-timeline-editor/04-04-PLAN.md
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
---
|
||||||
|
phase: 04-timeline-editor
|
||||||
|
plan: 04
|
||||||
|
type: execute
|
||||||
|
wave: 3
|
||||||
|
depends_on: [04-02]
|
||||||
|
files_modified:
|
||||||
|
- lightsync/frontend/timeline/inspector.js
|
||||||
|
- lightsync/frontend/timeline/timeline.js
|
||||||
|
- lightsync/frontend/timeline/commands.js
|
||||||
|
- lightsync/frontend/app.js
|
||||||
|
autonomous: true
|
||||||
|
requirements: [TL-04]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Selecting a block shows the inspector strip between timeline and transport bar"
|
||||||
|
- "Inspector displays animation type label, color picker, speed, direction, and length controls"
|
||||||
|
- "Changing color in inspector updates the block params and is visible on canvas"
|
||||||
|
- "Changing speed/direction/length updates block params through command pattern (undoable)"
|
||||||
|
- "Deselecting a block hides the inspector"
|
||||||
|
- "Remove button in inspector deletes the block"
|
||||||
|
artifacts:
|
||||||
|
- path: "lightsync/frontend/timeline/inspector.js"
|
||||||
|
provides: "BlockInspector class rendering the inspector strip"
|
||||||
|
exports: ["BlockInspector"]
|
||||||
|
min_lines: 60
|
||||||
|
key_links:
|
||||||
|
- from: "lightsync/frontend/timeline/inspector.js"
|
||||||
|
to: "lightsync/frontend/timeline/timeline.js"
|
||||||
|
via: "timeline._onSelectBlock callback triggers inspector show/hide"
|
||||||
|
pattern: "_onSelectBlock"
|
||||||
|
- from: "lightsync/frontend/timeline/inspector.js"
|
||||||
|
to: "lightsync/frontend/timeline/commands.js"
|
||||||
|
via: "param changes create UpdateParamsCommand through history"
|
||||||
|
pattern: "UpdateParamsCommand\|history\\.execute"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Build the block inspector panel — a compact horizontal strip that shows animation type, color picker, and parameter controls for the selected block. All parameter changes go through the command pattern for undo/redo support. Per D-01/D-02/D-03 from CONTEXT.md.
|
||||||
|
|
||||||
|
Purpose: Without the inspector, users cannot edit block parameters (color, speed, direction). This is the last piece of the timeline editing UX.
|
||||||
|
Output: A working inspector strip with color picker and parameter controls, fully integrated with undo/redo.
|
||||||
|
</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/phases/04-timeline-editor/04-CONTEXT.md
|
||||||
|
@.planning/phases/04-timeline-editor/04-RESEARCH.md
|
||||||
|
@.planning/phases/04-timeline-editor/04-UI-SPEC.md
|
||||||
|
@.planning/phases/04-timeline-editor/04-02-SUMMARY.md
|
||||||
|
|
||||||
|
@lightsync/frontend/timeline/timeline.js
|
||||||
|
@lightsync/frontend/timeline/commands.js
|
||||||
|
@lightsync/frontend/timeline/history.js
|
||||||
|
@lightsync/frontend/index.html
|
||||||
|
@lightsync/frontend/style.css
|
||||||
|
@lightsync/frontend/app.js
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- From timeline.js -->
|
||||||
|
class TimelineCanvas {
|
||||||
|
selectedBlock: object | null // {id, timestamp, duration, animation, params, device_id}
|
||||||
|
_onSelectBlock: function | null // callback(block) called on select/deselect
|
||||||
|
history: CommandHistory
|
||||||
|
tracks: [{device_id, device_name, strip_type, cues: []}]
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Block params structure (from CueModel) -->
|
||||||
|
block.params = {
|
||||||
|
color: '#00ffff', // hex string
|
||||||
|
speed: 1.0, // float 0.1-10
|
||||||
|
direction: 'forward', // 'forward' | 'reverse'
|
||||||
|
length: 0.3 // float 0.05-1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- From style.css (Plan 01) -->
|
||||||
|
.block-inspector { height: 40px; display: flex; ... }
|
||||||
|
.inspector-label { color: var(--text-accent); font-size: 14px; font-weight: 600; }
|
||||||
|
.inspector-field { display: flex; align-items: center; gap: 4px; font-size: 10px; }
|
||||||
|
.inspector-remove { margin-left: auto; }
|
||||||
|
|
||||||
|
<!-- Inspector HTML container in index.html -->
|
||||||
|
<div id="block-inspector" class="block-inspector" style="display:none;"></div>
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: UpdateParamsCommand + BlockInspector module</name>
|
||||||
|
<files>lightsync/frontend/timeline/commands.js, lightsync/frontend/timeline/inspector.js</files>
|
||||||
|
<read_first>lightsync/frontend/timeline/commands.js, lightsync/frontend/timeline/history.js, lightsync/frontend/index.html, lightsync/frontend/style.css</read_first>
|
||||||
|
<action>
|
||||||
|
**1. Add UpdateParamsCommand to commands.js:**
|
||||||
|
|
||||||
|
Add a new command class and export it alongside the existing 4 commands:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
export class UpdateParamsCommand {
|
||||||
|
constructor(tracks, block, oldParams, newParams) {
|
||||||
|
this.tracks = tracks;
|
||||||
|
this.block = block;
|
||||||
|
this.oldParams = { ...oldParams }; // shallow copy
|
||||||
|
this.newParams = { ...newParams }; // shallow copy
|
||||||
|
}
|
||||||
|
execute() {
|
||||||
|
Object.assign(this.block.params, this.newParams);
|
||||||
|
syncBlock(this._showId, this.block.device_id, this.block, 'PATCH');
|
||||||
|
}
|
||||||
|
undo() {
|
||||||
|
Object.assign(this.block.params, this.oldParams);
|
||||||
|
syncBlock(this._showId, this.block.device_id, this.block, 'PATCH');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `UpdateParamsCommand` to the export list.
|
||||||
|
|
||||||
|
**2. Create `lightsync/frontend/timeline/inspector.js` — BlockInspector class:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { UpdateParamsCommand } from './commands.js';
|
||||||
|
|
||||||
|
export class BlockInspector {
|
||||||
|
constructor(containerEl, history) {
|
||||||
|
this.el = containerEl; // #block-inspector div
|
||||||
|
this.history = history; // CommandHistory reference
|
||||||
|
this.tracks = null; // set by timeline
|
||||||
|
this.currentBlock = null;
|
||||||
|
this._colorInput = null; // hidden <input type="color">
|
||||||
|
}
|
||||||
|
|
||||||
|
show(block) {
|
||||||
|
this.currentBlock = block;
|
||||||
|
if (!block) {
|
||||||
|
this.el.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.el.style.display = 'flex';
|
||||||
|
this._render(block);
|
||||||
|
}
|
||||||
|
|
||||||
|
hide() {
|
||||||
|
this.currentBlock = null;
|
||||||
|
this.el.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
_render(block) {
|
||||||
|
const animName = (block.animation || 'BLOCK').toUpperCase().replace(/_/g, ' ');
|
||||||
|
const color = block.params.color || '#00ffff';
|
||||||
|
const speed = block.params.speed ?? 1.0;
|
||||||
|
const direction = block.params.direction || 'forward';
|
||||||
|
const length = block.params.length ?? 0.3;
|
||||||
|
|
||||||
|
this.el.innerHTML = `
|
||||||
|
<span class="inspector-label">${animName}</span>
|
||||||
|
<label class="inspector-field">
|
||||||
|
COLOR
|
||||||
|
<div class="inspector-color-swatch" id="color-swatch" style="background:${color};" title="Click to change color"></div>
|
||||||
|
<input type="color" id="inspector-color" value="${color}" style="display:none;">
|
||||||
|
</label>
|
||||||
|
<label class="inspector-field">
|
||||||
|
SPEED
|
||||||
|
<input type="number" id="inspector-speed" step="0.1" min="0.1" max="10" value="${speed}">
|
||||||
|
</label>
|
||||||
|
<label class="inspector-field">
|
||||||
|
DIR
|
||||||
|
<select id="inspector-direction">
|
||||||
|
<option value="forward" ${direction === 'forward' ? 'selected' : ''}>FWD</option>
|
||||||
|
<option value="reverse" ${direction === 'reverse' ? 'selected' : ''}>REV</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="inspector-field">
|
||||||
|
LEN
|
||||||
|
<input type="number" id="inspector-length" step="0.05" min="0.05" max="1" value="${length}">
|
||||||
|
</label>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<button class="inspector-remove" title="Remove block">✕</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this._wireEvents(block);
|
||||||
|
}
|
||||||
|
|
||||||
|
_wireEvents(block) {
|
||||||
|
// Color swatch → open hidden color input
|
||||||
|
const swatch = this.el.querySelector('#color-swatch');
|
||||||
|
const colorInput = this.el.querySelector('#inspector-color');
|
||||||
|
swatch?.addEventListener('click', () => colorInput?.click());
|
||||||
|
|
||||||
|
colorInput?.addEventListener('input', (e) => {
|
||||||
|
swatch.style.background = e.target.value;
|
||||||
|
});
|
||||||
|
colorInput?.addEventListener('change', (e) => {
|
||||||
|
const oldParams = { ...block.params };
|
||||||
|
const newParams = { ...block.params, color: e.target.value };
|
||||||
|
const cmd = new UpdateParamsCommand(this.tracks, block, oldParams, newParams);
|
||||||
|
this.history.execute(cmd);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Speed
|
||||||
|
this.el.querySelector('#inspector-speed')?.addEventListener('change', (e) => {
|
||||||
|
const val = parseFloat(e.target.value);
|
||||||
|
if (isNaN(val) || val < 0.1 || val > 10) return;
|
||||||
|
const oldParams = { ...block.params };
|
||||||
|
const newParams = { ...block.params, speed: val };
|
||||||
|
const cmd = new UpdateParamsCommand(this.tracks, block, oldParams, newParams);
|
||||||
|
this.history.execute(cmd);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Direction
|
||||||
|
this.el.querySelector('#inspector-direction')?.addEventListener('change', (e) => {
|
||||||
|
const oldParams = { ...block.params };
|
||||||
|
const newParams = { ...block.params, direction: e.target.value };
|
||||||
|
const cmd = new UpdateParamsCommand(this.tracks, block, oldParams, newParams);
|
||||||
|
this.history.execute(cmd);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Length
|
||||||
|
this.el.querySelector('#inspector-length')?.addEventListener('change', (e) => {
|
||||||
|
const val = parseFloat(e.target.value);
|
||||||
|
if (isNaN(val) || val < 0.05 || val > 1) return;
|
||||||
|
const oldParams = { ...block.params };
|
||||||
|
const newParams = { ...block.params, length: val };
|
||||||
|
const cmd = new UpdateParamsCommand(this.tracks, block, oldParams, newParams);
|
||||||
|
this.history.execute(cmd);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove button
|
||||||
|
this.el.querySelector('.inspector-remove')?.addEventListener('click', () => {
|
||||||
|
if (this._onDelete) this._onDelete(block);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `_onDelete` callback will be set by app.js to fire a DeleteBlockCommand through the timeline's history.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/claude/led2 && test -f lightsync/frontend/timeline/inspector.js && grep -q "export class BlockInspector" lightsync/frontend/timeline/inspector.js && grep -q "inspector-color" lightsync/frontend/timeline/inspector.js && grep -q "inspector-speed" lightsync/frontend/timeline/inspector.js && grep -q "inspector-direction" lightsync/frontend/timeline/inspector.js && grep -q "inspector-length" lightsync/frontend/timeline/inspector.js && grep -q "inspector-remove" lightsync/frontend/timeline/inspector.js && grep -q "UpdateParamsCommand" lightsync/frontend/timeline/commands.js && grep -q "UpdateParamsCommand" lightsync/frontend/timeline/inspector.js && echo "ALL PASS"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- lightsync/frontend/timeline/commands.js exports UpdateParamsCommand with execute()/undo() storing old/new params
|
||||||
|
- lightsync/frontend/timeline/inspector.js exports BlockInspector class
|
||||||
|
- BlockInspector.show(block) renders inspector HTML with animation label, color swatch, speed, direction, length inputs
|
||||||
|
- BlockInspector.show(null) or hide() sets display:none
|
||||||
|
- Color swatch click opens hidden `<input type="color">`
|
||||||
|
- Speed input fires UpdateParamsCommand on change
|
||||||
|
- Direction select fires UpdateParamsCommand on change
|
||||||
|
- Length input fires UpdateParamsCommand on change
|
||||||
|
- Remove button calls _onDelete callback
|
||||||
|
- All inspector inputs use font-size 12px, monospace font, var(--bg-panel) background
|
||||||
|
- Color input change event (not input) creates command (avoids flood during drag)
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>UpdateParamsCommand added to command suite. BlockInspector renders compact strip with all parameter controls. All changes are undoable.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Wire inspector into timeline and app.js</name>
|
||||||
|
<files>lightsync/frontend/timeline/timeline.js, lightsync/frontend/app.js</files>
|
||||||
|
<read_first>lightsync/frontend/timeline/timeline.js, lightsync/frontend/timeline/inspector.js, lightsync/frontend/app.js, lightsync/frontend/index.html</read_first>
|
||||||
|
<action>
|
||||||
|
**1. Update app.js initTimeline() to create and wire BlockInspector:**
|
||||||
|
|
||||||
|
Add import at top of app.js:
|
||||||
|
```javascript
|
||||||
|
import { BlockInspector } from './timeline/inspector.js';
|
||||||
|
import { DeleteBlockCommand } from './timeline/commands.js';
|
||||||
|
```
|
||||||
|
|
||||||
|
In initTimeline(), after creating the TimelineCanvas instance, add:
|
||||||
|
```javascript
|
||||||
|
// Inspector
|
||||||
|
const inspectorEl = document.getElementById('block-inspector');
|
||||||
|
const inspector = new BlockInspector(inspectorEl, timeline.history);
|
||||||
|
inspector.tracks = timeline.tracks;
|
||||||
|
|
||||||
|
// Wire timeline selection → inspector
|
||||||
|
timeline._onSelectBlock = (block) => {
|
||||||
|
inspector.show(block);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wire inspector delete → timeline
|
||||||
|
inspector._onDelete = (block) => {
|
||||||
|
const cmd = new DeleteBlockCommand(timeline.tracks, block);
|
||||||
|
timeline.history.execute(cmd);
|
||||||
|
timeline.selectedBlock = null;
|
||||||
|
inspector.hide();
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Also, after `timeline.loadTracks(devices)` resolves, update inspector.tracks:
|
||||||
|
```javascript
|
||||||
|
fetch('/api/devices').then(r => r.json()).then(devices => {
|
||||||
|
timeline.loadTracks(devices);
|
||||||
|
inspector.tracks = timeline.tracks; // keep reference in sync
|
||||||
|
}).catch(e => console.warn('[timeline]', e));
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Update timeline.js render() to use block color for block fill:**
|
||||||
|
|
||||||
|
In the block rendering layer (layer 5 of render()), when drawing block fill, use the block's `params.color` if available to add a color accent to the block. Instead of flat `#0a2a2a`, use a dim version of the block's color:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In _drawBlocks or block rendering section:
|
||||||
|
for (const track of this.tracks) {
|
||||||
|
const trackIdx = this.tracks.indexOf(track);
|
||||||
|
for (const cue of track.cues) {
|
||||||
|
const x = this.timeToX(cue.timestamp);
|
||||||
|
const w = cue.duration * this.pixelsPerSecond;
|
||||||
|
const y = this.trackIndexToY(trackIdx);
|
||||||
|
|
||||||
|
// Skip if off-screen
|
||||||
|
if (x + w < HEADER_WIDTH || x > width) continue;
|
||||||
|
|
||||||
|
const isSelected = this.selectedBlock && this.selectedBlock.id === cue.id;
|
||||||
|
|
||||||
|
// Block fill — use block color at low opacity for visual distinction
|
||||||
|
const blockColor = cue.params?.color || '#00ffff';
|
||||||
|
if (isSelected) {
|
||||||
|
ctx.fillStyle = '#0d3a3a';
|
||||||
|
} else {
|
||||||
|
// Dim the block color: parse hex, set low alpha
|
||||||
|
ctx.fillStyle = blockColor + '1a'; // ~10% opacity hex suffix
|
||||||
|
}
|
||||||
|
ctx.fillRect(x, y + 1, w, TRACK_HEIGHT - 2);
|
||||||
|
|
||||||
|
// Border
|
||||||
|
ctx.strokeStyle = isSelected ? '#00ffff' : '#1a4a4a';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeRect(x, y + 1, w, TRACK_HEIGHT - 2);
|
||||||
|
|
||||||
|
// Label
|
||||||
|
ctx.fillStyle = '#00ffff';
|
||||||
|
ctx.font = '10px monospace';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
const label = (cue.animation || '').toUpperCase().replace(/_/g, ' ');
|
||||||
|
// Clip label to block width
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.rect(x + 4, y, w - 8, TRACK_HEIGHT);
|
||||||
|
ctx.clip();
|
||||||
|
ctx.fillText(label, x + 6, y + TRACK_HEIGHT / 2);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This makes color changes immediately visible on the canvas since the block fill incorporates the selected color.
|
||||||
|
|
||||||
|
**3. Ensure undo of param change re-renders inspector:**
|
||||||
|
|
||||||
|
After undo/redo in the keydown handler (already in timeline.js from Plan 02), if `this.selectedBlock` is still set, re-trigger `_onSelectBlock` to refresh the inspector display:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In the undo keydown handler:
|
||||||
|
if (e.ctrlKey && !e.shiftKey && e.key === 'z') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.history.undo();
|
||||||
|
if (this._onSelectBlock) this._onSelectBlock(this.selectedBlock);
|
||||||
|
}
|
||||||
|
// Same for redo:
|
||||||
|
if ((e.ctrlKey && e.shiftKey && e.key === 'Z') || (e.ctrlKey && e.key === 'y')) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.history.redo();
|
||||||
|
if (this._onSelectBlock) this._onSelectBlock(this.selectedBlock);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This ensures the inspector values refresh when undo/redo changes the selected block's params.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/claude/led2 && grep -q "import.*BlockInspector" lightsync/frontend/app.js && grep -q "_onSelectBlock" lightsync/frontend/app.js && grep -q "inspector\\.show\|inspector\\.hide" lightsync/frontend/app.js && grep -q "_onDelete" lightsync/frontend/app.js && grep -q "params.*color\|blockColor" lightsync/frontend/timeline/timeline.js && grep -q "_onSelectBlock.*selectedBlock" lightsync/frontend/timeline/timeline.js && echo "ALL PASS"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- app.js imports BlockInspector and DeleteBlockCommand
|
||||||
|
- app.js creates BlockInspector with #block-inspector element and timeline.history
|
||||||
|
- app.js sets timeline._onSelectBlock callback to call inspector.show(block)
|
||||||
|
- app.js sets inspector._onDelete callback to execute DeleteBlockCommand and hide inspector
|
||||||
|
- inspector.tracks is set to timeline.tracks reference
|
||||||
|
- timeline.js block rendering uses block's params.color for fill accent
|
||||||
|
- timeline.js undo/redo handlers re-trigger _onSelectBlock to refresh inspector
|
||||||
|
- Clicking a block shows inspector strip with correct animation name, color, speed, direction, length
|
||||||
|
- Clicking canvas background hides inspector
|
||||||
|
- Changing color in inspector updates block on canvas
|
||||||
|
- Remove button in inspector deletes block and hides inspector
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Inspector fully wired: shows on block select, hides on deselect, all param changes undoable, remove button works, block color visible on canvas, undo/redo refreshes inspector values.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
1. Docker rebuild and restart
|
||||||
|
2. Place a block on timeline — click it — inspector appears between timeline and transport bar (per D-03)
|
||||||
|
3. Inspector shows: animation label in cyan uppercase, color swatch, SPEED input, DIR select, LEN input, remove button (per D-02)
|
||||||
|
4. Click color swatch — native color picker opens
|
||||||
|
5. Change color — block on canvas updates to show new color accent
|
||||||
|
6. Change speed to 2.0 — press Ctrl+Z — speed reverts to 1.0 (undo works for params)
|
||||||
|
7. Click remove button — block deleted, inspector hides
|
||||||
|
8. Ctrl+Z — block reappears, click it — inspector shows with original params
|
||||||
|
9. Click empty canvas area — inspector hides (per D-01: hidden when nothing selected)
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Inspector panel shows/hides based on block selection (D-01)
|
||||||
|
- Inspector displays label, color, speed, direction, length in horizontal strip (D-02)
|
||||||
|
- Layout is TIMELINE > INSPECTOR > TRANSPORT (D-03)
|
||||||
|
- All parameter changes go through command pattern (undoable)
|
||||||
|
- Block color is visually reflected on canvas
|
||||||
|
- Remove button deletes block via command pattern
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/04-timeline-editor/04-04-SUMMARY.md`
|
||||||
|
</output>
|
||||||
Reference in New Issue
Block a user