feat(04-02): command pattern + CommandHistory + block CRUD backend
- CommandHistory class with execute/undo/redo, 50-step max, redo-clear on new action
- PlaceBlockCommand, MoveBlockCommand, ResizeBlockCommand, DeleteBlockCommand
- setCurrentShowId() module-level function for backend sync routing
- syncBlock() fire-and-forget fetch helper for backend sync
- POST/PATCH/DELETE /api/shows/{show_id}/tracks/{device_id}/blocks endpoints
- timeline router registered in main.py with prefix=/api
This commit is contained in:
86
lightsync/api/timeline.py
Normal file
86
lightsync/api/timeline.py
Normal file
@@ -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"}
|
||||||
110
lightsync/frontend/timeline/commands.js
Normal file
110
lightsync/frontend/timeline/commands.js
Normal file
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
36
lightsync/frontend/timeline/history.js
Normal file
36
lightsync/frontend/timeline/history.js
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
@@ -42,11 +42,12 @@ def create_app() -> FastAPI:
|
|||||||
app = FastAPI(title="LightSync", lifespan=lifespan, redirect_slashes=False)
|
app = FastAPI(title="LightSync", lifespan=lifespan, redirect_slashes=False)
|
||||||
|
|
||||||
# API routes (registered before static mount — see Pattern 7)
|
# 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(shows.router, prefix="/api/shows")
|
||||||
app.include_router(devices.router, prefix="/api/devices")
|
app.include_router(devices.router, prefix="/api/devices")
|
||||||
app.include_router(ws.router)
|
app.include_router(ws.router)
|
||||||
app.include_router(audio.router, prefix="/api/audio")
|
app.include_router(audio.router, prefix="/api/audio")
|
||||||
|
app.include_router(timeline.router, prefix="/api")
|
||||||
|
|
||||||
# Serve uploaded audio files so <audio src=...> can load them
|
# Serve uploaded audio files so <audio src=...> can load them
|
||||||
shows_dir = Path("/app/shows")
|
shows_dir = Path("/app/shows")
|
||||||
|
|||||||
Reference in New Issue
Block a user