From a75e9151ed2aae7f6d65aa7ac013a1fa5c032e1e Mon Sep 17 00:00:00 2001 From: th Date: Mon, 20 Apr 2026 20:34:44 +0200 Subject: [PATCH] macro-clips slice 1: types + store for buttons, scripts, markers - ButtonClip/ButtonInstance, ScriptTrack/ScriptFrame, Marker, Action union - new optional scene collections (buttonClips, buttonInstances, scriptTracks, scriptFrames, markers); normalizeScene initializes them on load - store actions for all new entities, marker name + button label uniqueness, time clamping, cascading deletes (script frames with their track, button instances with their clip), script tracks join trackOrder + setTrackLocked - design doc docs/macro-clips-and-scripts.md - tests cover add/remove/rename/retime paths, uniqueness, sort order, label collisions, and normalization Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/macro-clips-and-scripts.md | 437 ++++++++++++++++++++++++++++++++ src/devtools/tests.ts | 239 +++++++++++++++++ src/stores/scene.ts | 193 ++++++++++++++ src/types/scene.ts | 83 ++++++ 4 files changed, 952 insertions(+) create mode 100644 docs/macro-clips-and-scripts.md diff --git a/docs/macro-clips-and-scripts.md b/docs/macro-clips-and-scripts.md new file mode 100644 index 0000000..c1922bc --- /dev/null +++ b/docs/macro-clips-and-scripts.md @@ -0,0 +1,437 @@ +# Macro Clips & Scripts — Featureset Plan + +Interaktive Buttons plus eine kleine Script-Sprache, mit der Timelines +gesteuert werden können. + +## Getroffene Entscheidungen + +1. Buttons sind **Definitionen + Instanzen** (wie normale Clips): einmal + definieren, beliebig oft auf Clip-Tracks instanziieren. +2. `pressed` ist **rein visueller State**. Aktionen hängen an einem + `onClick` pro Button-**Instanz** (nicht pro Definition), damit derselbe + Button in unterschiedlichen Kontexten andere Dinge tun kann. +3. Script-Actions feuern **nur bei vorwärts laufendem Playback**, nicht + beim Scrubben und nicht beim Rückwärtslaufen. + +## Datenmodell + +### Clip mit Varianten + +```ts +type Clip = AnimationClip | ButtonClip + +interface AnimationClip { + kind: 'animation' // default, bestehendes Verhalten + id: string + name: string + duration: number + layers: Layer[] +} + +type ButtonState = 'normal' | 'hover' | 'pressed' | 'disabled' + +interface ButtonClip { + kind: 'button' + id: string + name: string + states: Record // 4 Zellen, jeweils ein Stack + transitions?: StateTransition[] + hitArea?: { width: number; height: number; offsetX?: number; offsetY?: number } +} + +interface StateTransition { + from: ButtonState + to: ButtonState + duration: number + easing: Easing +} +``` + +### Instanz-Erweiterung + +```ts +interface ButtonInstance extends ClipInstance { + // clipId zeigt auf einen ButtonClip; Trim ist irrelevant + onClick?: Action[] + disabled?: boolean + // runtime-only state, nicht persistiert: hover / pressed / disabled flags +} +``` + +### Script-Track + +```ts +interface ScriptTrack { + id: string + name?: string + color?: string | null + locked?: boolean +} + +interface ScriptFrame { + id: string + trackId: string + time: number + actions: Action[] + // optional label zum Anzeigen im Timeline-Badge + label?: string +} +``` + +### Marker + +Marker sind benannte Zeit-Bookmarks auf der Haupt-Timeline, die als +Sprungziele dienen — sowohl in der DSL (`jump @intro`) als auch über +die UI (Doppelklick auf Marker springt dort hin). + +```ts +interface Marker { + id: string + name: string // muss unique innerhalb der Szene sein (validate!) + time: number + color?: string | null +} +``` + +**Scoping:** Marker leben auf Szenen-Ebene. Innerhalb eines +`AnimationClip`-Edits sind sie nicht sichtbar/nutzbar (Animation-Clips +haben lokale Zeit). Später evtl. `clip.markers` ergänzen. + +**Rendering:** Marker erscheinen auf dem Scrub-Ruler oberhalb der +Keyframes als kleine Fahnen mit Label. Per Default in der Akzentfarbe, +optional individuell einfärbbar. Klick setzt Playhead dorthin, +Rechtsklick öffnet Menü (umbenennen, färben, löschen). + +**Namensregeln:** `[a-zA-Z_][a-zA-Z0-9_-]*`, case-sensitive, unique. +Bei Konflikt zeigt UI einen Fehler, DSL-Parser löst den Namen zur +Laufzeit auf — fehlender Name = Action wird silent no-op (mit Warning +im Dev-Overlay). + +### Action-Union + +```ts +type Action = + | { type: 'play' } + | { type: 'pause' } + | { type: 'rewindToLoop' } // Playhead → playbackIn + | { type: 'jumpTo'; time: number } // absolute Zeit + | { type: 'jumpToFrame'; frame: number } // frame = time * fps + | { type: 'jumpToMarker'; name: string } // Marker-Name → time + | { type: 'setButtonState'; instanceId: string; state: ButtonState } + | { type: 'setButtonDisabled'; instanceId: string; disabled: boolean } + | { type: 'script'; source: string } // DSL, Phase 7 +``` + +**Button-Instanz-Referenzen** via `instanceId` (opak) funktionieren im +Code, aber für den User unhandlich. Jede `ButtonInstance` bekommt +deshalb zusätzlich ein optionales `label: string` (unique-scoped), +und Action-Editor-Dropdowns zeigen Labels statt IDs. DSL referenziert +`btn:label`. + +Szene-Erweiterung: +- `scene.scriptTracks?: ScriptTrack[]` +- `scene.scriptFrames?: ScriptFrame[]` +- `scene.markers?: Marker[]` +- `scene.trackOrder` kann `ScriptTrack`-IDs enthalten. + +## Script-Sprache (Phase 7) + +Zeilenbasierte Mini-DSL, parsed zu `Action[]`. Comments beginnen mit +`//`. Argumente space-separated. Tokens: + +| Token | Bedeutung | +| -------------- | --------------------------------------- | +| `1.5` | Zeit in Sekunden | +| `#135` | Frame-Nummer | +| `@intro` | Marker-Name | +| `btn:label` | Button-Instanz (über ihr Label) | +| `play`/`pause` | Bare-Command | + +MVP-Beispiele: + +``` +play +pause +rewind // → rewindToLoop +jump 2.5 // Sekunden +jump #135 // Frame +jump @intro // Marker +state btn:muteToggle pressed +enable btn:playButton +disable btn:playButton +// kommentar, wird ignoriert +``` + +Erweiterung später (außerhalb MVP): `if state(btn:mute) == pressed +then ... else ...`, Variablen, lokale Labels im Script. Fürs erste +flache Action-Liste. + +**Parse-Fehler** werden pro Frame/Button gesammelt und im +Properties-Panel als roter Hinweis angezeigt; Runtime skippt +fehlerhafte Frames/Buttons ohne zu crashen. + +## Runtime + +### evalAction + +Zentrale reine Funktion `evalAction(action, runtime)`, shared zwischen +Editor und Export-Runtime. `runtime`-Interface: + +```ts +interface ActionRuntime { + fps: number + playbackIn: number + playbackOut: number + duration: number + markers: Record // name → time, vorauflösend + setPlayhead(t: number): void + play(): void + pause(): void + setButtonState(instanceId: string, state: ButtonState): void + setButtonDisabled(instanceId: string, disabled: boolean): void + warn(msg: string): void // für devtools / console +} +``` + +`evalAction` macht Range-Clamping (Zeit in `[0, duration]`), +Marker-Lookup, und dispatcht auf `runtime.*`. + +### Script-Track-Evaluation + +Im `tick`: Frames deren `time` im Interval `(prevPlayhead, +nextPlayhead]` liegen feuern einmalig, sortiert nach `time`. Kein +Firing bei: +- Rückwärts-Scrubbing (`nextPlayhead < prevPlayhead`) +- Loop-Wrap (wenn Playhead von `playbackOut` zurück auf `playbackIn` + springt — das Intervall wird als Reset behandelt) +- Manuellen `setPlayhead` Aufrufen (scrub, keyboard-jump, Marker-Klick) + +Das heißt: Firing nur bei natürlichem Vorwärts-`tick`. `lastTickTime` +speichert den vorherigen Playhead im Store. + +### Button-Rendering + +- Render den `states[currentState]` Layer-Stack. +- State-Wechsel → passende Transition suchen (matching `from`/`to`). + Wenn vorhanden: `interpProgress = 0..1` über `duration * easing`, + zwischen den beiden State-Stacks per layer-id matchen und blenden. + Wenn kein Match: Cross-Fade zwischen den gesamten Stacks. +- Kein Match zwischen `disabled` und irgendwas → instant switch (keine + Transition für Disable-Toggle). + +### Button-Pointer-Handling + +- Hit-Area: bbox aller sichtbaren Layer im `normal`-State (cached), + überschreibbar per `ButtonClip.hitArea`. +- Event-Logik (State-Machine): + - `mouseenter` bei state=`normal` → `hover`. + - `mouseleave` bei state=`hover` oder `pressed` → `normal` (drag-out + cancelt den Click — Standard-HTML-Button-Verhalten). + - `mousedown` bei state=`hover` → `pressed`. + - `mouseup` bei state=`pressed` innerhalb der Hit-Area → + `onClick` feuern, dann → `hover`. + - state=`disabled` blockt alle Pointer-Events. +- `setButtonState`-Action überschreibt die state-machine (für + programmatische Zustandswechsel). Nächster natürlicher Pointer-Event + kann wieder übernehmen. +- `disabled`-Action setzt persistent `state=disabled` bis + `enable`-Action oder manueller Reset. + +## Editor UI + +### Sidebar +- „New Button" → Dialog, erstellt `ButtonClip` mit vier leeren State-Slots + und einem Default-Rect im `normal`-State. +- Button-Definitionen erscheinen wie normale Clips in der Clipart-Liste, + mit kleinem Badge-Icon zur Unterscheidung. + +### Button-Edit-Modus +- Wie `enterClip`, aber mit State-Tabs oben: `N / H / P / D`. Jeder Tab + editiert den eigenen Layer-Stack. Layer-Namen sollten zwischen States + matchen, damit Transitions sie paaren können (Info-Tooltip bei + mismatch). +- Unter den Tabs: Liste der definierten Transitions mit Add/Remove. +- Hit-Area-Overlay als Gizmo (draggable rect) wenn im Button-Edit aktiv. + +### Properties-Panel — ButtonInstance +- Label-Feld (unique). +- `onClick` Action-Editor: Liste mit Add/Up/Down/Remove, pro Action + eine type-spezifische Param-Form. +- Disabled-Checkbox (initial state). + +### Timeline + +**Script-Track-Row:** +- Kleiner als ClipTrack (kein großer Header), Frames als orange Dots + mit Action-Count-Badge (`3` bei drei Actions). +- Klick auf Frame → Action-Dialog. +- „Add Script Track" im Timeline-Add-Menü. + +**ButtonClip-Instanz:** +- Vier nebeneinanderliegende Zellen (statt einer Leiste), jede ~2× + so breit wie ein normaler ClipBlock-Frame, beschriftet mit + `N / H / P / D`. +- Active-state (zur Laufzeit) wird visuell hervorgehoben. +- Drag/Drop funktioniert auf ganze Instanz — einzelne Zellen sind + nicht per Drag verschiebbar. + +**Marker:** +- Scrub-Ruler bekommt einen zweiten Layer über den Ticks für + Marker-Fahnen (Dreieck/Flag mit Namen rechts daneben). +- Rechtsklick auf leere Ruler-Fläche → „Add marker here". +- Shortcut `m` → Marker am Playhead hinzufügen, direkt mit + Rename-Prompt. +- Doppelklick auf Marker = rename, Drag = move, Delete/Backspace + (Marker-selektiert) = remove. +- Marker-Klick (einfach) springt Playhead dorthin. + +### Keyboard-Shortcuts +- `m` — Marker am Playhead. +- (bestehend: `i`, `o`, `r`, `space`, `shift-space`.) + +## Slicing (Abarbeitungs-Reihenfolge) + +### Slice 1 — Datenmodell + Store +- Types einführen (`Clip` als Union mit `kind: 'animation' | 'button'`, + `ButtonClip`, `ButtonInstance`, `ScriptTrack`, `ScriptFrame`, + `Marker`, `Action`). +- `normalizeScene`: setzt `kind: 'animation'` auf bestehende Clips, + initialisiert neue Arrays (`scriptTracks`, `scriptFrames`, + `markers`), validiert Marker-Namen-Uniqueness. +- Store-Actions: + - Button: `addButtonClip`, `updateButtonClip`, + `addButtonInstance`, `setButtonInstanceLabel`, + `setButtonOnClick`, `setButtonDisabled`, `removeButtonClip`. + - Script: `addScriptTrack`, `removeScriptTrack`, + `addScriptFrame`, `removeScriptFrame`, `setScriptFrameActions`, + `setScriptFrameTime`. + - Marker: `addMarker`, `removeMarker`, `renameMarker`, + `setMarkerTime`, `setMarkerColor`. +- Tests für alle Store-Actions und normalizeScene-Migration. + +### Slice 2 — Marker-UI +- Scrub-Ruler bekommt Marker-Layer mit Flags + Labels. +- Shortcut `m`, Rechtsklick-Menü, Drag/Doppelklick/Delete. +- Marker-Panel im Document-Settings (Liste mit Edit). +- Marker sind reine Navigations-Hilfen — Actions kommen erst später, + aber das Datenmodell ist bereit. + +### Slice 3 — Timeline-Rendering (Buttons + Scripts) +- Script-Track-Row (analog zu ClipTrack-Row, eigener Renderer). +- ButtonClip-Instanzen: 4-Zellen-Darstellung. +- Drag/Drop von ButtonClip-Definition auf Clip-Tracks. +- Keine Edit-/Runtime-Features — nur Sichtbarkeit. + +### Slice 4 — Editor-Dialoge +- Button-Definition-Dialog + Enter/Exit-Button-Edit-Modus mit + State-Tabs. +- Hit-Area-Gizmo im Button-Edit. +- Properties-Panel für ButtonInstance (Label, onClick-Editor, + Disabled). +- Script-Frame-Dialog (Action-Liste + type-specific Forms). +- Action-Typ-Dropdowns zeigen Marker-Namen + Button-Labels statt IDs. + +### Slice 5 — Runtime (Editor) +- `src/lib/actions.ts` mit `evalAction(action, runtime)`. +- `useSceneStore.tick`: ScriptFrames feuern nach Forward-Only-Regel. +- CanvasStage: Pointer-Handler und State-Machine für + Button-Instanzen, onClick-Dispatch via `evalAction`. +- Buttons rendern den aktuellen State-Stack (ohne Transitions). + +### Slice 6 — Runtime (Export) +- Port `evalAction` + Button/Script-Handling zu `exportPlayer.ts` und + `server/render.html` (shared source via Template-Literal aus + `actions.ts`). +- RUNTIME_VERSION bump. +- Export-Tests (headless Eval mit mock runtime). + +### Slice 7 — Transitions +- Transition-Definitions-UI im Button-Edit. +- Layer-Matching per id/name; Interpolations-Logik in Renderer und + Export-Runtime. +- Crossfade als Fallback. + +### Slice 8 — Script-DSL +- Parser (`src/lib/scriptDsl.ts`), klein, selbst-geschrieben, mit + Error-Reporting. +- `Action` bekommt `type: 'script'` → zur Compile-Zeit (beim Save) in + Actions expandiert, Source bleibt für Edit erhalten. +- Properties-Panel kriegt Code-Editor (Textarea + Parse-Errors). +- DSL-Tests (Parser + Roundtrip). + +## Offene Fragen + +1. **Mehrere Script-Tracks parallel?** Vermutlich ja, für Organisation. + Default: einer wird automatisch angelegt wenn der User die erste + Script-Aktion anlegt. +2. **Button-Hit-Area:** automatisch bbox des `normal` States oder muss + explizit definiert werden? Vorschlag: bbox als Default, manuell + überschreibbar im Button-Edit. +3. **Script-Aktionen bei Loop-Wrap:** entschieden → nein, Wrap = Reset, + kein Firing. +4. **Keyboard-Shortcuts für Buttons:** nice-to-have, als eigene Action + `triggerButton` plus Keymap? Später. +5. **Persistenz der Runtime-States** (welcher Button ist aktuell + pressed): nein, nicht im Projektfile. +6. **Button-Toggle-Verhalten:** momentary vs toggle? Vorschlag: erstmal + nur momentary, Toggle baut der User per Script + (click → if state==normal then setState pressed else setState normal). + Später evtl. dediziertes `behavior: 'momentary' | 'toggle'` Feld. +7. **Marker im Export:** sollen Marker in der exportierten Runtime + sichtbar sein (z.B. als Overlay im Dev-Mode)? Standardmäßig nein — + Marker sind Authoring-Tool, für Runtime nur als Sprungziele relevant. +8. **Label-Uniqueness für Button-Instanzen:** Scene-weit unique oder + nur pro ClipTrack? Vorschlag: Scene-weit, damit Scripts aus jedem + Kontext referenzieren können. +9. **Marker als Script-Frame-Ersatz?** Ein Marker mit Actions wäre + quasi ein Script-Frame. Vorschlag: nein, separate Konzepte — + Marker sind passive Sprungziele, Script-Frames sind aktive Aktionen. + Beide benutzen aber die gleiche Action-Union. +10. **jumpTo im Animation-Clip-Context:** wenn ein Button in einem + AnimationClip-Edit sitzt und `jumpTo` ausführt — meint das die + Haupt-Timeline oder die Clip-Timeline? Vorschlag: immer die + Haupt-Timeline (dahin scrollt der Playhead sowieso). Clip-interne + Sprünge wären separate Actions. + +## Testing-Strategie + +Jede Slice landet mit Tests: + +- **Slice 1:** Store-Actions komplett abgedeckt, normalizeScene- + Migration (alte Clips → `kind:'animation'`), Marker-Uniqueness- + Validierung. +- **Slice 2:** Marker-Scrub-Ruler-Interaktion (add/move/delete), `m` + shortcut. +- **Slice 3:** Timeline-Rendering per Snapshot/DOM-Assertion. +- **Slice 4:** Dialog-Flow-Tests (Action hinzufügen/entfernen, Typ + ändern). +- **Slice 5:** `evalAction` als reine Funktion mit Mock-Runtime — alle + Action-Typen abgedeckt; ScriptFrame-Firing-Regeln + (Forward/Loop-Wrap/Scrub). +- **Slice 6:** Export-Runtime testet die gleichen Fälle wie Slice 5, + aber über generierten JS-Code (Fixture-Based). +- **Slice 7:** Transition-Interpolation an konkreten Beispielen + (layer-match vs crossfade). +- **Slice 8:** DSL-Parser-Tests (gültige/ungültige Zeilen, Roundtrip + `source → actions → source`). + +## Abhängigkeiten & Risiken + +- `exportPlayer.ts` und `server/render.html` werden deutlich komplexer, + weil sie Pointer-Handling und einen Action-Interpreter kriegen. → + Shared module in `src/lib/actions.ts` das beide Varianten konsumieren + können (Template für Export-Runtime). +- Der DSL-Parser muss klein bleiben, sonst frisst das Budget. +- Transitions zwischen States brauchen ein Layer-Matching (id/name) — + wenn der User States mit unterschiedlichen Layer-Sets baut, gibt's + nichts sauberes zum interpolieren → Fallback crossfade. +- Scene-Schema wächst merklich (Clips als Union, neue Arrays). Alte + Projektdateien müssen via `normalizeScene` migrieren — wir bumpen + `Scene.version` nicht, solange wir defaults korrekt befüllen. Wenn + Breaking-Changes nötig werden, dann Version-Bump + explizite + Migration. +- ButtonClip-States sind im MVP **statische Posen** (Layer-Stack zu + t=0). Intern-animierte States (z.B. pulsierender Hover) sind + bewusst raus — könnten später als „State ist selbst ein Mini-Clip + mit eigener Duration" ergänzt werden, braucht dann aber + Zustands-Scheduling. diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts index 9e3ec1e..06eb7af 100644 --- a/src/devtools/tests.ts +++ b/src/devtools/tests.ts @@ -2987,4 +2987,243 @@ export function registerAllTests(): void { expect(outside[3] > 200).toBe(true) }) }) + + suite('scene store — markers', () => { + test('addMarker defaults the name when none provided', () => { + const s = makeFreshStore() + const m = s.addMarker(2) + expect(m.name).toBe('Marker') + expect(m.time).toBe(2) + expect(s.scene.markers).toHaveLength(1) + }) + + test('addMarker de-dupes colliding names with suffix', () => { + const s = makeFreshStore() + s.addMarker(1, 'Intro') + s.addMarker(2, 'Intro') + s.addMarker(3, 'Intro') + const names = (s.scene.markers ?? []).map((m) => m.name) + expect(names).toEqual(['Intro', 'Intro 2', 'Intro 3']) + }) + + test('addMarker clamps time to [0, duration]', () => { + const s = makeFreshStore() + s.setDuration(10) + const a = s.addMarker(-5) + const b = s.addMarker(99) + expect(a.time).toBe(0) + expect(b.time).toBe(10) + }) + + test('addMarker keeps the list sorted by time', () => { + const s = makeFreshStore() + s.addMarker(5, 'C') + s.addMarker(1, 'A') + s.addMarker(3, 'B') + const times = (s.scene.markers ?? []).map((m) => m.time) + expect(times).toEqual([1, 3, 5]) + }) + + test('renameMarker succeeds on unique, rejects duplicates', () => { + const s = makeFreshStore() + const a = s.addMarker(1, 'A') + s.addMarker(2, 'B') + expect(s.renameMarker(a.id, 'A2')).toBe(true) + expect(s.renameMarker(a.id, 'B')).toBe(false) + const byId = (s.scene.markers ?? []).find((m) => m.id === a.id) + expect(byId?.name).toBe('A2') + }) + + test('removeMarker drops the entry', () => { + const s = makeFreshStore() + const m = s.addMarker(1) + s.removeMarker(m.id) + expect(s.scene.markers).toHaveLength(0) + }) + + test('setMarkerTime clamps and re-sorts', () => { + const s = makeFreshStore() + s.setDuration(10) + const a = s.addMarker(1, 'A') + const b = s.addMarker(2, 'B') + s.setMarkerTime(a.id, 99) + const sortedNames = (s.scene.markers ?? []).map((m) => m.name) + expect(sortedNames).toEqual(['B', 'A']) + const updated = (s.scene.markers ?? []).find((m) => m.id === a.id)! + expect(updated.time).toBe(10) + void b + }) + + test('setMarkerColor sets and clears', () => { + const s = makeFreshStore() + const m = s.addMarker(1, 'A') + s.setMarkerColor(m.id, '#ff8800') + const get = () => s.scene.markers?.find((x) => x.id === m.id) + expect(get()?.color).toBe('#ff8800') + s.setMarkerColor(m.id, null) + expect(get()?.color).toBe(undefined) + }) + }) + + suite('scene store — script tracks & frames', () => { + test('addScriptTrack appends and adds to trackOrder', () => { + const s = makeFreshStore() + const t = s.addScriptTrack('Triggers') + expect(s.scene.scriptTracks).toHaveLength(1) + expect(s.scene.trackOrder?.includes(t.id)).toBe(true) + }) + + test('addScriptFrame clamps time and returns frame', () => { + const s = makeFreshStore() + s.setDuration(10) + const t = s.addScriptTrack() + const f = s.addScriptFrame(t.id, 99, [{ type: 'pause' }]) + expect(f).toBeTruthy() + expect(f!.time).toBe(10) + expect(f!.actions).toHaveLength(1) + }) + + test('addScriptFrame returns null for unknown trackId', () => { + const s = makeFreshStore() + const f = s.addScriptFrame('no-such-id', 1) + expect(f).toBe(null) + }) + + test('script frames stay sorted by time', () => { + const s = makeFreshStore() + const t = s.addScriptTrack() + s.addScriptFrame(t.id, 5) + s.addScriptFrame(t.id, 1) + s.addScriptFrame(t.id, 3) + const times = (s.scene.scriptFrames ?? []).map((f) => f.time) + expect(times).toEqual([1, 3, 5]) + }) + + test('removeScriptTrack cascades its frames and trackOrder entry', () => { + const s = makeFreshStore() + const t = s.addScriptTrack() + s.addScriptFrame(t.id, 1) + s.addScriptFrame(t.id, 2) + s.removeScriptTrack(t.id) + expect(s.scene.scriptTracks).toHaveLength(0) + expect(s.scene.scriptFrames).toHaveLength(0) + expect(s.scene.trackOrder?.includes(t.id)).toBe(false) + }) + + test('setScriptFrameActions replaces actions', () => { + const s = makeFreshStore() + const t = s.addScriptTrack() + const f = s.addScriptFrame(t.id, 1, [{ type: 'play' }])! + s.setScriptFrameActions(f.id, [{ type: 'pause' }, { type: 'rewindToLoop' }]) + const updated = s.scene.scriptFrames?.find((x) => x.id === f.id) + expect(updated?.actions).toHaveLength(2) + expect(updated?.actions[0].type).toBe('pause') + }) + + test('setScriptFrameTime clamps and re-sorts', () => { + const s = makeFreshStore() + s.setDuration(10) + const t = s.addScriptTrack() + const a = s.addScriptFrame(t.id, 1)! + const b = s.addScriptFrame(t.id, 2)! + s.setScriptFrameTime(a.id, 99) + const times = (s.scene.scriptFrames ?? []).map((f) => f.time) + expect(times).toEqual([2, 10]) + void b + }) + }) + + suite('scene store — button clips & instances', () => { + test('addButtonClip creates 4 empty state stacks', () => { + const s = makeFreshStore() + const bc = s.addButtonClip('Play') + expect(bc.name).toBe('Play') + expect(bc.states.normal).toHaveLength(0) + expect(bc.states.hover).toHaveLength(0) + expect(bc.states.pressed).toHaveLength(0) + expect(bc.states.disabled).toHaveLength(0) + }) + + test('addButtonInstance validates clip and track ids', () => { + const s = makeFreshStore() + const bc = s.addButtonClip('B') + const ct = s.addClipTrack() + expect(s.addButtonInstance('no-clip', ct.id, 0)).toBe(null) + expect(s.addButtonInstance(bc.id, 'no-track', 0)).toBe(null) + const inst = s.addButtonInstance(bc.id, ct.id, 0) + expect(inst).toBeTruthy() + expect(s.scene.buttonInstances).toHaveLength(1) + }) + + test('removeButtonClip cascades its instances', () => { + const s = makeFreshStore() + const bc = s.addButtonClip('B') + const ct = s.addClipTrack() + s.addButtonInstance(bc.id, ct.id, 0) + s.addButtonInstance(bc.id, ct.id, 1) + s.removeButtonClip(bc.id) + expect(s.scene.buttonClips).toHaveLength(0) + expect(s.scene.buttonInstances).toHaveLength(0) + }) + + test('setButtonInstanceLabel enforces scene-wide uniqueness', () => { + const s = makeFreshStore() + const bc = s.addButtonClip('B') + const ct = s.addClipTrack() + const a = s.addButtonInstance(bc.id, ct.id, 0)! + const b = s.addButtonInstance(bc.id, ct.id, 1)! + expect(s.setButtonInstanceLabel(a.id, 'play')).toBe(true) + expect(s.setButtonInstanceLabel(b.id, 'play')).toBe(false) + expect(s.setButtonInstanceLabel(b.id, 'mute')).toBe(true) + const getA = s.scene.buttonInstances?.find((x) => x.id === a.id) + const getB = s.scene.buttonInstances?.find((x) => x.id === b.id) + expect(getA?.label).toBe('play') + expect(getB?.label).toBe('mute') + }) + + test('setButtonInstanceOnClick + setButtonInstanceDisabled', () => { + const s = makeFreshStore() + const bc = s.addButtonClip('B') + const ct = s.addClipTrack() + const inst = s.addButtonInstance(bc.id, ct.id, 0)! + s.setButtonInstanceOnClick(inst.id, [{ type: 'pause' }]) + s.setButtonInstanceDisabled(inst.id, true) + const get = () => s.scene.buttonInstances?.find((x) => x.id === inst.id) + expect(get()?.onClick).toHaveLength(1) + expect(get()?.disabled).toBe(true) + s.setButtonInstanceOnClick(inst.id, []) + s.setButtonInstanceDisabled(inst.id, false) + expect(get()?.onClick).toBe(undefined) + expect(get()?.disabled).toBe(undefined) + }) + }) + + suite('scene store — macro model normalization', () => { + test('loadScene initializes new arrays when missing', () => { + const s = makeFreshStore() + const bare = { + version: 1 as const, + canvas: { width: 640, height: 480, background: '#ffffff' }, + duration: 5, + fps: 30, + assets: [], + layers: [], + } + s.loadScene(bare) + expect(s.scene.buttonClips).toEqual([]) + expect(s.scene.buttonInstances).toEqual([]) + expect(s.scene.scriptTracks).toEqual([]) + expect(s.scene.scriptFrames).toEqual([]) + expect(s.scene.markers).toEqual([]) + }) + + test('setTrackLocked also works on script tracks', () => { + const s = makeFreshStore() + const t = s.addScriptTrack() + s.setTrackLocked(t.id, true) + expect(s.scene.scriptTracks?.find((x) => x.id === t.id)?.locked).toBe(true) + s.setTrackLocked(t.id, false) + expect(s.scene.scriptTracks?.find((x) => x.id === t.id)?.locked).toBe(undefined) + }) + }) } diff --git a/src/stores/scene.ts b/src/stores/scene.ts index 480d76f..6f80856 100644 --- a/src/stores/scene.ts +++ b/src/stores/scene.ts @@ -1,16 +1,22 @@ import { defineStore } from 'pinia' import type { + Action, AnimatableProps, Asset, BackgroundVideo, + ButtonClip, + ButtonInstance, Clip, ClipInstance, ClipTrack, Easing, Keyframe, Layer, + Marker, MaskMode, Scene, + ScriptFrame, + ScriptTrack, TrackGroup, } from '@/types/scene' import { DEFAULT_BASE_PROPS } from '@/types/scene' @@ -112,6 +118,11 @@ function normalizeScene(scene: Scene): Scene { scene.clipTracks ||= [] scene.clipInstances ||= [] scene.groups ||= [] + scene.buttonClips ||= [] + scene.buttonInstances ||= [] + scene.scriptTracks ||= [] + scene.scriptFrames ||= [] + scene.markers ||= [] if (!scene.canvas.background) scene.canvas.background = '#ffffff' const known = new Set() @@ -1001,12 +1012,194 @@ export const useSceneStore = defineStore('scene', { else delete clipTrack.locked return } + const scriptTrack = this.scene.scriptTracks?.find((t) => t.id === trackId) + if (scriptTrack) { + if (locked) scriptTrack.locked = true + else delete scriptTrack.locked + return + } const group = this.scene.groups?.find((g) => g.id === trackId) if (group) { if (locked) group.locked = true else delete group.locked } }, + + // -------- Markers -------------------------------------------------- + + addMarker(time: number, name?: string, color?: string | null): Marker { + const t = Math.max(0, Math.min(this.scene.duration, time)) + const markers = (this.scene.markers ||= []) + const used = new Set(markers.map((m) => m.name)) + let base = (name ?? '').trim() || 'Marker' + let final = base + let i = 2 + while (used.has(final)) final = `${base} ${i++}` + const m: Marker = { id: uid(), name: final, time: t } + if (color != null) m.color = color + markers.push(m) + markers.sort((a, b) => a.time - b.time) + return m + }, + removeMarker(id: string) { + if (!this.scene.markers) return + this.scene.markers = this.scene.markers.filter((m) => m.id !== id) + }, + renameMarker(id: string, name: string): boolean { + const markers = this.scene.markers + if (!markers) return false + const target = markers.find((m) => m.id === id) + if (!target) return false + const next = name.trim() + if (!next) return false + if (next === target.name) return true + if (markers.some((m) => m.id !== id && m.name === next)) return false + target.name = next + return true + }, + setMarkerTime(id: string, time: number) { + const marker = this.scene.markers?.find((m) => m.id === id) + if (!marker) return + marker.time = Math.max(0, Math.min(this.scene.duration, time)) + this.scene.markers?.sort((a, b) => a.time - b.time) + }, + setMarkerColor(id: string, color: string | null) { + const marker = this.scene.markers?.find((m) => m.id === id) + if (!marker) return + if (color == null) delete marker.color + else marker.color = color + }, + + // -------- Script tracks & frames ----------------------------------- + + addScriptTrack(name?: string): ScriptTrack { + const track: ScriptTrack = { id: uid(), name } + ;(this.scene.scriptTracks ||= []).push(track) + ;(this.scene.trackOrder ||= []).push(track.id) + return track + }, + removeScriptTrack(id: string) { + if (this.scene.scriptTracks) { + this.scene.scriptTracks = this.scene.scriptTracks.filter((t) => t.id !== id) + } + if (this.scene.scriptFrames) { + this.scene.scriptFrames = this.scene.scriptFrames.filter((f) => f.trackId !== id) + } + if (this.scene.trackOrder) { + this.scene.trackOrder = this.scene.trackOrder.filter((tid) => tid !== id) + } + }, + setScriptTrackName(id: string, name: string) { + const track = this.scene.scriptTracks?.find((t) => t.id === id) + if (track) track.name = name.trim() || undefined + }, + addScriptFrame( + trackId: string, + time: number, + actions: Action[] = [], + label?: string, + ): ScriptFrame | null { + const track = this.scene.scriptTracks?.find((t) => t.id === trackId) + if (!track) return null + const t = Math.max(0, Math.min(this.scene.duration, time)) + const frame: ScriptFrame = { + id: uid(), + trackId, + time: t, + actions: [...actions], + } + if (label) frame.label = label + ;(this.scene.scriptFrames ||= []).push(frame) + this.scene.scriptFrames.sort((a, b) => a.time - b.time) + return frame + }, + removeScriptFrame(id: string) { + if (!this.scene.scriptFrames) return + this.scene.scriptFrames = this.scene.scriptFrames.filter((f) => f.id !== id) + }, + setScriptFrameActions(id: string, actions: Action[]) { + const frame = this.scene.scriptFrames?.find((f) => f.id === id) + if (frame) frame.actions = [...actions] + }, + setScriptFrameTime(id: string, time: number) { + const frame = this.scene.scriptFrames?.find((f) => f.id === id) + if (!frame) return + frame.time = Math.max(0, Math.min(this.scene.duration, time)) + this.scene.scriptFrames?.sort((a, b) => a.time - b.time) + }, + + // -------- Button clips & instances --------------------------------- + + addButtonClip(name?: string): ButtonClip { + const clip: ButtonClip = { + id: uid(), + name: name?.trim() || 'Button', + states: { normal: [], hover: [], pressed: [], disabled: [] }, + } + ;(this.scene.buttonClips ||= []).push(clip) + return clip + }, + removeButtonClip(id: string) { + if (this.scene.buttonClips) { + this.scene.buttonClips = this.scene.buttonClips.filter((c) => c.id !== id) + } + if (this.scene.buttonInstances) { + this.scene.buttonInstances = this.scene.buttonInstances.filter( + (bi) => bi.buttonClipId !== id, + ) + } + }, + setButtonClipName(id: string, name: string) { + const clip = this.scene.buttonClips?.find((c) => c.id === id) + if (clip) clip.name = name.trim() || clip.name + }, + addButtonInstance( + buttonClipId: string, + clipTrackId: string, + startTime: number, + ): ButtonInstance | null { + if (!this.scene.buttonClips?.some((c) => c.id === buttonClipId)) return null + if (!this.scene.clipTracks?.some((t) => t.id === clipTrackId)) return null + const inst: ButtonInstance = { + id: uid(), + buttonClipId, + clipTrackId, + startTime: Math.max(0, Math.min(this.scene.duration, startTime)), + } + ;(this.scene.buttonInstances ||= []).push(inst) + return inst + }, + removeButtonInstance(id: string) { + if (!this.scene.buttonInstances) return + this.scene.buttonInstances = this.scene.buttonInstances.filter((bi) => bi.id !== id) + }, + setButtonInstanceLabel(id: string, label: string): boolean { + const instances = this.scene.buttonInstances + if (!instances) return false + const target = instances.find((bi) => bi.id === id) + if (!target) return false + const next = label.trim() + if (!next) { + delete target.label + return true + } + if (next === target.label) return true + if (instances.some((bi) => bi.id !== id && bi.label === next)) return false + target.label = next + return true + }, + setButtonInstanceOnClick(id: string, actions: Action[]) { + const target = this.scene.buttonInstances?.find((bi) => bi.id === id) + if (!target) return + if (actions.length) target.onClick = [...actions] + else delete target.onClick + }, + setButtonInstanceDisabled(id: string, disabled: boolean) { + const target = this.scene.buttonInstances?.find((bi) => bi.id === id) + if (!target) return + if (disabled) target.disabled = true + else delete target.disabled + }, setLayerSeparated(layerId: string, separated: boolean) { const layer = this.activeLayers.find((l) => l.id === layerId) if (!layer) return diff --git a/src/types/scene.ts b/src/types/scene.ts index 64da182..b2609ba 100644 --- a/src/types/scene.ts +++ b/src/types/scene.ts @@ -162,6 +162,84 @@ export interface BackgroundVideo { opacity?: number } +// ---- Macro clips & scripts ------------------------------------------------ + +export type ButtonState = 'normal' | 'hover' | 'pressed' | 'disabled' + +export interface StateTransition { + from: ButtonState + to: ButtonState + duration: number + easing: Easing +} + +export interface ButtonHitArea { + width: number + height: number + offsetX?: number + offsetY?: number +} + +export interface ButtonClip { + id: string + name: string + states: { + normal: Layer[] + hover: Layer[] + pressed: Layer[] + disabled: Layer[] + } + transitions?: StateTransition[] + hitArea?: ButtonHitArea +} + +export interface ButtonInstance { + id: string + buttonClipId: string + clipTrackId: string + startTime: number + /** Unique, user-facing handle used in dropdowns and script DSL. */ + label?: string + onClick?: Action[] + /** Initial disabled state at runtime start. */ + disabled?: boolean +} + +export interface ScriptTrack { + id: string + name?: string + color?: string | null + locked?: boolean +} + +export interface ScriptFrame { + id: string + trackId: string + time: number + actions: Action[] + label?: string +} + +export interface Marker { + id: string + name: string + time: number + color?: string | null +} + +export type Action = + | { type: 'play' } + | { type: 'pause' } + | { type: 'rewindToLoop' } + | { type: 'jumpTo'; time: number } + | { type: 'jumpToFrame'; frame: number } + | { type: 'jumpToMarker'; name: string } + | { type: 'setButtonState'; instanceId: string; state: ButtonState } + | { type: 'setButtonDisabled'; instanceId: string; disabled: boolean } + | { type: 'script'; source: string } + +// --------------------------------------------------------------------------- + export interface Scene { version: 1 canvas: { @@ -183,6 +261,11 @@ export interface Scene { trackOrder?: string[] groups?: TrackGroup[] backgroundVideo?: BackgroundVideo + buttonClips?: ButtonClip[] + buttonInstances?: ButtonInstance[] + scriptTracks?: ScriptTrack[] + scriptFrames?: ScriptFrame[] + markers?: Marker[] } export const DEFAULT_BASE_PROPS: AnimatableProps = {