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 = {
|