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:
1 parent
616973b0fa
commit
31604052ca
16 files changed
+84
-2809
No files matched your search
@@ -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.
|
||||
@@ -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)
|
||||
```
|
||||
@@ -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') }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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[]
|
||||
|
||||
Reference in new issue
Block a user