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:
thandClaude Opus 4.7 committed 2026-04-20 20:34:44 +02:00
1 parent d4c4830fe2
commit a75e9151ed
4 files changed
+952

No files matched your search

+437
View File
@@ -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.