revert macro-clips button feature (keep markers + scripts)

The button authoring ended up too far from the intended model after
three correction passes. Pulling it out entirely so we can rebuild
from a clean spec. Markers, script tracks, script frames, the Action
editor, the Script DSL and the runtime for all of the above stay —
they work.

Removed:
- types: ButtonState, StateTransition, ButtonHitArea, ButtonClip,
  ButtonInstance, setButtonState/setButtonDisabled Action variants,
  Scene.buttonClips/buttonInstances
- store: activeButtonClip* getters, all add/remove/setButton* actions,
  seedDefaultButtonClip, enterButtonEdit/setActiveButtonState/
  exitButtonEdit, button-label resolution in actionRuntime
- useButtonRuntime composable, buttonBlend helper,
  ButtonInstanceDialog, sidebar Buttons section, Timeline button
  rendering + pointer handlers, CanvasStage button rendering +
  pointer state machine, exportPlayer button runtime + hit-testing
- DSL commands state/enable/disable and their parse errors
- 35 button-related tests + helpers; stale testing doc; stale design
  doc

Re-applied three uncommitted fixes that were unrelated to the button
work:
- clicking a marker no longer jumps the playhead (only drag / dblclick
  act on it)
- script tracks get a right-click context menu (rename + delete,
  color in the existing palette)
- onTracksPointerDown excludes .script-lane / .script-frame so
  clicks on empty lanes or on frame dots reach the frame-open path
  instead of being swallowed by box-select pointer capture

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-20 23:37:49 +02:00
1 parent 616973b0fa
commit 31604052ca
16 files changed
+84 -2809

No files matched your search

