macro-clips slice 1: types + store for buttons, scripts, markers
- ButtonClip/ButtonInstance, ScriptTrack/ScriptFrame, Marker, Action union - new optional scene collections (buttonClips, buttonInstances, scriptTracks, scriptFrames, markers); normalizeScene initializes them on load - store actions for all new entities, marker name + button label uniqueness, time clamping, cascading deletes (script frames with their track, button instances with their clip), script tracks join trackOrder + setTrackLocked - design doc docs/macro-clips-and-scripts.md - tests cover add/remove/rename/retime paths, uniqueness, sort order, label collisions, and normalization Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d4c4830fe2
commit
a75e9151ed
4 files changed
+952
No files matched your search
@@ -0,0 +1,437 @@
|
|||||||
|
# Macro Clips & Scripts — Featureset Plan
|
||||||
|
|
||||||
|
Interaktive Buttons plus eine kleine Script-Sprache, mit der Timelines
|
||||||
|
gesteuert werden können.
|
||||||
|
|
||||||
|
## Getroffene Entscheidungen
|
||||||
|
|
||||||
|
1. Buttons sind **Definitionen + Instanzen** (wie normale Clips): einmal
|
||||||
|
definieren, beliebig oft auf Clip-Tracks instanziieren.
|
||||||
|
2. `pressed` ist **rein visueller State**. Aktionen hängen an einem
|
||||||
|
`onClick` pro Button-**Instanz** (nicht pro Definition), damit derselbe
|
||||||
|
Button in unterschiedlichen Kontexten andere Dinge tun kann.
|
||||||
|
3. Script-Actions feuern **nur bei vorwärts laufendem Playback**, nicht
|
||||||
|
beim Scrubben und nicht beim Rückwärtslaufen.
|
||||||
|
|
||||||
|
## Datenmodell
|
||||||
|
|
||||||
|
### Clip mit Varianten
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type Clip = AnimationClip | ButtonClip
|
||||||
|
|
||||||
|
interface AnimationClip {
|
||||||
|
kind: 'animation' // default, bestehendes Verhalten
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
duration: number
|
||||||
|
layers: Layer[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ButtonState = 'normal' | 'hover' | 'pressed' | 'disabled'
|
||||||
|
|
||||||
|
interface ButtonClip {
|
||||||
|
kind: 'button'
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
states: Record<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.
|
||||||
@@ -2987,4 +2987,243 @@ export function registerAllTests(): void {
|
|||||||
expect(outside[3] > 200).toBe(true)
|
expect(outside[3] > 200).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
suite('scene store — markers', () => {
|
||||||
|
test('addMarker defaults the name when none provided', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const m = s.addMarker(2)
|
||||||
|
expect(m.name).toBe('Marker')
|
||||||
|
expect(m.time).toBe(2)
|
||||||
|
expect(s.scene.markers).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('addMarker de-dupes colliding names with suffix', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
s.addMarker(1, 'Intro')
|
||||||
|
s.addMarker(2, 'Intro')
|
||||||
|
s.addMarker(3, 'Intro')
|
||||||
|
const names = (s.scene.markers ?? []).map((m) => m.name)
|
||||||
|
expect(names).toEqual(['Intro', 'Intro 2', 'Intro 3'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('addMarker clamps time to [0, duration]', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
s.setDuration(10)
|
||||||
|
const a = s.addMarker(-5)
|
||||||
|
const b = s.addMarker(99)
|
||||||
|
expect(a.time).toBe(0)
|
||||||
|
expect(b.time).toBe(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('addMarker keeps the list sorted by time', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
s.addMarker(5, 'C')
|
||||||
|
s.addMarker(1, 'A')
|
||||||
|
s.addMarker(3, 'B')
|
||||||
|
const times = (s.scene.markers ?? []).map((m) => m.time)
|
||||||
|
expect(times).toEqual([1, 3, 5])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renameMarker succeeds on unique, rejects duplicates', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const a = s.addMarker(1, 'A')
|
||||||
|
s.addMarker(2, 'B')
|
||||||
|
expect(s.renameMarker(a.id, 'A2')).toBe(true)
|
||||||
|
expect(s.renameMarker(a.id, 'B')).toBe(false)
|
||||||
|
const byId = (s.scene.markers ?? []).find((m) => m.id === a.id)
|
||||||
|
expect(byId?.name).toBe('A2')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('removeMarker drops the entry', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const m = s.addMarker(1)
|
||||||
|
s.removeMarker(m.id)
|
||||||
|
expect(s.scene.markers).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('setMarkerTime clamps and re-sorts', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
s.setDuration(10)
|
||||||
|
const a = s.addMarker(1, 'A')
|
||||||
|
const b = s.addMarker(2, 'B')
|
||||||
|
s.setMarkerTime(a.id, 99)
|
||||||
|
const sortedNames = (s.scene.markers ?? []).map((m) => m.name)
|
||||||
|
expect(sortedNames).toEqual(['B', 'A'])
|
||||||
|
const updated = (s.scene.markers ?? []).find((m) => m.id === a.id)!
|
||||||
|
expect(updated.time).toBe(10)
|
||||||
|
void b
|
||||||
|
})
|
||||||
|
|
||||||
|
test('setMarkerColor sets and clears', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const m = s.addMarker(1, 'A')
|
||||||
|
s.setMarkerColor(m.id, '#ff8800')
|
||||||
|
const get = () => s.scene.markers?.find((x) => x.id === m.id)
|
||||||
|
expect(get()?.color).toBe('#ff8800')
|
||||||
|
s.setMarkerColor(m.id, null)
|
||||||
|
expect(get()?.color).toBe(undefined)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
suite('scene store — script tracks & frames', () => {
|
||||||
|
test('addScriptTrack appends and adds to trackOrder', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const t = s.addScriptTrack('Triggers')
|
||||||
|
expect(s.scene.scriptTracks).toHaveLength(1)
|
||||||
|
expect(s.scene.trackOrder?.includes(t.id)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('addScriptFrame clamps time and returns frame', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
s.setDuration(10)
|
||||||
|
const t = s.addScriptTrack()
|
||||||
|
const f = s.addScriptFrame(t.id, 99, [{ type: 'pause' }])
|
||||||
|
expect(f).toBeTruthy()
|
||||||
|
expect(f!.time).toBe(10)
|
||||||
|
expect(f!.actions).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('addScriptFrame returns null for unknown trackId', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const f = s.addScriptFrame('no-such-id', 1)
|
||||||
|
expect(f).toBe(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('script frames stay sorted by time', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const t = s.addScriptTrack()
|
||||||
|
s.addScriptFrame(t.id, 5)
|
||||||
|
s.addScriptFrame(t.id, 1)
|
||||||
|
s.addScriptFrame(t.id, 3)
|
||||||
|
const times = (s.scene.scriptFrames ?? []).map((f) => f.time)
|
||||||
|
expect(times).toEqual([1, 3, 5])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('removeScriptTrack cascades its frames and trackOrder entry', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const t = s.addScriptTrack()
|
||||||
|
s.addScriptFrame(t.id, 1)
|
||||||
|
s.addScriptFrame(t.id, 2)
|
||||||
|
s.removeScriptTrack(t.id)
|
||||||
|
expect(s.scene.scriptTracks).toHaveLength(0)
|
||||||
|
expect(s.scene.scriptFrames).toHaveLength(0)
|
||||||
|
expect(s.scene.trackOrder?.includes(t.id)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('setScriptFrameActions replaces actions', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const t = s.addScriptTrack()
|
||||||
|
const f = s.addScriptFrame(t.id, 1, [{ type: 'play' }])!
|
||||||
|
s.setScriptFrameActions(f.id, [{ type: 'pause' }, { type: 'rewindToLoop' }])
|
||||||
|
const updated = s.scene.scriptFrames?.find((x) => x.id === f.id)
|
||||||
|
expect(updated?.actions).toHaveLength(2)
|
||||||
|
expect(updated?.actions[0].type).toBe('pause')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('setScriptFrameTime clamps and re-sorts', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
s.setDuration(10)
|
||||||
|
const t = s.addScriptTrack()
|
||||||
|
const a = s.addScriptFrame(t.id, 1)!
|
||||||
|
const b = s.addScriptFrame(t.id, 2)!
|
||||||
|
s.setScriptFrameTime(a.id, 99)
|
||||||
|
const times = (s.scene.scriptFrames ?? []).map((f) => f.time)
|
||||||
|
expect(times).toEqual([2, 10])
|
||||||
|
void b
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
suite('scene store — button clips & instances', () => {
|
||||||
|
test('addButtonClip creates 4 empty state stacks', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const bc = s.addButtonClip('Play')
|
||||||
|
expect(bc.name).toBe('Play')
|
||||||
|
expect(bc.states.normal).toHaveLength(0)
|
||||||
|
expect(bc.states.hover).toHaveLength(0)
|
||||||
|
expect(bc.states.pressed).toHaveLength(0)
|
||||||
|
expect(bc.states.disabled).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('addButtonInstance validates clip and track ids', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const bc = s.addButtonClip('B')
|
||||||
|
const ct = s.addClipTrack()
|
||||||
|
expect(s.addButtonInstance('no-clip', ct.id, 0)).toBe(null)
|
||||||
|
expect(s.addButtonInstance(bc.id, 'no-track', 0)).toBe(null)
|
||||||
|
const inst = s.addButtonInstance(bc.id, ct.id, 0)
|
||||||
|
expect(inst).toBeTruthy()
|
||||||
|
expect(s.scene.buttonInstances).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('removeButtonClip cascades its instances', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const bc = s.addButtonClip('B')
|
||||||
|
const ct = s.addClipTrack()
|
||||||
|
s.addButtonInstance(bc.id, ct.id, 0)
|
||||||
|
s.addButtonInstance(bc.id, ct.id, 1)
|
||||||
|
s.removeButtonClip(bc.id)
|
||||||
|
expect(s.scene.buttonClips).toHaveLength(0)
|
||||||
|
expect(s.scene.buttonInstances).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('setButtonInstanceLabel enforces scene-wide uniqueness', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const bc = s.addButtonClip('B')
|
||||||
|
const ct = s.addClipTrack()
|
||||||
|
const a = s.addButtonInstance(bc.id, ct.id, 0)!
|
||||||
|
const b = s.addButtonInstance(bc.id, ct.id, 1)!
|
||||||
|
expect(s.setButtonInstanceLabel(a.id, 'play')).toBe(true)
|
||||||
|
expect(s.setButtonInstanceLabel(b.id, 'play')).toBe(false)
|
||||||
|
expect(s.setButtonInstanceLabel(b.id, 'mute')).toBe(true)
|
||||||
|
const getA = s.scene.buttonInstances?.find((x) => x.id === a.id)
|
||||||
|
const getB = s.scene.buttonInstances?.find((x) => x.id === b.id)
|
||||||
|
expect(getA?.label).toBe('play')
|
||||||
|
expect(getB?.label).toBe('mute')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('setButtonInstanceOnClick + setButtonInstanceDisabled', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const bc = s.addButtonClip('B')
|
||||||
|
const ct = s.addClipTrack()
|
||||||
|
const inst = s.addButtonInstance(bc.id, ct.id, 0)!
|
||||||
|
s.setButtonInstanceOnClick(inst.id, [{ type: 'pause' }])
|
||||||
|
s.setButtonInstanceDisabled(inst.id, true)
|
||||||
|
const get = () => s.scene.buttonInstances?.find((x) => x.id === inst.id)
|
||||||
|
expect(get()?.onClick).toHaveLength(1)
|
||||||
|
expect(get()?.disabled).toBe(true)
|
||||||
|
s.setButtonInstanceOnClick(inst.id, [])
|
||||||
|
s.setButtonInstanceDisabled(inst.id, false)
|
||||||
|
expect(get()?.onClick).toBe(undefined)
|
||||||
|
expect(get()?.disabled).toBe(undefined)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
suite('scene store — macro model normalization', () => {
|
||||||
|
test('loadScene initializes new arrays when missing', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const bare = {
|
||||||
|
version: 1 as const,
|
||||||
|
canvas: { width: 640, height: 480, background: '#ffffff' },
|
||||||
|
duration: 5,
|
||||||
|
fps: 30,
|
||||||
|
assets: [],
|
||||||
|
layers: [],
|
||||||
|
}
|
||||||
|
s.loadScene(bare)
|
||||||
|
expect(s.scene.buttonClips).toEqual([])
|
||||||
|
expect(s.scene.buttonInstances).toEqual([])
|
||||||
|
expect(s.scene.scriptTracks).toEqual([])
|
||||||
|
expect(s.scene.scriptFrames).toEqual([])
|
||||||
|
expect(s.scene.markers).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('setTrackLocked also works on script tracks', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const t = s.addScriptTrack()
|
||||||
|
s.setTrackLocked(t.id, true)
|
||||||
|
expect(s.scene.scriptTracks?.find((x) => x.id === t.id)?.locked).toBe(true)
|
||||||
|
s.setTrackLocked(t.id, false)
|
||||||
|
expect(s.scene.scriptTracks?.find((x) => x.id === t.id)?.locked).toBe(undefined)
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,22 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import type {
|
import type {
|
||||||
|
Action,
|
||||||
AnimatableProps,
|
AnimatableProps,
|
||||||
Asset,
|
Asset,
|
||||||
BackgroundVideo,
|
BackgroundVideo,
|
||||||
|
ButtonClip,
|
||||||
|
ButtonInstance,
|
||||||
Clip,
|
Clip,
|
||||||
ClipInstance,
|
ClipInstance,
|
||||||
ClipTrack,
|
ClipTrack,
|
||||||
Easing,
|
Easing,
|
||||||
Keyframe,
|
Keyframe,
|
||||||
Layer,
|
Layer,
|
||||||
|
Marker,
|
||||||
MaskMode,
|
MaskMode,
|
||||||
Scene,
|
Scene,
|
||||||
|
ScriptFrame,
|
||||||
|
ScriptTrack,
|
||||||
TrackGroup,
|
TrackGroup,
|
||||||
} from '@/types/scene'
|
} from '@/types/scene'
|
||||||
import { DEFAULT_BASE_PROPS } from '@/types/scene'
|
import { DEFAULT_BASE_PROPS } from '@/types/scene'
|
||||||
@@ -112,6 +118,11 @@ function normalizeScene(scene: Scene): Scene {
|
|||||||
scene.clipTracks ||= []
|
scene.clipTracks ||= []
|
||||||
scene.clipInstances ||= []
|
scene.clipInstances ||= []
|
||||||
scene.groups ||= []
|
scene.groups ||= []
|
||||||
|
scene.buttonClips ||= []
|
||||||
|
scene.buttonInstances ||= []
|
||||||
|
scene.scriptTracks ||= []
|
||||||
|
scene.scriptFrames ||= []
|
||||||
|
scene.markers ||= []
|
||||||
if (!scene.canvas.background) scene.canvas.background = '#ffffff'
|
if (!scene.canvas.background) scene.canvas.background = '#ffffff'
|
||||||
|
|
||||||
const known = new Set<string>()
|
const known = new Set<string>()
|
||||||
@@ -1001,12 +1012,194 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
else delete clipTrack.locked
|
else delete clipTrack.locked
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const scriptTrack = this.scene.scriptTracks?.find((t) => t.id === trackId)
|
||||||
|
if (scriptTrack) {
|
||||||
|
if (locked) scriptTrack.locked = true
|
||||||
|
else delete scriptTrack.locked
|
||||||
|
return
|
||||||
|
}
|
||||||
const group = this.scene.groups?.find((g) => g.id === trackId)
|
const group = this.scene.groups?.find((g) => g.id === trackId)
|
||||||
if (group) {
|
if (group) {
|
||||||
if (locked) group.locked = true
|
if (locked) group.locked = true
|
||||||
else delete group.locked
|
else delete group.locked
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// -------- Markers --------------------------------------------------
|
||||||
|
|
||||||
|
addMarker(time: number, name?: string, color?: string | null): Marker {
|
||||||
|
const t = Math.max(0, Math.min(this.scene.duration, time))
|
||||||
|
const markers = (this.scene.markers ||= [])
|
||||||
|
const used = new Set(markers.map((m) => m.name))
|
||||||
|
let base = (name ?? '').trim() || 'Marker'
|
||||||
|
let final = base
|
||||||
|
let i = 2
|
||||||
|
while (used.has(final)) final = `${base} ${i++}`
|
||||||
|
const m: Marker = { id: uid(), name: final, time: t }
|
||||||
|
if (color != null) m.color = color
|
||||||
|
markers.push(m)
|
||||||
|
markers.sort((a, b) => a.time - b.time)
|
||||||
|
return m
|
||||||
|
},
|
||||||
|
removeMarker(id: string) {
|
||||||
|
if (!this.scene.markers) return
|
||||||
|
this.scene.markers = this.scene.markers.filter((m) => m.id !== id)
|
||||||
|
},
|
||||||
|
renameMarker(id: string, name: string): boolean {
|
||||||
|
const markers = this.scene.markers
|
||||||
|
if (!markers) return false
|
||||||
|
const target = markers.find((m) => m.id === id)
|
||||||
|
if (!target) return false
|
||||||
|
const next = name.trim()
|
||||||
|
if (!next) return false
|
||||||
|
if (next === target.name) return true
|
||||||
|
if (markers.some((m) => m.id !== id && m.name === next)) return false
|
||||||
|
target.name = next
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
setMarkerTime(id: string, time: number) {
|
||||||
|
const marker = this.scene.markers?.find((m) => m.id === id)
|
||||||
|
if (!marker) return
|
||||||
|
marker.time = Math.max(0, Math.min(this.scene.duration, time))
|
||||||
|
this.scene.markers?.sort((a, b) => a.time - b.time)
|
||||||
|
},
|
||||||
|
setMarkerColor(id: string, color: string | null) {
|
||||||
|
const marker = this.scene.markers?.find((m) => m.id === id)
|
||||||
|
if (!marker) return
|
||||||
|
if (color == null) delete marker.color
|
||||||
|
else marker.color = color
|
||||||
|
},
|
||||||
|
|
||||||
|
// -------- Script tracks & frames -----------------------------------
|
||||||
|
|
||||||
|
addScriptTrack(name?: string): ScriptTrack {
|
||||||
|
const track: ScriptTrack = { id: uid(), name }
|
||||||
|
;(this.scene.scriptTracks ||= []).push(track)
|
||||||
|
;(this.scene.trackOrder ||= []).push(track.id)
|
||||||
|
return track
|
||||||
|
},
|
||||||
|
removeScriptTrack(id: string) {
|
||||||
|
if (this.scene.scriptTracks) {
|
||||||
|
this.scene.scriptTracks = this.scene.scriptTracks.filter((t) => t.id !== id)
|
||||||
|
}
|
||||||
|
if (this.scene.scriptFrames) {
|
||||||
|
this.scene.scriptFrames = this.scene.scriptFrames.filter((f) => f.trackId !== id)
|
||||||
|
}
|
||||||
|
if (this.scene.trackOrder) {
|
||||||
|
this.scene.trackOrder = this.scene.trackOrder.filter((tid) => tid !== id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setScriptTrackName(id: string, name: string) {
|
||||||
|
const track = this.scene.scriptTracks?.find((t) => t.id === id)
|
||||||
|
if (track) track.name = name.trim() || undefined
|
||||||
|
},
|
||||||
|
addScriptFrame(
|
||||||
|
trackId: string,
|
||||||
|
time: number,
|
||||||
|
actions: Action[] = [],
|
||||||
|
label?: string,
|
||||||
|
): ScriptFrame | null {
|
||||||
|
const track = this.scene.scriptTracks?.find((t) => t.id === trackId)
|
||||||
|
if (!track) return null
|
||||||
|
const t = Math.max(0, Math.min(this.scene.duration, time))
|
||||||
|
const frame: ScriptFrame = {
|
||||||
|
id: uid(),
|
||||||
|
trackId,
|
||||||
|
time: t,
|
||||||
|
actions: [...actions],
|
||||||
|
}
|
||||||
|
if (label) frame.label = label
|
||||||
|
;(this.scene.scriptFrames ||= []).push(frame)
|
||||||
|
this.scene.scriptFrames.sort((a, b) => a.time - b.time)
|
||||||
|
return frame
|
||||||
|
},
|
||||||
|
removeScriptFrame(id: string) {
|
||||||
|
if (!this.scene.scriptFrames) return
|
||||||
|
this.scene.scriptFrames = this.scene.scriptFrames.filter((f) => f.id !== id)
|
||||||
|
},
|
||||||
|
setScriptFrameActions(id: string, actions: Action[]) {
|
||||||
|
const frame = this.scene.scriptFrames?.find((f) => f.id === id)
|
||||||
|
if (frame) frame.actions = [...actions]
|
||||||
|
},
|
||||||
|
setScriptFrameTime(id: string, time: number) {
|
||||||
|
const frame = this.scene.scriptFrames?.find((f) => f.id === id)
|
||||||
|
if (!frame) return
|
||||||
|
frame.time = Math.max(0, Math.min(this.scene.duration, time))
|
||||||
|
this.scene.scriptFrames?.sort((a, b) => a.time - b.time)
|
||||||
|
},
|
||||||
|
|
||||||
|
// -------- Button clips & instances ---------------------------------
|
||||||
|
|
||||||
|
addButtonClip(name?: string): ButtonClip {
|
||||||
|
const clip: ButtonClip = {
|
||||||
|
id: uid(),
|
||||||
|
name: name?.trim() || 'Button',
|
||||||
|
states: { normal: [], hover: [], pressed: [], disabled: [] },
|
||||||
|
}
|
||||||
|
;(this.scene.buttonClips ||= []).push(clip)
|
||||||
|
return clip
|
||||||
|
},
|
||||||
|
removeButtonClip(id: string) {
|
||||||
|
if (this.scene.buttonClips) {
|
||||||
|
this.scene.buttonClips = this.scene.buttonClips.filter((c) => c.id !== id)
|
||||||
|
}
|
||||||
|
if (this.scene.buttonInstances) {
|
||||||
|
this.scene.buttonInstances = this.scene.buttonInstances.filter(
|
||||||
|
(bi) => bi.buttonClipId !== id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setButtonClipName(id: string, name: string) {
|
||||||
|
const clip = this.scene.buttonClips?.find((c) => c.id === id)
|
||||||
|
if (clip) clip.name = name.trim() || clip.name
|
||||||
|
},
|
||||||
|
addButtonInstance(
|
||||||
|
buttonClipId: string,
|
||||||
|
clipTrackId: string,
|
||||||
|
startTime: number,
|
||||||
|
): ButtonInstance | null {
|
||||||
|
if (!this.scene.buttonClips?.some((c) => c.id === buttonClipId)) return null
|
||||||
|
if (!this.scene.clipTracks?.some((t) => t.id === clipTrackId)) return null
|
||||||
|
const inst: ButtonInstance = {
|
||||||
|
id: uid(),
|
||||||
|
buttonClipId,
|
||||||
|
clipTrackId,
|
||||||
|
startTime: Math.max(0, Math.min(this.scene.duration, startTime)),
|
||||||
|
}
|
||||||
|
;(this.scene.buttonInstances ||= []).push(inst)
|
||||||
|
return inst
|
||||||
|
},
|
||||||
|
removeButtonInstance(id: string) {
|
||||||
|
if (!this.scene.buttonInstances) return
|
||||||
|
this.scene.buttonInstances = this.scene.buttonInstances.filter((bi) => bi.id !== id)
|
||||||
|
},
|
||||||
|
setButtonInstanceLabel(id: string, label: string): boolean {
|
||||||
|
const instances = this.scene.buttonInstances
|
||||||
|
if (!instances) return false
|
||||||
|
const target = instances.find((bi) => bi.id === id)
|
||||||
|
if (!target) return false
|
||||||
|
const next = label.trim()
|
||||||
|
if (!next) {
|
||||||
|
delete target.label
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (next === target.label) return true
|
||||||
|
if (instances.some((bi) => bi.id !== id && bi.label === next)) return false
|
||||||
|
target.label = next
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
setButtonInstanceOnClick(id: string, actions: Action[]) {
|
||||||
|
const target = this.scene.buttonInstances?.find((bi) => bi.id === id)
|
||||||
|
if (!target) return
|
||||||
|
if (actions.length) target.onClick = [...actions]
|
||||||
|
else delete target.onClick
|
||||||
|
},
|
||||||
|
setButtonInstanceDisabled(id: string, disabled: boolean) {
|
||||||
|
const target = this.scene.buttonInstances?.find((bi) => bi.id === id)
|
||||||
|
if (!target) return
|
||||||
|
if (disabled) target.disabled = true
|
||||||
|
else delete target.disabled
|
||||||
|
},
|
||||||
setLayerSeparated(layerId: string, separated: boolean) {
|
setLayerSeparated(layerId: string, separated: boolean) {
|
||||||
const layer = this.activeLayers.find((l) => l.id === layerId)
|
const layer = this.activeLayers.find((l) => l.id === layerId)
|
||||||
if (!layer) return
|
if (!layer) return
|
||||||
|
|||||||
@@ -162,6 +162,84 @@ export interface BackgroundVideo {
|
|||||||
opacity?: number
|
opacity?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Macro clips & scripts ------------------------------------------------
|
||||||
|
|
||||||
|
export type ButtonState = 'normal' | 'hover' | 'pressed' | 'disabled'
|
||||||
|
|
||||||
|
export interface StateTransition {
|
||||||
|
from: ButtonState
|
||||||
|
to: ButtonState
|
||||||
|
duration: number
|
||||||
|
easing: Easing
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ButtonHitArea {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
offsetX?: number
|
||||||
|
offsetY?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ButtonClip {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
states: {
|
||||||
|
normal: Layer[]
|
||||||
|
hover: Layer[]
|
||||||
|
pressed: Layer[]
|
||||||
|
disabled: Layer[]
|
||||||
|
}
|
||||||
|
transitions?: StateTransition[]
|
||||||
|
hitArea?: ButtonHitArea
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ButtonInstance {
|
||||||
|
id: string
|
||||||
|
buttonClipId: string
|
||||||
|
clipTrackId: string
|
||||||
|
startTime: number
|
||||||
|
/** Unique, user-facing handle used in dropdowns and script DSL. */
|
||||||
|
label?: string
|
||||||
|
onClick?: Action[]
|
||||||
|
/** Initial disabled state at runtime start. */
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScriptTrack {
|
||||||
|
id: string
|
||||||
|
name?: string
|
||||||
|
color?: string | null
|
||||||
|
locked?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScriptFrame {
|
||||||
|
id: string
|
||||||
|
trackId: string
|
||||||
|
time: number
|
||||||
|
actions: Action[]
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Marker {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
time: number
|
||||||
|
color?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Action =
|
||||||
|
| { type: 'play' }
|
||||||
|
| { type: 'pause' }
|
||||||
|
| { type: 'rewindToLoop' }
|
||||||
|
| { type: 'jumpTo'; time: number }
|
||||||
|
| { type: 'jumpToFrame'; frame: number }
|
||||||
|
| { type: 'jumpToMarker'; name: string }
|
||||||
|
| { type: 'setButtonState'; instanceId: string; state: ButtonState }
|
||||||
|
| { type: 'setButtonDisabled'; instanceId: string; disabled: boolean }
|
||||||
|
| { type: 'script'; source: string }
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface Scene {
|
export interface Scene {
|
||||||
version: 1
|
version: 1
|
||||||
canvas: {
|
canvas: {
|
||||||
@@ -183,6 +261,11 @@ export interface Scene {
|
|||||||
trackOrder?: string[]
|
trackOrder?: string[]
|
||||||
groups?: TrackGroup[]
|
groups?: TrackGroup[]
|
||||||
backgroundVideo?: BackgroundVideo
|
backgroundVideo?: BackgroundVideo
|
||||||
|
buttonClips?: ButtonClip[]
|
||||||
|
buttonInstances?: ButtonInstance[]
|
||||||
|
scriptTracks?: ScriptTrack[]
|
||||||
|
scriptFrames?: ScriptFrame[]
|
||||||
|
markers?: Marker[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_BASE_PROPS: AnimatableProps = {
|
export const DEFAULT_BASE_PROPS: AnimatableProps = {
|
||||||
|
|||||||
Reference in new issue
Block a user