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

16 KiB
Raw Blame History

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

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

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

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).

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

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:

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.