-437
View File
@@ -1,437 +0,0 @@
# 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<ButtonState, Layer[]> // 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<string, number> // 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.
-244
View File
@@ -1,244 +0,0 @@
# Macro Clips & Scripts — Testing Guide
Eine Walkthrough für die Features aus `docs/macro-clips-and-scripts.md`.
Start: App mit `npm run dev`, leeres Projekt öffnen („New Project" im
Welcome-Dialog).
---
## 1. Marker
**Ziel:** Sprungziele auf der Haupt-Timeline setzen und benennen.
1. Playhead an eine beliebige Stelle ziehen (z. B. t = 2 s).
2. `m` drücken → ein Marker wird an der Playhead-Position angelegt und ein
Rename-Prompt öffnet sich. Namen eingeben (z. B. `intro`), `Enter`.
3. Marker erscheint als kleine Fahne oben auf der Scrub-Ruler.
4. **Interaktionen am Marker:**
- Einfacher Klick → Playhead springt dort hin.
- Drag → Marker zeitlich verschieben.
- Doppelklick → Rename.
- Marker anwählen, `Backspace`/`Delete` → löscht ihn. `Esc` → entauswählt.
**Erwartung:**
- Mehrere Marker bleiben zeitlich sortiert.
- Zweiter Marker mit gleichem Namen bekommt automatisch Suffix (`intro 2`).
- Namen müssen Scene-weit unique sein (Rename zeigt Fehler-Dialog bei
Kollision und öffnet den Prompt erneut).
---
## 2. Script-Tracks & Action-Frames
**Ziel:** Zeit-gebundene Aktionen definieren, die bei Playback feuern.
1. Im Timeline-Header die **Code-Taste** (`</>`-Icon) klicken → ein neuer
Script-Track wird angelegt (kleinere Zeile als Clip-Tracks).
2. Auf der leeren Script-Lane an beliebiger Zeitposition klicken → ein
neuer Action-Frame wird erstellt und der **ScriptFrameDialog** öffnet
sich.
3. Im Dialog **Add action** drücken → ein Dropdown zeigt alle Action-Typen.
- Beispiel: `Pause` auswählen → nur Typ, keine Params nötig.
- Weitere Action hinzufügen: `Jump to marker` → Marker-Name aus dem
Dropdown wählen (dafür muss aus Schritt 1 ein Marker existieren).
4. Dialog schließen mit **Done**.
5. Mehrere Actions pro Frame sind erlaubt — die Zahl rechts neben dem
Frame-Dot zeigt deren Anzahl.
**Testen:**
- Playhead VOR den Action-Frame ziehen, dann `Space` (Play).
- Playback läuft, und **genau wenn der Playhead über den Frame hinausgeht**,
feuern die Actions in Listen-Reihenfolge.
- Beispiel `pause + jumpToMarker intro` → Playback pausiert und
Playhead springt zum `intro`-Marker.
**Erwartung:**
- Beim Scrubben (Ziehen des Playheads oder Marker-Klick) feuern
**keine** Actions.
- Beim Loop-Wrap (von `playbackOut` zurück nach `playbackIn`) feuern
ebenfalls keine Actions.
- Rückwärtsbewegen der Zeit feuert nichts.
---
## 3. Button-Clips erzeugen und platzieren
**Ziel:** Einen klickbaren Button definieren, auf die Stage bringen und
onClick-Actions hinterlegen.
1. In der Asset-Sidebar den Abschnitt **Buttons** → `+` klicken. Namen
eingeben (z. B. `PlayButton`), `Enter`.
2. Der Button erscheint in der Liste mit einem Cursor-Icon. Die 4 States
sind automatisch als bunte Rects vorausgefüllt
(blau / hellblau / dunkelblau / grau).
3. Button-Clip aus der Sidebar auf eine Clip-Track ziehen (Drag-and-Drop).
- Wenn keine Clip-Track existiert, in den Timeline-Bereich unter den
Tracks droppen → neue Clip-Track entsteht automatisch.
4. Auf der Clip-Track erscheint ein 4-Zellen-Block (`N H P D`) an der
Drop-Position.
5. Auf dem Canvas erscheint der Button (standardmäßig in der Stage-Mitte)
— wenn der Playhead ≥ Instanz-Startzeit.
**Button editieren:**
1. Auf den 4-Zellen-Block klicken → **ButtonInstanceDialog** öffnet sich.
2. **Label** vergeben (z. B. `play`) — wichtig, weil:
- Im DSL über `btn:play` referenzierbar.
- Im Dropdown von `Set button state`-Actions sichtbar.
3. **On click** → Actions definieren (z. B. `Play` und `Jump to marker intro`).
4. **Start disabled** aktivieren → Button startet grau/schraffiert,
reagiert nicht auf Klicks.
5. **Done**.
---
## 4. Button-Runtime auf der Stage testen
**Voraussetzung:** Ein Button ist platziert und der Playhead ist auf
oder nach dessen Startzeit.
1. Maus über den Button bewegen → Farbe wechselt zu *hellblau* (`hover`).
2. Maus-Down → Farbe wird *dunkelblau* (`pressed`).
3. Maus-Up (immer noch im Button) → `onClick`-Actions feuern; State geht
zurück zu `hover`.
4. Maus-Down und **rausziehen** vor dem Maus-Up → kein `onClick`,
State kehrt zu `normal` zurück (Standard-Browser-Button-Verhalten).
5. Disabled-Button hat keine Hover-/Press-Reaktion, ignoriert Klicks.
**Skript-getriggert:**
- In einem ScriptFrame eine `Set button state`-Action mit Target = Button-Label
und state = `pressed` anlegen.
- Beim Playback überqueren → der Button schaltet visuell in `pressed`,
ohne dass der User hovert.
- `Set button disabled` / `Enable` toggelt den Disabled-Flag zur Laufzeit.
---
## 5. Transitions zwischen States
**Ziel:** Glatter visueller Übergang zwischen States statt hartem Wechsel.
1. ButtonInstance-Dialog öffnen (4-Zellen-Block klicken).
2. Unter **Transitions** → `+ Add transition`.
3. Zeile: `normal → hover`, Duration `0.15`, Easing `easeOut`.
4. Weitere Zeile: `hover → normal`, Duration `0.2`, Easing `easeOut`.
5. Done.
6. Auf dem Canvas Maus über den Button → die Farbe **fade**t zwischen
Blau und Hellblau über 150 ms, statt instant zu wechseln.
**Hinweise:**
- Transitions sind Clip-scoped, d. h. sie gelten für **alle** Instanzen
des Buttons.
- Layer werden zwischen den beiden States per `name` gematcht (beim
Default-Seed heißen alle Face-Layer schlicht `Face` → werden immer
interpoliert). Unmatched Layer faden nur rein/raus (Crossfade).
- Ohne Transition: State-Wechsel ist sofort (Verhalten wie vor Slice 7).
---
## 6. Script-DSL testen
**Ziel:** Komplexere Logik in einer kompakten Textform statt manuellem
Zusammenklicken mehrerer Actions.
1. ScriptFrameDialog öffnen (oder ButtonInstanceDialog).
2. **Add action** → Typ `Run script` wählen.
3. Im Textarea:
```
// setzt Pause und springt an den intro-Marker
pause
jump @intro
state btn:play pressed
disable btn:play
```
4. Done.
**Testen:**
- Frame überqueren → alle parsed Aktionen feuern in Reihenfolge.
- Falsche Syntax → Warnung erscheint in der Devtools-Konsole
(`[animatr] Script line N: ...`). Die fehlerhafte Zeile wird
übersprungen; valide Zeilen feuern trotzdem.
**DSL-Referenz (aktueller Sprachumfang):**
```
// Kommentar (bis Zeilenende)
play // Playback starten
pause // Playback pausieren
rewind // Playhead → playbackIn (in-point)
jump 2.5 // Zeit in Sekunden
jump #135 // Frame-Nummer (durch fps geteilt)
jump @markerName // benannter Marker
state btn:label <state> // <state> ∈ normal|hover|pressed|disabled
enable btn:label
disable btn:label
```
---
## 7. Export-Runtime prüfen
**Ziel:** Sicherstellen dass Buttons, Scripts, Marker im exportierten
Projekt genauso funktionieren wie im Editor.
1. Menü → Export (oder beliebiger Export-Trigger je nach UI).
2. `AnimatrPlayer`-HTML-Export wählen → Datei öffnen im Browser.
3. **Erwartet:**
- Buttons rendern an ihren Positionen, mit Pointer-Reaktion.
- onClick feuert die hinterlegten Aktionen (z. B. `jump @intro` springt
im exportierten Player-Playhead zum Marker).
- Transitions animieren sauber.
- Script-Frames feuern bei natürlichem Playback.
4. Devtools-Konsole im Export-Tab → `window.AnimatrPlayer.__v` sollte
`'6'` ergeben. Cache-Probleme? Hard-Reload mit Shift+Cmd+R / Ctrl+F5.
---
## 8. Runtime-Tests (in-app)
`Ctrl+Shift+Alt+T` öffnet den Test-Runner. Relevante Suiten:
- `scene store — markers` / `script tracks & frames` / `button clips & instances`
- `actions — evalAction with mock runtime`
- `actions — script type expands via evalAction`
- `scriptDsl — parser`
- `button runtime — state machine + action wiring`
- `scene store — tick fires script frames forward-only`
Alle sollten grün laufen.
---
## Bekannte Einschränkungen
- **Kein Button-State-Editor in der UI:** Die 4 States sind beim Seed mit
farbigen Rects vorausgefüllt. Um die Optik anzupassen müsste man
aktuell `scene.buttonClips[i].states[…]` direkt im gespeicherten
Projekt-JSON editieren. Ein eigener Edit-Modus ist geplant, aber
nicht implementiert.
- **Server-Render (PNG-Sequence / MP4)** bakt Script-Actions und
Button-Interaktionen bewusst nicht ein — das wäre keine deterministische
Animation mehr. Scripts wirken nur in der interaktiven
Browser-Laufzeit (Editor + `AnimatrPlayer`-HTML-Export).
---
## Devtools-Cheat-Sheet
Für Tests ohne UI:
```js
// Pinia store im Devtools:
const s = __pinia.state.value.scene
const store = useSceneStore() // im Vue-Devtools verfügbar
// Button-Instanz direkt einen bestimmten State geben:
const rt = useButtonRuntime()
rt.setState(instanceId, 'pressed')
rt.reset()
// Alle Marker sehen:
console.table(store.scene.markers)
// Alle Button-Instanzen:
console.table(store.scene.buttonInstances)
```
+1 -69
View File
@@ -8,7 +8,7 @@ import {
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { t } from '@/i18n'
import type { Action, ButtonState } from '@/types/scene'
import type { Action } from '@/types/scene'
const props = defineProps<{ actions: Action[] }>()
const emit = defineEmits<{ 'update:actions': [value: Action[]] }>()
@@ -19,19 +19,6 @@ const markerNames = computed(() =>
(store.scene.markers ?? []).map((m) => m.name),
)
interface ButtonInstanceOption {
id: string
label: string
}
const buttonInstanceOptions = computed<ButtonInstanceOption[]>(() =>
(store.scene.buttonInstances ?? [])
.filter((bi) => !!bi.label)
.map((bi) => ({ id: bi.id, label: bi.label! })),
)
const BUTTON_STATES: ButtonState[] = ['normal', 'hover', 'pressed', 'disabled']
const ACTION_TYPES: Action['type'][] = [
'play',
'pause',
@@ -39,8 +26,6 @@ const ACTION_TYPES: Action['type'][] = [
'jumpTo',
'jumpToFrame',
'jumpToMarker',
'setButtonState',
'setButtonDisabled',
'script',
]
@@ -60,18 +45,6 @@ function makeDefault(type: Action['type']): Action {
return { type: 'jumpToFrame', frame: 0 }
case 'jumpToMarker':
return { type: 'jumpToMarker', name: markerNames.value[0] ?? '' }
case 'setButtonState':
return {
type: 'setButtonState',
instanceId: buttonInstanceOptions.value[0]?.id ?? '',
state: 'normal',
}
case 'setButtonDisabled':
return {
type: 'setButtonDisabled',
instanceId: buttonInstanceOptions.value[0]?.id ?? '',
disabled: true,
}
case 'script':
return { type: 'script', source: '' }
}
@@ -179,47 +152,6 @@ function onTypeChange(index: number, newType: Action['type']) {
</select>
</label>
</div>
<div v-else-if="a.type === 'setButtonState'" class="params">
<label>
{{ t('actions.param.button') }}
<select
:value="a.instanceId"
@change="setAt(i, { type: 'setButtonState', instanceId: ($event.target as HTMLSelectElement).value, state: a.state })"
>
<option v-if="!buttonInstanceOptions.length" value="" disabled>{{ t('actions.noButtons') }}</option>
<option v-for="b in buttonInstanceOptions" :key="b.id" :value="b.id">{{ b.label }}</option>
</select>
</label>
<label>
{{ t('actions.param.state') }}
<select
:value="a.state"
@change="setAt(i, { type: 'setButtonState', instanceId: a.instanceId, state: ($event.target as HTMLSelectElement).value as ButtonState })"
>
<option v-for="s in BUTTON_STATES" :key="s" :value="s">{{ s }}</option>
</select>
</label>
</div>
<div v-else-if="a.type === 'setButtonDisabled'" class="params">
<label>
{{ t('actions.param.button') }}
<select
:value="a.instanceId"
@change="setAt(i, { type: 'setButtonDisabled', instanceId: ($event.target as HTMLSelectElement).value, disabled: a.disabled })"
>
<option v-if="!buttonInstanceOptions.length" value="" disabled>{{ t('actions.noButtons') }}</option>
<option v-for="b in buttonInstanceOptions" :key="b.id" :value="b.id">{{ b.label }}</option>
</select>
</label>
<label class="bool">
<input
type="checkbox"
:checked="a.disabled"
@change="setAt(i, { type: 'setButtonDisabled', instanceId: a.instanceId, disabled: ($event.target as HTMLInputElement).checked })"
/>
{{ t('actions.param.disabled') }}
</label>
</div>
<div v-else-if="a.type === 'script'" class="params">
<label class="block">
{{ t('actions.param.source') }}
-86
View File
@@ -2,7 +2,6 @@
import { computed, ref, watch } from 'vue'
import {
PhCircle,
PhCursorClick,
PhFilmStrip,
PhImage,
PhImageSquare,
@@ -108,7 +107,6 @@ async function addSampleText() {
}
const clips = computed(() => store.scene.clips ?? [])
const buttonClips = computed(() => store.scene.buttonClips ?? [])
async function addClip() {
const input = await promptDialog({
@@ -149,45 +147,6 @@ function onClipDragStart(e: DragEvent, clipId: string) {
e.dataTransfer.setData('text/plain', clipId)
}
async function addButtonClip() {
const input = await promptDialog({
title: t('sidebar.newButtonDialog.title'),
message: t('sidebar.newButtonDialog.message'),
defaultValue: t('sidebar.newButtonDialog.default'),
confirmLabel: t('sidebar.newButtonDialog.confirm'),
})
if (input === null) return
store.seedDefaultButtonClip(input || t('sidebar.newButtonDialog.default'))
}
async function renameButtonClipPrompt(id: string, current: string) {
const input = await promptDialog({
title: t('sidebar.renameButtonDialog.title'),
defaultValue: current,
confirmLabel: t('sidebar.renameButtonDialog.confirm'),
})
if (input === null) return
store.setButtonClipName(id, input)
}
async function deleteButtonClip(id: string) {
const ok = await confirmDialog({
title: t('sidebar.deleteButtonDialog.title'),
message: t('sidebar.deleteButtonDialog.message'),
confirmLabel: t('sidebar.deleteButtonDialog.confirm'),
variant: 'danger',
})
if (!ok) return
store.removeButtonClip(id)
}
function onButtonClipDragStart(e: DragEvent, id: string) {
if (!e.dataTransfer) return
e.dataTransfer.effectAllowed = 'copy'
e.dataTransfer.setData('application/x-animatr-button-clip', id)
e.dataTransfer.setData('text/plain', id)
}
async function handleFileSelected(event: Event) {
uploadError.value = null
const input = event.target as HTMLInputElement
@@ -377,51 +336,6 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
</li>
<li v-if="!clips.length" class="empty">{{ t('sidebar.noClips') }}</li>
</ul>
<div class="lib-header clips-header">
<h4>{{ t('sidebar.buttons') }}</h4>
<button
class="thumb-toggle"
:title="t('sidebar.createButtonTooltip')"
@click="addButtonClip"
>
<PhPlus :size="12" weight="bold" />
</button>
</div>
<ul class="clips">
<li
v-for="bc in buttonClips"
:key="bc.id"
class="clip button-clip"
draggable="true"
@dragstart="onButtonClipDragStart($event, bc.id)"
>
<div class="clip-icon">
<PhCursorClick :size="16" />
</div>
<div class="clip-meta">
<span class="clip-name" :title="bc.name">{{ bc.name }}</span>
<span class="clip-sub">{{ t('sidebar.buttonSubtitle') }}</span>
</div>
<div class="clip-actions">
<button
class="clip-btn"
:title="t('sidebar.renameTooltip')"
@click.stop="renameButtonClipPrompt(bc.id, bc.name)"
>
<PhPencilSimple :size="11" />
</button>
<button
class="clip-btn danger"
:title="t('sidebar.deleteTooltip')"
@click.stop="deleteButtonClip(bc.id)"
>
<PhTrash :size="11" />
</button>
</div>
</li>
<li v-if="!buttonClips.length" class="empty">{{ t('sidebar.noButtons') }}</li>
</ul>
</div>
</template>
-368
View File
@@ -1,368 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { PhPlus, PhTrash } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { t } from '@/i18n'
import { Modal } from '@/components/ui'
import ActionEditor from './ActionEditor.vue'
import type { Action, ButtonState, Easing, StateTransition } from '@/types/scene'
const props = defineProps<{
modelValue: boolean
instanceId: string | null
}>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const store = useSceneStore()
const instance = computed(() => {
if (!props.instanceId) return null
return (
store.scene.buttonInstances?.find((bi) => bi.id === props.instanceId) ??
null
)
})
const buttonClip = computed(() => {
const bi = instance.value
if (!bi) return null
return store.scene.buttonClips?.find((c) => c.id === bi.buttonClipId) ?? null
})
const localLabel = ref('')
const localDisabled = ref(false)
const localActions = ref<Action[]>([])
const localTransitions = ref<StateTransition[]>([])
const labelError = ref<string | null>(null)
const BUTTON_STATES: ButtonState[] = ['normal', 'hover', 'pressed', 'disabled']
const EASINGS: Easing[] = ['linear', 'easeIn', 'easeOut', 'easeInOut']
watch(
[() => props.modelValue, () => props.instanceId],
([open, iid]) => {
if (!open || !iid) return
const bi = store.scene.buttonInstances?.find((x) => x.id === iid)
if (!bi) return
localLabel.value = bi.label ?? ''
localDisabled.value = !!bi.disabled
localActions.value = (bi.onClick ?? []).map((a) => ({ ...a }))
const clip = store.scene.buttonClips?.find((c) => c.id === bi.buttonClipId)
localTransitions.value = (clip?.transitions ?? []).map((t) => ({ ...t }))
labelError.value = null
},
{ immediate: true },
)
function commit(): boolean {
if (!props.instanceId) return true
// Label uniqueness.
const label = localLabel.value.trim()
const ok = store.setButtonInstanceLabel(props.instanceId, label)
if (!ok) {
labelError.value = t('buttonInstanceDialog.labelCollision', { name: label })
return false
}
store.setButtonInstanceOnClick(props.instanceId, localActions.value)
store.setButtonInstanceDisabled(props.instanceId, localDisabled.value)
const bi = store.scene.buttonInstances?.find((x) => x.id === props.instanceId)
if (bi) store.setButtonClipTransitions(bi.buttonClipId, localTransitions.value)
return true
}
function addTransition() {
localTransitions.value = [
...localTransitions.value,
{ from: 'normal', to: 'hover', duration: 0.15, easing: 'easeOut' },
]
}
function removeTransition(index: number) {
const next = [...localTransitions.value]
next.splice(index, 1)
localTransitions.value = next
}
function updateTransition(index: number, patch: Partial<StateTransition>) {
const next = [...localTransitions.value]
next[index] = { ...next[index], ...patch }
localTransitions.value = next
}
function close() {
if (commit()) emit('update:modelValue', false)
}
function removeInstance() {
if (!props.instanceId) return
store.removeButtonInstance(props.instanceId)
emit('update:modelValue', false)
}
</script>
<template>
<Modal
:model-value="modelValue"
size="md"
:title="t('buttonInstanceDialog.title')"
@update:model-value="(v) => (v ? null : close())"
>
<div v-if="instance" class="body">
<div class="meta">
<span class="meta-label">{{ t('buttonInstanceDialog.clipName') }}</span>
<span class="meta-value">{{ buttonClip?.name ?? '?' }}</span>
</div>
<label class="field">
{{ t('buttonInstanceDialog.label') }}
<input
type="text"
:value="localLabel"
:placeholder="t('buttonInstanceDialog.labelPlaceholder')"
@input="localLabel = ($event.target as HTMLInputElement).value; labelError = null"
/>
<span v-if="labelError" class="err">{{ labelError }}</span>
</label>
<label class="field row">
<input
type="checkbox"
:checked="localDisabled"
@change="localDisabled = ($event.target as HTMLInputElement).checked"
/>
{{ t('buttonInstanceDialog.disabled') }}
</label>
<div class="field">
<span class="field-label">{{ t('buttonInstanceDialog.onClick') }}</span>
<ActionEditor
:actions="localActions"
@update:actions="localActions = $event"
/>
</div>
<div class="field">
<span class="field-label">
{{ t('buttonInstanceDialog.transitions') }}
<small class="field-hint">{{ t('buttonInstanceDialog.transitionsHint') }}</small>
</span>
<ol v-if="localTransitions.length" class="trans-list">
<li v-for="(tr, i) in localTransitions" :key="i" class="trans-row">
<select
:value="tr.from"
@change="updateTransition(i, { from: ($event.target as HTMLSelectElement).value as ButtonState })"
>
<option v-for="s in BUTTON_STATES" :key="s" :value="s">{{ s }}</option>
</select>
<span class="arrow">→</span>
<select
:value="tr.to"
@change="updateTransition(i, { to: ($event.target as HTMLSelectElement).value as ButtonState })"
>
<option v-for="s in BUTTON_STATES" :key="s" :value="s">{{ s }}</option>
</select>
<input
type="number"
step="0.01"
min="0"
:value="tr.duration"
@input="updateTransition(i, { duration: Number(($event.target as HTMLInputElement).value) })"
class="dur"
/>
<select
:value="tr.easing"
@change="updateTransition(i, { easing: ($event.target as HTMLSelectElement).value as Easing })"
>
<option v-for="e in EASINGS" :key="e" :value="e">{{ e }}</option>
</select>
<button class="icon-btn danger" :title="t('actions.remove')" @click="removeTransition(i)">
<PhTrash :size="13" />
</button>
</li>
</ol>
<button class="add-btn" @click="addTransition">
<PhPlus :size="12" weight="bold" />
{{ t('buttonInstanceDialog.addTransition') }}
</button>
</div>
</div>
<template #footer>
<button class="btn danger" @click="removeInstance">
<PhTrash :size="12" />
{{ t('buttonInstanceDialog.remove') }}
</button>
<span class="spacer"></span>
<button class="btn primary" @click="close">
{{ t('buttonInstanceDialog.done') }}
</button>
</template>
</Modal>
</template>
<style scoped>
.body {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px 14px;
}
.meta {
display: flex;
gap: 8px;
align-items: baseline;
font-size: 12px;
}
.meta-label {
color: var(--text-dim);
}
.meta-value {
color: var(--text);
font-weight: 500;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 11px;
color: var(--text-dim);
}
.field.row {
flex-direction: row;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text);
}
.field-label {
font-size: 11px;
color: var(--text-dim);
}
.field input[type='text'] {
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
padding: 4px 7px;
border-radius: var(--radius-sm);
font-size: 12px;
font-family: inherit;
}
.err {
color: var(--danger);
font-size: 11px;
}
.btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 10px;
background: var(--panel);
border: 1px solid var(--border);
color: var(--text);
border-radius: var(--radius-sm);
font-size: 12px;
}
.btn:hover {
background: var(--panel-hover);
}
.btn.primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.btn.danger:hover {
background: var(--danger-soft);
color: var(--danger);
border-color: var(--danger);
}
.spacer {
flex: 1;
}
.field-hint {
margin-left: 6px;
color: var(--text-dim);
font-weight: normal;
font-size: 10px;
}
.trans-list {
list-style: none;
padding: 0;
margin: 0 0 6px 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.trans-row {
display: flex;
align-items: center;
gap: 4px;
}
.trans-row select,
.trans-row input.dur {
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
padding: 3px 5px;
border-radius: var(--radius-sm);
font-size: 11px;
font-family: inherit;
}
.trans-row input.dur {
width: 56px;
}
.trans-row .arrow {
color: var(--text-dim);
font-size: 11px;
}
.icon-btn {
width: 22px;
height: 22px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-dim);
cursor: pointer;
}
.icon-btn.danger:hover {
background: var(--danger-soft);
color: var(--danger);
}
.add-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 9px;
background: var(--panel);
border: 1px dashed var(--border-strong);
color: var(--text);
border-radius: var(--radius-sm);
font-size: 11px;
align-self: flex-start;
cursor: pointer;
}
.add-btn:hover {
background: var(--panel-hover);
}
</style>
-207
View File
@@ -8,22 +8,12 @@ import { importImageFile, loadImage } from '@/lib/fileLoader'
import { useClipartStore } from '@/stores/clipart'
import { drawSceneFrame, loadSceneImages } from '@/lib/render'
import type {
Action,
AnimatableProps,
Asset,
ButtonClip,
ButtonInstance,
ButtonState,
Keyframe,
Layer,
Point,
} from '@/types/scene'
import {
applyButtonStateChange,
useButtonRuntime,
} from '@/composables/useButtonRuntime'
import { evalAction } from '@/lib/actions'
import { blendButtonStates } from '@/lib/buttonBlend'
import { t } from '@/i18n'
import { useKfSelection } from '@/composables/useKfSelection'
import { showLocked } from '@/composables/useShowLocked'
@@ -197,167 +187,6 @@ const canvasBorderConfig = computed(() => ({
const zoomPercent = computed(() => Math.round(zoom.value * 100))
const buttonRt = useButtonRuntime()
interface ButtonRenderItem {
bi: ButtonInstance
clip: ButtonClip
state: ButtonState
disabled: boolean
layers: Array<{
layer: Layer
asset: Asset
props: AnimatableProps
}>
hit: { width: number; height: number; offsetX: number; offsetY: number }
}
function sampleStateLayers(
clip: ButtonClip,
state: ButtonState,
): ButtonRenderItem['layers'] {
const layers = clip.states[state] ?? []
const out: ButtonRenderItem['layers'] = []
for (const layer of layers) {
const asset = store.scene.assets.find((a) => a.id === layer.assetId)
if (!asset) continue
out.push({ layer, asset, props: sampleLayer(layer, 0) })
}
return out
}
function blendStates(
clip: ButtonClip,
from: ButtonState,
to: ButtonState,
u: number,
): ButtonRenderItem['layers'] {
return blendButtonStates(clip, from, to, u, (id) =>
store.scene.assets.find((a) => a.id === id),
)
}
const activeButtonInstances = computed<ButtonRenderItem[]>(() => {
// Force re-evaluation on every transition RAF tick.
void buttonRt.transitionTick.value
const out: ButtonRenderItem[] = []
const instances = store.scene.buttonInstances ?? []
const clips = store.scene.buttonClips ?? []
const clipById = new Map(clips.map((c) => [c.id, c]))
const now = performance.now()
for (const bi of instances) {
if (store.playhead < bi.startTime) continue
const clip = clipById.get(bi.buttonClipId)
if (!clip) continue
const disabled = buttonRt.isDisabled(bi.id, bi.disabled)
const state = buttonRt.getState(bi.id, bi.disabled)
const tr = buttonRt.sampleTransition(bi.id, now)
let rendered: ButtonRenderItem['layers']
if (tr && !tr.done && !disabled) {
rendered = blendStates(clip, tr.from, tr.to, tr.u)
} else {
rendered = sampleStateLayers(clip, state)
}
// Hit area: explicit hitArea on the clip, or fall back to the bbox of
// the first rect-like asset in the normal state.
let hit = clip.hitArea
? {
width: clip.hitArea.width,
height: clip.hitArea.height,
offsetX: clip.hitArea.offsetX ?? clip.hitArea.width / 2,
offsetY: clip.hitArea.offsetY ?? clip.hitArea.height / 2,
}
: null
if (!hit) {
for (const l of clip.states.normal) {
const a = store.scene.assets.find((x) => x.id === l.assetId)
if (!a) continue
if (a.kind === 'rect' || a.kind === 'image') {
hit = {
width: a.width,
height: a.height,
offsetX: l.offsetX ?? a.width / 2,
offsetY: l.offsetY ?? a.height / 2,
}
break
}
if (a.kind === 'circle') {
hit = {
width: a.radius * 2,
height: a.radius * 2,
offsetX: (l.offsetX ?? 0) + a.radius,
offsetY: (l.offsetY ?? 0) + a.radius,
}
break
}
}
}
if (!hit) hit = { width: 120, height: 40, offsetX: 60, offsetY: 20 }
out.push({ bi, clip, state, disabled, layers: rendered, hit })
}
return out
})
function buttonGroupConfig(bi: ButtonInstance) {
return {
id: `btn:${bi.id}`,
x: bi.x ?? 0,
y: bi.y ?? 0,
listening: true,
}
}
function buttonHitRectConfig(item: ButtonRenderItem) {
return {
x: 0,
y: 0,
width: item.hit.width,
height: item.hit.height,
offsetX: item.hit.offsetX,
offsetY: item.hit.offsetY,
fill: 'rgba(0,0,0,0.001)', // nearly transparent but hittable
listening: !item.disabled,
name: 'button-hit',
}
}
function buttonChildConfig<T extends Record<string, unknown>>(cfg: T): T {
return { ...cfg, draggable: false, listening: false }
}
function transitionsFor(bi: ButtonInstance) {
return store.scene.buttonClips?.find((c) => c.id === bi.buttonClipId)?.transitions
}
function onButtonMouseEnter(bi: ButtonInstance) {
const cur = buttonRt.getState(bi.id, bi.disabled)
if (cur === 'normal') applyButtonStateChange(bi.id, 'hover', transitionsFor(bi), bi.disabled)
}
function onButtonMouseLeave(bi: ButtonInstance) {
const cur = buttonRt.getState(bi.id, bi.disabled)
if (cur === 'hover' || cur === 'pressed')
applyButtonStateChange(bi.id, 'normal', transitionsFor(bi), bi.disabled)
}
function onButtonMouseDown(bi: ButtonInstance) {
const cur = buttonRt.getState(bi.id, bi.disabled)
if (cur === 'hover') applyButtonStateChange(bi.id, 'pressed', transitionsFor(bi), bi.disabled)
}
function onButtonMouseUp(bi: ButtonInstance) {
const cur = buttonRt.getState(bi.id, bi.disabled)
if (cur !== 'pressed') return
applyButtonStateChange(bi.id, 'hover', transitionsFor(bi), bi.disabled)
const actions = bi.onClick
if (actions && actions.length) runButtonActions(actions)
}
function runButtonActions(actions: Action[]) {
const rt = store.actionRuntime()
for (const a of actions) evalAction(a, rt)
}
const assetMap = computed(() => {
const m = new Map<string, Asset>()
for (const a of store.scene.assets) m.set(a.id, a)
@@ -2523,42 +2352,6 @@ onBeforeUnmount(() => {
/>
</template>
</template>
<template v-for="item in activeButtonInstances" :key="`bi-${item.bi.id}`">
<v-group
:config="buttonGroupConfig(item.bi)"
@mouseenter="onButtonMouseEnter(item.bi)"
@mouseleave="onButtonMouseLeave(item.bi)"
@mousedown="onButtonMouseDown(item.bi)"
@mouseup="onButtonMouseUp(item.bi)"
>
<v-rect :config="buttonHitRectConfig(item)" />
<template
v-for="(entry, bli) in item.layers"
:key="`${item.bi.id}-${entry.layer.id}-${bli}`"
>
<v-rect
v-if="entry.asset.kind === 'rect'"
:config="buttonChildConfig(rectConfig(entry.layer, entry.asset, entry.props))"
/>
<v-circle
v-else-if="entry.asset.kind === 'circle'"
:config="buttonChildConfig(circleConfig(entry.layer, entry.asset, entry.props))"
/>
<v-text
v-else-if="entry.asset.kind === 'text'"
:config="buttonChildConfig(textConfig(entry.layer, entry.asset, entry.props))"
/>
<v-line
v-else-if="entry.asset.kind === 'polygon'"
:config="buttonChildConfig(polygonConfig(entry.layer, entry.asset, entry.props))"
/>
<v-image
v-else-if="entry.asset.kind === 'image' && imageConfig(entry.layer, entry.asset, entry.props)"
:config="buttonChildConfig(imageConfig(entry.layer, entry.asset, entry.props)!)"
/>
</template>
</v-group>
</template>
</v-group>
<v-rect :config="canvasBorderConfig" />
<v-line v-if="rotationArmConfig" :config="rotationArmConfig" />
+69 -158
View File
@@ -31,14 +31,12 @@ import { sampleLayer } from '@/lib/interpolate'
import { snapToCandidates } from '@/lib/timelineSnap'
import { confirmDialog, promptDialog } from '@/components/ui'
import ScriptFrameDialog from './ScriptFrameDialog.vue'
import ButtonInstanceDialog from './ButtonInstanceDialog.vue'
import { t } from '@/i18n'
import { kfSelectionKey, useKfSelection, type KfSelectionKey } from '@/composables/useKfSelection'
import { showLocked } from '@/composables/useShowLocked'
import type {
AnimatableProps,
Asset,
ButtonInstance,
Clip,
ClipInstance,
Easing,
@@ -135,21 +133,6 @@ const scriptFramesByTrack = computed<Record<string, typeof store.scene.scriptFra
}
return out
})
const buttonClipById = computed(() => {
const m = new Map<string, NonNullable<typeof store.scene.buttonClips>[number]>()
for (const c of store.scene.buttonClips ?? []) m.set(c.id, c)
return m
})
const buttonInstancesByTrack = computed<
Record<string, NonNullable<typeof store.scene.buttonInstances>>
>(() => {
const out: Record<string, NonNullable<typeof store.scene.buttonInstances>> = {}
for (const bi of store.scene.buttonInstances ?? []) {
;(out[bi.clipTrackId] ||= []).push(bi)
}
return out
})
const TRACK_NAME_WIDTH_KEY = 'animatr:trackNameWidth'
const TRACK_NAME_WIDTH_DEFAULT = 140
const TRACK_NAME_WIDTH_MIN = 80
@@ -460,14 +443,8 @@ function onMarkerPointerMove(e: PointerEvent, markerId: string) {
function onMarkerPointerUp(e: PointerEvent, markerId: string) {
const el = e.currentTarget as HTMLElement
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
const drag = markerDrag.value
markerDrag.value = null
if (!drag || drag.id !== markerId) return
// Click (no movement) → jump playhead to the marker.
if (!drag.moved) {
const m = store.scene.markers?.find((x) => x.id === markerId)
if (m) store.setPlayhead(m.time)
}
void markerId
}
async function renameMarkerPrompt(markerId: string) {
@@ -1077,7 +1054,7 @@ function duplicateFromMenu() {
}
interface TrackMenuState {
kind: 'layer' | 'clip' | 'group'
kind: 'layer' | 'clip' | 'group' | 'script'
trackId: string
x: number
y: number
@@ -1277,7 +1254,7 @@ function onTracksPointerDown(e: PointerEvent) {
if (!target) return
if (
target.closest(
'.kf, .clip-block, .clip-handle, .track-name, .drag-handle, .add-clip-btn, .playhead',
'.kf, .clip-block, .clip-handle, .track-name, .drag-handle, .add-clip-btn, .playhead, .script-lane, .script-frame',
)
) {
return
@@ -1364,11 +1341,7 @@ function pixelTimeFromEvent(e: DragEvent | PointerEvent, el: HTMLElement): numbe
}
function onClipLaneDragOver(e: DragEvent, _clipTrackId: string) {
const types = e.dataTransfer?.types
if (
types?.includes('application/x-animatr-clip') ||
types?.includes('application/x-animatr-button-clip')
) {
if (e.dataTransfer?.types.includes('application/x-animatr-clip')) {
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
}
@@ -1381,14 +1354,7 @@ function onClipLaneDrop(e: DragEvent, clipTrackId: string) {
const el = e.currentTarget as HTMLElement
const time = pixelTimeFromEvent(e, el)
const clipId = e.dataTransfer?.getData('application/x-animatr-clip')
if (clipId) {
store.addClipInstance(clipId, time, clipTrackId)
return
}
const buttonClipId = e.dataTransfer?.getData('application/x-animatr-button-clip')
if (buttonClipId) {
store.addButtonInstance(buttonClipId, clipTrackId, time)
}
if (clipId) store.addClipInstance(clipId, time, clipTrackId)
}
interface ClipMoveState {
@@ -1447,24 +1413,16 @@ function onClipBlockPointerUp(e: PointerEvent) {
const clipDragOverDropzone = ref(false)
function isClipDragLike(e: DragEvent): boolean {
const types = e.dataTransfer?.types
return !!(
types?.includes('application/x-animatr-clip') ||
types?.includes('application/x-animatr-button-clip')
)
}
function onNewTrackDragEnter(e: DragEvent) {
if (!isClipDragLike(e)) return
if (!e.dataTransfer?.types.includes('application/x-animatr-clip')) return
e.preventDefault()
clipDragOverDropzone.value = true
}
function onNewTrackDragOver(e: DragEvent) {
if (!isClipDragLike(e)) return
if (!e.dataTransfer?.types.includes('application/x-animatr-clip')) return
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
e.dataTransfer.dropEffect = 'copy'
clipDragOverDropzone.value = true
}
@@ -1475,14 +1433,12 @@ function onNewTrackDragLeave() {
function onNewTrackDrop(e: DragEvent) {
clipDragOverDropzone.value = false
const clipId = e.dataTransfer?.getData('application/x-animatr-clip')
const buttonClipId = e.dataTransfer?.getData('application/x-animatr-button-clip')
if (!clipId && !buttonClipId) return
if (!clipId) return
e.preventDefault()
const el = e.currentTarget as HTMLElement
const time = pixelTimeFromLane(e, el)
const track = store.addClipTrack()
if (clipId) store.addClipInstance(clipId, time, track.id)
else if (buttonClipId) store.addButtonInstance(buttonClipId, track.id, time)
store.addClipInstance(clipId, time, track.id)
}
function pixelTimeFromLane(e: DragEvent, el: HTMLElement): number {
@@ -1515,20 +1471,10 @@ function clipBlockStyle(ci: ClipInstance) {
}
}
const BUTTON_STATES = ['normal', 'hover', 'pressed', 'disabled'] as const
// Script frame dialog state
const scriptDialogOpen = ref(false)
const scriptDialogFrameId = ref<string | null>(null)
// Button instance dialog state
const buttonDialogOpen = ref(false)
const buttonDialogInstanceId = ref<string | null>(null)
function openButtonInstance(id: string) {
buttonDialogInstanceId.value = id
buttonDialogOpen.value = true
}
function openScriptFrame(frameId: string) {
scriptDialogFrameId.value = frameId
scriptDialogOpen.value = true
@@ -1545,16 +1491,6 @@ function onScriptLaneClick(e: MouseEvent, trackId: string) {
const frame = store.addScriptFrame(trackId, time)
if (frame) openScriptFrame(frame.id)
}
const BUTTON_CELL_SEC = 0.5 // visual width of each of the 4 state cells
function buttonInstanceStyle(bi: ButtonInstance) {
// Four cells, each with a fixed per-cell visual width. Kept as a fixed
// (time-like) span so the block scales with timeline zoom.
const totalSec = BUTTON_CELL_SEC * 4
return {
left: pxFromTime(bi.startTime) + 'px',
width: Math.max(80, pxFromTime(totalSec)) + 'px',
}
}
function trimTooltip(ci: ClipInstance): string {
const name = clipById.value.get(ci.clipId)?.name ?? '?'
@@ -1699,6 +1635,22 @@ function onClipTrackContextMenu(e: MouseEvent, clipTrackId: string) {
}
}
function onScriptTrackContextMenu(e: MouseEvent, scriptTrackId: string) {
e.preventDefault()
e.stopPropagation()
const track = store.scene.scriptTracks?.find((t) => t.id === scriptTrackId)
if (!track) return
trackMenu.value = {
kind: 'script',
trackId: scriptTrackId,
x: e.clientX,
y: e.clientY,
separated: false,
currentColor: track.color ?? null,
locked: !!track.locked,
}
}
function applyTrackColor(color: string | null) {
if (!trackMenu.value) return
store.setTrackColor(trackMenu.value.trackId, color)
@@ -1722,6 +1674,8 @@ function deleteTrack() {
store.removeLayer(trackMenu.value.trackId)
} else if (trackMenu.value.kind === 'clip') {
store.removeClipTrack(trackMenu.value.trackId)
} else if (trackMenu.value.kind === 'script') {
store.removeScriptTrack(trackMenu.value.trackId)
} else {
store.removeTrackGroup(trackMenu.value.trackId)
}
@@ -1730,19 +1684,35 @@ function deleteTrack() {
async function renameFromMenu() {
if (!trackMenu.value) return
if (trackMenu.value.kind !== 'group') return
const group = groupById.value.get(trackMenu.value.trackId)
if (!group) return
const id = trackMenu.value.trackId
const current = group.name
trackMenu.value = null
const next = await promptDialog({
title: t('timeline.renameGroupDialog.title'),
defaultValue: current,
confirmLabel: t('timeline.renameGroupDialog.confirm'),
})
if (next === null) return
store.renameGroup(id, next)
if (trackMenu.value.kind === 'group') {
const group = groupById.value.get(trackMenu.value.trackId)
if (!group) return
const id = trackMenu.value.trackId
const current = group.name
trackMenu.value = null
const next = await promptDialog({
title: t('timeline.renameGroupDialog.title'),
defaultValue: current,
confirmLabel: t('timeline.renameGroupDialog.confirm'),
})
if (next === null) return
store.renameGroup(id, next)
return
}
if (trackMenu.value.kind === 'script') {
const track = scriptTrackById.value.get(trackMenu.value.trackId)
if (!track) return
const id = trackMenu.value.trackId
const current = track.name ?? t('timeline.scriptTrackName')
trackMenu.value = null
const next = await promptDialog({
title: t('timeline.renameScriptTrackDialog.title'),
defaultValue: current,
confirmLabel: t('timeline.renameScriptTrackDialog.confirm'),
})
if (next === null) return
store.setScriptTrackName(id, next)
}
}
function onDocumentPointerDown(e: PointerEvent) {
@@ -2313,22 +2283,6 @@ onBeforeUnmount(() => {
@click.stop
/>
</span>
<span
v-for="bi in buttonInstancesByTrack[track.clipTrackId] ?? []"
:key="`bi-${bi.id}`"
class="button-instance"
:class="{ disabled: bi.disabled }"
:style="buttonInstanceStyle(bi)"
:title="bi.label ?? buttonClipById.get(bi.buttonClipId)?.name ?? ''"
@click.stop="openButtonInstance(bi.id)"
>
<span
v-for="state in BUTTON_STATES"
:key="state"
class="button-instance-cell"
:data-state="state"
>{{ state[0].toUpperCase() }}</span>
</span>
</div>
</div>
@@ -2351,6 +2305,7 @@ onBeforeUnmount(() => {
<span
class="track-name script-track-name"
:draggable="!track.locked"
@contextmenu="!track.locked ? onScriptTrackContextMenu($event, track.scriptTrackId) : null"
@dragstart="!track.locked ? onTrackDragStart($event, track.scriptTrackId) : null"
@dragend="onTrackDragEnd"
>
@@ -2592,6 +2547,14 @@ onBeforeUnmount(() => {
{{ t('timeline.renameGroup') }}
</button>
</template>
<template v-else-if="trackMenu.kind === 'script'">
<div class="kf-menu-divider"></div>
<button class="kf-menu-item" @click="renameFromMenu">
<span class="check"></span>
<PhArrowsSplit :size="12" class="kf-menu-icon" style="visibility: hidden" />
{{ t('timeline.renameScriptTrack') }}
</button>
</template>
<template v-else>
<template v-if="trackMenu.kind === 'layer'">
<div class="kf-menu-divider"></div>
@@ -2617,7 +2580,9 @@ onBeforeUnmount(() => {
? t('timeline.deleteLayer')
: trackMenu.kind === 'clip'
? t('timeline.deleteClipTrack')
: t('timeline.ungroup')
: trackMenu.kind === 'script'
? t('timeline.deleteScriptTrack')
: t('timeline.ungroup')
}}
</button>
</div>
@@ -2625,10 +2590,6 @@ onBeforeUnmount(() => {
v-model="scriptDialogOpen"
:frame-id="scriptDialogFrameId"
/>
<ButtonInstanceDialog
v-model="buttonDialogOpen"
:instance-id="buttonDialogInstanceId"
/>
</div>
</template>
@@ -3496,56 +3457,6 @@ onBeforeUnmount(() => {
text-align: center;
}
/* ---- Button instance (4 cells) ---- */
.button-instance {
position: absolute;
top: 4px;
bottom: 4px;
display: inline-flex;
border: 1px solid var(--track-color, var(--accent));
border-radius: var(--radius-sm);
overflow: hidden;
background: color-mix(in srgb, var(--track-color, var(--accent)) 10%, transparent);
color: var(--track-color, var(--accent));
font-size: 10px;
font-weight: 600;
}
.button-instance.disabled {
opacity: 0.5;
}
.button-instance-cell {
flex: 1 1 0;
display: flex;
align-items: center;
justify-content: center;
border-right: 1px solid color-mix(in srgb, var(--track-color, var(--accent)) 40%, transparent);
background: color-mix(in srgb, var(--track-color, var(--accent)) 8%, transparent);
letter-spacing: 0.04em;
}
.button-instance-cell:last-child {
border-right: none;
}
.button-instance-cell[data-state='hover'] {
background: color-mix(in srgb, var(--track-color, var(--accent)) 14%, transparent);
}
.button-instance-cell[data-state='pressed'] {
background: color-mix(in srgb, var(--track-color, var(--accent)) 22%, transparent);
}
.button-instance-cell[data-state='disabled'] {
background: repeating-linear-gradient(
45deg,
color-mix(in srgb, var(--track-color, var(--accent)) 8%, transparent) 0 4px,
transparent 4px 8px
);
}
.clip-block {
position: absolute;
top: 4px;
-159
View File
@@ -1,159 +0,0 @@
import { reactive, ref } from 'vue'
import type { ButtonState, Easing, StateTransition } from '@/types/scene'
export interface ActiveTransition {
from: ButtonState
to: ButtonState
startedAt: number
duration: number
easing: Easing
}
// Runtime-only per-instance state. Not persisted; resets on page reload.
interface RuntimeState {
// Which state the button is currently showing. Undefined = 'normal'.
state: Map<string, ButtonState>
// Runtime disabled flag — overrides (and can be overridden by) the
// instance's persisted `disabled` field. Undefined = use persisted.
disabled: Map<string, boolean>
// Currently-running transition per instance.
transition: Map<string, ActiveTransition>
}
const runtime: RuntimeState = reactive({
state: new Map<string, ButtonState>(),
disabled: new Map<string, boolean>(),
transition: new Map<string, ActiveTransition>(),
})
// Bumps whenever a transition is started/ended so Vue computeds tied to
// transition progress can re-evaluate on the next frame.
const transitionTick = ref(0)
export function pokeTransitionTick() {
transitionTick.value++
}
// Self-driving RAF loop so transitions animate even while playback is
// paused. Idles when no transitions are active.
let rafId = 0
function ensureTransitionLoop() {
if (rafId) return
const step = () => {
const now = performance.now()
let anyActive = false
for (const [id, tr] of runtime.transition) {
const elapsedSec = (now - tr.startedAt) / 1000
if (elapsedSec >= tr.duration) {
runtime.transition.delete(id)
} else {
anyActive = true
}
}
transitionTick.value++
rafId = anyActive ? requestAnimationFrame(step) : 0
}
rafId = requestAnimationFrame(step)
}
/**
* Change a button's state. If a matching from→to transition is defined
* on the clip, start the animated transition. Otherwise switch instantly.
*/
export function applyButtonStateChange(
id: string,
to: ButtonState,
transitions: StateTransition[] | undefined,
persistedDisabled: boolean | undefined,
): void {
const rt = useButtonRuntime()
const from = rt.getState(id, persistedDisabled)
if (from === to) return
const match = transitions?.find((t) => t.from === from && t.to === to)
if (match && match.duration > 0) {
rt.startTransition(id, from, to, match.duration, match.easing, performance.now())
}
rt.setState(id, to)
}
function easingProgress(u: number, e: Easing): number {
if (u <= 0) return 0
if (u >= 1) return 1
if (e === 'easeIn') return u * u
if (e === 'easeOut') return 1 - (1 - u) * (1 - u)
if (e === 'easeInOut')
return u < 0.5 ? 2 * u * u : 1 - Math.pow(-2 * u + 2, 2) / 2
return u
}
export function useButtonRuntime() {
return {
state: runtime.state,
disabled: runtime.disabled,
transition: runtime.transition,
transitionTick,
getState(id: string, persistedDisabled?: boolean): ButtonState {
const override = runtime.disabled.get(id)
const effectiveDisabled =
override !== undefined ? override : !!persistedDisabled
if (effectiveDisabled) return 'disabled'
return runtime.state.get(id) ?? 'normal'
},
isDisabled(id: string, persistedDisabled?: boolean): boolean {
const override = runtime.disabled.get(id)
return override !== undefined ? override : !!persistedDisabled
},
setState(id: string, next: ButtonState) {
if (next === 'normal') runtime.state.delete(id)
else runtime.state.set(id, next)
},
setDisabled(id: string, next: boolean) {
runtime.disabled.set(id, next)
},
clearDisabled(id: string) {
runtime.disabled.delete(id)
},
/**
* Start a transition between two states; caller supplies performance.now()
* as `startedAt` so the runtime can interpolate without re-reading time.
*/
startTransition(
id: string,
from: ButtonState,
to: ButtonState,
duration: number,
easing: Easing,
startedAt: number,
) {
runtime.transition.set(id, { from, to, startedAt, duration, easing })
transitionTick.value++
if (duration > 0) ensureTransitionLoop()
},
/**
* Return interpolated progress u ∈ [0..1] (easing applied) + raw for the
* given instance's transition at `now`. If the transition has ended,
* returns { done: true, u: 1 }. Returns null if no active transition.
*/
sampleTransition(id: string, now: number): {
u: number
from: ButtonState
to: ButtonState
done: boolean
} | null {
const t = runtime.transition.get(id)
if (!t) return null
const dur = Math.max(0.0001, t.duration)
const raw = (now - t.startedAt) / 1000 / dur
if (raw >= 1) return { u: 1, from: t.from, to: t.to, done: true }
return { u: easingProgress(raw, t.easing), from: t.from, to: t.to, done: false }
},
endTransition(id: string) {
if (runtime.transition.delete(id)) transitionTick.value++
},
reset() {
runtime.state.clear()
runtime.disabled.clear()
runtime.transition.clear()
transitionTick.value++
},
}
}
+3 -449
View File
@@ -1,10 +1,5 @@
import { createApp, h, nextTick, type Component, type Slot } from 'vue'
import { createBlankScene, useSceneStore } from '@/stores/scene'
import {
applyButtonStateChange,
useButtonRuntime,
} from '@/composables/useButtonRuntime'
import { blendButtonStates } from '@/lib/buttonBlend'
import {
buildMarkerMap,
evalAction,
@@ -12,7 +7,7 @@ import {
type ActionRuntime,
} from '@/lib/actions'
import { parseScript } from '@/lib/scriptDsl'
import type { Action, Asset, ButtonClip, Layer } from '@/types/scene'
import type { Action } from '@/types/scene'
import { sampleLayer, lerpColor } from '@/lib/interpolate'
import { snapToCandidates } from '@/lib/timelineSnap'
import {
@@ -3146,97 +3141,11 @@ export function registerAllTests(): void {
})
})
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('seedDefaultButtonClip produces four populated state stacks', () => {
const s = makeFreshStore()
const bc = s.seedDefaultButtonClip('Play')
expect(bc.states.normal).toHaveLength(1)
expect(bc.states.hover).toHaveLength(1)
expect(bc.states.pressed).toHaveLength(1)
expect(bc.states.disabled).toHaveLength(1)
// Each state has its own face layer, all pointing at the same asset.
const assetIds = new Set(
[...bc.states.normal, ...bc.states.hover, ...bc.states.pressed, ...bc.states.disabled]
.map((l) => l.assetId),
)
expect(assetIds.size).toBe(1)
// Layer names consistent so transitions can match across states.
for (const state of ['normal', 'hover', 'pressed', 'disabled'] as const) {
expect(bc.states[state][0].name).toBe('Face')
}
})
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('actions — evalAction with mock runtime', () => {
function makeMockRuntime(init?: Partial<ActionRuntime>): ActionRuntime & {
calls: string[]
playhead: number
playing: boolean
buttonStates: Record<string, string>
buttonDisabled: Record<string, boolean>
warnings: string[]
} {
const rt = {
@@ -3247,8 +3156,6 @@ export function registerAllTests(): void {
markers: { intro: 2, outro: 8 },
playhead: 0,
playing: false,
buttonStates: {} as Record<string, string>,
buttonDisabled: {} as Record<string, boolean>,
warnings: [] as string[],
calls: [] as string[],
setPlayhead(t: number) {
@@ -3263,14 +3170,6 @@ export function registerAllTests(): void {
this.calls.push('pause')
this.playing = false
},
setButtonState(id: string, state: string) {
this.calls.push(`btnState:${id}=${state}`)
this.buttonStates[id] = state
},
setButtonDisabled(id: string, d: boolean) {
this.calls.push(`btnDisabled:${id}=${d}`)
this.buttonDisabled[id] = d
},
warn(msg: string) {
this.warnings.push(msg)
},
@@ -3320,14 +3219,6 @@ export function registerAllTests(): void {
expect(rt.warnings[0]).toContain('missing')
})
test('setButtonState + setButtonDisabled dispatch to runtime', () => {
const rt = makeMockRuntime()
evalAction({ type: 'setButtonState', instanceId: 'b1', state: 'pressed' }, rt)
evalAction({ type: 'setButtonDisabled', instanceId: 'b2', disabled: true }, rt)
expect(rt.buttonStates.b1).toBe('pressed')
expect(rt.buttonDisabled.b2).toBe(true)
})
test('script action parses and dispatches sub-actions (no warn on valid DSL)', () => {
const rt = makeMockRuntime()
evalAction({ type: 'script', source: 'play\npause' }, rt)
@@ -3451,34 +3342,6 @@ export function registerAllTests(): void {
expect(errors).toHaveLength(4)
})
test('state requires btn:<label> and a valid state', () => {
const good = parseScript('state btn:play pressed')
expect(good.errors).toHaveLength(0)
expect(good.actions[0]).toEqual({
type: 'setButtonState',
instanceId: 'play',
state: 'pressed',
})
const bad1 = parseScript('state play pressed')
expect(bad1.errors).toHaveLength(1)
const bad2 = parseScript('state btn:play weird')
expect(bad2.errors).toHaveLength(1)
})
test('enable / disable map to setButtonDisabled', () => {
const { actions } = parseScript('enable btn:play\ndisable btn:play')
expect(actions[0]).toEqual({
type: 'setButtonDisabled',
instanceId: 'play',
disabled: false,
})
expect(actions[1]).toEqual({
type: 'setButtonDisabled',
instanceId: 'play',
disabled: true,
})
})
test('unknown commands report error, other lines still parse', () => {
const { actions, errors } = parseScript('play\nwat\npause')
expect(errors).toHaveLength(1)
@@ -3499,18 +3362,13 @@ export function registerAllTests(): void {
setPlayhead: (t) => calls.push(`seek:${t}`),
play: () => calls.push('play'),
pause: () => calls.push('pause'),
setButtonState: (id, s) => calls.push(`state:${id}=${s}`),
setButtonDisabled: (id, d) => calls.push(`disabled:${id}=${d}`),
warn: () => {},
}
evalAction(
{
type: 'script',
source: 'pause\njump @intro\nstate btn:play pressed',
},
{ type: 'script', source: 'pause\njump @intro\nplay' },
rt,
)
expect(calls).toEqual(['pause', 'seek:2', 'state:play=pressed'])
expect(calls).toEqual(['pause', 'seek:2', 'play'])
})
test('script errors surface via rt.warn, valid actions still fire', () => {
@@ -3525,8 +3383,6 @@ export function registerAllTests(): void {
setPlayhead: () => {},
play: () => fired.push('play'),
pause: () => fired.push('pause'),
setButtonState: () => {},
setButtonDisabled: () => {},
warn: (m) => warnings.push(m),
}
evalAction({ type: 'script', source: 'play\nwat\npause' }, rt)
@@ -3536,80 +3392,6 @@ export function registerAllTests(): void {
})
})
suite('button runtime — state machine + action wiring', () => {
test('default state is normal; set/clear round-trip works', () => {
const rt = useButtonRuntime()
rt.reset()
expect(rt.getState('b1')).toBe('normal')
rt.setState('b1', 'pressed')
expect(rt.getState('b1')).toBe('pressed')
rt.setState('b1', 'normal') // normal uses delete internally
expect(rt.getState('b1')).toBe('normal')
})
test('persisted disabled surfaces as state=disabled until runtime overrides', () => {
const rt = useButtonRuntime()
rt.reset()
expect(rt.getState('b1', true)).toBe('disabled')
rt.setDisabled('b1', false) // runtime enables
expect(rt.getState('b1', true)).toBe('normal')
rt.setDisabled('b1', true)
expect(rt.getState('b1', false)).toBe('disabled')
})
test('applyButtonStateChange starts a transition only when one matches', () => {
const rt = useButtonRuntime()
rt.reset()
// No transitions defined: state flips instantly, no transition entry.
applyButtonStateChange('b1', 'hover', undefined, undefined)
expect(rt.getState('b1')).toBe('hover')
expect(rt.transition.has('b1')).toBe(false)
// With a matching transition: flip state AND start transition.
rt.reset()
applyButtonStateChange(
'b1',
'hover',
[{ from: 'normal', to: 'hover', duration: 0.2, easing: 'easeOut' }],
undefined,
)
expect(rt.getState('b1')).toBe('hover')
expect(rt.transition.has('b1')).toBe(true)
rt.endTransition('b1')
})
test('sampleTransition returns null outside of transitions, u∈[0..1] within', () => {
const rt = useButtonRuntime()
rt.reset()
expect(rt.sampleTransition('b1', performance.now())).toBe(null)
const t0 = performance.now()
rt.startTransition('b1', 'normal', 'hover', 1, 'linear', t0)
const mid = rt.sampleTransition('b1', t0 + 500)!
expect(mid.done).toBe(false)
expect(mid.u > 0.4 && mid.u < 0.6).toBe(true)
const end = rt.sampleTransition('b1', t0 + 1100)!
expect(end.done).toBe(true)
expect(end.u).toBe(1)
rt.endTransition('b1')
})
test('store.actionRuntime resolves labels and drives useButtonRuntime', () => {
const s = makeFreshStore()
const rt = useButtonRuntime()
rt.reset()
const bc = s.addButtonClip('B')
const ct = s.addClipTrack()
const inst = s.addButtonInstance(bc.id, ct.id, 0)!
s.setButtonInstanceLabel(inst.id, 'play')
const ar = s.actionRuntime()
ar.setButtonState('play', 'pressed')
expect(rt.getState(inst.id)).toBe('pressed')
ar.setButtonDisabled('play', true)
expect(rt.getState(inst.id)).toBe('disabled')
ar.setButtonDisabled('play', false)
expect(rt.getState(inst.id, inst.disabled)).toBe('pressed')
})
})
suite('scene store — macro model normalization', () => {
test('loadScene initializes new arrays when missing', () => {
const s = makeFreshStore()
@@ -3622,8 +3404,6 @@ export function registerAllTests(): void {
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([])
@@ -3669,220 +3449,6 @@ export function registerAllTests(): void {
expect(s.scene.scriptFrames).toEqual([])
})
test('removeButtonInstance removes the instance but keeps the clip', () => {
const s = makeFreshStore()
const bc = s.addButtonClip('B')
const ct = s.addClipTrack()
const inst = s.addButtonInstance(bc.id, ct.id, 0)!
s.removeButtonInstance(inst.id)
expect(s.scene.buttonInstances).toHaveLength(0)
expect(s.scene.buttonClips).toHaveLength(1)
})
test('addButtonInstance defaults x/y to stage center', () => {
const s = makeFreshStore()
const bc = s.addButtonClip('B')
const ct = s.addClipTrack()
const inst = s.addButtonInstance(bc.id, ct.id, 0)!
expect(inst.x).toBe(s.scene.canvas.width / 2)
expect(inst.y).toBe(s.scene.canvas.height / 2)
})
test('setButtonInstancePosition updates x/y', () => {
const s = makeFreshStore()
const bc = s.addButtonClip('B')
const ct = s.addClipTrack()
const inst = s.addButtonInstance(bc.id, ct.id, 0)!
s.setButtonInstancePosition(inst.id, 42, 99)
const got = s.scene.buttonInstances?.find((x) => x.id === inst.id)
expect(got?.x).toBe(42)
expect(got?.y).toBe(99)
})
test('setButtonInstanceLabel("") clears the label', () => {
const s = makeFreshStore()
const bc = s.addButtonClip('B')
const ct = s.addClipTrack()
const inst = s.addButtonInstance(bc.id, ct.id, 0)!
s.setButtonInstanceLabel(inst.id, 'play')
expect(s.setButtonInstanceLabel(inst.id, '')).toBe(true)
const got = s.scene.buttonInstances?.find((x) => x.id === inst.id)
expect(got?.label).toBe(undefined)
})
test('setButtonClipTransitions stores list and deletes it when empty', () => {
const s = makeFreshStore()
const bc = s.addButtonClip('B')
s.setButtonClipTransitions(bc.id, [
{ from: 'normal', to: 'hover', duration: 0.2, easing: 'easeOut' },
])
const got = () => s.scene.buttonClips?.find((c) => c.id === bc.id)
expect(got()?.transitions).toHaveLength(1)
s.setButtonClipTransitions(bc.id, [])
expect(got()?.transitions).toBe(undefined)
})
})
suite('macro-clips — button runtime edge cases', () => {
test('applyButtonStateChange is a no-op when target equals current', () => {
const rt = useButtonRuntime()
rt.reset()
applyButtonStateChange(
'b1',
'normal',
[{ from: 'normal', to: 'normal', duration: 1, easing: 'linear' }],
undefined,
)
expect(rt.getState('b1')).toBe('normal')
expect(rt.transition.has('b1')).toBe(false)
})
test('applyButtonStateChange honors persisted disabled flag as the from-state', () => {
const rt = useButtonRuntime()
rt.reset()
// persistedDisabled=true → current 'disabled'; transition from disabled
// to normal should fire if defined.
applyButtonStateChange(
'b1',
'normal',
[{ from: 'disabled', to: 'normal', duration: 0.1, easing: 'linear' }],
true,
)
expect(rt.transition.has('b1')).toBe(true)
rt.endTransition('b1')
})
test('reset() clears state, disabled and transitions', () => {
const rt = useButtonRuntime()
rt.setState('b1', 'pressed')
rt.setDisabled('b2', true)
rt.startTransition('b1', 'normal', 'hover', 1, 'linear', performance.now())
rt.reset()
expect(rt.getState('b1')).toBe('normal')
expect(rt.isDisabled('b2')).toBe(false)
expect(rt.transition.size).toBe(0)
})
test('store.actionRuntime warns + skips for unknown button refs', () => {
const s = makeFreshStore()
const warned: string[] = []
const origWarn = console.warn
console.warn = (...a: unknown[]) => { warned.push(a.map(String).join(' ')) }
try {
const ar = s.actionRuntime()
ar.setButtonState('ghost', 'pressed')
ar.setButtonDisabled('ghost', true)
} finally {
console.warn = origWarn
}
expect(warned.length >= 2).toBe(true)
})
})
suite('macro-clips — blendButtonStates', () => {
function makeLayer(name: string, fill: string): Layer {
return {
id: `l-${name}-${fill}`,
name,
assetId: 'a1',
visible: true,
base: { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1, fill },
keyframes: [
{
id: `kf-${name}-${fill}`,
time: 0,
props: { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1, fill },
easing: 'linear',
},
],
}
}
const asset: Asset = {
id: 'a1',
kind: 'rect',
name: 'face',
width: 40,
height: 20,
fill: '#000',
}
const assetBy = (id: string) => (id === 'a1' ? asset : undefined)
test('matched layer interpolates props via lerpProps', () => {
const clip: ButtonClip = {
id: 'bc',
name: 'b',
states: {
normal: [makeLayer('Face', '#ff0000')],
hover: [makeLayer('Face', '#00ff00')],
pressed: [],
disabled: [],
},
}
const out = blendButtonStates(clip, 'normal', 'hover', 0.5, assetBy)
expect(out).toHaveLength(1)
expect(out[0].layer.name).toBe('Face')
// At u=0.5 the fill should be the mid of #ff0000 → #00ff00 (roughly).
const f = out[0].props.fill!
// Red and green channels should each be around 0x7f-ish
const n = parseInt(f.replace('#', ''), 16)
const r = (n >> 16) & 255
const g = (n >> 8) & 255
expect(r > 100 && r < 160).toBe(true)
expect(g > 100 && g < 160).toBe(true)
})
test('unmatched from-layer fades out, unmatched to-layer fades in', () => {
const clip: ButtonClip = {
id: 'bc',
name: 'b',
states: {
normal: [makeLayer('Only-N', '#ff0000')],
hover: [makeLayer('Only-H', '#00ff00')],
pressed: [],
disabled: [],
},
}
const u = 0.25
const out = blendButtonStates(clip, 'normal', 'hover', u, assetBy)
// First: unmatched from-layer, opacity * (1 - u) = 0.75
expect(out[0].layer.name).toBe('Only-N')
expect(out[0].props.opacity).toBeCloseTo(1 - u, 3)
// Second: unmatched to-layer, opacity * u = 0.25
expect(out[1].layer.name).toBe('Only-H')
expect(out[1].props.opacity).toBeCloseTo(u, 3)
})
test('u=0 is pure from-state, u=1 is pure to-state (matched layer)', () => {
const clip: ButtonClip = {
id: 'bc',
name: 'b',
states: {
normal: [makeLayer('Face', '#ff0000')],
hover: [makeLayer('Face', '#00ff00')],
pressed: [],
disabled: [],
},
}
const atZero = blendButtonStates(clip, 'normal', 'hover', 0, assetBy)
expect(atZero[0].props.fill).toBe('#ff0000')
const atOne = blendButtonStates(clip, 'normal', 'hover', 1, assetBy)
expect(atOne[0].props.fill).toBe('#00ff00')
})
test('skips layers whose asset id does not resolve', () => {
const clip: ButtonClip = {
id: 'bc',
name: 'b',
states: {
normal: [makeLayer('Face', '#ff0000')],
hover: [makeLayer('Face', '#00ff00')],
pressed: [],
disabled: [],
},
}
const out = blendButtonStates(clip, 'normal', 'hover', 0.5, () => undefined)
expect(out).toHaveLength(0)
})
})
suite('macro-clips — DSL edge cases', () => {
@@ -3904,18 +3470,6 @@ export function registerAllTests(): void {
expect(errors).toHaveLength(0)
})
test('state command with wrong ref prefix reports error', () => {
const { errors } = parseScript('state label pressed')
expect(errors).toHaveLength(1)
expect(errors[0].message).toContain('btn:')
})
test('disable without argument reports error', () => {
const { errors } = parseScript('disable')
expect(errors).toHaveLength(1)
expect(errors[0].message).toContain('disable')
})
test('jump with empty @marker reports error', () => {
const { errors } = parseScript('jump @')
expect(errors).toHaveLength(1)
-39
View File
@@ -95,25 +95,6 @@ export const en = {
message: 'The clip and all placed instances will be removed.',
confirm: 'Delete',
},
buttons: 'Buttons',
buttonSubtitle: 'Button · 4 states',
noButtons: 'No buttons yet',
createButtonTooltip: 'Create new button',
newButtonDialog: {
title: 'New button',
message: 'Button name:',
default: 'Button',
confirm: 'Create',
},
renameButtonDialog: {
title: 'Rename button',
confirm: 'Apply',
},
deleteButtonDialog: {
title: 'Delete button?',
message: 'The button and all placed instances will be removed.',
confirm: 'Delete',
},
},
properties: {
@@ -385,7 +366,6 @@ export const en = {
moveUp: 'Move up',
moveDown: 'Move down',
noMarkers: 'No markers defined',
noButtons: 'No labeled button instances',
type: {
play: 'Play',
pause: 'Pause',
@@ -393,17 +373,12 @@ export const en = {
jumpTo: 'Jump to time',
jumpToFrame: 'Jump to frame',
jumpToMarker: 'Jump to marker',
setButtonState: 'Set button state',
setButtonDisabled: 'Set button disabled',
script: 'Run script',
},
param: {
time: 'Seconds',
frame: 'Frame',
marker: 'Marker',
button: 'Button',
state: 'State',
disabled: 'Disabled',
source: 'Script source',
},
},
@@ -415,20 +390,6 @@ export const en = {
remove: 'Delete frame',
done: 'Done',
},
buttonInstanceDialog: {
title: 'Button instance',
clipName: 'Button',
label: 'Label',
labelPlaceholder: 'e.g. playButton',
labelCollision: 'Label "{name}" is already used by another button instance.',
disabled: 'Start disabled',
onClick: 'On click',
transitions: 'Transitions',
transitionsHint: '(shared by all instances of this button)',
addTransition: 'Add transition',
remove: 'Delete instance',
done: 'Done',
},
} as const
export type Messages = typeof en
+1 -13
View File
@@ -1,4 +1,4 @@
import type { Action, ButtonState, Marker } from '@/types/scene'
import type { Action, Marker } from '@/types/scene'
import { parseScript } from './scriptDsl'
export interface ActionRuntime {
@@ -10,12 +10,6 @@ export interface ActionRuntime {
setPlayhead(t: number): void
play(): void
pause(): void
/**
* `instanceIdOrLabel` may be a real ButtonInstance id OR a user-facing
* label — the runtime resolves via the label map internally.
*/
setButtonState(instanceIdOrLabel: string, state: ButtonState): void
setButtonDisabled(instanceIdOrLabel: string, disabled: boolean): void
warn(msg: string): void
}
@@ -55,12 +49,6 @@ export function evalAction(action: Action, rt: ActionRuntime): void {
rt.setPlayhead(clamp(t, 0, rt.duration))
return
}
case 'setButtonState':
rt.setButtonState(action.instanceId, action.state)
return
case 'setButtonDisabled':
rt.setButtonDisabled(action.instanceId, action.disabled)
return
case 'script': {
const parsed = parseScript(action.source)
if (parsed.errors.length) {
-55
View File
@@ -1,55 +0,0 @@
import type { AnimatableProps, Asset, ButtonClip, ButtonState, Layer } from '@/types/scene'
import { lerpProps, sampleLayer } from '@/lib/interpolate'
export interface BlendedLayerRender {
layer: Layer
asset: Asset
props: AnimatableProps
}
/**
* Blend the "from" state's layers into the "to" state's layers by layer
* name. Matched pairs lerp their sampled (t=0) props; unmatched
* from-layers fade out (opacity × (1 − u)), unmatched to-layers fade in
* (opacity × u). Used by the canvas renderer and the export runtime
* while a button state transition is in progress.
*
* `u` is expected to be post-easing progress ∈ [0, 1].
*/
export function blendButtonStates(
clip: ButtonClip,
from: ButtonState,
to: ButtonState,
u: number,
assetBy: (id: string) => Asset | undefined,
): BlendedLayerRender[] {
const fromLayers = clip.states[from] ?? []
const toLayers = clip.states[to] ?? []
const fromByName = new Map<string, Layer>()
const toByName = new Map<string, Layer>()
for (const l of fromLayers) fromByName.set(l.name, l)
for (const l of toLayers) toByName.set(l.name, l)
const out: BlendedLayerRender[] = []
// Unmatched from-layers render first (behind to-layers) and fade out.
for (const l of fromLayers) {
if (toByName.has(l.name)) continue
const asset = assetBy(l.assetId)
if (!asset) continue
const base = sampleLayer(l, 0)
out.push({ layer: l, asset, props: { ...base, opacity: base.opacity * (1 - u) } })
}
for (const l of toLayers) {
const asset = assetBy(l.assetId)
if (!asset) continue
const fromLayer = fromByName.get(l.name)
if (fromLayer) {
const a = sampleLayer(fromLayer, 0)
const b = sampleLayer(l, 0)
out.push({ layer: l, asset, props: lerpProps(a, b, u) })
} else {
const base = sampleLayer(l, 0)
out.push({ layer: l, asset, props: { ...base, opacity: base.opacity * u } })
}
}
return out
}
+2 -254
View File
@@ -1,6 +1,6 @@
import type { Scene } from '@/types/scene'
const RUNTIME_VERSION = '6'
const RUNTIME_VERSION = '7'
const ENGINE_SOURCE = `(function(){
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
@@ -197,174 +197,9 @@ const ENGINE_SOURCE = `(function(){
jumpTo: null,
forcePause: false,
forcePlay: false,
buttonStates: {},
buttonDisabled: {},
buttonLabels: {},
warn: function(m){ if(window.console)window.console.warn('[animatr]',m); }
};
(scene.markers||[]).forEach(function(mk){ runtime.markers[mk.name]=mk.time; });
(scene.buttonInstances||[]).forEach(function(bi){
if(bi.label) runtime.buttonLabels[bi.label]=bi.id;
});
function resolveButtonRef(ref){
// Try as real instance id first, then as label.
var bis=scene.buttonInstances||[];
for(var bri=0;bri<bis.length;bri++) if(bis[bri].id===ref) return ref;
return runtime.buttonLabels[ref]||null;
}
var buttonHits=[]; // refreshed per draw()
function getEffectiveButtonState(bi){
var ovD=runtime.buttonDisabled[bi.id];
var isDis=ovD===undefined?!!bi.disabled:ovD;
if(isDis) return 'disabled';
return runtime.buttonStates[bi.id]||'normal';
}
function setButtonState(id, st){
if(st==='normal') delete runtime.buttonStates[id];
else runtime.buttonStates[id]=st;
}
var buttonTransitions={}; // id -> { from, to, startedAt, duration, easing }
function startButtonTransition(id, from, to, duration, easing){
buttonTransitions[id]={from:from, to:to, startedAt:performance.now(), duration:duration, easing:easing};
}
function easingProgressRt(u, e){
if(u<=0) return 0; if(u>=1) return 1;
if(e==='easeIn') return u*u;
if(e==='easeOut') return 1-(1-u)*(1-u);
if(e==='easeInOut') return u<0.5?2*u*u:1-Math.pow(-2*u+2,2)/2;
return u;
}
function sampleButtonTransition(id){
var tr=buttonTransitions[id]; if(!tr) return null;
var dur=Math.max(0.0001, tr.duration);
var raw=(performance.now()-tr.startedAt)/1000/dur;
if(raw>=1){ delete buttonTransitions[id]; return { u:1, from:tr.from, to:tr.to, done:true }; }
return { u:easingProgressRt(raw, tr.easing), from:tr.from, to:tr.to, done:false };
}
function applyButtonChange(bi, clip, to){
var from=getEffectiveButtonState(bi);
if(from===to) return;
var trs=clip.transitions||[];
for(var ti=0; ti<trs.length; ti++){
if(trs[ti].from===from && trs[ti].to===to && trs[ti].duration>0){
startButtonTransition(bi.id, from, to, trs[ti].duration, trs[ti].easing);
break;
}
}
setButtonState(bi.id, to);
}
function lerpScalar(a,b,t){return a+(b-a)*t;}
function lerpRuntimeProps(a,b,t){
var out={
x:lerpScalar(a.x,b.x,t),
y:lerpScalar(a.y,b.y,t),
scaleX:lerpScalar(a.scaleX,b.scaleX,t),
scaleY:lerpScalar(a.scaleY,b.scaleY,t),
rotation:lerpScalar(a.rotation,b.rotation,t),
opacity:lerpScalar(a.opacity,b.opacity,t)
};
if(a.fill&&b.fill) out.fill=lerpColor(a.fill,b.fill,t);
else if(b.fill) out.fill=b.fill;
else if(a.fill) out.fill=a.fill;
return out;
}
function drawButtonStatesBlended(clip, from, to, u){
var fromLs=(clip.states&&clip.states[from])||[];
var toLs=(clip.states&&clip.states[to])||[];
var toByName={};
for(var ii=0; ii<toLs.length; ii++) toByName[toLs[ii].name]=toLs[ii];
var fromByName={};
for(var ij=0; ij<fromLs.length; ij++) fromByName[fromLs[ij].name]=fromLs[ij];
// Unmatched from-layers fade out.
for(var fi=0; fi<fromLs.length; fi++){
var fl=fromLs[fi];
if(toByName[fl.name]) continue;
var flp=sample(fl,0);
drawLayer(fl, 0, { opacity: flp.opacity*(1-u) });
}
for(var ti2=0; ti2<toLs.length; ti2++){
var tl=toLs[ti2];
var fm=fromByName[tl.name];
if(fm){
var ap=sample(fm,0);
var bp=sample(tl,0);
drawLayer(tl, 0, lerpRuntimeProps(ap,bp,u));
} else {
var tlp=sample(tl,0);
drawLayer(tl, 0, { opacity: tlp.opacity*u });
}
}
}
function pointerToSceneXY(e){
var rc=canvas.getBoundingClientRect();
var lw=scene.canvas.width, lh=scene.canvas.height;
var cw=size.w, ch=size.h;
if(!cw||!ch||!rc.width||!rc.height) return null;
var sc=Math.min(cw/lw, ch/lh);
var ox=(cw-lw*sc)/2, oy=(ch-lh*sc)/2;
var cx=(e.clientX-rc.left)/rc.width*cw;
var cy=(e.clientY-rc.top)/rc.height*ch;
return { x:(cx-ox)/sc, y:(cy-oy)/sc };
}
function hitButtonAt(p){
for(var i=buttonHits.length-1; i>=0; i--){
var h=buttonHits[i];
var lx=p.x-(h.bi.x||0), ly=p.y-(h.bi.y||0);
if(lx>=h.hit.left && lx<=h.hit.right && ly>=h.hit.top && ly<=h.hit.bottom) return h;
}
return null;
}
function findClipForInstance(bi){
var cs=scene.buttonClips||[];
for(var i=0;i<cs.length;i++) if(cs[i].id===bi.buttonClipId) return cs[i];
return null;
}
function onStageMove(e){
var p=pointerToSceneXY(e);
if(!p) return;
var hit=hitButtonAt(p);
for(var i=0;i<buttonHits.length;i++){
var bh=buttonHits[i];
if(getEffectiveButtonState(bh.bi)==='disabled') continue;
var clip=findClipForInstance(bh.bi);
if(!clip) continue;
if(hit && hit.bi.id===bh.bi.id){
if(getEffectiveButtonState(bh.bi)==='normal') applyButtonChange(bh.bi, clip, 'hover');
} else {
var cur=getEffectiveButtonState(bh.bi);
if(cur==='hover'||cur==='pressed') applyButtonChange(bh.bi, clip, 'normal');
}
}
}
function onStageDown(e){
var p=pointerToSceneXY(e);
if(!p) return;
var hit=hitButtonAt(p);
if(!hit) return;
if(getEffectiveButtonState(hit.bi)==='disabled') return;
var clip=findClipForInstance(hit.bi);
if(clip) applyButtonChange(hit.bi, clip, 'pressed');
}
function onStageUp(e){
var p=pointerToSceneXY(e);
if(!p) return;
for(var i=0;i<buttonHits.length;i++){
var bh=buttonHits[i];
if(getEffectiveButtonState(bh.bi)!=='pressed') continue;
var lx=p.x-(bh.bi.x||0), ly=p.y-(bh.bi.y||0);
var inside=lx>=bh.hit.left&&lx<=bh.hit.right&&ly>=bh.hit.top&&ly<=bh.hit.bottom;
var clip=findClipForInstance(bh.bi);
if(clip) applyButtonChange(bh.bi, clip, inside?'hover':'normal');
if(inside){
var acts=bh.bi.onClick||[];
for(var ai=0; ai<acts.length; ai++) evalAction(acts[ai]);
}
}
}
canvas.addEventListener('mousemove', onStageMove);
canvas.addEventListener('mousedown', onStageDown);
canvas.addEventListener('mouseup', onStageUp);
var BUTTON_STATES=['normal','hover','pressed','disabled'];
function parseScriptSource(source){
var outActs=[];
var outErrs=[];
@@ -399,18 +234,6 @@ const ENGINE_SOURCE = `(function(){
else parsed={type:'jumpTo',time:tm};
}
}
} else if(cmd==='state'){
if(toks.length<3) err='state: expected btn:<label> <state>';
else if(toks[1].indexOf('btn:')!==0) err='state: expected btn:<label>';
else {
var st=toks[2];
if(BUTTON_STATES.indexOf(st)<0) err='state: unknown state '+st;
else parsed={type:'setButtonState',instanceId:toks[1].slice(4),state:st};
}
} else if(cmd==='enable'||cmd==='disable'){
if(toks.length<2) err=cmd+': missing btn:<label>';
else if(toks[1].indexOf('btn:')!==0) err=cmd+': expected btn:<label>';
else parsed={type:'setButtonDisabled',instanceId:toks[1].slice(4),disabled:cmd==='disable'};
} else {
err='unknown command "'+cmd+'"';
}
@@ -432,26 +255,6 @@ const ENGINE_SOURCE = `(function(){
runtime.jumpTo=Math.max(0,Math.min(runtime.duration,mt));
return;
}
case 'setButtonState': {
var sid=resolveButtonRef(act.instanceId);
if(!sid){ runtime.warn('Unknown button ref: '+act.instanceId); return; }
var sBi=null, sClip=null;
var sBis=scene.buttonInstances||[];
for(var sbI=0; sbI<sBis.length; sbI++) if(sBis[sbI].id===sid){ sBi=sBis[sbI]; break; }
if(sBi){
var sClips=scene.buttonClips||[];
for(var scI=0; scI<sClips.length; scI++) if(sClips[scI].id===sBi.buttonClipId){ sClip=sClips[scI]; break; }
}
if(sBi && sClip) applyButtonChange(sBi, sClip, act.state);
else setButtonState(sid, act.state);
return;
}
case 'setButtonDisabled': {
var did=resolveButtonRef(act.instanceId);
if(!did){ runtime.warn('Unknown button ref: '+act.instanceId); return; }
runtime.buttonDisabled[did]=act.disabled;
return;
}
case 'script': {
var parsed=parseScriptSource(act.source);
if(parsed.errors.length){
@@ -573,11 +376,10 @@ const ENGINE_SOURCE = `(function(){
}
ctxm.restore();
}
function drawLayer(layer,time,propsOverride){
function drawLayer(layer,time){
var asset=assetMap[layer.assetId];
if(!asset||!layer.visible)return;
var p=sample(layer,time);
if(propsOverride){ for(var poKey in propsOverride) p[poKey]=propsOverride[poKey]; }
ctx.save();
ctx.globalAlpha=ctx.globalAlpha*p.opacity;
ctx.translate(p.x,p.y);
@@ -807,60 +609,6 @@ const ENGINE_SOURCE = `(function(){
ctx.drawImage(topOff,0,0);
ctx.restore();
}
// Draw button instances on top of everything else, and record their
// scene-space hit rectangles for pointer handling.
buttonHits.length=0;
var bInsts=scene.buttonInstances||[];
var bClips=scene.buttonClips||[];
var bClipById={};
for(var bcI=0;bcI<bClips.length;bcI++) bClipById[bClips[bcI].id]=bClips[bcI];
for(var biI=0;biI<bInsts.length;biI++){
var bi=bInsts[biI];
if(time<bi.startTime) continue;
var bclp=bClipById[bi.buttonClipId];
if(!bclp) continue;
var bst=getEffectiveButtonState(bi);
var btr=sampleButtonTransition(bi.id);
var bx=bi.x||0, by=bi.y||0;
ctx.save();
ctx.translate(bx, by);
if(btr && !btr.done && bst!=='disabled'){
drawButtonStatesBlended(bclp, btr.from, btr.to, btr.u);
} else {
var bStack=(bclp.states&&bclp.states[bst])||[];
for(var blI=0; blI<bStack.length; blI++) drawLayer(bStack[blI], 0);
}
ctx.restore();
// Hit rect: explicit hitArea on clip, else bbox of first rect/circle
// layer in the normal state.
var hit=null;
if(bclp.hitArea){
var ox2=bclp.hitArea.offsetX==null?(bclp.hitArea.width/2):bclp.hitArea.offsetX;
var oy2=bclp.hitArea.offsetY==null?(bclp.hitArea.height/2):bclp.hitArea.offsetY;
hit={left:-ox2, top:-oy2, right:bclp.hitArea.width-ox2, bottom:bclp.hitArea.height-oy2};
}
if(!hit){
var normalLs=(bclp.states&&bclp.states.normal)||[];
for(var nli=0; nli<normalLs.length; nli++){
var nl=normalLs[nli];
var na=assetMap[nl.assetId];
if(!na) continue;
if(na.kind==='rect'||na.kind==='image'){
var rox=nl.offsetX==null?na.width/2:nl.offsetX;
var roy=nl.offsetY==null?na.height/2:nl.offsetY;
hit={left:-rox, top:-roy, right:na.width-rox, bottom:na.height-roy};
break;
}
if(na.kind==='circle'){
var cox=nl.offsetX||0, coy=nl.offsetY||0;
hit={left:cox-na.radius, top:coy-na.radius, right:cox+na.radius, bottom:coy+na.radius};
break;
}
}
}
if(!hit) hit={left:-60, top:-20, right:60, bottom:20};
buttonHits.push({bi:bi, hit:hit});
}
ctx.restore();
}
loadImages(scene.assets).then(function(loaded){
+1 -50
View File
@@ -1,4 +1,4 @@
import type { Action, ButtonState } from '@/types/scene'
import type { Action } from '@/types/scene'
export interface ParseError {
line: number
@@ -10,13 +10,6 @@ export interface ParseResult {
errors: ParseError[]
}
const BUTTON_STATES = new Set<ButtonState>([
'normal',
'hover',
'pressed',
'disabled',
])
/**
* Parse a small line-based DSL into Action[]. Comments start with `//`
* and each non-empty line is one command. Known commands:
@@ -25,12 +18,6 @@ const BUTTON_STATES = new Set<ButtonState>([
* jump <seconds> (e.g. `jump 2.5`)
* jump #<frame> (e.g. `jump #135`)
* jump @<marker> (e.g. `jump @intro`)
* state btn:<label> <state> (state ∈ normal|hover|pressed|disabled)
* enable btn:<label>
* disable btn:<label>
*
* Button labels are left as-is in `instanceId`. The runtime resolves
* labels to real instance IDs at eval time.
*/
export function parseScript(source: string): ParseResult {
const actions: Action[] = []
@@ -64,12 +51,6 @@ function parseLine(line: string): { action?: Action; error?: string } {
return ok({ type: 'rewindToLoop' })
case 'jump':
return parseJump(tokens)
case 'state':
return parseState(tokens)
case 'enable':
return parseEnable(tokens, false)
case 'disable':
return parseEnable(tokens, true)
default:
return { error: `unknown command "${cmd}"` }
}
@@ -96,33 +77,3 @@ function parseJump(tokens: string[]): { action?: Action; error?: string } {
if (!Number.isFinite(time)) return { error: `jump: invalid time "${arg}"` }
return ok({ type: 'jumpTo', time })
}
function parseButtonRef(token: string): string | null {
if (!token.startsWith('btn:')) return null
const label = token.slice(4)
return label || null
}
function parseState(tokens: string[]): { action?: Action; error?: string } {
if (tokens.length < 3) return { error: 'state: expected `btn:<label> <state>`' }
const label = parseButtonRef(tokens[1])
if (!label) return { error: `state: expected button ref like btn:<label>, got "${tokens[1]}"` }
const state = tokens[2] as ButtonState
if (!BUTTON_STATES.has(state)) {
return { error: `state: unknown state "${state}"` }
}
return ok({ type: 'setButtonState', instanceId: label, state })
}
function parseEnable(
tokens: string[],
disabled: boolean,
): { action?: Action; error?: string } {
const verb = disabled ? 'disable' : 'enable'
if (tokens.length < 2) return { error: `${verb}: missing button ref` }
const label = parseButtonRef(tokens[1])
if (!label) {
return { error: `${verb}: expected button ref like btn:<label>` }
}
return ok({ type: 'setButtonDisabled', instanceId: label, disabled })
}
+6 -172
View File
@@ -4,8 +4,6 @@ import type {
AnimatableProps,
Asset,
BackgroundVideo,
ButtonClip,
ButtonInstance,
Clip,
ClipInstance,
ClipTrack,
@@ -27,10 +25,6 @@ import {
scriptFramesInRange,
type ActionRuntime,
} from '@/lib/actions'
import {
applyButtonStateChange,
useButtonRuntime,
} from '@/composables/useButtonRuntime'
const uid = () => Math.random().toString(36).slice(2, 10)
@@ -128,8 +122,6 @@ function normalizeScene(scene: Scene): Scene {
scene.clipTracks ||= []
scene.clipInstances ||= []
scene.groups ||= []
scene.buttonClips ||= []
scene.buttonInstances ||= []
scene.scriptTracks ||= []
scene.scriptFrames ||= []
scene.markers ||= []
@@ -566,6 +558,12 @@ export const useSceneStore = defineStore('scene', {
else clipTrack.color = color
return
}
const scriptTrack = this.scene.scriptTracks?.find((t) => t.id === trackId)
if (scriptTrack) {
if (color === null) delete scriptTrack.color
else scriptTrack.color = color
return
}
const group = this.scene.groups?.find((g) => g.id === trackId)
if (group) {
if (color === null) delete group.color
@@ -1138,139 +1136,6 @@ export const useSceneStore = defineStore('scene', {
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
},
/**
* Create a ButtonClip pre-populated with a visible rectangle for each
* of the four states (different shades per state). The single shared
* asset is added to the scene. Intended for the "+ Button" sidebar
* flow — advanced users can still edit or replace the layers later.
*/
seedDefaultButtonClip(name?: string): ButtonClip {
const title = name?.trim() || 'Button'
const clip = this.addButtonClip(title)
const asset = this.addAsset({
kind: 'rect',
name: `${title} face`,
width: 120,
height: 40,
fill: '#3b82f6',
}) as Extract<Asset, { kind: 'rect' }>
const makeFace = (fill: string, layerName: string): Layer => ({
id: uid(),
name: layerName,
assetId: asset.id,
visible: true,
base: { ...DEFAULT_BASE_PROPS, fill },
keyframes: [
{
id: uid(),
time: 0,
props: { ...DEFAULT_BASE_PROPS, fill },
easing: 'linear',
},
],
offsetX: asset.width / 2,
offsetY: asset.height / 2,
})
const target = this.scene.buttonClips?.find((c) => c.id === clip.id)
if (target) {
target.states = {
normal: [makeFace('#3b82f6', 'Face')],
hover: [makeFace('#60a5fa', 'Face')],
pressed: [makeFace('#2563eb', 'Face')],
disabled: [makeFace('#9ca3af', 'Face')],
}
}
return target ?? 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
},
setButtonClipTransitions(
id: string,
transitions: NonNullable<ButtonClip['transitions']>,
) {
const clip = this.scene.buttonClips?.find((c) => c.id === id)
if (!clip) return
if (transitions.length) clip.transitions = [...transitions]
else delete clip.transitions
},
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)),
x: this.scene.canvas.width / 2,
y: this.scene.canvas.height / 2,
}
;(this.scene.buttonInstances ||= []).push(inst)
return inst
},
setButtonInstancePosition(id: string, x: number, y: number) {
const target = this.scene.buttonInstances?.find((bi) => bi.id === id)
if (!target) return
target.x = x
target.y = y
},
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
@@ -1558,15 +1423,6 @@ export const useSceneStore = defineStore('scene', {
},
actionRuntime(): ActionRuntime {
const self = this
const instances = self.scene.buttonInstances ?? []
const byId = new Set(instances.map((bi) => bi.id))
const byLabel = new Map<string, string>()
for (const bi of instances) if (bi.label) byLabel.set(bi.label, bi.id)
function resolve(ref: string): string | null {
if (byId.has(ref)) return ref
return byLabel.get(ref) ?? null
}
const btnRt = useButtonRuntime()
return {
fps: self.scene.fps,
playbackIn: self.playbackIn,
@@ -1582,28 +1438,6 @@ export const useSceneStore = defineStore('scene', {
pause() {
self.pause()
},
setButtonState(ref, state) {
const id = resolve(ref)
if (!id) {
// eslint-disable-next-line no-console
console.warn('[animatr] unknown button ref:', ref)
return
}
const inst = instances.find((x) => x.id === id)
const clip = self.scene.buttonClips?.find(
(c) => c.id === inst?.buttonClipId,
)
applyButtonStateChange(id, state, clip?.transitions, inst?.disabled)
},
setButtonDisabled(ref, disabled) {
const id = resolve(ref)
if (!id) {
// eslint-disable-next-line no-console
console.warn('[animatr] unknown button ref:', ref)
return
}
btnRt.setDisabled(id, disabled)
},
warn(msg: string) {
// eslint-disable-next-line no-console
console.warn('[animatr]', msg)
+1 -49
View File
@@ -162,51 +162,7 @@ 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
/** Stage-space position of the button's origin. */
x?: number
y?: number
/** Unique, user-facing handle used in dropdowns and script DSL. */
label?: string
onClick?: Action[]
/** Initial disabled state at runtime start. */
disabled?: boolean
}
// ---- Scripts & markers ----------------------------------------------------
export interface ScriptTrack {
id: string
@@ -237,8 +193,6 @@ export type Action =
| { 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 }
// ---------------------------------------------------------------------------
@@ -264,8 +218,6 @@ export interface Scene {
trackOrder?: string[]
groups?: TrackGroup[]
backgroundVideo?: BackgroundVideo
buttonClips?: ButtonClip[]
buttonInstances?: ButtonInstance[]
scriptTracks?: ScriptTrack[]
scriptFrames?: ScriptFrame[]
markers?: Marker[]