diff --git a/docs/macro-clips-and-scripts.md b/docs/macro-clips-and-scripts.md deleted file mode 100644 index c1922bc..0000000 --- a/docs/macro-clips-and-scripts.md +++ /dev/null @@ -1,437 +0,0 @@ -# Macro Clips & Scripts — Featureset Plan - -Interaktive Buttons plus eine kleine Script-Sprache, mit der Timelines -gesteuert werden können. - -## Getroffene Entscheidungen - -1. Buttons sind **Definitionen + Instanzen** (wie normale Clips): einmal - definieren, beliebig oft auf Clip-Tracks instanziieren. -2. `pressed` ist **rein visueller State**. Aktionen hängen an einem - `onClick` pro Button-**Instanz** (nicht pro Definition), damit derselbe - Button in unterschiedlichen Kontexten andere Dinge tun kann. -3. Script-Actions feuern **nur bei vorwärts laufendem Playback**, nicht - beim Scrubben und nicht beim Rückwärtslaufen. - -## Datenmodell - -### Clip mit Varianten - -```ts -type Clip = AnimationClip | ButtonClip - -interface AnimationClip { - kind: 'animation' // default, bestehendes Verhalten - id: string - name: string - duration: number - layers: Layer[] -} - -type ButtonState = 'normal' | 'hover' | 'pressed' | 'disabled' - -interface ButtonClip { - kind: 'button' - id: string - name: string - states: Record // 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 // 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. diff --git a/docs/macro-clips-testing.md b/docs/macro-clips-testing.md deleted file mode 100644 index 4ad9274..0000000 --- a/docs/macro-clips-testing.md +++ /dev/null @@ -1,244 +0,0 @@ -# Macro Clips & Scripts — Testing Guide - -Eine Walkthrough für die Features aus `docs/macro-clips-and-scripts.md`. -Start: App mit `npm run dev`, leeres Projekt öffnen („New Project" im -Welcome-Dialog). - ---- - -## 1. Marker - -**Ziel:** Sprungziele auf der Haupt-Timeline setzen und benennen. - -1. Playhead an eine beliebige Stelle ziehen (z. B. t = 2 s). -2. `m` drücken → ein Marker wird an der Playhead-Position angelegt und ein - Rename-Prompt öffnet sich. Namen eingeben (z. B. `intro`), `Enter`. -3. Marker erscheint als kleine Fahne oben auf der Scrub-Ruler. -4. **Interaktionen am Marker:** - - Einfacher Klick → Playhead springt dort hin. - - Drag → Marker zeitlich verschieben. - - Doppelklick → Rename. - - Marker anwählen, `Backspace`/`Delete` → löscht ihn. `Esc` → entauswählt. - -**Erwartung:** -- Mehrere Marker bleiben zeitlich sortiert. -- Zweiter Marker mit gleichem Namen bekommt automatisch Suffix (`intro 2`). -- Namen müssen Scene-weit unique sein (Rename zeigt Fehler-Dialog bei - Kollision und öffnet den Prompt erneut). - ---- - -## 2. Script-Tracks & Action-Frames - -**Ziel:** Zeit-gebundene Aktionen definieren, die bei Playback feuern. - -1. Im Timeline-Header die **Code-Taste** (``-Icon) klicken → ein neuer - Script-Track wird angelegt (kleinere Zeile als Clip-Tracks). -2. Auf der leeren Script-Lane an beliebiger Zeitposition klicken → ein - neuer Action-Frame wird erstellt und der **ScriptFrameDialog** öffnet - sich. -3. Im Dialog **Add action** drücken → ein Dropdown zeigt alle Action-Typen. - - Beispiel: `Pause` auswählen → nur Typ, keine Params nötig. - - Weitere Action hinzufügen: `Jump to marker` → Marker-Name aus dem - Dropdown wählen (dafür muss aus Schritt 1 ein Marker existieren). -4. Dialog schließen mit **Done**. -5. Mehrere Actions pro Frame sind erlaubt — die Zahl rechts neben dem - Frame-Dot zeigt deren Anzahl. - -**Testen:** -- Playhead VOR den Action-Frame ziehen, dann `Space` (Play). -- Playback läuft, und **genau wenn der Playhead über den Frame hinausgeht**, - feuern die Actions in Listen-Reihenfolge. -- Beispiel `pause + jumpToMarker intro` → Playback pausiert und - Playhead springt zum `intro`-Marker. - -**Erwartung:** -- Beim Scrubben (Ziehen des Playheads oder Marker-Klick) feuern - **keine** Actions. -- Beim Loop-Wrap (von `playbackOut` zurück nach `playbackIn`) feuern - ebenfalls keine Actions. -- Rückwärtsbewegen der Zeit feuert nichts. - ---- - -## 3. Button-Clips erzeugen und platzieren - -**Ziel:** Einen klickbaren Button definieren, auf die Stage bringen und -onClick-Actions hinterlegen. - -1. In der Asset-Sidebar den Abschnitt **Buttons** → `+` klicken. Namen - eingeben (z. B. `PlayButton`), `Enter`. -2. Der Button erscheint in der Liste mit einem Cursor-Icon. Die 4 States - sind automatisch als bunte Rects vorausgefüllt - (blau / hellblau / dunkelblau / grau). -3. Button-Clip aus der Sidebar auf eine Clip-Track ziehen (Drag-and-Drop). - - Wenn keine Clip-Track existiert, in den Timeline-Bereich unter den - Tracks droppen → neue Clip-Track entsteht automatisch. -4. Auf der Clip-Track erscheint ein 4-Zellen-Block (`N H P D`) an der - Drop-Position. -5. Auf dem Canvas erscheint der Button (standardmäßig in der Stage-Mitte) - — wenn der Playhead ≥ Instanz-Startzeit. - -**Button editieren:** -1. Auf den 4-Zellen-Block klicken → **ButtonInstanceDialog** öffnet sich. -2. **Label** vergeben (z. B. `play`) — wichtig, weil: - - Im DSL über `btn:play` referenzierbar. - - Im Dropdown von `Set button state`-Actions sichtbar. -3. **On click** → Actions definieren (z. B. `Play` und `Jump to marker intro`). -4. **Start disabled** aktivieren → Button startet grau/schraffiert, - reagiert nicht auf Klicks. -5. **Done**. - ---- - -## 4. Button-Runtime auf der Stage testen - -**Voraussetzung:** Ein Button ist platziert und der Playhead ist auf -oder nach dessen Startzeit. - -1. Maus über den Button bewegen → Farbe wechselt zu *hellblau* (`hover`). -2. Maus-Down → Farbe wird *dunkelblau* (`pressed`). -3. Maus-Up (immer noch im Button) → `onClick`-Actions feuern; State geht - zurück zu `hover`. -4. Maus-Down und **rausziehen** vor dem Maus-Up → kein `onClick`, - State kehrt zu `normal` zurück (Standard-Browser-Button-Verhalten). -5. Disabled-Button hat keine Hover-/Press-Reaktion, ignoriert Klicks. - -**Skript-getriggert:** -- In einem ScriptFrame eine `Set button state`-Action mit Target = Button-Label - und state = `pressed` anlegen. -- Beim Playback überqueren → der Button schaltet visuell in `pressed`, - ohne dass der User hovert. -- `Set button disabled` / `Enable` toggelt den Disabled-Flag zur Laufzeit. - ---- - -## 5. Transitions zwischen States - -**Ziel:** Glatter visueller Übergang zwischen States statt hartem Wechsel. - -1. ButtonInstance-Dialog öffnen (4-Zellen-Block klicken). -2. Unter **Transitions** → `+ Add transition`. -3. Zeile: `normal → hover`, Duration `0.15`, Easing `easeOut`. -4. Weitere Zeile: `hover → normal`, Duration `0.2`, Easing `easeOut`. -5. Done. -6. Auf dem Canvas Maus über den Button → die Farbe **fade**t zwischen - Blau und Hellblau über 150 ms, statt instant zu wechseln. - -**Hinweise:** -- Transitions sind Clip-scoped, d. h. sie gelten für **alle** Instanzen - des Buttons. -- Layer werden zwischen den beiden States per `name` gematcht (beim - Default-Seed heißen alle Face-Layer schlicht `Face` → werden immer - interpoliert). Unmatched Layer faden nur rein/raus (Crossfade). -- Ohne Transition: State-Wechsel ist sofort (Verhalten wie vor Slice 7). - ---- - -## 6. Script-DSL testen - -**Ziel:** Komplexere Logik in einer kompakten Textform statt manuellem -Zusammenklicken mehrerer Actions. - -1. ScriptFrameDialog öffnen (oder ButtonInstanceDialog). -2. **Add action** → Typ `Run script` wählen. -3. Im Textarea: - ``` - // setzt Pause und springt an den intro-Marker - pause - jump @intro - state btn:play pressed - disable btn:play - ``` -4. Done. - -**Testen:** -- Frame überqueren → alle parsed Aktionen feuern in Reihenfolge. -- Falsche Syntax → Warnung erscheint in der Devtools-Konsole - (`[animatr] Script line N: ...`). Die fehlerhafte Zeile wird - übersprungen; valide Zeilen feuern trotzdem. - -**DSL-Referenz (aktueller Sprachumfang):** -``` -// Kommentar (bis Zeilenende) - -play // Playback starten -pause // Playback pausieren -rewind // Playhead → playbackIn (in-point) - -jump 2.5 // Zeit in Sekunden -jump #135 // Frame-Nummer (durch fps geteilt) -jump @markerName // benannter Marker - -state btn:label // ∈ normal|hover|pressed|disabled -enable btn:label -disable btn:label -``` - ---- - -## 7. Export-Runtime prüfen - -**Ziel:** Sicherstellen dass Buttons, Scripts, Marker im exportierten -Projekt genauso funktionieren wie im Editor. - -1. Menü → Export (oder beliebiger Export-Trigger je nach UI). -2. `AnimatrPlayer`-HTML-Export wählen → Datei öffnen im Browser. -3. **Erwartet:** - - Buttons rendern an ihren Positionen, mit Pointer-Reaktion. - - onClick feuert die hinterlegten Aktionen (z. B. `jump @intro` springt - im exportierten Player-Playhead zum Marker). - - Transitions animieren sauber. - - Script-Frames feuern bei natürlichem Playback. -4. Devtools-Konsole im Export-Tab → `window.AnimatrPlayer.__v` sollte - `'6'` ergeben. Cache-Probleme? Hard-Reload mit Shift+Cmd+R / Ctrl+F5. - ---- - -## 8. Runtime-Tests (in-app) - -`Ctrl+Shift+Alt+T` öffnet den Test-Runner. Relevante Suiten: -- `scene store — markers` / `script tracks & frames` / `button clips & instances` -- `actions — evalAction with mock runtime` -- `actions — script type expands via evalAction` -- `scriptDsl — parser` -- `button runtime — state machine + action wiring` -- `scene store — tick fires script frames forward-only` - -Alle sollten grün laufen. - ---- - -## Bekannte Einschränkungen - -- **Kein Button-State-Editor in der UI:** Die 4 States sind beim Seed mit - farbigen Rects vorausgefüllt. Um die Optik anzupassen müsste man - aktuell `scene.buttonClips[i].states[…]` direkt im gespeicherten - Projekt-JSON editieren. Ein eigener Edit-Modus ist geplant, aber - nicht implementiert. -- **Server-Render (PNG-Sequence / MP4)** bakt Script-Actions und - Button-Interaktionen bewusst nicht ein — das wäre keine deterministische - Animation mehr. Scripts wirken nur in der interaktiven - Browser-Laufzeit (Editor + `AnimatrPlayer`-HTML-Export). - ---- - -## Devtools-Cheat-Sheet - -Für Tests ohne UI: -```js -// Pinia store im Devtools: -const s = __pinia.state.value.scene -const store = useSceneStore() // im Vue-Devtools verfügbar - -// Button-Instanz direkt einen bestimmten State geben: -const rt = useButtonRuntime() -rt.setState(instanceId, 'pressed') -rt.reset() - -// Alle Marker sehen: -console.table(store.scene.markers) - -// Alle Button-Instanzen: -console.table(store.scene.buttonInstances) -``` diff --git a/src/components/ActionEditor.vue b/src/components/ActionEditor.vue index 1c14a1e..8c71b4e 100644 --- a/src/components/ActionEditor.vue +++ b/src/components/ActionEditor.vue @@ -8,7 +8,7 @@ import { } from '@phosphor-icons/vue' import { useSceneStore } from '@/stores/scene' import { t } from '@/i18n' -import type { Action, ButtonState } from '@/types/scene' +import type { Action } from '@/types/scene' const props = defineProps<{ actions: Action[] }>() const emit = defineEmits<{ 'update:actions': [value: Action[]] }>() @@ -19,19 +19,6 @@ const markerNames = computed(() => (store.scene.markers ?? []).map((m) => m.name), ) -interface ButtonInstanceOption { - id: string - label: string -} - -const buttonInstanceOptions = computed(() => - (store.scene.buttonInstances ?? []) - .filter((bi) => !!bi.label) - .map((bi) => ({ id: bi.id, label: bi.label! })), -) - -const BUTTON_STATES: ButtonState[] = ['normal', 'hover', 'pressed', 'disabled'] - const ACTION_TYPES: Action['type'][] = [ 'play', 'pause', @@ -39,8 +26,6 @@ const ACTION_TYPES: Action['type'][] = [ 'jumpTo', 'jumpToFrame', 'jumpToMarker', - 'setButtonState', - 'setButtonDisabled', 'script', ] @@ -60,18 +45,6 @@ function makeDefault(type: Action['type']): Action { return { type: 'jumpToFrame', frame: 0 } case 'jumpToMarker': return { type: 'jumpToMarker', name: markerNames.value[0] ?? '' } - case 'setButtonState': - return { - type: 'setButtonState', - instanceId: buttonInstanceOptions.value[0]?.id ?? '', - state: 'normal', - } - case 'setButtonDisabled': - return { - type: 'setButtonDisabled', - instanceId: buttonInstanceOptions.value[0]?.id ?? '', - disabled: true, - } case 'script': return { type: 'script', source: '' } } @@ -179,47 +152,6 @@ function onTypeChange(index: number, newType: Action['type']) { -
- - -
-
- - -
diff --git a/src/components/ButtonInstanceDialog.vue b/src/components/ButtonInstanceDialog.vue deleted file mode 100644 index f261123..0000000 --- a/src/components/ButtonInstanceDialog.vue +++ /dev/null @@ -1,368 +0,0 @@ - - - - - diff --git a/src/components/CanvasStage.vue b/src/components/CanvasStage.vue index 0604c83..2a86853 100644 --- a/src/components/CanvasStage.vue +++ b/src/components/CanvasStage.vue @@ -8,22 +8,12 @@ import { importImageFile, loadImage } from '@/lib/fileLoader' import { useClipartStore } from '@/stores/clipart' import { drawSceneFrame, loadSceneImages } from '@/lib/render' import type { - Action, AnimatableProps, Asset, - ButtonClip, - ButtonInstance, - ButtonState, Keyframe, Layer, Point, } from '@/types/scene' -import { - applyButtonStateChange, - useButtonRuntime, -} from '@/composables/useButtonRuntime' -import { evalAction } from '@/lib/actions' -import { blendButtonStates } from '@/lib/buttonBlend' import { t } from '@/i18n' import { useKfSelection } from '@/composables/useKfSelection' import { showLocked } from '@/composables/useShowLocked' @@ -197,167 +187,6 @@ const canvasBorderConfig = computed(() => ({ const zoomPercent = computed(() => Math.round(zoom.value * 100)) -const buttonRt = useButtonRuntime() - -interface ButtonRenderItem { - bi: ButtonInstance - clip: ButtonClip - state: ButtonState - disabled: boolean - layers: Array<{ - layer: Layer - asset: Asset - props: AnimatableProps - }> - hit: { width: number; height: number; offsetX: number; offsetY: number } -} - -function sampleStateLayers( - clip: ButtonClip, - state: ButtonState, -): ButtonRenderItem['layers'] { - const layers = clip.states[state] ?? [] - const out: ButtonRenderItem['layers'] = [] - for (const layer of layers) { - const asset = store.scene.assets.find((a) => a.id === layer.assetId) - if (!asset) continue - out.push({ layer, asset, props: sampleLayer(layer, 0) }) - } - return out -} - -function blendStates( - clip: ButtonClip, - from: ButtonState, - to: ButtonState, - u: number, -): ButtonRenderItem['layers'] { - return blendButtonStates(clip, from, to, u, (id) => - store.scene.assets.find((a) => a.id === id), - ) -} - -const activeButtonInstances = computed(() => { - // Force re-evaluation on every transition RAF tick. - void buttonRt.transitionTick.value - const out: ButtonRenderItem[] = [] - const instances = store.scene.buttonInstances ?? [] - const clips = store.scene.buttonClips ?? [] - const clipById = new Map(clips.map((c) => [c.id, c])) - const now = performance.now() - for (const bi of instances) { - if (store.playhead < bi.startTime) continue - const clip = clipById.get(bi.buttonClipId) - if (!clip) continue - const disabled = buttonRt.isDisabled(bi.id, bi.disabled) - const state = buttonRt.getState(bi.id, bi.disabled) - const tr = buttonRt.sampleTransition(bi.id, now) - let rendered: ButtonRenderItem['layers'] - if (tr && !tr.done && !disabled) { - rendered = blendStates(clip, tr.from, tr.to, tr.u) - } else { - rendered = sampleStateLayers(clip, state) - } - // Hit area: explicit hitArea on the clip, or fall back to the bbox of - // the first rect-like asset in the normal state. - let hit = clip.hitArea - ? { - width: clip.hitArea.width, - height: clip.hitArea.height, - offsetX: clip.hitArea.offsetX ?? clip.hitArea.width / 2, - offsetY: clip.hitArea.offsetY ?? clip.hitArea.height / 2, - } - : null - if (!hit) { - for (const l of clip.states.normal) { - const a = store.scene.assets.find((x) => x.id === l.assetId) - if (!a) continue - if (a.kind === 'rect' || a.kind === 'image') { - hit = { - width: a.width, - height: a.height, - offsetX: l.offsetX ?? a.width / 2, - offsetY: l.offsetY ?? a.height / 2, - } - break - } - if (a.kind === 'circle') { - hit = { - width: a.radius * 2, - height: a.radius * 2, - offsetX: (l.offsetX ?? 0) + a.radius, - offsetY: (l.offsetY ?? 0) + a.radius, - } - break - } - } - } - if (!hit) hit = { width: 120, height: 40, offsetX: 60, offsetY: 20 } - out.push({ bi, clip, state, disabled, layers: rendered, hit }) - } - return out -}) - -function buttonGroupConfig(bi: ButtonInstance) { - return { - id: `btn:${bi.id}`, - x: bi.x ?? 0, - y: bi.y ?? 0, - listening: true, - } -} - -function buttonHitRectConfig(item: ButtonRenderItem) { - return { - x: 0, - y: 0, - width: item.hit.width, - height: item.hit.height, - offsetX: item.hit.offsetX, - offsetY: item.hit.offsetY, - fill: 'rgba(0,0,0,0.001)', // nearly transparent but hittable - listening: !item.disabled, - name: 'button-hit', - } -} - -function buttonChildConfig>(cfg: T): T { - return { ...cfg, draggable: false, listening: false } -} - -function transitionsFor(bi: ButtonInstance) { - return store.scene.buttonClips?.find((c) => c.id === bi.buttonClipId)?.transitions -} - -function onButtonMouseEnter(bi: ButtonInstance) { - const cur = buttonRt.getState(bi.id, bi.disabled) - if (cur === 'normal') applyButtonStateChange(bi.id, 'hover', transitionsFor(bi), bi.disabled) -} - -function onButtonMouseLeave(bi: ButtonInstance) { - const cur = buttonRt.getState(bi.id, bi.disabled) - if (cur === 'hover' || cur === 'pressed') - applyButtonStateChange(bi.id, 'normal', transitionsFor(bi), bi.disabled) -} - -function onButtonMouseDown(bi: ButtonInstance) { - const cur = buttonRt.getState(bi.id, bi.disabled) - if (cur === 'hover') applyButtonStateChange(bi.id, 'pressed', transitionsFor(bi), bi.disabled) -} - -function onButtonMouseUp(bi: ButtonInstance) { - const cur = buttonRt.getState(bi.id, bi.disabled) - if (cur !== 'pressed') return - applyButtonStateChange(bi.id, 'hover', transitionsFor(bi), bi.disabled) - const actions = bi.onClick - if (actions && actions.length) runButtonActions(actions) -} - -function runButtonActions(actions: Action[]) { - const rt = store.actionRuntime() - for (const a of actions) evalAction(a, rt) -} - const assetMap = computed(() => { const m = new Map() for (const a of store.scene.assets) m.set(a.id, a) @@ -2523,42 +2352,6 @@ onBeforeUnmount(() => { /> - diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue index 28fb129..83f765c 100644 --- a/src/components/Timeline.vue +++ b/src/components/Timeline.vue @@ -31,14 +31,12 @@ import { sampleLayer } from '@/lib/interpolate' import { snapToCandidates } from '@/lib/timelineSnap' import { confirmDialog, promptDialog } from '@/components/ui' import ScriptFrameDialog from './ScriptFrameDialog.vue' -import ButtonInstanceDialog from './ButtonInstanceDialog.vue' import { t } from '@/i18n' import { kfSelectionKey, useKfSelection, type KfSelectionKey } from '@/composables/useKfSelection' import { showLocked } from '@/composables/useShowLocked' import type { AnimatableProps, Asset, - ButtonInstance, Clip, ClipInstance, Easing, @@ -135,21 +133,6 @@ const scriptFramesByTrack = computed { - const m = new Map[number]>() - for (const c of store.scene.buttonClips ?? []) m.set(c.id, c) - return m -}) -const buttonInstancesByTrack = computed< - Record> ->(() => { - const out: Record> = {} - for (const bi of store.scene.buttonInstances ?? []) { - ;(out[bi.clipTrackId] ||= []).push(bi) - } - return out -}) - const TRACK_NAME_WIDTH_KEY = 'animatr:trackNameWidth' const TRACK_NAME_WIDTH_DEFAULT = 140 const TRACK_NAME_WIDTH_MIN = 80 @@ -460,14 +443,8 @@ function onMarkerPointerMove(e: PointerEvent, markerId: string) { function onMarkerPointerUp(e: PointerEvent, markerId: string) { const el = e.currentTarget as HTMLElement if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId) - const drag = markerDrag.value markerDrag.value = null - if (!drag || drag.id !== markerId) return - // Click (no movement) → jump playhead to the marker. - if (!drag.moved) { - const m = store.scene.markers?.find((x) => x.id === markerId) - if (m) store.setPlayhead(m.time) - } + void markerId } async function renameMarkerPrompt(markerId: string) { @@ -1077,7 +1054,7 @@ function duplicateFromMenu() { } interface TrackMenuState { - kind: 'layer' | 'clip' | 'group' + kind: 'layer' | 'clip' | 'group' | 'script' trackId: string x: number y: number @@ -1277,7 +1254,7 @@ function onTracksPointerDown(e: PointerEvent) { if (!target) return if ( target.closest( - '.kf, .clip-block, .clip-handle, .track-name, .drag-handle, .add-clip-btn, .playhead', + '.kf, .clip-block, .clip-handle, .track-name, .drag-handle, .add-clip-btn, .playhead, .script-lane, .script-frame', ) ) { return @@ -1364,11 +1341,7 @@ function pixelTimeFromEvent(e: DragEvent | PointerEvent, el: HTMLElement): numbe } function onClipLaneDragOver(e: DragEvent, _clipTrackId: string) { - const types = e.dataTransfer?.types - if ( - types?.includes('application/x-animatr-clip') || - types?.includes('application/x-animatr-button-clip') - ) { + if (e.dataTransfer?.types.includes('application/x-animatr-clip')) { e.preventDefault() if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy' } @@ -1381,14 +1354,7 @@ function onClipLaneDrop(e: DragEvent, clipTrackId: string) { const el = e.currentTarget as HTMLElement const time = pixelTimeFromEvent(e, el) const clipId = e.dataTransfer?.getData('application/x-animatr-clip') - if (clipId) { - store.addClipInstance(clipId, time, clipTrackId) - return - } - const buttonClipId = e.dataTransfer?.getData('application/x-animatr-button-clip') - if (buttonClipId) { - store.addButtonInstance(buttonClipId, clipTrackId, time) - } + if (clipId) store.addClipInstance(clipId, time, clipTrackId) } interface ClipMoveState { @@ -1447,24 +1413,16 @@ function onClipBlockPointerUp(e: PointerEvent) { const clipDragOverDropzone = ref(false) -function isClipDragLike(e: DragEvent): boolean { - const types = e.dataTransfer?.types - return !!( - types?.includes('application/x-animatr-clip') || - types?.includes('application/x-animatr-button-clip') - ) -} - function onNewTrackDragEnter(e: DragEvent) { - if (!isClipDragLike(e)) return + if (!e.dataTransfer?.types.includes('application/x-animatr-clip')) return e.preventDefault() clipDragOverDropzone.value = true } function onNewTrackDragOver(e: DragEvent) { - if (!isClipDragLike(e)) return + if (!e.dataTransfer?.types.includes('application/x-animatr-clip')) return e.preventDefault() - if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy' + e.dataTransfer.dropEffect = 'copy' clipDragOverDropzone.value = true } @@ -1475,14 +1433,12 @@ function onNewTrackDragLeave() { function onNewTrackDrop(e: DragEvent) { clipDragOverDropzone.value = false const clipId = e.dataTransfer?.getData('application/x-animatr-clip') - const buttonClipId = e.dataTransfer?.getData('application/x-animatr-button-clip') - if (!clipId && !buttonClipId) return + if (!clipId) return e.preventDefault() const el = e.currentTarget as HTMLElement const time = pixelTimeFromLane(e, el) const track = store.addClipTrack() - if (clipId) store.addClipInstance(clipId, time, track.id) - else if (buttonClipId) store.addButtonInstance(buttonClipId, track.id, time) + store.addClipInstance(clipId, time, track.id) } function pixelTimeFromLane(e: DragEvent, el: HTMLElement): number { @@ -1515,20 +1471,10 @@ function clipBlockStyle(ci: ClipInstance) { } } -const BUTTON_STATES = ['normal', 'hover', 'pressed', 'disabled'] as const - // Script frame dialog state const scriptDialogOpen = ref(false) const scriptDialogFrameId = ref(null) -// Button instance dialog state -const buttonDialogOpen = ref(false) -const buttonDialogInstanceId = ref(null) -function openButtonInstance(id: string) { - buttonDialogInstanceId.value = id - buttonDialogOpen.value = true -} - function openScriptFrame(frameId: string) { scriptDialogFrameId.value = frameId scriptDialogOpen.value = true @@ -1545,16 +1491,6 @@ function onScriptLaneClick(e: MouseEvent, trackId: string) { const frame = store.addScriptFrame(trackId, time) if (frame) openScriptFrame(frame.id) } -const BUTTON_CELL_SEC = 0.5 // visual width of each of the 4 state cells -function buttonInstanceStyle(bi: ButtonInstance) { - // Four cells, each with a fixed per-cell visual width. Kept as a fixed - // (time-like) span so the block scales with timeline zoom. - const totalSec = BUTTON_CELL_SEC * 4 - return { - left: pxFromTime(bi.startTime) + 'px', - width: Math.max(80, pxFromTime(totalSec)) + 'px', - } -} function trimTooltip(ci: ClipInstance): string { const name = clipById.value.get(ci.clipId)?.name ?? '?' @@ -1699,6 +1635,22 @@ function onClipTrackContextMenu(e: MouseEvent, clipTrackId: string) { } } +function onScriptTrackContextMenu(e: MouseEvent, scriptTrackId: string) { + e.preventDefault() + e.stopPropagation() + const track = store.scene.scriptTracks?.find((t) => t.id === scriptTrackId) + if (!track) return + trackMenu.value = { + kind: 'script', + trackId: scriptTrackId, + x: e.clientX, + y: e.clientY, + separated: false, + currentColor: track.color ?? null, + locked: !!track.locked, + } +} + function applyTrackColor(color: string | null) { if (!trackMenu.value) return store.setTrackColor(trackMenu.value.trackId, color) @@ -1722,6 +1674,8 @@ function deleteTrack() { store.removeLayer(trackMenu.value.trackId) } else if (trackMenu.value.kind === 'clip') { store.removeClipTrack(trackMenu.value.trackId) + } else if (trackMenu.value.kind === 'script') { + store.removeScriptTrack(trackMenu.value.trackId) } else { store.removeTrackGroup(trackMenu.value.trackId) } @@ -1730,19 +1684,35 @@ function deleteTrack() { async function renameFromMenu() { if (!trackMenu.value) return - if (trackMenu.value.kind !== 'group') return - const group = groupById.value.get(trackMenu.value.trackId) - if (!group) return - const id = trackMenu.value.trackId - const current = group.name - trackMenu.value = null - const next = await promptDialog({ - title: t('timeline.renameGroupDialog.title'), - defaultValue: current, - confirmLabel: t('timeline.renameGroupDialog.confirm'), - }) - if (next === null) return - store.renameGroup(id, next) + if (trackMenu.value.kind === 'group') { + const group = groupById.value.get(trackMenu.value.trackId) + if (!group) return + const id = trackMenu.value.trackId + const current = group.name + trackMenu.value = null + const next = await promptDialog({ + title: t('timeline.renameGroupDialog.title'), + defaultValue: current, + confirmLabel: t('timeline.renameGroupDialog.confirm'), + }) + if (next === null) return + store.renameGroup(id, next) + return + } + if (trackMenu.value.kind === 'script') { + const track = scriptTrackById.value.get(trackMenu.value.trackId) + if (!track) return + const id = trackMenu.value.trackId + const current = track.name ?? t('timeline.scriptTrackName') + trackMenu.value = null + const next = await promptDialog({ + title: t('timeline.renameScriptTrackDialog.title'), + defaultValue: current, + confirmLabel: t('timeline.renameScriptTrackDialog.confirm'), + }) + if (next === null) return + store.setScriptTrackName(id, next) + } } function onDocumentPointerDown(e: PointerEvent) { @@ -2313,22 +2283,6 @@ onBeforeUnmount(() => { @click.stop /> - - {{ state[0].toUpperCase() }} - @@ -2351,6 +2305,7 @@ onBeforeUnmount(() => { @@ -2592,6 +2547,14 @@ onBeforeUnmount(() => { {{ t('timeline.renameGroup') }} +