Files
RUWIO/docs/macro-clips-and-scripts.md
T
thandClaude Opus 4.7 a75e9151ed 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>
2026-04-20 20:34:44 +02:00

438 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.