diff --git a/lightsync/api/timeline.py b/lightsync/api/timeline.py new file mode 100644 index 0000000..14d3450 --- /dev/null +++ b/lightsync/api/timeline.py @@ -0,0 +1,86 @@ +from uuid import UUID +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from typing import Any +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"} diff --git a/lightsync/frontend/timeline/commands.js b/lightsync/frontend/timeline/commands.js new file mode 100644 index 0000000..2d66049 --- /dev/null +++ b/lightsync/frontend/timeline/commands.js @@ -0,0 +1,110 @@ +// Timeline block commands — command pattern for undo/redo support +// All mutations go through these commands, which also sync to the backend. +// Backend sync is fire-and-forget (no await during rapid undo/redo). + +let currentShowId = null; + +export function setCurrentShowId(id) { + currentShowId = id; +} + +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 ──────────────────────────────────────────────────────── + +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(currentShowId, 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(currentShowId, this.block.device_id, this.block, 'DELETE'); + } +} + +// ── MoveBlockCommand ───────────────────────────────────────────────────────── + +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(currentShowId, this.block.device_id, this.block, 'PATCH'); + } + + undo() { + this.block.timestamp = this.oldTimestamp; + syncBlock(currentShowId, this.block.device_id, this.block, 'PATCH'); + } +} + +// ── ResizeBlockCommand ─────────────────────────────────────────────────────── +// Left-edge resize changes both timestamp and duration (right edge stays fixed). +// Right-edge resize changes only duration. + +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(currentShowId, this.block.device_id, this.block, 'PATCH'); + } + + undo() { + this.block.timestamp = this.oldTimestamp; + this.block.duration = this.oldDuration; + syncBlock(currentShowId, this.block.device_id, this.block, 'PATCH'); + } +} + +// ── DeleteBlockCommand ─────────────────────────────────────────────────────── + +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(currentShowId, 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(currentShowId, this.block.device_id, this.block, 'POST'); + } +} diff --git a/lightsync/frontend/timeline/history.js b/lightsync/frontend/timeline/history.js new file mode 100644 index 0000000..b71780c --- /dev/null +++ b/lightsync/frontend/timeline/history.js @@ -0,0 +1,36 @@ +// CommandHistory — undo/redo stack for timeline block mutations +// Supports min 50 steps. execute() clears redo stack to prevent branching corruption. + +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; } +} diff --git a/lightsync/main.py b/lightsync/main.py index b3e7d9d..279c0de 100644 --- a/lightsync/main.py +++ b/lightsync/main.py @@ -42,11 +42,12 @@ def create_app() -> FastAPI: app = FastAPI(title="LightSync", lifespan=lifespan, redirect_slashes=False) # API routes (registered before static mount — see Pattern 7) - from lightsync.api import shows, devices, ws, audio + from lightsync.api import shows, devices, ws, audio, timeline app.include_router(shows.router, prefix="/api/shows") app.include_router(devices.router, prefix="/api/devices") app.include_router(ws.router) app.include_router(audio.router, prefix="/api/audio") + app.include_router(timeline.router, prefix="/api") # Serve uploaded audio files so