From eb5884889bd724fa2bc99a29982e9d4da21a03c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 11 Jun 2026 18:59:25 -0400 Subject: [PATCH] fix(studio): per-property-group keyframe tracks + foundation fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the bundled percentage-keyframe model with per-property-group tweens. Each studio operation creates/edits only its own group's tween: drag → position, resize → scale/size, rotate → rotation. Includes off-screen element indicators with drag-to-move, unclipped overlay for off-screen interaction, gesture recording replacement, and all 6 root cause foundation fixes from the keyframe trace investigation. --- ...06-07-004-fix-gesture-recording-ux-plan.md | 161 ++++ ...-06-07-005-feat-ae-level-keyframes-plan.md | 874 ++++++++++++++++++ ...-fix-webgl-black-render-regression-plan.md | 173 ++++ ...-fix-proxy-stub-navigation-timeout-plan.md | 93 ++ ...09-001-refactor-split-keyframe-prs-plan.md | 400 ++++++++ ...-09-002-fix-audio-lock-timing-race-plan.md | 94 ++ ...e-delete-hooks-keyframe-corruption-plan.md | 299 ++++++ ...002-feat-timeline-inline-expansion-plan.md | 269 ++++++ ...-feat-per-property-keyframe-tracks-plan.md | 390 ++++++++ ...1-002-feat-responsive-aspect-ratio-plan.md | 119 +++ ...lip-overlay-for-offscreen-elements-plan.md | 79 ++ docs/plans/handoff-keyframes-session.md | 103 +++ .../parsers/__goldens__/complex.parsed.json | 53 +- .../parsers/__goldens__/complex.serialized.js | 6 +- .../parsers/__goldens__/fromto.parsed.json | 2 + .../parsers/__goldens__/minimal.parsed.json | 2 + .../parsers/__goldens__/moderate.parsed.json | 18 +- packages/core/src/parsers/gsapConstants.ts | 40 + .../src/parsers/gsapParser.stress.test.ts | 8 +- packages/core/src/parsers/gsapParser.test.ts | 351 ++++++- packages/core/src/parsers/gsapParser.ts | 428 ++++++++- packages/core/src/parsers/gsapSerialize.ts | 14 +- packages/core/src/studio-api/routes/files.ts | 41 +- .../src/components/StudioPreviewArea.tsx | 42 +- .../src/components/editor/DomEditOverlay.tsx | 79 ++ .../src/components/editor/PropertyPanel.tsx | 29 +- .../src/components/editor/manualEditsDom.ts | 30 +- .../components/editor/manualEditsSnapshot.ts | 16 + .../editor/propertyPanel3dTransform.tsx | 23 +- .../editor/useOffScreenIndicators.ts | 184 ++++ .../studio/src/components/nle/NLELayout.tsx | 30 +- packages/studio/src/hooks/gsapDragCommit.ts | 175 +++- .../src/hooks/gsapKeyframeCacheHelpers.ts | 13 +- .../studio/src/hooks/gsapRuntimeBridge.ts | 306 +++++- .../studio/src/hooks/gsapRuntimeReaders.ts | 18 +- .../src/hooks/useAnimatedPropertyCommit.ts | 16 +- .../studio/src/hooks/useDomEditSession.ts | 18 +- .../studio/src/hooks/useEnableKeyframes.ts | 4 +- packages/studio/src/hooks/useGestureCommit.ts | 61 +- .../studio/src/hooks/useGestureRecording.ts | 20 +- .../studio/src/hooks/useGsapScriptCommits.ts | 16 +- .../studio/src/hooks/useGsapTweenCache.ts | 40 +- .../components/KeyframeDiamondContextMenu.tsx | 3 +- .../studio/src/player/components/Timeline.tsx | 10 +- .../components/TimelineClipDiamonds.tsx | 4 +- .../studio/src/player/store/playerStore.ts | 12 + .../src/utils/globalTimeCompiler.test.ts | 4 +- .../studio/src/utils/globalTimeCompiler.ts | 3 +- .../studio/src/utils/gsapSoftReload.test.ts | 16 + packages/studio/src/utils/gsapSoftReload.ts | 51 +- packages/studio/src/utils/rdpSimplify.ts | 5 +- 51 files changed, 4971 insertions(+), 274 deletions(-) create mode 100644 docs/plans/2026-06-07-004-fix-gesture-recording-ux-plan.md create mode 100644 docs/plans/2026-06-07-005-feat-ae-level-keyframes-plan.md create mode 100644 docs/plans/2026-06-08-001-fix-webgl-black-render-regression-plan.md create mode 100644 docs/plans/2026-06-08-002-fix-proxy-stub-navigation-timeout-plan.md create mode 100644 docs/plans/2026-06-09-001-refactor-split-keyframe-prs-plan.md create mode 100644 docs/plans/2026-06-09-002-fix-audio-lock-timing-race-plan.md create mode 100644 docs/plans/2026-06-10-001-fix-gate-delete-hooks-keyframe-corruption-plan.md create mode 100644 docs/plans/2026-06-10-002-feat-timeline-inline-expansion-plan.md create mode 100644 docs/plans/2026-06-11-001-feat-per-property-keyframe-tracks-plan.md create mode 100644 docs/plans/2026-06-11-002-feat-responsive-aspect-ratio-plan.md create mode 100644 docs/plans/2026-06-11-002-fix-unclip-overlay-for-offscreen-elements-plan.md create mode 100644 docs/plans/handoff-keyframes-session.md create mode 100644 packages/studio/src/components/editor/useOffScreenIndicators.ts diff --git a/docs/plans/2026-06-07-004-fix-gesture-recording-ux-plan.md b/docs/plans/2026-06-07-004-fix-gesture-recording-ux-plan.md new file mode 100644 index 000000000..6989f5cd8 --- /dev/null +++ b/docs/plans/2026-06-07-004-fix-gesture-recording-ux-plan.md @@ -0,0 +1,161 @@ +--- +title: "fix: gesture recording UX + keyframes documentation" +type: fix +status: active +date: 2026-06-07 +--- + +# fix: Gesture recording UX + keyframes documentation + +## Summary + +Fix three bugs in gesture-to-keyframes recording that make it unusable, then write Mintlify docs explaining keyframes and arc motion. The recording must work as: click Record → timeline plays → drag element → pointer path becomes GSAP keyframes. + +--- + +## Problem Frame + +The gesture recording feature shipped with three bugs that make it confusing: (1) the element position is captured relative to the scrubbed time, not its initial CSS position, so the resulting keyframes have wrong offsets; (2) the preview panel overlaps other UI and blocks interaction; (3) state transitions are unclear — playback doesn't pause on stop, and it's not obvious what the recording captured. Users also lack documentation for the keyframe and arc motion features. + +--- + +## Requirements + +- R1. Recording starts timeline playback and captures pointer deltas from the element's position at recording start time +- R2. Recorded keyframes represent motion relative to the element's starting position, not the scrubbed position +- R3. The preview panel does not block element interaction — it shows inline in the design panel, not as a floating overlay +- R4. Stop recording pauses timeline, seeks back to start of the recorded segment, and shows preview +- R5. Mintlify docs explain keyframes (timeline diamonds, design panel editing, arc motion toggle/curviness/auto-rotate) + +--- + +## Key Technical Decisions + +KTD1. **Capture element's computed transform at recording start, not pointer position.** The current bug: `startPointerRef` captures cursor position before recording. Fix: read the element's current GSAP-interpolated x/y via `gsap.getProperty()` at recording start, and compute deltas from that baseline — not from where the cursor happens to be. + +KTD2. **Preview panel inline in design panel, not floating overlay.** The floating `z-[90]` div blocks interaction. Move the GesturePreviewPanel into the PropertyPanel's Animation section (where the Record button already lives), replacing the Record button while in preview state. + +KTD3. **Seek to recording start on stop.** When recording stops, seek the timeline back to `recordingStartTimeRef.current` so the user sees the element in its pre-recording position. This makes the preview meaningful — they see where the motion starts. + +KTD4. **Remove toast notifications during recording.** The "Recording — drag in preview" toast is redundant when the Record button already shows the recording state. Remove the toasts for start/stop — the button state is sufficient feedback. + +--- + +## Implementation Units + +### U1. Fix position capture baseline + +**Goal:** Record pointer deltas relative to the element's GSAP position at recording start, not cursor position. + +**Requirements:** R1, R2 + +**Files:** +- `packages/studio/src/hooks/useGestureRecording.ts` +- `packages/studio/src/App.tsx` + +**Approach:** +In `startRecording`, read the element's current x/y via `gsap.getProperty(element, "x")` and `gsap.getProperty(element, "y")` from the iframe's GSAP instance. Store as `elementBaselineRef`. In each RAF tick, compute `dx = currentPointerX - dragStartPointerX` (pointer delta from drag start), not absolute position. The resulting samples are relative motion deltas — exactly what GSAP keyframes need. + +In App.tsx, store `recordingStartTimeRef.current = store.currentTime` before starting playback. + +**Test scenarios:** +- Element at x=100, y=200 at scrub time. Record produces keyframes starting at {x:0, y:0}, not {x:100, y:200} +- Dragging right 300px produces x delta of 300, regardless of initial element position +- Recording at t=2s produces keyframes timed from t=2s, not t=0 + +**Verification:** Record a gesture on fly-item at different scrub positions. Keyframes should represent the same motion regardless of start time. + +--- + +### U2. Move preview panel inline in design panel + +**Goal:** Show the gesture preview inside the Animation section instead of a floating overlay. + +**Requirements:** R3 + +**Files:** +- `packages/studio/src/App.tsx` +- `packages/studio/src/components/editor/PropertyPanel.tsx` + +**Approach:** +Remove the floating `
` wrapper from App.tsx. Instead, render `GesturePreviewPanel` inside PropertyPanel's animation section — when `recordingState === "preview"`, show the panel in place of the Record button. Pass the same props (samples, totalDuration, onCommit, onDiscard, onReRecord) through the PropertyPanel props. + +**Test scenarios:** +- After recording stops, preview panel appears inside the design panel below the animation section +- Preview panel does not block clicking on elements in the preview +- Commit/Discard/Re-record buttons work from the inline position + +**Verification:** Record a gesture, verify the preview shows inline in the design panel without overlapping the preview area. + +--- + +### U3. Fix state transitions — seek on stop, remove toasts + +**Goal:** Clean up the recording lifecycle — pause and seek on stop, no redundant toasts. + +**Requirements:** R4 + +**Files:** +- `packages/studio/src/App.tsx` + +**Approach:** +In `stopRecording`: +1. Stop timeline playback (`setIsPlaying(false)`) +2. Seek to `recordingStartTimeRef.current` so the user sees the element at its pre-recording position +3. Remove `showToast()` calls from start/stop — the button state (red pulsing → amber preview) is sufficient +4. Clear the auto-stop interval + +In the Record button (PropertyPanel), show clear state labels: "Record gesture (R)" → "Stop (R)" → "Commit / Discard" with no toast interruption. + +**Test scenarios:** +- Stop recording seeks timeline to the recording start time +- No toast appears on start or stop +- Button text changes through states correctly +- Auto-stop at composition end seeks to start and enters preview + +**Verification:** Record, stop, verify timeline is at the right position and no toasts appeared. + +--- + +### U4. Keyframes and arc motion documentation + +**Goal:** Write a Mintlify guide page explaining keyframes, timeline diamonds, arc motion, and gesture recording. + +**Requirements:** R5 + +**Files:** +- `docs/guides/keyframes.mdx` (new) +- `docs/docs.json` (modify — add to navigation after gsap-animation) + +**Approach:** +Single MDX page covering: +1. **Timeline keyframe diamonds** — what they represent, how they map to GSAP tweens +2. **Design panel editing** — changing tween properties (Move X/Y, Scale, Opacity, Ease) +3. **Arc motion** — step-by-step: select element with x/y tween → toggle Arc Motion → adjust curviness (0=straight, 3=extreme arc) → toggle auto-rotate → verify in Code tab +4. **Gesture recording** — select element → click Record → drag in preview → stop → adjust smoothing → commit +5. **Clipboard context** — the clipboard icon copies element info for AI agents + +Use the same MDX conventions as `gsap-animation.mdx`: ``, ``, ``, code blocks with labels. + +Add `"guides/keyframes"` to `docs/docs.json` navigation after `"guides/gsap-animation"`. + +**Test scenarios:** +Test expectation: none -- documentation only + +**Verification:** Run `npx mintlify dev` and verify the page renders correctly with navigation. + +--- + +## Scope Boundaries + +### In scope +- Fix position capture baseline bug +- Move preview panel inline +- Fix state transitions (seek on stop, remove toasts) +- Keyframes + arc motion Mintlify docs + +### Deferred to Follow-Up Work +- Drawable easing canvas (jhey-style signature-to-easing) +- Multi-element gesture recording +- Gesture recording for rotation/scale/opacity via modifier keys (hooks exist but not wired to commit) +- Layered multi-pass recording diff --git a/docs/plans/2026-06-07-005-feat-ae-level-keyframes-plan.md b/docs/plans/2026-06-07-005-feat-ae-level-keyframes-plan.md new file mode 100644 index 000000000..2415818c5 --- /dev/null +++ b/docs/plans/2026-06-07-005-feat-ae-level-keyframes-plan.md @@ -0,0 +1,874 @@ +--- +title: "feat: AE-level keyframes for HyperFrames Studio" +type: feat +status: active +date: 2026-06-07 +--- + +# AE-Level Keyframes for HyperFrames Studio + +## Summary + +Introduce a global-time, property-first keyframe system to HyperFrames Studio that matches Adobe After Effects' authoring model while preserving GSAP as the sole source of truth. Users scrub to any point in time, change a property, and a keyframe appears — the system compiles global-time intent down to GSAP percentage keyframes transparently. Delivered in three phases: core keyframe loop, easing polish and path visualization, then multi-element coordination with stagger. + +--- + +## Problem Frame + +HyperFrames Studio currently exposes GSAP's internal model directly: percentage-based keyframes within a tween's local duration. This creates three friction points that block motion-designer adoption: + +1. **Keyframe creation at scrubbed time fails** — scrubbing to t=1.5s on a 0.5s tween produces meaningless percentage values because the computation uses the composition's `data-duration` instead of the tween's duration. +2. **No per-property keyframe independence** — all properties are bundled into the same percentage blocks with shared easing, preventing the independent-per-property curves AE users expect. +3. **No spatial feedback** — no visible motion path, no canvas-direct keyframe creation, no multi-element choreography tools. + +The target user is a motion designer switching from AE who expects: scrub → change → keyframe appears, per-property easing, diamond indicators, and multi-select stagger. + +--- + +## Requirements + +**Core model** + +- R1. The Studio UI presents keyframes in absolute time (seconds), not tween-local percentages. The underlying GSAP code uses percentage keyframes — Studio handles the conversion transparently. +- R2. Each property has an independent keyframe timeline. A keyframe on `x` at t=0.5s does not require `y` to also have a keyframe at t=0.5s. GSAP representation uses sparse percentage blocks within a single tween. +- R3. Flat tweens (no explicit keyframes) auto-display as 2-keyframe animations (start + end) throughout the UI. + +**Keyframe creation** + +- R4. The first keyframe on a property requires an explicit action (diamond click or `K` shortcut). Once a property has 2+ keyframes, further edits at new scrub times auto-create keyframes. +- R5. Adding a keyframe outside an existing tween's time range creates a new tween rather than extending the existing one. Users can merge tweens by dragging a keyframe into an adjacent clip. +- R6. Canvas manipulation is context-aware: no keyframes → sets static value; on existing keyframe → updates it; property has keyframes + at new time → auto-keys. + +**UI indicators** + +- R7. A diamond indicator appears next to each keyframeable property in the design panel. Three states: hidden (no keyframes), outlined (keyframed but between keyframes), filled (on a keyframe). +- R8. The Animation section shows a mini dopesheet strip per tween inside each AnimationCard, with clickable diamonds at keyframe positions and selected-keyframe detail below. +- R9. Timeline shows expandable property sub-rows per element. Collapsed view shows merged diamonds; expanded shows per-property diamond tracks. + +**Easing and interpolation** + +- R10. Preset ease grid plus mini cubic-bezier curve editor for custom easing, reusing the existing `EaseCurveSection`. Full graph editor deferred. +- R11. Two interpolation modes: bezier (default, smooth) and hold (step, no interpolation). Hold keyframes render as square diamonds. + +**Properties** + +- R12. Curated whitelist of 16 keyframeable properties: `x`, `y`, `scale`, `scaleX`, `scaleY`, `rotation`, `opacity`, `backgroundColor`, `color`, `borderColor`, `borderRadius`, `clipPath`, `filter`, `boxShadow`, `width`, `height`. + +**Multi-element** + +- R13. Multi-select elements and add/edit keyframes on all simultaneously. Stagger supported via numeric offset input and drag-to-fan interaction, with order modes (DOM, reverse, center-out, random). +- R14. Copy/paste keyframes between elements — serializes a property's keyframe array and applies it to the target element's tween. + +**Timeline interaction** + +- R15. Smart snapping: keyframes snap to frame boundaries (at composition FPS), cross-element keyframes, and audio beat markers. `Cmd` held disables snapping. +- R16. Keyboard shortcuts mirror AE conventions: `K` add keyframe, `J`/`Shift+J` prev/next, `H` toggle hold/bezier, `U` expand/collapse property rows, arrow keys nudge keyframes in time. + +**Motion path** + +- R17. When an element with x/y keyframes is selected, its motion path renders on canvas as a dashed line. Direct path editing (drag path to adjust curviness) connects to existing MotionPath integration. + +**Preview and sync** + +- R18. During drag/edit operations, canvas updates optimistically via direct GSAP runtime manipulation. Code is written once on pointer release. One interaction = one undo point. + +--- + +## Key Technical Decisions + +KTD-1. **GSAP remains sole source of truth**: No new JSON keyframe format. Studio reconstructs the global-time view by parsing GSAP calls (tween start time + duration + percentage = absolute time). The parser already extracts `position`, `duration`, and `keyframes` — the conversion is pure arithmetic. + +KTD-2. **Sparse percentage blocks in a single tween per element**: Per-property independence is achieved by allowing keyframes to contain only the properties that change at each time. GSAP interpolates between the percentage blocks that contain a given property (it does NOT hold values — properties missing from a block are simply skipped). This means the existing `backfillDefaults` parameter in `addKeyframeToScript` is required: when a keyframe introduces a property absent from other keyframes, default values must be backfilled so GSAP has values to interpolate between. This avoids splitting into multiple tweens per property, which would complicate the parser, undo system, and timeline display. + +KTD-3. **New tween creation outside bounds**: When a keyframe is placed outside any existing tween's time range, a new tween call is added to the script via a new `add-with-keyframes` mutation. Merge-via-drag is a timeline UI operation that moves keyframes between tween clips. + +KTD-4. **Optimistic runtime preview**: During drag/edit, the live GSAP instance in the iframe is manipulated directly via `gsap.getProperty` / `gsap.set` on the runtime bridge. No script writes until pointer release. This matches the existing drag intercept pattern in `gsapRuntimeBridge.ts`. + +KTD-5. **Global-time ↔ percentage conversion lives client-side**: The conversion between absolute time and percentage is pure math (`percentage = (absoluteTime - tweenStart) / tweenDuration * 100`) and runs in Studio hooks/components, not in the server-side parser. The parser continues to work with percentages internally. + +KTD-6. **Extend `KeyframeNavigation` for diamond indicators**: The existing component already implements the three-state diamond (ghost/inactive/active) with prev/next arrows per property. It will be extended to all keyframeable properties in the design panel, not just the Layout section. + +KTD-7. **Beat marker snapping requires onset detection**: Audio beat snapping (Phase 3) needs an onset detection library. The choice (aubio.js, Meyda, essentia.js, or Web Audio API `AnalyserNode`) is deferred to implementation — the snapping engine's interface accepts an array of beat times regardless of source. + +--- + +## High-Level Technical Design + +### Data flow: user action → GSAP code + +```mermaid +flowchart TB + A[User scrubs to t=1.2s
changes x to 200] --> B{Property has
2+ keyframes?} + B -->|No first KF| C[Require explicit action
K key or diamond click] + B -->|Yes auto-key| D[Compute target tween] + C --> D + D --> E{Time within
existing tween?} + E -->|Yes| F[percentage = t-start / duration × 100] + E -->|No| G[Create new tween at t=1.2s] + F --> H[Optimistic: gsap.set on runtime] + G --> H + H --> I[On pointer release:
commitMutation add-keyframe] + I --> J[Server: gsapParser.addKeyframeToScript
sparse % block insertion] + J --> K[Soft reload iframe] + K --> L[Cache update: keyframeCache
timeline diamonds refresh] +``` + +### Timeline expandable property rows + +```mermaid +flowchart LR + subgraph Collapsed + C1["◇───◇───◇ #title (all props merged)"] + end + subgraph Expanded + E1["x ◇───────◇"] + E2["y ────◇───────◇"] + E3["opacity ◇──────────◇"] + end + Collapsed -->|click disclosure / U key| Expanded + Expanded -->|click disclosure / U key| Collapsed +``` + +### Component architecture + +```mermaid +flowchart TB + subgraph DesignPanel + KN[KeyframeNavigation
per property diamond] + AC[AnimationCard
+ mini dopesheet strip] + EC[EaseCurveSection
+ preset grid] + end + subgraph Timeline + TC[TimelineCanvas] + TD[TimelineClipDiamonds
collapsed view] + TPR[TimelinePropertyRows
expanded per-property tracks] + end + subgraph Canvas + MP[MotionPathOverlay
dashed path + handles] + DEO[DomEditOverlay
context-aware drag] + end + subgraph Hooks + GTC[globalTimeCompiler
abs time ↔ percentage] + GSC[useGsapScriptCommits
mutation orchestration] + GRB[gsapRuntimeBridge
optimistic preview] + end + KN --> GTC + AC --> GTC + TD --> GTC + TPR --> GTC + DEO --> GRB + GTC --> GSC + GRB --> GSC +``` + +--- + +## Scope Boundaries + +### In scope + +- Global-time keyframe model with percentage compilation +- All locked UX decisions from the brainstorming session (20 decisions across Questions 1-23) +- Three-phase delivery (core → polish → multi-element) + +### Deferred to follow-up work + +- Full graph editor (value-over-time curves with multi-keyframe handle editing) +- Ghost frames / onion skinning +- Expression system / linked property values +- Keyframe presets library +- 3D transform keyframing (z, rotationX/Y/Z) + +### Prerequisites + +- Gesture recording position fix (plan `2026-06-07-004`) should land first — it establishes the `gsap.getProperty()` baseline pattern that this work extends. + +--- + +## Risks & Dependencies + +- **Parser complexity**: Adding an `add-with-keyframes` mutation type to `gsapParser.ts` (already 1910 lines) increases parser surface. Mitigated by following the existing `locateAnimation` + recast pattern and adding test coverage for the new path. +- **Keyframe cache additive-only invariant**: Three cache-clearing bugs were previously fixed. New features must not introduce clearing paths for runtime-scanned entries. Mitigated by explicit test scenarios. +- **Animation ID stability across conversions**: When `convertToKeyframesInScript` changes a `from()` to `to()`, the ID changes. The regex fallback handles this, but new tween creation paths must also produce stable, predictable IDs. Mitigated by following `assignStableIds` conventions. +- **Beat marker snapping library**: Phase 3 depends on an audio analysis library for onset detection. If none meets the size/quality bar, beat snapping degrades to manual marker placement. + +--- + +## Phase 1 — Core Keyframe Loop + +### U1. Global-time ↔ percentage compiler hook + +**Goal:** Provide utility functions that convert between absolute time (seconds) and tween-local percentages, enabling all downstream UI to work in absolute time. + +**Requirements:** R1, R2 + +**Dependencies:** None + +**Files:** +- Create `packages/studio/src/utils/globalTimeCompiler.ts` +- Modify `packages/studio/src/hooks/useGsapTweenCache.ts` — extend cache entries with `tweenStart` and `tweenDuration` fields +- Test `packages/studio/src/utils/globalTimeCompiler.test.ts` + +**Approach:** Pure utility functions that accept `GsapAnimation[]` as a parameter (callers pass in the animations they already hold from cache reads). Functions: +- `absoluteToPercentage(time, animation) → number` — clamps to [0, 100] +- `percentageToAbsolute(pct, animation) → number` +- `isTimeWithinTween(time, animation) → boolean` +- `findTweenAtTime(time, animations, selector) → GsapAnimation | null` + +These are stateless transformations — no React hook needed. Consistent with the existing `computeCurrentPercentage` pattern in `gsapRuntimeBridge.ts`. Cache entries are extended to include `tweenStart` and `tweenDuration` derived from `position` and `duration` on the `GsapAnimation`. + +When `GsapAnimation.position` is a string label (e.g., `"+=0.5"`, `"myLabel"`), the static arithmetic is undefined. Fallback: read `tween.startTime()` from the runtime via the `RuntimeTween` interface in `gsapRuntimeKeyframes.ts`. This gives the resolved absolute start time regardless of how the position was specified in code. + +**Patterns to follow:** `computeCurrentPercentage` in `gsapRuntimeBridge.ts` for the existing percentage math pattern. + +**Test scenarios:** +- Keyframe at t=0.5s within a tween starting at 0s with duration 2s → 25% +- Keyframe at t=1.0s within a tween starting at 0.5s with duration 1s → 50% +- Time before tween start clamps to 0% +- Time after tween end clamps to 100% +- `findTweenAtTime` returns null for gaps between tweens +- `findTweenAtTime` returns the correct tween when multiple tweens exist for the same selector +- Handles tweens with string position labels by falling back to `tween.startTime()` from the runtime +- Falls back gracefully when iframe is not loaded (returns null for unresolvable positions) + +**Verification:** Unit tests pass. The utility module is importable from Studio components without pulling in server-side parser code. + +--- + +### U2. Flat tween auto-display as 2-keyframe animations + +**Goal:** Make every flat tween (no explicit `keyframes:{}`) appear throughout the UI as having a start keyframe (0%) and end keyframe (100%), so the entire system treats all tweens uniformly. + +**Requirements:** R3 + +**Dependencies:** U1 + +**Files:** +- Modify `packages/studio/src/hooks/gsapRuntimeKeyframes.ts` — ensure `scanAllRuntimeKeyframes` always synthesizes start+end for flat tweens +- Modify `packages/studio/src/hooks/useGsapTweenCache.ts` — ensure cache population includes synthesized keyframes for flat tweens +- Modify `packages/studio/src/player/store/playerStore.ts` — verify cache lookup always returns data for elements with animations + +**Approach:** The runtime scan already synthesizes start+end keyframes for flat tweens. Verify this path is robust: +- Flat `to()`: synthesize 0% = default values, 100% = tween properties +- Flat `from()`: synthesize 0% = from properties, 100% = inferred values +- Flat `fromTo()`: synthesize 0% = from properties, 100% = to properties +- Flat `set()`: synthesize a single keyframe at 0% + +Ensure the AST fetch path (`usePopulateKeyframeCacheForFile`) also synthesizes these entries when the parsed animation has no `keyframes` field, so diamonds appear even before the iframe loads. + +**Patterns to follow:** Existing `scanAllRuntimeKeyframes` synthesis logic. The additive-only cache invariant — never clear runtime-scanned entries. + +**Test scenarios:** +- Flat `gsap.to("#el", { x: 100, duration: 1 })` displays as 2 diamonds at 0% and 100% +- Flat `gsap.from("#el", { opacity: 0, duration: 0.5 })` displays as 2 diamonds +- Flat `gsap.fromTo("#el", { x: 0 }, { x: 200, duration: 2 })` displays as 2 diamonds with correct from/to values +- `gsap.set("#el", { opacity: 0 })` displays as 1 diamond at 0% +- Editing any property of a displayed 2-keyframe flat tween triggers conversion to explicit keyframes (R4 activation) +- Cache is not cleared when switching between elements with flat tweens + +**Verification:** Timeline shows diamonds for all tweens in a test composition with mixed flat/keyframed animations. No diamond flickering on element selection changes. + +--- + +### U3. Explicit-then-auto-key creation logic + +**Goal:** Implement the keyframe creation rules: explicit first keyframe required (click diamond or `K`), then auto-key once a property has 2+ keyframes. + +**Requirements:** R4, R6 + +**Dependencies:** U1, U2 + +**Files:** +- Modify `packages/studio/src/components/editor/KeyframeNavigation.tsx` — extend to all keyframeable properties, add `K` shortcut handling +- Modify `packages/studio/src/hooks/useGsapScriptCommits.ts` — add `commitKeyframeAtTime` method that uses the global-time compiler utilities +- Modify `packages/studio/src/hooks/gsapRuntimeBridge.ts` — update `tryGsapDragIntercept` for context-aware behavior; migrate all 4 `computeCurrentPercentage` call sites (lines 278, 311, 464, 548) to use `absoluteToPercentage` from `globalTimeCompiler.ts` +- Modify `packages/studio/src/components/editor/PropertyPanel.tsx` — wire `KeyframeNavigation` to all whitelisted properties +- Test `packages/studio/src/hooks/useGsapScriptCommits.test.ts` + +**Approach:** + +The `commitKeyframeAtTime(absoluteTime, animationId, properties)` method: +1. Uses `absoluteToPercentage` from `globalTimeCompiler.ts` to convert absolute time → percentage +2. If the property has no keyframes yet and this is the first explicit action: calls `convert-to-keyframes` mutation first (creates 0% and 100% entries), then adds the new keyframe at the computed percentage +3. If the property already has 2+ keyframes: directly calls `add-keyframe` mutation + +Context-aware canvas drag (R6): +- `tryGsapDragIntercept` checks the animation's keyframe state before committing +- No keyframes + no explicit action → falls back to `update-property` (sets static value) +- On existing keyframe → `update-keyframe` at current percentage +- Between keyframes (property has 2+) → `add-keyframe` at current percentage + +The `K` shortcut is registered globally (when an element is selected) and triggers `onAddKeyframe(currentPercentage)` on the `KeyframeNavigation` for the currently focused property (or all animated properties if no property is focused). + +**Patterns to follow:** `tryGsapDragIntercept` existing flow; `KeyframeNavigation` three-state diamond logic. + +**Test scenarios:** +- Clicking ghost diamond on `x` converts flat tween to keyframes (2 entries) and adds a keyframe at current scrub time +- After conversion, editing `x` value at a new scrub time auto-creates a keyframe (no click needed) +- Dragging element when `x` has no keyframes sets static value (no keyframe created) +- Dragging element when `x` has 2+ keyframes at a new time creates a new keyframe +- Dragging element when at an existing keyframe time updates that keyframe +- `K` shortcut adds keyframe at current time for the selected element +- Auto-key does not activate for properties with only 1 keyframe (the initial convert creates 2) + +**Verification:** The full creation flow works end-to-end: select element → scrub → press K → keyframe appears in timeline and AnimationCard → scrub to new time → change value → second keyframe auto-created. + +--- + +### U4. New tween creation outside bounds + +**Goal:** When a keyframe is placed at a time outside any existing tween's range, create a new tween rather than extending the existing one. + +**Requirements:** R5 + +**Dependencies:** U1, U3 + +**Files:** +- Modify `packages/core/src/parsers/gsapParser.ts` — add `addAnimationWithKeyframesToScript` function +- Modify `packages/core/src/studio-api/routes/files.ts` — add `add-with-keyframes` mutation type +- Modify `packages/studio/src/hooks/useGsapScriptCommits.ts` — route to new mutation when time is outside bounds +- Test `packages/core/src/parsers/gsapParser.test.ts` + +**Approach:** + +New parser function `addAnimationWithKeyframesToScript(script, selector, position, duration, keyframes, ease?)`: +- Locates the timeline variable in the AST (same pattern as `addAnimationToScript`) +- Constructs a `tl.to(selector, { keyframes: { "0%": {...}, "100%": {...} }, duration }, position)` call +- Inserts it in timeline-position order among existing tween calls + +The routing logic in `commitKeyframeAtTime`: +1. Call `findTweenAtTime(absoluteTime, selector)` from U1 +2. If a tween is found → add keyframe within it (existing path) +3. If no tween found → call `add-with-keyframes` mutation, creating a new tween starting at the keyframe time with a default duration (0.5s or matching the nearest existing tween's duration) + +**Patterns to follow:** `addAnimationToScript` in gsapParser.ts for AST insertion; `assignStableIds` for ID generation. + +**Test scenarios:** +- Adding a keyframe at t=3s when the only tween covers 0-1s creates a new tween starting at t=3s +- The new tween gets a stable ID following the `${selector}-to-${posKey}` convention +- The new tween appears as a separate clip in the timeline +- Adding a keyframe between two existing tweens creates a third tween, not extending either +- The original tween's code is not modified +- Script with multiple existing tweens for same selector handles position ordering correctly + +**Verification:** In a test composition, scrub past the last tween's end, press K, and verify a new clip appears in the timeline with the correct start time. The original animation is unchanged. + +--- + +### U5. Diamond indicators in design panel for all keyframeable properties + +**Goal:** Show the keyframe diamond indicator (hidden/outlined/filled) next to every whitelisted property field in the design panel. + +**Requirements:** R7, R12 + +**Dependencies:** U1, U2, U3 + +**Files:** +- Modify `packages/studio/src/components/editor/PropertyPanel.tsx` — add `KeyframeNavigation` to all whitelisted property rows +- Modify `packages/studio/src/components/editor/gsapAnimationConstants.ts` — define `KEYFRAMEABLE_PROPERTIES` whitelist +- Modify `packages/studio/src/components/editor/KeyframeNavigation.tsx` — accept `absoluteTime` prop (converted from `currentPercentage`), integrate with compiler utilities + +**Approach:** + +Define `KEYFRAMEABLE_PROPERTIES` as a Set in constants: +``` +x, y, scale, scaleX, scaleY, rotation, opacity, backgroundColor, color, +borderColor, borderRadius, clipPath, filter, boxShadow, width, height +``` + +The PropertyPanel renders `KeyframeNavigation` next to each property field whose name is in the whitelist. The navigation component receives the element's animations and the current scrub time, using the global-time compiler to determine the diamond state. + +Property values in the design panel update live during scrub to show the interpolated value at the current time (read from `gsap.getProperty` via the runtime bridge). + +**Patterns to follow:** Existing `KeyframeNavigation` integration in the Layout section fields (X, Y, W, H, R). + +**Test scenarios:** +- `x` field shows ghost diamond when no animations exist for the element +- `x` field shows filled diamond when scrubbed to a time with an x keyframe +- `x` field shows outlined diamond when scrubbed between x keyframes +- `opacity` field shows the interpolated value (e.g., 0.5) when scrubbed between opacity 0 and opacity 1 keyframes +- Non-whitelisted properties (e.g., `innerText`) do not show diamond indicators +- Changing scrub position updates diamond states without full re-render (memo + selectors) + +**Verification:** Select an element with keyframed properties, scrub through the timeline, and verify diamond states update correctly for each property field. + +--- + +### U6. Mini dopesheet strip in AnimationCard + +**Goal:** Add a horizontal dopesheet strip inside each AnimationCard showing clickable diamond markers at keyframe positions, with selected-keyframe property detail below. + +**Requirements:** R8 + +**Dependencies:** U1, U2, U5 + +**Files:** +- Create `packages/studio/src/components/editor/DopesheetStrip.tsx` +- Modify `packages/studio/src/components/editor/AnimationCard.tsx` — integrate dopesheet strip above property rows +- Modify `packages/studio/src/player/store/playerStore.ts` — add `selectedAnimationKeyframe` state for tracking which keyframe is selected in the card + +**Approach:** + +`DopesheetStrip` renders a horizontal bar representing the tween's duration, with diamond markers positioned at `(percentage / 100) * stripWidth`. Clicking a diamond: +1. Selects that keyframe (updates `selectedAnimationKeyframe` in store) +2. The AnimationCard's property rows below update to show values at that keyframe's percentage +3. The ease curve editor shows the segment ease between the selected keyframe and the next one + +The strip shows a playhead indicator at the current scrub time's percentage within this tween. Diamonds use the same three-state rendering as `TimelineClipDiamonds` but at smaller scale. + +Time labels below the strip show absolute times (converted via compiler hook). + +**Patterns to follow:** `TimelineClipDiamonds` for diamond rendering logic; `AnimationCard` existing layout structure. + +**Test scenarios:** +- Strip renders diamonds at correct positions for a 3-keyframe animation +- Clicking a diamond selects it (accent color) and shows that keyframe's properties below +- Current scrub position shows as a thin vertical line on the strip +- Dragging a diamond repositions it (updates percentage via mutation) +- Strip width adapts to the AnimationCard's container width +- Empty strip (flat tween → 2 synthesized keyframes) shows start and end diamonds + +**Verification:** Open a composition with keyframed elements, verify the dopesheet strip mirrors the timeline diamonds, and that clicking diamonds updates the property values shown below. + +--- + +### U7. Optimistic preview with commit-on-release + +**Goal:** Make keyframe editing feel instant by manipulating the live GSAP runtime during drag/edit and writing to code only on pointer release. + +**Requirements:** R18 + +**Dependencies:** U1, U3 + +**Files:** +- Modify `packages/studio/src/hooks/gsapRuntimeBridge.ts` — add `previewKeyframeChange(iframe, selector, property, value)` for live preview +- Modify `packages/studio/src/hooks/useGsapScriptCommits.ts` — ensure mutations only fire on release, with undo coalescing per interaction +- Modify `packages/studio/src/components/editor/PropertyPanel.tsx` — wire live preview on value scrub (not just commit) + +**Approach:** + +`previewKeyframeChange` uses `iframe.contentWindow.gsap.set(selector, { [property]: value })` to instantly show the value change in the preview. This is a temporary override — the next seek or soft reload restores the timeline's computed state. + +During a drag/scrub interaction: +1. `onLivePreview(property, value)` → calls `previewKeyframeChange` (no code write) +2. `onLivePreviewEnd()` → no-op (used for cleanup if needed) +3. `onCommit(property, value)` → calls `commitKeyframeAtTime` (writes to code, creates undo point) + +The undo coalescing key for keyframe operations: `keyframe:${animationId}:${percentage}:${property}`. This ensures rapid repeated edits to the same keyframe property merge into one undo entry (300ms coalesce window via `DEFAULT_COALESCE_MS` in `editHistory.ts`). The key is passed through the existing `options.coalesceKey` parameter in `commitMutation`. + +**Patterns to follow:** `onLivePreview`/`onLivePreviewEnd` pattern already used in `AnimationCard`; `executeOptimistic` for cache updates. + +**Test scenarios:** +- Dragging a value slider shows the change in the preview iframe in real-time (no delay) +- Releasing the slider writes one mutation to the script +- Undo after release reverts to the pre-drag state (not intermediate positions) +- Two rapid edits to the same keyframe property merge into one undo entry +- Preview state is cleared on next seek (timeline scrub restores computed values) +- Failed mutation rolls back the optimistic cache update + +**Verification:** Drag a property slider back and forth, verify the preview updates in real-time with no visible lag. Release, undo, and verify it reverts to the pre-drag state in a single step. + +--- + +## Phase 2 — Polish & Curves + +### U8. Preset ease grid + mini bezier editor enhancement + +**Goal:** Add a visual preset grid above the existing `EaseCurveSection` and enhance it for per-keyframe-segment easing. + +**Requirements:** R10 + +**Dependencies:** U6 + +**Files:** +- Modify `packages/studio/src/components/editor/EaseCurveSection.tsx` — add preset grid above the curve editor +- Modify `packages/studio/src/components/editor/gsapAnimationConstants.ts` — define `EASE_PRESETS` with thumbnails/preview data +- Modify `packages/studio/src/components/editor/AnimationCard.tsx` — wire ease editing to the selected keyframe segment + +**Approach:** + +Add a grid of 8-10 named ease presets (power1-4 in/out, elastic.out, bounce, back.out, linear) as small curve thumbnails above the existing bezier editor. Clicking a preset applies it and populates the bezier handles for further tweaking. + +The ease applies to the *segment* between the selected keyframe and the next one. The UI labels this: "Ease: [selected kf time] → [next kf time]". + +Presets map to GSAP ease strings; custom curves map to `CustomEase.create()` with `M0,0 C, , 1,1` path strings. + +**Patterns to follow:** Existing `EaseCurveSection` SVG rendering; `EASE_CURVES` map in `gsapAnimationConstants.ts`. + +**Test scenarios:** +- Clicking a preset applies the ease to the selected keyframe and updates the bezier handles +- Custom handle drag updates the ease string in real-time +- The preview dot animation reflects the selected ease +- Ease persists after soft reload (written to script correctly) +- Per-keyframe ease in GSAP code: `"50%": { x: 100, ease: "power2.out" }` + +**Verification:** Select a keyframe, pick different presets, verify the animation changes character. Drag custom handles, verify smooth curve editing. + +--- + +### U9. Hold keyframes (step interpolation) + +**Goal:** Support hold/step interpolation mode where a value stays constant until it snaps to the next keyframe. Hold keyframes render as square diamonds. + +**Requirements:** R11 + +**Dependencies:** U6, U8 + +**Files:** +- Modify `packages/studio/src/components/editor/KeyframeDiamond.tsx` — add `isHold` prop for square rendering +- Modify `packages/studio/src/player/components/TimelineClipDiamonds.tsx` — detect hold keyframes and render square +- Modify `packages/studio/src/components/editor/DopesheetStrip.tsx` — square diamonds for hold +- Modify `packages/core/src/parsers/gsapParser.ts` — parse/emit `ease: "steps(1)"` as hold mode + +**Approach:** + +A keyframe is "hold" when its ease is `"steps(1)"`. The parser recognizes this on read and marks it. Add `"steps(1)"` to `SUPPORTED_EASES` in `gsapConstants.ts` so the linter and ease picker accept it. The UI toggles between bezier/hold via the `H` shortcut or a toggle button in the ease editor. + +Square diamond rendering: instead of a 45° rotated square (diamond), render an axis-aligned square of the same size. Color coding remains the same. + +**Patterns to follow:** `TimelineClipDiamonds` existing diamond rendering for the base shape. + +**Test scenarios:** +- Setting ease to `steps(1)` on a keyframe renders it as a square diamond in timeline and dopesheet +- The `H` shortcut toggles the selected keyframe between bezier (last-used preset) and hold +- Hold keyframes produce correct GSAP output: `"50%": { x: 100, ease: "steps(1)" }` +- Property value jumps instantly at the hold keyframe time during playback (no interpolation) +- Round-trip: hold keyframe survives parse → display → edit → save → re-parse + +**Verification:** Create a hold keyframe, play the animation, and verify the value snaps rather than interpolating. Verify the diamond appears square in both timeline and dopesheet. + +--- + +### U10. Expandable property rows in timeline + +**Goal:** Allow timeline clips to expand into per-property sub-rows showing independent diamond tracks for each animated property. + +**Requirements:** R9 + +**Dependencies:** U1, U2, U5 + +**Files:** +- Create `packages/studio/src/player/components/TimelinePropertyRows.tsx` +- Modify `packages/studio/src/player/components/TimelineCanvas.tsx` — render property rows when element is expanded +- Modify `packages/studio/src/player/components/timelineLayout.ts` — dynamic track height for expanded elements +- Modify `packages/studio/src/player/store/playerStore.ts` — add `expandedTimelineElements: Set` state + +**Approach:** + +Each animated property gets its own sub-row below the element's main clip row. Sub-rows are narrower (24px vs 48px main track) with a property label on the left. Diamonds in sub-rows only show keyframes for that specific property. + +Expand/collapse: clicking the element's disclosure triangle or pressing `U` with the element selected. Auto-expand on selection of an element with keyframes; manual toggle pins the state. + +Track height calculation becomes dynamic: `trackHeight = collapsed ? TRACK_H : TRACK_H + (animatedPropertyCount * SUB_TRACK_H)`. + +**Patterns to follow:** `TimelineClipDiamonds` for diamond rendering in sub-rows; `timelineLayout.ts` for layout constants. + +**Test scenarios:** +- Clicking disclosure triangle expands to show per-property rows +- Each property row only shows diamonds for that property +- `U` shortcut toggles expand/collapse for the selected element +- Selecting an element with keyframes auto-expands its property rows +- Deselecting collapses property rows (unless manually pinned) +- Timeline scroll and zoom work correctly with variable-height tracks +- Property label (e.g., "x", "opacity") appears on the left of each sub-row + +**Verification:** Open a composition with an element animated on x, y, and opacity. Expand the element, verify three property sub-rows with independent diamond positions. + +--- + +### U11. Motion path visualization and direct editing + +**Goal:** Show a visible motion path on the canvas for elements with x/y keyframes, with direct drag-to-curve editing. + +**Requirements:** R17 + +**Dependencies:** U1, U5 + +**Files:** +- Create `packages/studio/src/components/editor/MotionPathOverlay.tsx` +- Modify `packages/studio/src/components/editor/DomEditOverlay.tsx` — render motion path overlay for selected element +- Modify `packages/studio/src/hooks/gsapRuntimeBridge.ts` — add `readMotionPathPoints(iframe, selector)` to extract path waypoints + +**Approach:** + +When an element with x/y keyframes is selected, render an SVG overlay above the iframe showing: +1. A dashed line connecting all keyframe positions (x,y pairs at each keyframe time) +2. Small dots at each keyframe position +3. Draggable curve handles between waypoints (connects to MotionPath curviness) + +`readMotionPathPoints` reads `gsap.getProperty(element, "x")` and `gsap.getProperty(element, "y")` at each keyframe time by seeking the timeline to those times, collecting the interpolated positions. + +Dragging a curve handle between two waypoints adjusts the `curviness` parameter on the corresponding arc segment (using existing `set-arc-path` / `update-arc-segment` mutations). + +**Patterns to follow:** `ArcPathControls.tsx` for curviness editing UI; `DomEditOverlay` coordinate transform between iframe space and screen space. + +**Test scenarios:** +- Selecting an element with x/y keyframes shows a dashed path on the canvas +- Path accurately reflects the animation trajectory (waypoints at correct positions) +- Dragging a curve handle adjusts curviness and updates the path preview +- Path disappears when the element is deselected +- Path updates after keyframe add/move/delete +- Elements without x/y keyframes show no motion path + +**Verification:** Create an element with 3 position keyframes, select it, verify the dashed path appears connecting the three positions. Drag a curve handle and verify the path updates. + +--- + +### U12. Smart snapping engine + +**Goal:** Implement a snapping system that snaps keyframes to frame boundaries, cross-element keyframes, and (architecture for) beat markers. `Cmd` disables snapping. + +**Requirements:** R15 + +**Dependencies:** U6, U10 + +**Files:** +- Create `packages/studio/src/utils/keyframeSnapping.ts` +- Modify `packages/studio/src/player/components/TimelineClipDiamonds.tsx` — apply snapping during diamond drag +- Modify `packages/studio/src/components/editor/DopesheetStrip.tsx` — apply snapping during strip drag + +**Approach:** + +`KeyframeSnapper` class with: +- `constructor(fps: number, allKeyframeTimes: number[], beatTimes: number[])` +- `snap(time: number, modifiers: { cmd: boolean }) → { snappedTime: number, snapType: 'frame' | 'keyframe' | 'beat' | null }` + +Snap logic (priority order, 5px visual threshold adaptive to zoom): +1. If `Cmd` held → no snapping, return raw time +2. Cross-element keyframes (other elements' keyframe times) → snap if within threshold +3. Frame boundaries (1/fps intervals) → snap if within threshold +4. Beat markers (if provided) → snap if within threshold + +The snapper is instantiated with current state at drag start. Visual snap lines (thin vertical guides) appear when snapping is active. + +Beat marker times are accepted as an array — Phase 3 provides the onset detection that fills this array. + +**Patterns to follow:** Existing snap-to-frame logic in `timelineEditing.ts` (if any); standard snapping threshold patterns. + +**Test scenarios:** +- Dragging a keyframe near a frame boundary (at 30fps: 33.3ms intervals) snaps to it +- Dragging near another element's keyframe time snaps to it (visual guide line appears) +- Holding `Cmd` during drag disables all snapping +- Snap threshold adapts to zoom level (higher zoom = more precise snapping) +- Empty beat times array → no beat snapping (graceful degradation) +- Snap type is reported so the UI can show different-colored guide lines per type + +**Verification:** Drag a keyframe near another element's keyframe in the timeline. Verify it snaps and a vertical guide line appears. Hold `Cmd` and verify free positioning. + +--- + +## Phase 3 — Multi-Element & Power Features + +### U13. Multi-select keyframe operations + +**Goal:** Select multiple elements and add/edit keyframes on all simultaneously. + +**Requirements:** R13 + +**Dependencies:** U3, U10 + +**Files:** +- Modify `packages/studio/src/player/store/playerStore.ts` — extend `selectedElementId` to `selectedElementIds: Set` +- Modify `packages/studio/src/hooks/useGsapScriptCommits.ts` — `commitKeyframeAtTimeMulti(time, elementIds, properties)` batched mutation +- Modify `packages/studio/src/components/editor/PropertyPanel.tsx` — multi-element property display (show shared values, blank for divergent) +- Modify `packages/studio/src/player/components/TimelineCanvas.tsx` — multi-select element highlighting + +**Approach:** + +Multi-select via `Shift+click` on timeline clips or `Cmd+click` on canvas elements. `selectedElementIds` replaces `selectedElementId` (backwards compatible: single select → Set with one entry). + +`commitKeyframeAtTimeMulti` iterates over all selected elements and calls `commitKeyframeAtTime` for each, batched in a single undo group. The undo system already supports multi-file entries — extend to support multiple mutations in one entry. + +PropertyPanel in multi-select mode shows the intersection of animated properties across all selected elements. Values that are identical show the value; values that differ show "Mixed". + +**Patterns to follow:** Multi-select patterns in timeline (if existing); batched mutation pattern in `useGsapScriptCommits`. + +**Test scenarios:** +- Shift+click selects multiple elements in the timeline +- Adding a keyframe with multiple elements selected creates keyframes on all +- Undo reverts all keyframes in one step +- Property panel shows "Mixed" for properties with different values across selection +- Deselecting one element from multi-select works correctly +- `Cmd+A` selects all elements (when timeline is focused) + +**Verification:** Select 3 elements, press K, verify all 3 get keyframes at the current time. Undo and verify all 3 keyframes are removed. + +--- + +### U14. Stagger: numeric input and drag-to-fan + +**Goal:** When adding keyframes to multiple elements, support staggered timing with numeric offset and drag-to-fan interaction. + +**Requirements:** R13 (stagger detail) + +**Dependencies:** U13 + +**Files:** +- Create `packages/studio/src/components/editor/StaggerControls.tsx` +- Modify `packages/studio/src/hooks/useGsapScriptCommits.ts` — apply stagger offset to multi-element keyframe operations +- Modify `packages/studio/src/player/components/TimelineClipDiamonds.tsx` — drag-to-fan gesture on multi-selected diamonds + +**Approach:** + +**Numeric stagger:** `StaggerControls` renders when multiple elements are selected. Shows: offset input (ms), order dropdown (DOM, reverse, center-out, edges-in, random). Applying stagger adjusts each element's keyframe time by `index * offset` in the selected order. + +**Drag-to-fan:** When multiple keyframe diamonds are selected across elements, `Alt+drag` fans them proportionally. The drag delta is divided across the selection count to compute per-element offset. Visual feedback shows the fanning in real-time (optimistic preview). + +Stagger maps to GSAP's `stagger` property on the generated tween. For multi-tween scenarios (each element has its own tween), stagger adjusts the `position` parameter on each tween. + +**Patterns to follow:** Existing `onDragKeyframe` handler in `TimelineClipDiamonds`; GSAP stagger API conventions. + +**Test scenarios:** +- Setting stagger offset to 100ms with 3 elements creates keyframes at t, t+100ms, t+200ms +- DOM order follows the document order of elements +- Reverse order applies stagger in reverse DOM order +- Center-out order staggers from the middle outward +- `Alt+drag` on multi-selected diamonds fans the timing proportionally +- Releasing Alt+drag commits the fanned timing as one undo point +- Stagger of 0ms creates all keyframes at the same time (no offset) + +**Verification:** Select 5 elements, set stagger to 80ms with center-out order, add a keyframe. Verify the staggered timing in the timeline. Alt+drag to adjust and verify smooth fanning. + +--- + +### U15. Copy/paste keyframes between elements + +**Goal:** Copy a property's keyframe data from one element and paste it onto another. + +**Requirements:** R14 + +**Dependencies:** U3, U13 + +**Files:** +- Modify `packages/studio/src/hooks/useGsapScriptCommits.ts` — `copyKeyframes(animationId, property?)` and `pasteKeyframes(targetAnimationId, atTime)` +- Modify `packages/studio/src/player/store/playerStore.ts` — add `keyframeClipboard` state +- Modify `packages/studio/src/player/components/TimelineClipDiamonds.tsx` — `Cmd+C`/`Cmd+V` handlers when keyframes are selected + +**Approach:** + +Copy serializes the selected keyframes as an array of `{ relativeTime: number, value: number | string, ease?: string }` entries. The times are relative to the first keyframe (so the first is always 0). + +Paste at the current scrub time: the first copied keyframe lands at the scrub position, subsequent keyframes are offset by their relative times. If the target element has no existing tween at that time, a new tween is created (U4 path). + +Copy/paste works per-property (copy just `x` keyframes) or per-element (copy all properties' keyframes). + +**Patterns to follow:** Standard clipboard pattern; `addKeyframeToScript` with backfill for new properties. + +**Test scenarios:** +- Copy 3 x-keyframes from element A, paste onto element B at t=2s → element B gets 3 x-keyframes starting at t=2s +- Copy all properties, paste onto element with existing keyframes → merges without destroying existing keyframes on non-overlapping properties +- Paste when target has no animation creates a new tween +- `Cmd+C` with keyframe diamond selected copies; `Cmd+V` pastes at playhead position +- Pasting across elements with different tween durations adjusts percentages correctly + +**Verification:** Copy keyframes from one element, select another, paste, and verify the animation is applied correctly at the paste position. + +--- + +### U16. Full AE keyboard shortcut set + +**Goal:** Implement the complete AE-mirrored keyboard shortcut vocabulary for keyframe operations. + +**Requirements:** R16 + +**Dependencies:** U3, U6, U9, U10, U15 + +**Files:** +- Modify `packages/studio/src/components/editor/KeyboardShortcuts.tsx` (or equivalent shortcut registry) +- Modify `packages/studio/src/player/hooks/usePlaybackKeyboard.ts` — refactor to yield J/K to keyframe shortcuts when a keyframe is selected +- Modify `packages/studio/src/player/store/playerStore.ts` — add handlers for each shortcut action + +**Approach:** + +Register shortcuts with conflict detection against existing bindings: + +| Action | Shortcut | Handler | +|--------|----------|---------| +| Add keyframe | `K` | `commitKeyframeAtTime` for selected element (note: conflicts with existing pause shortcut in `usePlaybackKeyboard.ts` — requires context-priority refactor) | +| Delete keyframe(s) | `Delete` / `Backspace` | Remove selected keyframes | +| Previous keyframe | `J` | Seek to nearest earlier keyframe (note: conflicts with existing play-backward shortcut — requires context-priority refactor) | +| Next keyframe | `Shift+J` | Seek to nearest later keyframe | +| Toggle hold/bezier | `H` | Toggle ease on selected keyframe | +| Expand/collapse properties | `U` | Toggle `expandedTimelineElements` | +| Select all keyframes | `Cmd+A` (timeline focused) | Select all keyframes for element | +| Copy keyframes | `Cmd+C` (keyframes selected) | Copy to clipboard | +| Paste keyframes | `Cmd+V` (at scrub position) | Paste from clipboard | +| Nudge keyframe +1 frame | `→` (keyframe selected) | Move by 1/fps seconds | +| Nudge keyframe -1 frame | `←` (keyframe selected) | Move by 1/fps seconds | +| Nudge +10 frames | `Shift+→` | Move by 10/fps seconds | +| Nudge -10 frames | `Shift+←` | Move by 10/fps seconds | + +Shortcuts are context-sensitive: `J`/`Shift+J` only activate when the timeline or design panel is focused, not during text editing. + +**Patterns to follow:** Existing shortcut registration pattern (e.g., `R` for gesture recording); conflict with existing bindings resolved by context scoping. + +**Test scenarios:** +- `K` adds a keyframe at current time for the selected element +- `J` seeks to the previous keyframe time (from current playhead) +- `Delete` removes the selected keyframe diamond +- `H` toggles between bezier and hold on the selected keyframe +- `U` expands/collapses property rows for the selected element +- Arrow keys nudge a selected keyframe by one frame (at composition FPS) +- Shortcuts are disabled during text input in property fields +- No shortcut conflicts with existing bindings (R for recording, spacebar for play/pause) + +**Verification:** Test each shortcut in sequence, verifying the correct action is triggered and no conflicts arise. + +--- + +### U17. Beat marker snapping via audio onset detection + +**Goal:** Analyze audio tracks to detect beat positions and feed them into the snapping engine (U12). + +**Requirements:** R15 (beat marker detail) + +**Dependencies:** U12 + +**Files:** +- Create `packages/studio/src/utils/audioBeatDetection.ts` +- Modify `packages/studio/src/utils/keyframeSnapping.ts` — consume beat times from the detector +- Modify `packages/studio/src/player/store/playerStore.ts` — add `beatMarkers: number[]` state + +**Approach:** + +The beat detection runs when an audio file is present in the composition: +1. Decode the audio file via Web Audio API `AudioContext.decodeAudioData` +2. Run onset detection (energy-based: compute RMS energy in windowed frames, detect peaks above a dynamic threshold) +3. Store detected beat times in `playerStore.beatMarkers` + +The implementation should start with a simple energy-based detector (no external library dependency). If quality is insufficient, evaluate aubio.js or Meyda as drop-in replacements — the `KeyframeSnapper` interface accepts `beatTimes: number[]` regardless of source. + +Beat markers render as faint vertical lines on the timeline, togglable via a toolbar button. + +**Patterns to follow:** Web Audio API decode pattern; `playerStore` state management. + +**Test scenarios:** +- Loading a composition with an audio track triggers beat detection +- Detected beats appear as faint lines on the timeline +- Dragging a keyframe near a beat snaps to it (with visual guide line) +- Toggling beat markers off removes the visual lines and disables beat snapping +- Compositions without audio show no beat markers (graceful no-op) +- Beat detection runs asynchronously without blocking the UI + +**Verification:** Load a composition with a music track, verify beat markers appear on the timeline. Drag a keyframe near a beat and verify snapping. + +--- + +## Open Questions + +- **Graph editor future**: The full value-over-time graph editor is deferred, but the data model should not preclude it. Per-keyframe ease as `cubic-bezier(x1,y1,x2,y2)` control points are sufficient as the underlying format — the graph editor would visualize these same values. +- **Merge-via-drag UX detail**: When the user drags a keyframe from one tween clip into another, the system needs to: (a) remove it from the source tween (possibly collapsing it), and (b) add it to the target tween. The exact drag UX (visual feedback, snap targets) can be refined during implementation. + +--- + +## Sources & Research + +- Handoff document: `docs/plans/handoff-keyframes-session.md` — current architecture, known bugs, cache invariants, file locations +- Existing keyframe navigation: `packages/studio/src/components/editor/KeyframeNavigation.tsx` — three-state diamond already implemented +- Ease curve editor: `packages/studio/src/components/editor/EaseCurveSection.tsx` — cubic bezier editor already built +- GSAP mutation API: `packages/core/src/studio-api/routes/files.ts` lines 574-674 — full mutation type catalog +- Runtime bridge: `packages/studio/src/hooks/gsapRuntimeBridge.ts` — drag intercept flow, `gsap.getProperty` pattern +- Keyframe root causes plan: `docs/superpowers/plans/2026-06-02-keyframe-root-causes.md` — five interacting bugs and fixes +- Native GSAP keyframes spec: `docs/superpowers/specs/2026-06-01-native-gsap-keyframes.md` — prior 8-phase spec +- Zustand re-render patterns: use selectors, never destructure store, wrap in `React.memo()` +- Cache additive-only invariant: no code path clears runtime-scanned cache entries (3 bugs fixed) diff --git a/docs/plans/2026-06-08-001-fix-webgl-black-render-regression-plan.md b/docs/plans/2026-06-08-001-fix-webgl-black-render-regression-plan.md new file mode 100644 index 000000000..e9882b184 --- /dev/null +++ b/docs/plans/2026-06-08-001-fix-webgl-black-render-regression-plan.md @@ -0,0 +1,173 @@ +--- +title: "fix: WebGL/Three.js content renders black in v0.6.80+ (issue #1260)" +type: fix +status: active +date: 2026-06-08 +--- + +# fix: WebGL/Three.js content renders black in v0.6.80+ (issue #1260) + +## Summary + +Three.js/WebGL content renders correctly in `hyperframes preview` but produces black frames in `hyperframes render` starting at v0.6.80. The regression is confirmed across multiple reporter environments (macOS arm64, Chrome 148, 16 GB RAM). Fix the root cause and add a CI regression test so WebGL capture failures are caught before release. + +## Problem Frame + +A user reported (#1260) that Three.js backgrounds render black in the MP4 output. The regression was introduced between v0.6.71 (works) and v0.6.80 (broken). Preview continues to work because it runs in a normal browser window — no headless Chrome, no CDP screenshots, no injected producer stubs. The render pipeline injects an early stub (`HF_EARLY_STUB`) and a bridge script that mediate between the composition and the engine's capture loop. + +The existing `three-boundary` test fixture in the producer has no `meta.json` and no golden baseline, so CI never actually validates WebGL capture — the fixture exists as source material only. + +--- + +## Requirements + +**Correctness** + +- R1. Three.js/WebGL content must render correctly in `hyperframes render` output — matching the visual result of `hyperframes preview`. +- R2. The fix must not regress the GSAP batching behavior introduced in #1231 (compositions with thousands of `tl.to()` calls must still render without main-thread hang). +- R3. The fix must not regress existing producer regression tests (PSNR baselines must pass). + +**Regression coverage** + +- R4. A Three.js regression test with `meta.json` and Docker-generated golden baseline must be added so CI catches WebGL capture failures. + +--- + +## Key Technical Decisions + +**Root cause hypothesis: GSAP batching proxy.** Commit `ebd156bc` (v0.6.80) installs `HF_EARLY_STUB` — a property trap on `window.gsap` that wraps `gsap.timeline()` with a proxy. The proxy queues `to/from/fromTo/set/add` calls and flushes them via `requestAnimationFrame` in batches of 100. Three.js compositions register their `renderer.render()` call inside an `onUpdate` callback on a queued `to()` — the callback only reaches the real timeline after the batch flushes. While the proxy's `seek()` calls `flushPendingOperations()` synchronously before delegating to the real timeline, there's a window during initialization where the proxy might interfere with how init.ts binds the timeline and how the bridge reports `__hf.duration`. This hypothesis will be confirmed or refuted by the bisect in U1. + +**Reproduce-first approach.** The user explicitly requested working backwards from a reproduction. The plan starts by reproducing the bug locally, then uses `git bisect` to identify the exact breaking commit, then fixes from there. + +**Docker-generated baselines only.** Per CLAUDE.md, golden baselines for `packages/producer/tests/` must be generated inside `Dockerfile.test`, not on the host. The Three.js regression test baseline will follow this contract. + +--- + +## High-Level Technical Design + +```mermaid +flowchart TB + A[Reproduce: render three-boundary at HEAD] --> B{Black output?} + B -->|yes| C[git bisect v0.6.71..HEAD] + B -->|no| D[Vary Chrome flags / GPU mode to reproduce] + C --> E[Identify breaking commit] + E --> F{GSAP batching proxy?} + F -->|yes| G[Fix proxy: ensure onUpdate fires on first seek] + F -->|no| H[Analyze breaking commit, fix root cause] + G --> I[Verify: render three-boundary, compare output] + H --> I + I --> J[Add meta.json + Docker golden baseline] + J --> K[Run full regression suite] +``` + +--- + +## Scope Boundaries + +### Deferred to Follow-Up Work + +- Applying the micro-screenshot flush from closed PR #1264 as a defense-in-depth measure — the root cause fix should make this unnecessary, but it could be revisited as a hardening measure +- Extending WebGL capture tests to cover `captureScreenshotWithAlpha` and `captureAlphaPng` paths (HDR pipeline) +- Testing on Linux BeginFrame mode (unaffected per research — uses `HeadlessExperimental.beginFrame`, not `Page.captureScreenshot`) + +--- + +## Implementation Units + +### U1. Reproduce and bisect the regression + +**Goal:** Confirm the bug reproduces locally and identify the exact breaking commit. + +**Requirements:** R1 + +**Dependencies:** None + +**Files:** +- `packages/producer/tests/distributed/three-boundary/src/index.html` (read only — existing fixture) +- `packages/engine/src/services/screenshotService.ts` (read only — verify capture params) + +**Approach:** Run the existing `three-boundary` composition through the render pipeline at HEAD. If it produces black WebGL content, use `git bisect` between `v0.6.71` and `HEAD` with a scripted test that checks for non-black pixels in the output. The bisect script should render the composition and verify that the output contains non-zero color values in the WebGL canvas area. If the bug doesn't reproduce with the existing fixture, vary the Chrome GPU flags (`--gpu`, `--no-browser-gpu`) and capture mode to match the reporter's environment. + +**Execution note:** This is an investigative unit — the output is the breaking commit hash, not code. + +**Test scenarios:** +- Render `three-boundary` at HEAD → output frames should show purple cube (currently expected to be black, confirming the bug) +- Render `three-boundary` at v0.6.71 → output frames should show purple cube (confirming the baseline works) +- `git bisect` identifies a single commit as the first-bad + +**Verification:** The bisect completes with a definitive commit hash. The breaking commit's diff is understood well enough to design a fix. + +--- + +### U2. Fix the root cause + +**Goal:** Patch the identified code so WebGL content renders correctly without regressing GSAP batching. + +**Requirements:** R1, R2, R3 + +**Dependencies:** U1 + +**Files:** (depends on bisect result — most likely one of these) +- `packages/producer/stubs/hf-early-stub.ts` +- `packages/producer/src/services/fileServer.ts` +- `packages/core/src/runtime/init.ts` + +**Approach:** The fix depends on the bisect result from U1. The most likely scenarios: + +1. **GSAP batching proxy issue** — If the proxy interferes with Three.js `onUpdate` callbacks, the fix would ensure that the proxy's `flushPendingOperations()` properly applies all queued tweens before the first engine seek, OR that the proxy correctly handles the timeline binding flow in init.ts so `__renderReady` is set and the duration getter returns the correct value. + +2. **`__renderReady` timing issue** — The GSAP batching commit removed `window.__renderReady = true` from the bridge's `waitForPlayer()` function. If init.ts doesn't set it in time for compositions that manually register timelines (like `three-boundary`), the duration getter's `if (!window.__renderReady) return 0` gate would prevent `pollHfReady` from resolving. The fix would ensure `__renderReady` is set correctly for all timeline registration patterns. + +3. **Something else entirely** — The bisect may reveal a different commit. Fix based on what the investigation shows. + +**Patterns to follow:** The existing `prepareFrameForCapture()` pattern in `frameCapture.ts` (line ~1266) already does a compositor-flush micro-screenshot for the shader transitions pipeline — if a compositor flush is needed, follow that pattern. + +**Test scenarios:** +- Render `three-boundary` at HEAD with fix applied → purple cube visible in all frames +- Render a composition with 1000+ `tl.to()` calls → renders without timeout (GSAP batching still works) +- Existing producer regression tests pass (PSNR baselines) +- `hyperframes preview` still shows Three.js content correctly (no regression in preview path) + +**Verification:** The `three-boundary` composition renders with visible WebGL content. No existing test regressions. + +--- + +### U3. Add Three.js regression test to CI + +**Goal:** Add `meta.json` and a Docker-generated golden baseline for the `three-boundary` fixture so CI validates WebGL capture on every release. + +**Requirements:** R4 + +**Dependencies:** U2 + +**Files:** +- `packages/producer/tests/distributed/three-boundary/meta.json` (create) +- `packages/producer/tests/distributed/three-boundary/output/output.mp4` (generate via Docker) +- `Dockerfile.test` (verify it handles the Three.js fixture) + +**Approach:** Create `meta.json` for the `three-boundary` test fixture with appropriate settings (320x180, 2s duration, fps matching other fixtures). Generate the golden baseline inside `Dockerfile.test` using: + +``` +bun run --cwd packages/producer docker:test:update three-boundary +``` + +The baseline must be generated inside Docker — not on the host — per CLAUDE.md. Verify that the regression harness picks up the fixture by checking `tryAddSuite()` no longer skips it. + +**Patterns to follow:** Existing test fixtures in `packages/producer/tests/distributed/` — mirror the `meta.json` structure from a similar simple fixture. + +**Test scenarios:** +- `three-boundary` fixture appears in the regression harness test list (not skipped for missing `meta.json`) +- PSNR comparison between rendered output and golden baseline passes within threshold +- Deliberately breaking WebGL capture (e.g., reverting the fix) causes the Three.js test to fail PSNR + +**Test expectation:** The test is the golden baseline itself — CI runs the render and compares against the Docker-generated `output.mp4`. + +**Verification:** The full regression test suite passes including the new `three-boundary` fixture. The fixture is not silently skipped. + +--- + +## Risks & Dependencies + +- **Bisect may not reproduce locally.** The reporter is on Chrome 148 / macOS arm64. If the local Chrome version differs, the bug might not reproduce. Mitigation: match Chrome version via Puppeteer's managed cache, or use `--gpu` / `--no-browser-gpu` flags to vary the GPU backend. +- **Docker baseline generation requires Docker running.** The golden baseline must be generated inside `Dockerfile.test`. If Docker isn't running on the dev machine, this step must be done remotely (e.g., on the devbox at `ubuntu@10.0.9.220`). +- **GSAP proxy fix may require careful threading.** If the root cause is in the proxy, the fix must preserve the batching behavior for large compositions while ensuring small compositions (especially those using `onUpdate` for WebGL) work correctly. diff --git a/docs/plans/2026-06-08-002-fix-proxy-stub-navigation-timeout-plan.md b/docs/plans/2026-06-08-002-fix-proxy-stub-navigation-timeout-plan.md new file mode 100644 index 000000000..cd48c84bb --- /dev/null +++ b/docs/plans/2026-06-08-002-fix-proxy-stub-navigation-timeout-plan.md @@ -0,0 +1,93 @@ +--- +title: "fix: Remove synchronous flush from Proxy get/set traps in hf-early-stub" +type: fix +status: active +date: 2026-06-08 +--- + +# fix: Remove synchronous flush from Proxy get/set traps in hf-early-stub + +## Summary + +Remove `flushPendingOperations()` from the Proxy `get` trap's non-function property-read path and from the `set` trap in the hf-early-stub. This eliminates a synchronous-flush regression introduced in commit `1bcd6ec3` (v0.6.82) that defeats the rAF-based batching mechanism and can starve `DOMContentLoaded`, causing `page.goto` navigation timeouts during render. + +## Problem Frame + +Commit `1bcd6ec3` rewrote the hf-early-stub's `wrapTimeline` from a plain-object proxy to a `new Proxy` to fix silently-dropped GSAP API surface (eventCallback, labels, repeat, etc.). The Proxy's `get` trap calls `flushPendingOperations()` on every non-function property read where `value !== undefined` (line 310), and the `set` trap does the same on every property write (line 315). This synchronously drains the entire pending operations queue — the same main-thread-blocking behavior the batching mechanism was designed to prevent. The regression manifests as `page.goto` navigation timeouts (60s) during render, observed in both CI (beginframe mode, flaky) and local renders (screenshot mode, large compositions). + +## Requirements + +- R1. Non-function property reads on the timeline proxy must NOT trigger `flushPendingOperations()`. Passive reads (`tl.vars`, `tl.data`, GSAP internals like `._dp`) return the real timeline's current value without side effects. +- R2. Property writes on the timeline proxy must NOT trigger `flushPendingOperations()`. Writes forward to the real timeline directly. +- R3. Non-batched method calls (`pause()`, `duration()`, `time()`, `seek()`, etc.) continue to trigger `flushPendingOperations()` before delegating — this is correct behavior ensuring consistent state for reading methods. +- R4. Batched methods (`to`, `from`, `fromTo`, `set`, `add`) continue to enqueue operations for rAF-based flush — no change. +- R5. The generated IIFE in `hf-early-stub-inline.ts` reflects the source changes. +- R6. Build and all existing tests pass. + +--- + +## Key Technical Decisions + +- **Remove property-read flush, keep method-call flush.** The original plain-object proxy never flushed on property reads — only on explicit method calls. The Proxy rewrite added property-read flushes "so post-batch reads see consistent state," but this is not worth the DOMContentLoaded starvation risk. Compositions that need consistent values use method calls (`tl.duration()`), not raw property reads. +- **Remove set-trap flush.** Property writes during construction (e.g., `tl.data = {...}`) should forward directly to the real timeline without flushing. The original plain-object proxy didn't expose a set trap at all. +- **Preserve the Proxy architecture.** The Proxy approach is correct — it fixes the silently-dropped API surface problem. Only the flush triggers are wrong. + +--- + +## Implementation Units + +### U1. Fix Proxy get/set traps in hf-early-stub + +**Goal:** Remove synchronous flush from property reads and writes while keeping it for method calls. + +**Requirements:** R1, R2, R3, R4 + +**Files:** +- `packages/producer/stubs/hf-early-stub.ts` (modify) + +**Approach:** In the `get` trap, remove the `if (value !== undefined) flushPendingOperations();` line — just return `value` directly. In the `set` trap, remove `flushPendingOperations();` — forward the write to the real timeline directly. Method calls still go through `createFlushingMethodWrapper` which flushes before delegating. + +**Patterns to follow:** The BATCHED_METHODS / PROXY_STATE_KEYS pattern is already correct. Only the fallthrough paths need change. + +**Test scenarios:** +- Batched methods (`to`, `from`, `fromTo`, `set`, `add`) still queue operations without immediate application +- Non-batched methods (`pause`, `duration`, `time`, `seek`) still flush before delegating +- Property reads (`tl.vars`, `tl.data`) return the real timeline's value without flushing pending operations +- Property writes (`tl.data = x`) forward to real timeline without flushing +- `__hfIsProxy`, `__hfReal`, `__hfQueue` reads bypass the real timeline entirely (existing behavior) + +**Verification:** Run `bun run build` and `bun run test` from repo root. + +### U2. Regenerate hf-early-stub-inline IIFE + +**Goal:** Rebuild the generated IIFE constant to reflect the source changes. + +**Requirements:** R5 + +**Dependencies:** U1 + +**Files:** +- `packages/producer/src/generated/hf-early-stub-inline.ts` (regenerate) + +**Approach:** Run the build script that compiles `stubs/hf-early-stub.ts` into the inline IIFE string constant. + +**Test expectation:** none -- generated artifact, verified by build. + +**Verification:** `bun run build` succeeds; the generated file content differs from the pre-change version. + +### U3. Verify build and tests + +**Goal:** Confirm no regressions from the Proxy trap changes. + +**Requirements:** R6 + +**Dependencies:** U1, U2 + +**Files:** +- (no new files) + +**Approach:** Run full build, typecheck, and test suite. Fix any failures. + +**Test expectation:** none -- verification step. + +**Verification:** `bun run build && bun run test` pass clean. diff --git a/docs/plans/2026-06-09-001-refactor-split-keyframe-prs-plan.md b/docs/plans/2026-06-09-001-refactor-split-keyframe-prs-plan.md new file mode 100644 index 000000000..41211ab8e --- /dev/null +++ b/docs/plans/2026-06-09-001-refactor-split-keyframe-prs-plan.md @@ -0,0 +1,400 @@ +--- +title: "refactor: Split keyframe PRs into ≤700 LOC stacked PRs" +status: active +date: 2026-06-09 +type: refactor +depth: standard +--- + +## Summary + +Reorganize three keyframe-system PRs (#1217, #1232, #1256) into a clean stack of 8 PRs, each ≤700 LOC. #1217 ships as-is. #1232 (3608 LOC) splits into 5 PRs following the dependency chain: parser → runtime → timeline UI → design panel → render queue. #1256 (2353 LOC) splits into 3 PRs: gesture recording core → bug fixes → integration wiring. The keyframe feature remains gated behind `VITE_STUDIO_ENABLE_KEYFRAMES=false`. + +--- + +## Problem Frame + +Three PRs implementing Studio's keyframe system are too large for effective review: +- #1232 at +3164/-444 (61 files) — reviewer fatigue, hard to reason about correctness +- #1256 at +1924/-429 (43 files) — mixes new features with 21 bug fixes + +Splitting them preserves the same total code but makes each PR reviewable in one sitting, with clear scope per PR and a dependency-ordered stack. + +--- + +## Requirements + +- **R1.** Each resulting PR ≤700 lines changed (additions + deletions) +- **R2.** Stack order respects import dependencies — no PR imports from a file introduced in a later PR +- **R3.** Each PR builds and type-checks independently (no broken intermediate states) +- **R4.** Squash to 1 commit per PR, rebase onto main +- **R5.** Feature gate `VITE_STUDIO_ENABLE_KEYFRAMES` defaults to `false` in the final PR +- **R6.** No plan files or design specs committed + +--- + +## Key Technical Decisions + +**KTD1. Split by dependency layer, not by feature slice.** +Splitting by feature (e.g., "arc motion PR" vs "dopesheet PR") would create circular dependencies since the parser, hooks, and UI all cross-reference. Layer-based splitting (parser → hooks → UI → integration) follows the import graph and produces PRs that each add one layer the next PR can build on. + +**KTD2. Shared-file changes go in the earliest PR that needs them.** +Files modified by both #1232 and #1256 (gsapParser.ts, useGsapTweenCache.ts, init.ts, PropertyPanel.tsx, etc.) — the #1232-era changes land in the #1232 split, and #1256-era changes land in the #1256 split. The stack order guarantees later PRs can build on earlier ones. + +**KTD3. RenderQueue and producer build are a standalone PR.** +These changes (+311 LOC combined) are independent of the keyframe feature. Shipping them separately unblocks the render pipeline improvements without waiting for keyframe review. + +**KTD4. Feature gate moved to the earliest PR that introduces the UI surface.** +The `STUDIO_KEYFRAMES_ENABLED` flag (default false) ships in PR 5 (Timeline UI), which is the first PR that exposes user-facing keyframe UI. All subsequent PRs respect the gate. + +--- + +## Scope Boundaries + +### In Scope +- Reorganizing existing committed code into smaller PRs +- Adjusting imports/exports if splitting a file's changes across PRs requires it +- Running type-check and lint on each intermediate PR + +### Out of Scope +- New features or bug fixes beyond what's already in #1217/#1232/#1256 +- Refactoring the code itself (only reorganizing commits) + +### Deferred to Follow-Up Work +- Fallow duplication/complexity findings (inherited, pre-existing) +- Gesture trail overlay positioning fix (known issue, not blocking) + +--- + +## Implementation Units + +### U1. PR 1 — fix(studio): keyframe stability + modular border-radius editor + +**Goal:** Ship #1217 as-is — already under 700 LOC. + +**Requirements:** R1 + +**Dependencies:** None (bases on main) + +**Files (333 LOC, 11 files):** +- `packages/studio/src/components/editor/BorderRadiusEditor.tsx` (+209) +- `packages/studio/src/components/editor/propertyPanelStyleSections.tsx` (+38) +- `packages/studio/src/components/editor/PropertyPanel.tsx` (+29) +- `packages/studio/src/hooks/useGsapScriptCommits.ts` (+23) +- `packages/studio/src/hooks/useDomEditSession.ts` (+15) +- `packages/studio/src/components/StudioHeader.tsx` (+14) +- Plus 5 smaller files (<10 LOC each) + +**Approach:** Already merged/reviewed. Rebase onto main if needed. No changes required. + +**Verification:** `bun run build && bunx tsc --noEmit` passes. PR is ≤333 LOC. + +--- + +### U2. PR 2 — feat(studio): GSAP parser — arc path mutations + keyframe CRUD + +**Goal:** Add all parser-level mutations for arc paths, keyframe add/remove/update, convert-to-keyframes, and the _auto flag for 100% keyframes. Includes tests and API route wiring. + +**Requirements:** R1, R2, R3 + +**Dependencies:** U1 + +**Files (~665 LOC, 5 files):** +- `packages/core/src/parsers/gsapParser.ts` (+392 from #1232, +41 from #1256 = ~433 combined) +- `packages/core/src/parsers/gsapParser.test.ts` (+200) +- `packages/core/src/studio-api/routes/files.ts` (+73 from #1232, +1 from #1256 = ~74) +- `packages/core/src/parsers/gsapSerialize.ts` (+16) — ArcPathConfig types +- `packages/core/src/parsers/gsapConstants.ts` (+1) + +**Approach:** Cherry-pick all gsapParser.ts changes from both branches into one PR. The parser has no studio-side dependencies — it's pure Node.js AST manipulation. Include the `_auto: 1` flag logic from the bug bash since it's parser-level. Route handler changes in files.ts wire the new mutation types. + +**Test scenarios:** +- `addKeyframeToScript` adds a keyframe at the correct percentage, sorted +- `addKeyframeToScript` with `_auto` flag on 100% — auto-updates when adding lower percentage +- `addKeyframeToScript` at 100% explicitly — strips `_auto` from existing 100% +- `removeKeyframeFromScript` collapses to flat tween when <2 keyframes remain +- `convertToKeyframesInScript` preserves from/to values for each method type +- `addAnimationWithKeyframesToScript` serializes `_auto: 1` on flagged keyframes +- Arc path mutations: `setArcPath`, `removeArcPath` modify the correct tween + +**Verification:** `bun run --cwd packages/core test` passes. `bunx tsc --noEmit --project packages/core/tsconfig.json` clean. + +--- + +### U3. PR 3 — feat(studio): runtime hooks — global time compiler + keyframe runtime + +**Goal:** Add the runtime layer that bridges GSAP timeline state to Studio's keyframe model: global time compilation, soft reload, runtime keyframe preview, and the keyframe commit helper. + +**Requirements:** R1, R2, R3 + +**Dependencies:** U2 + +**Files (~575 LOC, 8 files):** +- `packages/studio/src/utils/globalTimeCompiler.ts` (+77) NEW +- `packages/studio/src/utils/globalTimeCompiler.test.ts` (+169) NEW +- `packages/studio/src/hooks/gsapRuntimeKeyframes.ts` (+99) +- `packages/studio/src/hooks/gsapKeyframeCommit.ts` (+92) NEW +- `packages/studio/src/hooks/gsapRuntimePreview.ts` (+19) NEW +- `packages/studio/src/utils/gsapSoftReload.ts` (+38) +- `packages/core/src/runtime/init.ts` (+60 from #1232) +- `packages/studio/src/player/store/playerStore.ts` (+41) + +**Approach:** These files form the "data layer" between the parser (U2) and the UI (U5/U6). `globalTimeCompiler` converts tween-relative percentages to clip-relative for timeline rendering. `gsapKeyframeCommit` wraps mutation dispatch. `gsapRuntimeKeyframes` reads live GSAP state for drag intercept. `gsapSoftReload` handles iframe reload after mutations. + +**Test scenarios:** +- `globalTimeCompiler` converts tween percentage to clip percentage correctly +- `globalTimeCompiler` handles tweens that start mid-clip (position > 0) +- `globalTimeCompiler` handles zero-duration edge case +- `gsapSoftReload` triggers iframe content reload and calls post-reload callback + +**Verification:** Tests pass. Type-check clean. `globalTimeCompiler.test.ts` covers the conversion math. + +--- + +### U4. PR 4 — feat(studio): keyframe cache + commit hooks + +**Goal:** Add the Studio hooks that populate the keyframe cache (for timeline diamond rendering) and commit keyframe mutations to the parser via the API. + +**Requirements:** R1, R2, R3 + +**Dependencies:** U3 + +**Files (~575 LOC, 8 files):** +- `packages/studio/src/hooks/useGsapScriptCommits.ts` (+161 from #1232, +55 from #1256 = pick #1232 portion) +- `packages/studio/src/hooks/useGsapTweenCache.ts` (+120 from #1232, +59 from #1256 = pick #1232 portion) +- `packages/studio/src/utils/keyframeSnapping.ts` (+63) NEW +- `packages/studio/src/utils/keyframeSnapping.test.ts` (+74) NEW +- `packages/studio/src/utils/audioBeatDetection.ts` (+58) NEW +- `packages/studio/src/hooks/useDomEditSession.ts` (+21) +- `packages/studio/src/hooks/useGsapSelectionHandlers.ts` (+10) +- `packages/studio/src/contexts/DomEditContext.tsx` (+6) + +**Approach:** `useGsapTweenCache` populates the per-element keyframe cache from parsed animations using the global time compiler (U3). `useGsapScriptCommits` dispatches mutation requests to the API. `keyframeSnapping` provides snap-to-grid for diamond drag. `audioBeatDetection` provides beat markers for timeline snapping. + +**Test scenarios:** +- Keyframe cache populates correct clip-relative percentages for a tween starting at position > 0 +- Keyframe cache clears when animations are deleted +- `keyframeSnapping` snaps to nearest grid line within threshold +- `keyframeSnapping` returns original value when no grid line is within threshold +- Commit hook dispatches `add-keyframe` mutation with correct percentage and properties + +**Verification:** Tests pass. Type-check clean. Keyframe snapping tests cover snap/no-snap boundary. + +--- + +### U5. PR 5 — feat(studio): timeline UI — dopesheet diamonds + keyboard nav + +**Goal:** Add the visual keyframe editing surface: dopesheet strip with diamond indicators, keyboard navigation (J/Shift+J/Delete/K), timeline property rows, and the keyframe toggle in the toolbar. This is the first PR that introduces user-facing keyframe UI, so the feature gate ships here. + +**Requirements:** R1, R2, R3, R4, R5 + +**Dependencies:** U4 + +**Files (~600 LOC, 10 files):** +- `packages/studio/src/components/editor/DopesheetStrip.tsx` (+141) NEW +- `packages/studio/src/player/components/TimelinePropertyRows.tsx` (+120) NEW +- `packages/studio/src/hooks/useKeyframeKeyboard.ts` (+103) NEW +- `packages/studio/src/components/editor/DomEditOverlay.tsx` (+72) +- `packages/studio/src/components/TimelineToolbar.tsx` (+47 from #1232) +- `packages/studio/src/components/editor/KeyframeDiamond.tsx` (+39) +- `packages/studio/src/components/editor/useDomEditOverlayRects.ts` (+37) +- `packages/studio/src/components/editor/LayersPanel.tsx` (+24) +- `packages/studio/src/components/editor/manualEditingAvailability.ts` (+2) — gate default=false +- `packages/studio/src/components/editor/panelTokens.ts` (+10) NEW + +**Approach:** `DopesheetStrip` renders diamond keyframe indicators at clip-relative percentages. `TimelinePropertyRows` adds per-property keyframe rows below clips. `useKeyframeKeyboard` handles J/Shift+J/Delete/K shortcuts. The feature gate `STUDIO_KEYFRAMES_ENABLED` (default false) guards all new UI. + +**Test scenarios:** +- Diamond renders at correct horizontal position for a keyframe at 50% +- Clicking a diamond selects it (visual highlight) +- Pressing Delete on selected diamond removes the keyframe +- Pressing K toggles keyframe at current playhead position +- J/Shift+J navigate between keyframes +- Feature gate: diamonds don't render when `STUDIO_KEYFRAMES_ENABLED=false` + +**Verification:** Type-check clean. Manual verification: open Studio with `VITE_STUDIO_ENABLE_KEYFRAMES=true`, confirm diamonds appear on timeline clips. + +--- + +### U6. PR 6 — feat(studio): design panel — arc controls + ease curve + stagger + +**Goal:** Add the design panel components for arc path editing (curviness slider, auto-rotate toggle), ease curve visualization, stagger controls, and the expanded animation card with per-property keyframe fields. + +**Requirements:** R1, R2, R3 + +**Dependencies:** U5 + +**Files (~685 LOC, 12 files):** +- `packages/studio/src/components/editor/ArcPathControls.tsx` (+131) NEW +- `packages/studio/src/components/editor/MotionPathOverlay.tsx` (+146) NEW +- `packages/studio/src/components/editor/EaseCurveSection.tsx` (+89) +- `packages/studio/src/components/editor/StaggerControls.tsx` (+61) NEW +- `packages/studio/src/components/editor/AnimationCard.tsx` (+74) +- `packages/studio/src/components/editor/propertyPanelPrimitives.tsx` (+63) +- `packages/studio/src/components/editor/PropertyPanel.tsx` (+122 from #1232) +- Plus 5 smaller files: propertyPanelHelpers (+17), domEditingElement (+17), GsapAnimationSection (+15), propertyPanelSections (+10), propertyPanelColor (+4) + +**Approach:** `ArcPathControls` renders the curviness slider and auto-rotate toggle when an arc path animation is selected. `MotionPathOverlay` renders the SVG path visualization over the preview. `EaseCurveSection` shows the bezier curve editor. `StaggerControls` adds stagger timing fields. All integrate into the existing PropertyPanel via conditional rendering gated on `STUDIO_KEYFRAMES_ENABLED`. + +**Test scenarios:** +- Arc path controls appear when a MotionPath animation is selected +- Curviness slider dispatches `set-arc-path` mutation with correct value +- Ease curve section renders the correct bezier shape for power2.inOut +- Stagger controls appear for animations targeting multiple elements +- MotionPath overlay SVG path matches the animation's waypoints + +**Verification:** Type-check clean. Manual: select an element with a MotionPath animation, verify arc controls appear and curviness slider updates the path preview. + +--- + +### U7. PR 7 — chore(studio): render queue improvements + producer build + +**Goal:** Ship the render queue UI improvements and producer build changes independently of the keyframe feature. + +**Requirements:** R1, R3 + +**Dependencies:** None (independent, can base on main or any point in the stack) + +**Files (~370 LOC, 5 files):** +- `packages/studio/src/components/renders/RenderQueue.tsx` (+221) +- `packages/producer/build.mjs` (+90) +- `packages/studio/src/components/renders/RenderQueueItem.tsx` (+26 from #1232) +- `packages/studio/tailwind.config.js` (+15) +- `packages/studio/vite.config.ts` (+3) + +**Approach:** These changes are unrelated to keyframes — render queue progress indicators, download improvements, and producer build optimizations. Ship separately to unblock review. + +**Test scenarios:** +- Render queue displays progress percentage during active render +- Completed renders show download button +- Producer build script generates correct output artifact + +**Verification:** `bun run build` passes. Manual: trigger a render, verify progress updates in the queue. + +--- + +### U8. PR 8 — feat(studio): gesture recording core + +**Goal:** Add the gesture recording engine: RAF sampling loop, modifier key handling (Shift/Alt/Cmd for different properties), Ramer-Douglas-Peucker simplification, and the ghost trail SVG overlay. + +**Requirements:** R1, R2, R3 + +**Dependencies:** U6 + +**Files (~655 LOC, 3 files):** +- `packages/studio/src/hooks/useGestureRecording.ts` (+340) NEW +- `packages/studio/src/utils/rdpSimplify.ts` (+183) NEW +- `packages/studio/src/components/editor/GestureTrailOverlay.tsx` (+132) NEW + +**Approach:** `useGestureRecording` captures pointer events at 60fps, resolves property deltas based on modifier keys, and accumulates samples. `rdpSimplify` reduces raw samples to clean keyframes using the Ramer-Douglas-Peucker algorithm. `GestureTrailOverlay` renders an SVG polyline following the pointer during recording and shows simplified keyframe diamonds after. + +**Test scenarios:** +- Recording starts on `startRecording()` and stops on `stopRecording()` +- Samples accumulate at ~60fps with correct time stamps +- Shift+drag produces rotationX/rotationY properties (not x/y) +- Alt+drag produces rotation property +- Cmd+drag produces opacity property +- RDP simplification reduces 180 samples to 5-15 keyframes for a smooth arc +- RDP simplification preserves start and end points exactly +- Trail overlay renders polyline in viewport coordinates during recording +- Trail overlay renders simplified path with diamonds in preview mode + +**Verification:** Type-check clean. Manual: select element, press R, drag, press R — verify trail appears and keyframes are created. + +--- + +### U9. PR 9 — fix(studio): keyframe drag + recording bug bash + +**Goal:** Apply all 21 bug fixes from the bug bash session: GSAP coordinate system fixes, gesture recording position fixes, keyframe cache race conditions, overlay flash, re-render optimization, and the `_auto` flag integration in `useEnableKeyframes`. + +**Requirements:** R1, R2, R3 + +**Dependencies:** U8 + +**Files (~600 LOC, 15 files):** +- `packages/studio/src/hooks/gsapRuntimeBridge.ts` (+212) — drag intercept fixes +- `packages/studio/src/hooks/useEnableKeyframes.ts` (+171) NEW — centralized enable/toggle +- `packages/studio/src/components/editor/manualOffsetDrag.ts` (+59) — drag member fixes +- `packages/studio/src/hooks/useGsapTweenCache.ts` (+59 from #1256) — cache invalidation fix +- `packages/studio/src/hooks/useGsapScriptCommits.ts` (+55 from #1256) — mutation ordering +- `packages/core/src/runtime/init.ts` (+49 from #1256) — remove destructive stripping +- `packages/core/src/studio-api/helpers/sourceMutation.ts` (+45) +- `packages/studio/src/components/editor/manualEditsDom.ts` (+28) — draft gsap.set fix +- `packages/studio/src/hooks/useTimelineEditing.ts` (+36) — block edits during recording +- Plus 6 smaller files: useStudioContextValue (+9), DomEditOverlay (+9), useDomEditOverlayRects (+11), manualEdits (+3), contexts (+4+4) + +**Approach:** These are all fixes discovered during the bug bash. Key fixes: capture GSAP base at drag start to prevent cache corruption, set `translate:none` before `gsap.set` to prevent double-counting, skip `reapplyPathOffsets` for GSAP-animated elements, clamp recording seek to element end time, and the `_auto` flag for 100% keyframes (useEnableKeyframes). + +**Test scenarios:** +- Dragging a GSAP-animated element updates position correctly (no double-counting) +- Element stays visible during gesture recording past element midpoint +- Keyframe cache updates after soft reload (no stale diamonds) +- 100% keyframe auto-updates when user adds intermediate keyframes (while _auto present) +- 100% keyframe stops auto-updating after user explicitly edits it +- Overlay doesn't flash at (0,0) on first render +- Timeline edits are blocked during active recording + +**Verification:** Type-check clean. Manual: enable keyframes on element, drag at different times, verify position persists. Record gesture, verify element follows pointer. + +--- + +### U10. PR 10 — feat(studio): keyframe integration wiring + docs + +**Goal:** Wire everything together: App.tsx orchestration (recording toggle, commit flow), TimelineToolbar (R key, recording indicator), PropertyPanel keyframe diamonds, shortcuts panel, toast notifications, and the keyframes guide doc. + +**Requirements:** R1, R2, R3, R6 + +**Dependencies:** U9 + +**Files (~580 LOC, 14 files):** +- `packages/studio/src/App.tsx` (+163) — recording orchestration +- `packages/studio/src/components/TimelineToolbar.tsx` (+177 from #1256) — R key, enable keyframes button +- `packages/studio/src/components/editor/PropertyPanel.tsx` (+119 from #1256) — per-property diamonds +- `packages/studio/src/components/renders/RenderQueueItem.tsx` (+93 from #1256) +- `packages/studio/src/components/StudioToast.tsx` (+54) — recording toast +- `packages/studio/src/player/components/ShortcutsPanel.tsx` (+30) — K/R shortcuts +- `packages/studio/src/hooks/useAppHotkeys.ts` (+18) — R key binding +- Plus 7 smaller files: StudioPreviewArea (+7), StudioRightPanel (+9), StudioHeader (+13), useAskAgentModal (+6), useStudioUrlState (+3), LayersPanel (+2), SourceEditor (+1) + +**Approach:** This is the final "glue" PR. App.tsx orchestrates the recording flow (gestureStateRef, handleToggleRecording, commit path). TimelineToolbar adds the K/R buttons. PropertyPanel renders per-property keyframe diamond toggles. All gated on `STUDIO_KEYFRAMES_ENABLED`. + +**Note:** `docs/guides/keyframes.mdx` (+141) is included here. It documents the keyframe system for Studio users. + +**Test scenarios:** +- Pressing R in toolbar starts recording (toast appears, indicator visible) +- Pressing R again stops recording and commits keyframes +- K button toggles keyframe at current playhead +- PropertyPanel shows diamond indicators next to keyframeable properties +- Shortcut panel shows K and R shortcuts +- Recording blocked when no element is selected (toast warning) + +**Verification:** Full integration test: open Studio with `VITE_STUDIO_ENABLE_KEYFRAMES=true`, select element, press K (diamond appears), drag at different time (new diamond), press R (record gesture), verify smooth workflow end-to-end. + +--- + +## Stack Order Summary + +``` +main + └─ U1: fix(studio): keyframe stability + border-radius (333 LOC) #1217 + └─ U2: feat(studio): parser — arc path + keyframe CRUD (~665 LOC) + └─ U3: feat(studio): runtime — global time + reload (~575 LOC) + └─ U4: feat(studio): keyframe cache + commits (~575 LOC) + └─ U5: feat(studio): timeline UI + diamonds (~600 LOC) + └─ U6: feat(studio): design panel + arc (~685 LOC) + └─ U8: feat(studio): gesture core (~655 LOC) + └─ U9: fix(studio): bug bash (~600 LOC) + └─ U10: feat(studio): wiring (~580 LOC) + + └─ U7: chore(studio): render queue (independent) (~370 LOC) +``` + +--- + +## Open Questions + +### Deferred to Implementation + +- **Exact cherry-pick vs rewrite strategy** — Some files have interleaved changes from #1232 and #1256. The implementer will need to decide per-file whether to cherry-pick hunks or manually reconstruct. `git diff` between the two branches on shared files will guide this. +- **Intermediate test coverage** — Some test files (e.g., `gsapParser.test.ts`) test features across multiple PRs. Tests may need to be split or temporarily stubbed at intermediate stack points. +- **Fallow gate** — The inherited duplication/complexity findings will trigger fallow on every PR in the stack. May need `LEFTHOOK=0` for intermediate commits, with a clean fallow run on the final PR. diff --git a/docs/plans/2026-06-09-002-fix-audio-lock-timing-race-plan.md b/docs/plans/2026-06-09-002-fix-audio-lock-timing-race-plan.md new file mode 100644 index 000000000..faa89984a --- /dev/null +++ b/docs/plans/2026-06-09-002-fix-audio-lock-timing-race-plan.md @@ -0,0 +1,94 @@ +--- +title: "fix: Audio-lock muting fails on fast-loading iframes (timing race)" +status: active +type: fix +created: 2026-06-09 +origin: Slack thread — audio-lock muting on Claude desktop +depth: Lightweight +--- + +# fix: Audio-lock muting fails on fast-loading iframes (timing race) + +## Summary + +The `audio-locked` attribute on `` correctly hides volume controls and mutes audio on Claude web, but on Claude desktop audio still plays. The root cause is a timing race: `_handleMutedChange` sends `set-muted` via postMessage immediately when the attribute fires, but the iframe's runtime control bridge (`installRuntimeControlBridge`) isn't installed yet. On Claude web the CDN-loaded iframe is slow enough that the bridge is ready; on Claude desktop, the local `claude-media://` protocol loads the iframe faster than the bridge installs, so the message is silently dropped. + +Secondary issue: `_onIframeLoad` calls `_media.resetForIframeLoad()` but `_onProbeReady` never re-syncs player-side state (muted, volume, playback-rate) to the iframe, so even a reload loses the muted state. + +## Problem Frame + +The `_sendControl` path has no delivery guarantee — it fires postMessage into an iframe that may not have a listener yet. The `_onProbeReady` callback is the earliest point where the runtime bridge is guaranteed installed (the probe resolves by finding `window.__player` or `window.__timelines`, which only exist after `initSandboxRuntimeModular()` runs, which also calls `installRuntimeControlBridge`). But `_onProbeReady` currently syncs none of the player-side state. + +This isn't specific to `audio-locked` — any attribute that fires `_sendControl` before the probe resolves has the same race. `audio-locked` manifests it because it's set as a static HTML attribute, which fires `attributeChangedCallback` during element upgrade, well before any iframe content loads. + +## Requirements + +- **R1.** Audio must be muted when `audio-locked` is set, regardless of iframe load speed or protocol scheme +- **R2.** Muted, volume, and playback-rate state must survive iframe reloads +- **R3.** No regression on Claude web, direct embedding, or headless rendering + +--- + +## Key Technical Decisions + +**KTD-1. Sync all control state in `_onProbeReady`, not just muted.** + +Volume and playback-rate have the same race window — they just don't manifest because they're rarely set as static HTML attributes before load. Syncing all three in `_onProbeReady` is the correct fix because it's the one point where the bridge is guaranteed ready, and it covers both the initial-load race and the reload-drops-state secondary issue. + +**KTD-2. Unconditional sync, not conditional re-send.** + +Always send the current state in `_onProbeReady` rather than tracking whether an earlier `_sendControl` was "acked." The messages are idempotent — re-sending the same muted/volume/playback-rate values is a no-op on the runtime side. This avoids adding ack/retry complexity for a problem that unconditional sync solves cleanly. + +--- + +## Implementation Units + +### U1. Sync player-side state to iframe on probe ready + +**Goal:** Ensure muted, volume, and playback-rate are delivered to the runtime after the bridge is installed. + +**Requirements:** R1, R2 + +**Dependencies:** None + +**Files:** +- `packages/player/src/hyperframes-player.ts` (modify `_onProbeReady`) +- `packages/player/tests/hyperframes-player.test.ts` (if exists, add test scenarios) + +**Approach:** + +At the end of `_onProbeReady`, after the existing `setupFromIframe` / `autoplay` logic, send the current player-side state to the iframe: + +1. Send `set-muted` with the current `this.muted` value +2. Send `set-volume` with the current `this._volume` value +3. Send `set-playback-rate` with the current `this.playbackRate` value + +All three use the existing `_sendControl` method. This is safe because: +- `_sendControl` is already a no-op when `contentWindow` is null +- The runtime handlers are idempotent (they just set state) +- The probe resolving guarantees the bridge listener is installed + +**Patterns to follow:** The existing `_sendControl` calls in `attributeChangedCallback` for volume (line 211) and playback-rate (line 195) — same message shapes. + +**Test scenarios:** +- Muted attribute set before iframe loads → audio is muted after probe resolves +- Volume attribute set before iframe loads → volume is applied after probe resolves +- Playback-rate attribute set before iframe loads → rate is applied after probe resolves +- Iframe reload with muted=true → muted state is re-synced after probe re-resolves +- Default state (no muted/volume/rate attributes) → sync sends defaults, no regression + +**Verification:** Build passes (`bun run build`). Manual verification: set `audio-locked` on a `` element with a fast-loading src — audio should be muted on first load and after reload. + +--- + +## Scope Boundaries + +### Not in scope + +- Changes to the runtime bridge (`installRuntimeControlBridge`) — it's correct; the issue is the sender's timing +- Adding ack/retry to `_sendControl` — unconditional sync in `_onProbeReady` is simpler and sufficient +- Re-disabling TTS in Pacific — separate decision, separate PR + +### Deferred to Follow-Up Work + +- Auditing other `_sendControl` callers (e.g., `play`, `pause`, `seek`) for the same pre-bridge-ready race — these are user-initiated actions that only fire after the player is interactive, so they're unlikely to hit the race, but worth confirming diff --git a/docs/plans/2026-06-10-001-fix-gate-delete-hooks-keyframe-corruption-plan.md b/docs/plans/2026-06-10-001-fix-gate-delete-hooks-keyframe-corruption-plan.md new file mode 100644 index 000000000..8ccafb934 --- /dev/null +++ b/docs/plans/2026-06-10-001-fix-gate-delete-hooks-keyframe-corruption-plan.md @@ -0,0 +1,299 @@ +--- +title: "fix: Gate server delete hooks and fix keyframe value corruption" +type: fix +status: active +date: 2026-06-10 +--- + +# fix: Gate server delete hooks and fix keyframe value corruption + +## Summary + +Make branch `fix/gesture-recording-offset-v2` merge-safe by fixing regressions it introduced: the build is broken (6 tsc errors), server-side strip/bake hooks fire destructively on extend-tween drags, the VISUAL_BASELINE block bakes cross-tween values into keyframes, and diagnostic logs need stripping. + +--- + +## Problem Frame + +The branch added four bug fixes for element position corruption after "Delete All Keyframes": server-side offset stripping, opacity baking on delete, baseline visual property capture during drag, and scale-aware identity matrix. A comprehensive audit then found that three of these fixes introduce new regressions: + +1. The server `delete` handler fires `stripStudioEditsFromTarget` and `bakeVisibilityOnDelete` on *every* delete mutation — including the internal delete inside `extendTweenAndAddKeyframe`, which deletes-then-recreates the tween during drags outside the tween range. This bakes incorrect inline opacity and strips offsets mid-gesture, corrupting the saved file. + +2. `readAllAnimatedProperties` rounds all declared animation properties to integers (`Math.round(val)` at line 64), so a drag mid-fade stores `opacity: 0` instead of `0.4`. The new VISUAL_BASELINE block uses 3-decimal precision but only runs for properties *not* already read — it never compensates for the integer rounding. Separately, VISUAL_BASELINE reads runtime values without checking whether other tweens are animating those properties, so mid-fade opacity from tween A gets baked into tween B's keyframes. + +3. The build doesn't pass: 6 TypeScript errors in `gsapSoftReload.ts` from `as Record` casts on Element-typed targets that need the double cast through `unknown`. + +--- + +## Requirements + +**Build & merge readiness** + +- R1. `bunx tsc --noEmit` passes for `packages/studio` with zero errors. +- R2. All diagnostic log statements (`[HF:DRAG]`, `[HF:SOFT_RELOAD]`, `[HF:COMMIT]`, `[HF:MUTATION]`, `[HF:SERVER:DELETE]`) are removed before merge. + +**Server delete hook gating** + +- R3. `stripStudioEditsFromTarget` and `bakeVisibilityOnDelete` fire only on user-initiated animation deletes, not on internal delete-then-recreate operations. +- R4. User-level "Delete All Keyframes" and "Delete Animation" still strip offsets and bake opacity as before. + +**Property read precision** + +- R5. `readAllAnimatedProperties` uses per-property precision: integer rounding for positional properties (x, y, xPercent, yPercent), 3-decimal rounding for visual properties (opacity, scale, scaleX, scaleY, rotation). +- R6. The VISUAL_BASELINE block does not capture values that are being animated by a different timeline-registered tween on the same element (bare `gsap.to()` tweens outside `__timelines` are a known pre-existing gap per R-RISK-1). + +**Opacity bake correctness** + +- R7. `bakeVisibilityOnDelete` handles relative opacity values (e.g., `"+=0.5"`) without writing invalid CSS. +- R8. `bakeVisibilityOnDelete` scans all keyframes for the final effective opacity, not just the literal last keyframe object. + +--- + +## Key Technical Decisions + +- KTD-1. **Flag on the mutation type, not on the route or commit options.** The `delete` mutation type gains an optional `stripStudioEdits?: boolean` field. The server handler runs strip/bake only when `stripStudioEdits` is truthy. This is chosen over a route-level query parameter or a `commitMutation` option because the decision belongs to the mutation semantics, not the transport or the reload behavior. The flag defaults to `undefined` (falsy) so existing callers and the extend-tween path are safe without changes; only the user-level delete caller sets it to `true`. + +- KTD-2. **Per-property precision map instead of a blanket rounding change.** A `POSITION_PROPS` set (`x`, `y`, `xPercent`, `yPercent`) gets integer rounding; everything else gets 3-decimal. This avoids changing the behavior of position reads (which are genuinely integer-pixel in GSAP) while fixing corruption of fractional visual properties. + +- KTD-3. **VISUAL_BASELINE filters by checking all tweens on the element.** The guard queries `window.__timelines` to find all tweens targeting the element, collects their animated property sets, and skips any VISUAL_BASELINE property that appears in another tween's properties. This is a runtime check (not a cache lookup) because it needs to reflect the current timeline state at the moment of the drag commit. + +- KTD-4. **Opacity bake scans all keyframes in reverse for the effective final opacity.** Walking keyframes from last to first and taking the first one that has an `opacity` property correctly handles cases where the 100% keyframe only has position properties but an earlier keyframe (e.g., 20%) set opacity. Relative values (`+=`, `-=`, `*=`) are detected by string prefix and skipped (the bake becomes a no-op for relative opacity, which is safer than writing invalid CSS). + +--- + +## High-Level Technical Design + +```mermaid +sequenceDiagram + participant UI as Studio UI + participant DC as gsapDragCommit + participant SC as useGsapScriptCommits + participant API as files.ts (server) + + Note over UI: User drags element past tween end + UI->>DC: commitGsapPositionFromDrag() + DC->>SC: commitMutation({type:"delete", animationId}) + Note right of SC: stripStudioEdits NOT set (falsy) + SC->>API: POST /gsap-mutations + API->>API: delete handler: stripStudioEdits? NO → skip strip/bake + API-->>SC: {ok, script} + DC->>SC: commitMutation({type:"add-with-keyframes",...}) + SC->>API: POST /gsap-mutations + API-->>SC: {ok, script} + + Note over UI: User clicks "Delete Animation" + UI->>SC: deleteGsapAnimation() + SC->>SC: commitMutation({type:"delete", animationId, stripStudioEdits:true}) + SC->>API: POST /gsap-mutations + API->>API: delete handler: stripStudioEdits? YES → strip + bake + API-->>SC: {ok, script} +``` + +--- + +## Scope Boundaries + +### In scope + +- TypeScript build errors in `gsapSoftReload.ts` +- Server delete hook gating via mutation flag +- Property rounding precision in `readAllAnimatedProperties` +- VISUAL_BASELINE cross-tween guard +- `bakeVisibilityOnDelete` edge case hardening (relative values, keyframe scan) +- Diagnostic log removal from all files modified by this branch + +### Deferred to Follow-Up Work + +- Drag math: draft ignores GSAP base (§2.3 H1), falsy-zero doubles distance (H2), cancel paths don't restore gsap.set (H5) — pre-existing +- Soft reload: inline transform survives kill (§2.4 F1), stale offsets re-applied in live DOM (F3), MotionPathPlugin async (F4), bare tweens survive kill (F5) — pre-existing +- Drags on `from()`/`fromTo()` tweens are destructive (§2.5) — pre-existing +- Group/multi-element selector corruption (§3.1) — pre-existing +- Animation ID instability (§3.2) — pre-existing +- Percentage-space mismatches (§3.3) — pre-existing +- Error swallowing returning `ok: true` (§3.4) — pre-existing +- `_auto` 100%-keyframe drops properties (§3.5) — pre-existing +- Gesture recording regressions (§3.6) — partially from this branch but distinct feature surface +- `readGsapProperty` integer rounding (line 22) — pre-existing standalone function, not called during drag commit flow + +--- + +## Implementation Units + +### U1. Fix TypeScript build errors in gsapSoftReload.ts + +**Goal:** Unbreak the build (R1 partial). + +**Requirements:** R1 + +**Dependencies:** None — this unblocks all other work. + +**Files:** +- `packages/studio/src/utils/gsapSoftReload.ts` + +**Approach:** Six `as Record` casts on `Element`-typed targets need `as unknown as Record`. The Element type doesn't overlap with `Record` so TypeScript rejects the direct assertion. Lines: 76, 77 (the `gsapCache` cast is fine), 88, 90, 120, 121. + +**Patterns to follow:** The existing double-cast on line 79 (`as unknown as Record`) is the correct pattern already used once in the same file. + +**Test scenarios:** +- `bunx tsc --noEmit --project packages/studio/tsconfig.json` passes with zero errors after the fix + +**Verification:** `bunx tsc --noEmit` for the studio package exits 0. + +--- + +### U2. Gate server-side strip/bake hooks on user-level delete + +**Goal:** Prevent `stripStudioEditsFromTarget` and `bakeVisibilityOnDelete` from firing on internal delete-then-recreate drags (R3, R4). + +**Requirements:** R3, R4 + +**Dependencies:** U1 + +**Files:** +- `packages/core/src/studio-api/routes/files.ts` (delete handler, mutation type union) +- `packages/studio/src/hooks/useGsapScriptCommits.ts` (`deleteGsapAnimation` caller) + +**Approach:** Add `stripStudioEdits?: boolean` to the delete mutation type union (line 655). In the delete case handler (line 847), wrap the strip/bake calls in `if (body.stripStudioEdits)`. In `deleteGsapAnimation` (the user-level delete), pass `stripStudioEdits: true` in the mutation payload. The `extendTweenAndAddKeyframe` path in `gsapDragCommit.ts` already sends a bare `{ type: "delete", animationId }` — it remains unchanged and the flag defaults to falsy. + +**Patterns to follow:** Other optional mutation fields in the type union (e.g., `ease?: string` on add mutations). + +**Test scenarios:** +- Delete animation via context menu: strip and bake fire (offset attributes removed, opacity baked) +- Drag element past tween end (extend-tween path): strip and bake do NOT fire; offsets survive; inline opacity unchanged +- Drag element within tween range: no delete mutation dispatched at all (existing behavior preserved) +- `removeAllKeyframes` mutation: no strip/bake (this is a `remove-all-keyframes` type, not `delete`) + +**Verification:** Manual test with `bugbash-combined` project — drag `#bug-triangle` past tween end at t=2s, verify the rewritten tween preserves `ease: "none"` and does not bake inline opacity. Then delete the animation via context menu, verify offset stripped and opacity baked. + +--- + +### U3. Fix property rounding precision in readAllAnimatedProperties + +**Goal:** Prevent integer rounding from corrupting opacity, scale, and rotation values during drag commits (R5). + +**Requirements:** R5 + +**Dependencies:** U1 + +**Files:** +- `packages/studio/src/hooks/gsapRuntimeReaders.ts` + +**Approach:** Define a `POSITION_PROPS` set containing `x`, `y`, `xPercent`, `yPercent`. In the main property-reading loop (lines 62-65), use integer rounding only for properties in `POSITION_PROPS`; use `Math.round(val * 1000) / 1000` for all others. This aligns the declared-property loop with the VISUAL_BASELINE block's precision. + +**Patterns to follow:** The VISUAL_BASELINE block already uses `Math.round(val * 1000) / 1000` for 3-decimal precision. + +**Test scenarios:** +- Read `opacity: 0.4` mid-fade → returns `0.4`, not `0` +- Read `scale: 1.35` → returns `1.35`, not `1` +- Read `rotation: 45.7` → returns `45.7`, not `46` +- Read `x: 150.3` → returns `150` (integer, position prop) +- Read `y: 0` → returns `0` (integer, position prop) +- Existing `manualOffsetDrag.test.ts` tests continue to pass (11/11) + +**Verification:** `bun test --filter manualOffsetDrag` passes. Manual drag of a mid-fade element preserves opacity in the committed keyframe. + +--- + +### U4. Guard VISUAL_BASELINE against cross-tween contamination + +**Goal:** Prevent the baseline capture from baking opacity/scale values from other tweens into the drag target's keyframes (R6). + +**Requirements:** R6 + +**Dependencies:** U1, U3 + +**Files:** +- `packages/studio/src/hooks/gsapRuntimeReaders.ts` + +**Approach:** Before the VISUAL_BASELINE loop, collect the set of properties animated by *other* tweens targeting the same element. Query `iframe.contentWindow.__timelines` to iterate all timelines, find children targeting `selector`, and collect their animated property keys — excluding the current `anim.id`. In the VISUAL_BASELINE loop, skip any property that appears in this "animated elsewhere" set. + +The check is runtime-only (no cache dependency) because it must reflect the current timeline state at drag-commit time. The timeline iteration is O(tweens × targets) but Studio elements rarely have more than a handful of tweens, so this is negligible. + +**Patterns to follow:** `gsapRuntimeBridge.ts` already iterates `__timelines` children to find animations for an element — similar traversal pattern. + +**Test scenarios:** +- Element with tween A (opacity fade 0→1) and tween B (x position): dragging tween B does NOT capture `opacity` from tween A's runtime state +- Element with only one tween animating opacity: VISUAL_BASELINE correctly captures opacity when it's not in the tween's declared properties but differs from default +- Element with no other tweens: VISUAL_BASELINE captures all non-default visual properties as before +- Element with tween A animating `scale` and tween B animating `x`: dragging tween B does NOT capture `scale`, `scaleX`, or `scaleY` from tween A + +**Verification:** Console-check property reads during a drag on a multi-tween element — baseline capture excludes other-tween properties. + +--- + +### U5. Harden bakeVisibilityOnDelete edge cases + +**Goal:** Fix `bakeVisibilityOnDelete` to handle relative opacity values and multi-keyframe opacity lookup (R7, R8). + +**Requirements:** R7, R8 + +**Dependencies:** U2 (this function is now only called on user-level deletes, but the correctness fixes apply regardless) + +**Files:** +- `packages/core/src/studio-api/routes/files.ts` + +**Approach:** + +For R8 — scan keyframes in reverse for the effective final opacity: replace the current "check only the last keyframe" logic with a reverse walk. Starting from the last keyframe, iterate backwards and take the first keyframe whose `properties` includes `opacity`. This correctly handles `{"0%":{opacity:0}, "20%":{opacity:1}, "100%":{x:500}}` by finding `opacity:1` at 20%. + +For R7 — guard against relative values: after extracting `finalOpacity`, check if it's a string starting with `+=`, `-=`, or `*=`. If so, skip the bake (return early). Also add a `Number.isFinite` check after `Number(finalOpacity)` to catch any NaN that slips through. + +**Patterns to follow:** The existing guard `if (finalOpacity == null || Number(finalOpacity) === 0) return;` is the right shape — the fix extends it. + +**Test scenarios:** +- Keyframes `{0%: {opacity:0}, 20%: {opacity:1}, 100%: {x:500}}` → bakes `opacity: 1` (found at 20%, not missing from 100%) +- Keyframes `{0%: {opacity:0}, 100%: {opacity:1}}` → bakes `opacity: 1` (existing behavior preserved) +- Keyframes `{0%: {x:0}, 100%: {x:500}}` → no bake (no opacity in any keyframe) +- Relative opacity `"+=0.5"` → no bake (skip rather than write invalid CSS) +- Flat `to({opacity: 1})` → bakes `opacity: 1` (existing behavior preserved) +- Descending opacity `{0%: {opacity:1}, 50%: {opacity:0}, 100%: {x:500}}` → bakes `opacity: 0` (reverse-scan finds opacity at 50%, which is the correct final visual state after fade-out) +- `from({opacity: 0})` tween → no bake (method is `from`, final state is CSS state — existing skip) + +**Verification:** Unit-level review of the function behavior against each scenario. + +--- + +### U6. Strip diagnostic logs from all modified files + +**Goal:** Remove all debug logging added by this branch before merge (R2). + +**Requirements:** R2 + +**Dependencies:** U1, U2, U3, U4, U5 (strip after all other changes to avoid merge conflicts) + +**Files:** +- `packages/studio/src/utils/gsapSoftReload.ts` — remove all `[HF:SOFT_RELOAD]` console.log/warn calls +- `packages/studio/src/components/editor/manualOffsetDrag.ts` — remove all `[HF:DRAG]` console.log calls +- `packages/studio/src/hooks/useGsapScriptCommits.ts` — remove all `[HF:COMMIT]` and `[HF:MUTATION]` console.log calls +- `packages/core/src/studio-api/routes/files.ts` — remove `[HF:SERVER:DELETE]` console.log call + +**Approach:** Grep for `[HF:` prefix across all four files and remove each log statement. Some log statements span multiple lines (template literals with object dumps) — remove the entire statement including any preceding `const` that exists solely for the log. + +**Patterns to follow:** The codebase convention is zero diagnostic logging in production code. Logs belong in test harnesses only. + +**Test scenarios:** +- `grep -r "\[HF:" packages/studio/src packages/core/src` returns zero matches after cleanup +- `bunx tsc --noEmit` still passes (no orphaned variables from removed log statements) +- `bun test --filter manualOffsetDrag` still passes + +**Verification:** Grep confirms zero `[HF:` matches. Build passes. Tests pass. + +--- + +## Risks & Dependencies + +- R-RISK-1. The VISUAL_BASELINE cross-tween guard (U4) depends on `__timelines` being populated. If a tween is registered outside `__timelines` (bare `gsap.to()` not on a timeline), it won't be detected. This is a known pre-existing limitation (§2.4 F5 in the audit) and is out of scope for this fix. + +- R-RISK-2. The `bakeVisibilityOnDelete` reverse-scan (U5) changes the semantics of which keyframe is consulted for the baked opacity. If an animation has descending opacity (fade out), the bake will now pick the last keyframe with opacity rather than the literal last keyframe — which may be at 50% with `opacity: 0.5`. This is strictly more correct than the current behavior (which would miss opacity entirely if the 100% keyframe lacks it) but worth verifying against the test project. + +--- + +## Sources & Research + +- Audit document: `studio-keyframes-bug-audit.md` (root) — comprehensive findings with live runtime verification +- Handoff document: `handoff-gsap-drag-delete-v2.md` (root) — session 2 bug descriptions and fix history +- Delete handler: `packages/core/src/studio-api/routes/files.ts:847-854` +- Extend-tween delete dispatch: `packages/studio/src/hooks/gsapDragCommit.ts:131-135` +- Property reading with rounding: `packages/studio/src/hooks/gsapRuntimeReaders.ts:62-80` +- TypeScript errors: `packages/studio/src/utils/gsapSoftReload.ts:76,77,88,90,120,121` +- Diagnostic logs: grep `[HF:` across gsapSoftReload.ts, manualOffsetDrag.ts, useGsapScriptCommits.ts, files.ts diff --git a/docs/plans/2026-06-10-002-feat-timeline-inline-expansion-plan.md b/docs/plans/2026-06-10-002-feat-timeline-inline-expansion-plan.md new file mode 100644 index 000000000..6d516d437 --- /dev/null +++ b/docs/plans/2026-06-10-002-feat-timeline-inline-expansion-plan.md @@ -0,0 +1,269 @@ +--- +title: "feat: Timeline inline sub-composition expansion with Studio cleanup" +type: feat +status: active +date: 2026-06-10 +--- + +# feat: Timeline inline sub-composition expansion with Studio cleanup + +## Summary + +When a child element inside a sub-composition is selected, the timeline replaces the parent clip with the deepest-level siblings positioned at absolute master-timeline coordinates. Deselecting or selecting outside collapses back. This PR also fixes O(n²) GSAP element matching and adds missing memoization in PropertyPanel. + +--- + +## Problem Frame + +Sub-compositions appear as opaque blocks in the timeline — selecting a child element inside one (e.g., `#s4-w1` inside S4) still shows S4 as a single clip, giving no visibility into the child's timing. The existing drill-down (`useCompositionStack`) replaces the entire iframe with the sub-comp's content, which is a heavyweight operation that loses the master preview context and isn't what users want when they just need to see where children sit in the master timeline. + +Separately, the Studio codebase has accumulated performance debt: an O(n²) loop in `isElementGsapTargeted` that runs per-element per-render, and un-memoized DOM reads in PropertyPanel that fire on every render cycle. + +--- + +## Requirements + +**Inline expansion** + +- R1. When a child element inside a sub-composition is selected, the timeline replaces the parent clip with all siblings at that depth level +- R2. Expanded children are positioned at absolute master-timeline coordinates (verify whether manifest provides absolute or local times — see KTD-4) +- R3. Children are clamped to the parent clip's time bounds — no child clip extends past `parentClip.start + parentClip.duration` +- R4. Deselecting (click empty space, Escape) or selecting an element outside the sub-composition collapses back to the parent clip +- R5. Recursive expansion: selecting inside a nested sub-composition (S4 > S4-inner > element) shows only the deepest-level siblings +- R6. Expanded children have a minimalist visual distinction from top-level clips (subtle, not distracting) +- R7. The existing full drill-down (double-click to enter sub-comp) remains available and unchanged + +**Code quality** + +- R8. Eliminate O(n²) `isElementGsapTargeted` loop via Set-based index +- R9. Memoize expensive GSAP runtime reads in PropertyPanel + +--- + +## Key Technical Decisions + +- KTD-1. **Source of child clip data: clip manifest, not DOM re-parsing.** The iframe runtime exposes `__clipManifest` with ALL clips including sub-comp children (with `parentCompositionId` linking them). Currently `processTimelineMessage` filters these out (lines 70-73 of `useTimelineSyncCallbacks.ts`). We store the full manifest in playerStore and derive expanded elements from it — no DOM queries needed for expansion. + +- KTD-2. **Expansion is a computed view, not a store mutation.** The `elements` array in playerStore stays unchanged (root-level clips). A `useExpandedTimelineElements` hook derives the expanded list by merging expansion state with the stored manifest. This keeps collapse trivial (just clear expansion state) and avoids corrupting the canonical element list. + +- KTD-3. **Expansion state keyed by parent composition ID.** Store `expandedCompositionId: string | null` in playerStore — the composition whose children are currently displayed inline. When `selectedElementId` changes to a sub-comp child, set this to the parent's composition ID; on deselect/select-outside, clear it. For recursive nesting, this becomes the deepest expanded parent. + +- KTD-4. **Time mapping — verify coordinate space at implementation time.** The runtime's `startResolver.ts` may already add parent offsets to child clip starts, making manifest `start` values absolute. If so, use `child.start` directly (not `parentClip.start + child.start`). If manifest values are local, apply `parentClip.start + child.start`. Verify by logging a sub-comp's child manifest entries and comparing to the parent's start. Clamping (R3) always compares against `[parentClip.start, parentClip.start + parentClip.duration]` regardless of coordinate space. No time-stretching for v1. + +- KTD-5. **Visual distinction: tinted left border on expanded clips.** A 2px colored left border on expanded clips distinguishes them from top-level clips without adding clutter. Color derived from the parent clip's track style. No indentation (would break timeline alignment), no extra labels (would crowd small clips). + +--- + +## High-Level Technical Design + +```mermaid +flowchart TB + subgraph Store["playerStore (Zustand)"] + ELS["elements: TimelineElement[]
(root-level, unchanged)"] + MAN["clipManifest: ClipManifestClip[]
(full, including children)"] + SEL["selectedElementId"] + EXP["expandedCompositionId"] + end + + subgraph Hook["useExpandedTimelineElements"] + DERIVE["Derive expanded list:
replace parent clip with
manifest children mapped
to absolute time"] + end + + subgraph Timeline["Timeline rendering"] + TRACKS["tracks memo"] + CANVAS["TimelineCanvas"] + CLIP["TimelineClip
(with expansion visual cue)"] + end + + ELS --> DERIVE + MAN --> DERIVE + SEL --> DERIVE + EXP --> DERIVE + DERIVE --> TRACKS + TRACKS --> CANVAS + CANVAS --> CLIP +``` + +**Selection → Expansion flow:** + +```mermaid +sequenceDiagram + participant User + participant Store as playerStore + participant Hook as useExpandedTimelineElements + participant TL as Timeline + + User->>Store: select child element (#s4-w1) + Store->>Store: setSelectedElementId("s4-w1") + Store->>Store: find parent in clipManifest + Store->>Store: setExpandedCompositionId("s4-comp-id") + Hook->>Hook: derive: replace S4 clip with S4's children + Hook->>Hook: map children to absolute time + Hook-->>TL: expanded elements list + TL->>TL: recompute tracks, render + + User->>Store: click empty space (deselect) + Store->>Store: setSelectedElementId(null) + Store->>Store: setExpandedCompositionId(null) + Hook-->>TL: original elements (collapsed) +``` + +--- + +## Scope Boundaries + +### Deferred to Follow-Up Work + +- Time-stretching support for sub-comps with `playbackRate !== 1` +- Drag/resize editing of expanded child clips in the inline view +- Expanding multiple sub-compositions simultaneously +- Splitting large hooks (useDomEditCommits 594 LOC, useDomEditSession 575 LOC, useGsapScriptCommits 567 LOC) — worthwhile but out of scope for this PR +- Granular error boundaries around timeline/sidebar/editor subsections +- PropertyPanel component splitting (594 LOC, 56 props) + +--- + +## Implementation Units + +### U1. Store full clip manifest in playerStore + +- **Goal:** Preserve the unfiltered clip manifest so expansion can look up sub-comp children without re-querying the iframe DOM +- **Requirements:** R1, R2, R5 (provides the data layer) +- **Dependencies:** None +- **Files:** + - `packages/studio/src/player/store/playerStore.ts` — add `clipManifest`, `expandedCompositionId`, setters + - `packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts` — store full manifest before filtering +- **Approach:** Add `clipManifest: ClipManifestClip[] | null` and `setClipManifest` to the store. In `processTimelineMessage`, call `setClipManifest(data.clips)` before the existing root-level filter. Add `expandedCompositionId: string | null` and `setExpandedCompositionId` for expansion state. Include both in `reset()`. +- **Patterns to follow:** Existing store shape — flat state with dedicated setters, same pattern as `elements`/`setElements` +- **Test scenarios:** + - Store accepts and returns full clip manifest with both root and child clips + - `reset()` clears `clipManifest` to null and `expandedCompositionId` to null + - `setClipManifest` replaces previous value entirely (not merge) +- **Verification:** Build passes, existing timeline tests still pass — this is additive-only + +--- + +### U2. Create useExpandedTimelineElements hook + +- **Goal:** Derive the expanded element list from store state — the core logic that replaces parent clips with children when expansion is active +- **Requirements:** R1, R2, R3, R4, R5 +- **Dependencies:** U1 +- **Files:** + - `packages/studio/src/player/hooks/useExpandedTimelineElements.ts` (new) + - `packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts` (new) +- **Approach:** The hook reads `elements`, `clipManifest`, `selectedElementId`, and `expandedCompositionId` from the store. When `expandedCompositionId` is set: + 1. Find the parent TimelineElement whose `compositionSrc` matches or whose manifest entry's `compositionId` matches the expanded ID + 2. Find all manifest clips whose `parentCompositionId` equals the expanded composition ID + 3. Map each child clip to a TimelineElement with absolute timing (`parent.start + child.start`, clamped to parent bounds per R3) + 4. Return new elements array with parent replaced by mapped children + 5. For recursive nesting (R5): if the selected element is inside a nested sub-comp, only expand the deepest parent composition whose children contain the selected element — intermediate ancestors are NOT expanded, only the deepest-level siblings are displayed + 6. Mark expanded children with `expandedFromParent: string` (parent's element key) for visual distinction +- **Patterns to follow:** Pure derivation hook like the existing `tracks` useMemo in Timeline.tsx — compute from store selectors, return stable reference via useMemo +- **Test scenarios:** + - No expansion active: returns elements unchanged + - Single-level expansion: parent clip replaced by 3 children with correct absolute timing + - Child timing clamped to parent bounds: child starting before parent.start is clamped, child extending past parent end is truncated + - Child completely outside parent bounds is excluded + - Recursive expansion: S4 > S4-inner > element only shows S4-inner's children + - Selecting an element outside the expanded sub-composition collapses expansion and re-shows parent clip + - Deselect clears expansion: returns to original elements + - Empty manifest or no matching children: skip expansion silently, keep parent clip visible (no crash, no blank gap) + - Parent with `playbackStart` offset: children still use parent.start as base +- **Verification:** Unit tests pass for all scenarios above + +--- + +### U3. Wire expansion into Timeline and manage selection-driven state + +- **Goal:** Replace the raw elements with expanded elements in Timeline rendering, and auto-set/clear `expandedCompositionId` when selection changes +- **Requirements:** R1, R4, R7 +- **Dependencies:** U1, U2 +- **Files:** + - `packages/studio/src/player/components/Timeline.tsx` — use expanded elements for tracks memo, keep raw elements for effectiveDuration + - `packages/studio/src/player/components/TimelineCanvas.tsx` — pass expansion metadata to clips + - `packages/studio/src/player/hooks/useExpandedTimelineElements.ts` — add expansion sync useEffect (co-located with derivation hook from U2) + - `packages/studio/src/components/nle/NLELayout.tsx` — clear expandedCompositionId before drill-down in handleDrillDown +- **Approach:** + - In Timeline.tsx, keep `const rawElements = usePlayerStore((s) => s.elements)` for `effectiveDuration` computation. Add a separate `const expandedElements = useExpandedTimelineElements()` and use it for the `tracks` memo input. Two selectors, two purposes. + - Expansion state management lives inside `useExpandedTimelineElements` (or a sibling `useExpansionSync` hook), NOT in NLELayout. The hook uses a `useEffect` watching `selectedElementId` and `clipManifest` to auto-set/clear `expandedCompositionId`. This keeps the logic co-located with the expansion derivation and independently testable. `selectedElementId` is set from many call sites (Timeline, TimelineCanvas, useDomEditSession, App.tsx) — a store subscription catches all of them. + - The existing `onDrillDown` (double-click) continues to use `useCompositionStack` unchanged (R7). Entering drill-down always clears `expandedCompositionId` first. + - Playhead position and scrubbing behavior are unchanged during expansion — the master timeline playhead renders over expanded children at master time positions. + - Expanded children inherit the parent's track row. Multiple children on different sub-comp tracks collapse to the parent's single track row for v1. +- **Patterns to follow:** Store subscription pattern — useEffect watching Zustand selectors, same approach as existing `requestedSeekTime` handling +- **Test scenarios:** + - Selecting a child element inside a sub-comp triggers expansion — parent clip disappears, children appear + - Selecting an element outside the sub-comp collapses expansion + - Pressing Escape deselects and collapses + - Double-clicking a sub-comp clip still triggers the existing drill-down, not expansion + - Double-clicking while expansion is active clears expansion before drill-down fires + - Selecting between two different sub-comp children (same parent) keeps expansion active + - Selecting a child in a different sub-comp switches expansion to the new parent + - effectiveDuration uses raw elements and is unaffected by expansion state +- **Verification:** Manual testing in browser — select child in layers panel, verify timeline swaps clips; deselect, verify collapse + +--- + +### U4. Minimalist visual distinction for expanded clips + +- **Goal:** Visually differentiate expanded children from top-level clips with a subtle cue +- **Requirements:** R6 +- **Dependencies:** U2, U3 +- **Files:** + - `packages/studio/src/player/components/TimelineClip.tsx` — render left border when expanded + - `packages/studio/src/player/components/TimelineCanvas.tsx` — pass `isExpanded` flag to clips + - `packages/studio/src/player/store/playerStore.ts` — extend TimelineElement with optional `expandedFromParent` +- **Approach:** Add optional `expandedFromParent?: string` field to TimelineElement (set by the expansion hook in U2). In TimelineClip, when this field is truthy, render a 2px left border using the track's accent color at 60% opacity. No indentation, no extra labels — the border is enough to signal "this clip was expanded from a parent." +- **Patterns to follow:** Existing conditional styling in TimelineClip — the `isSelected` and `isHovered` states already modify clip appearance +- **Test scenarios:** + - Expanded clips render with left border, top-level clips do not + - Border color derives from track accent (not hardcoded) + - Very short clips still show visible border (2px minimum) + - Test expectation: none — visual verification only +- **Verification:** Visual inspection in browser — expanded clips have subtle left border, top-level clips don't + +--- + +### U5. Fix O(n²) isElementGsapTargeted with Set-based index + +- **Goal:** Eliminate the triple-nested loop in `isElementGsapTargeted` that checks every element against every GSAP timeline target +- **Requirements:** R8 +- **Dependencies:** None (independent of expansion feature) +- **Files:** + - `packages/studio/src/hooks/useDomEditCommits.ts` — replace loop with Set lookup +- **Approach:** Build a `Set` of GSAP-targeted element IDs once when timelines change (read `__timelines` from iframe, collect all target element IDs into a Set). Replace the `isElementGsapTargeted` function body with a `gsapTargetSet.has(elementId)` lookup. The Set is rebuilt when timelines change, cached in a ref between renders. +- **Patterns to follow:** Existing ref-caching pattern in useDomEditCommits for iframe-derived data +- **Test scenarios:** + - Element inside a GSAP timeline is detected as targeted (same behavior as before) + - Element not in any GSAP timeline returns false + - Set is rebuilt when iframe timelines change (e.g., after soft reload) + - Performance: O(1) lookup per element vs. previous O(n×m) nested loop +- **Verification:** Build passes, no regressions in drag/edit behavior, measurable reduction in timeline hover/select latency with many GSAP elements + +--- + +### U6. Memoize expensive PropertyPanel GSAP reads + +- **Goal:** Stop re-running DOM reads on every render cycle in PropertyPanel +- **Requirements:** R9 +- **Dependencies:** None (independent of expansion feature) +- **Files:** + - `packages/studio/src/components/editor/PropertyPanel.tsx` — wrap reads in useMemo +- **Approach:** The calls to `readGsapRuntimeValuesForPanel()` and `readGsapBorderRadiusForPanel()` (around lines 231-243) run on every render. Wrap each in `useMemo` keyed on `selectedElementId` and `currentTime` — these are the only inputs that change the read results. Leave the existing `forceRender` / `liveTime.subscribe` pattern unchanged — it is a working 30fps throttle that bridges non-React pub-sub with React rendering, not an anti-pattern. +- **Patterns to follow:** Existing useMemo patterns in Timeline.tsx for derived computations +- **Test scenarios:** + - GSAP runtime values update when selectedElementId changes + - Values update when currentTime changes (playhead scrub) + - Values do NOT re-read when unrelated props change (e.g., panel resize) + - Border radius values still display correctly after memoization +- **Verification:** Build passes, PropertyPanel still shows correct values during playback and scrubbing + +--- + +## Sources & Research + +- `packages/studio/src/player/lib/playbackTypes.ts` — `ClipManifestClip` has `parentCompositionId` linking children to parent compositions, and timing in local sub-comp space +- `packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts:70-73` — the root-level filter that currently excludes sub-comp children +- `packages/studio/src/components/nle/useCompositionStack.ts` — existing drill-down mechanism (stays unchanged) +- `packages/studio/src/player/store/playerStore.ts` — Zustand store structure, selection state, elements array +- `packages/studio/src/hooks/useDomEditCommits.ts:47-72` — O(n²) `isElementGsapTargeted` loop +- `packages/studio/src/components/editor/PropertyPanel.tsx:231-243` — un-memoized GSAP DOM reads diff --git a/docs/plans/2026-06-11-001-feat-per-property-keyframe-tracks-plan.md b/docs/plans/2026-06-11-001-feat-per-property-keyframe-tracks-plan.md new file mode 100644 index 000000000..4aab02ff5 --- /dev/null +++ b/docs/plans/2026-06-11-001-feat-per-property-keyframe-tracks-plan.md @@ -0,0 +1,390 @@ +--- +title: "feat: Per-property-group keyframe tracks" +status: active +created: 2026-06-11 +type: feat +depth: deep +origin: null +--- + +## Summary + +Replace the bundled percentage-keyframe model (one GSAP tween per element, all properties in each keyframe) with per-property-group tweens. Each studio operation (drag, resize, rotate) creates and edits only its own group's tween, eliminating cross-property contamination. Existing compositions remain readable; first keyframe edit splits legacy tweens into property groups. + +--- + +## Problem Frame + +The current keyframe architecture bundles all animated properties (`x`, `y`, `scale`, `width`, `height`, `rotation`, `opacity`, `transformOrigin`) into a single GSAP tween with percentage keyframes. This causes: + +1. **Cross-property contamination**: dragging (x/y) captures and overwrites scale; resizing captures and overwrites position; runtime reads bleed transient values from other properties into keyframes +2. **Backfill wars**: adding a new property at one keyframe requires filling it at all others — the "correct" fill value is unknowable without per-property interpolation context +3. **GSAP sparse-property hold behavior**: properties present in some keyframes but not others hold their last value instead of interpolating, producing unexpected visual results +4. **Normalization impossibility**: no single normalization strategy works because different properties have different identity values, different interpolation domains (numeric vs string), and different user expectations + +Professional animation tools (After Effects, CapCut, Premiere) solve this with per-property keyframe tracks. Each property group has independent timing, easing, and keyframes. Editing one property never touches another. + +--- + +## Requirements + +- **R1**: Drag commits write only to a position-group tween (`x`, `y`, `xPercent`, `yPercent`) +- **R2**: Resize commits write only to a scale-group tween (`scale`, `scaleX`, `scaleY`) or size-group tween (`width`, `height`) depending on context +- **R3**: Rotation commits write only to a rotation-group tween (`rotation`, `skewX`, `skewY`) +- **R4**: Each property group has independent keyframe timing — position keyframes at t=1,3 don't force scale keyframes at the same times +- **R5**: Legacy compositions (single tween with mixed properties) continue to parse and render correctly +- **R6**: First keyframe edit on a legacy tween splits it into property groups automatically +- **R7**: Animation IDs encode the property group: `#box-to-0-position`, `#box-from-500-scale` +- **R8**: Timeline diamonds show per-group keyframes (the existing `TimelinePropertyRows` component handles individual property display) +- **R9**: Undo/redo works correctly across property-group splits and per-group edits +- **R10**: The parser, serializer, server mutations, tween cache, and all intercepts are property-group aware + +--- + +## Key Technical Decisions + +### KTD1: Property Group Definitions + +| Group | Properties | Identity | Intercept | +|-------|-----------|----------|-----------| +| `position` | `x`, `y`, `xPercent`, `yPercent` | 0 | Drag | +| `scale` | `scale`, `scaleX`, `scaleY` | 1 | Resize (when tween animates scale) | +| `size` | `width`, `height` | CSS value | Resize (when no scale in tween) | +| `rotation` | `rotation`, `skewX`, `skewY` | 0 | Rotate | +| `visual` | `opacity`, `autoAlpha` | 1 | Property panel | +| `transform` | `transformOrigin` | "50% 50%" | Stays with the group it was authored in | +| `other` | everything else | varies | Property panel | + +`transformOrigin` is NOT its own group — it stays attached to whichever group's tween originally authored it (typically scale or the legacy mixed tween). This avoids creating a separate tween for a non-interpolatable string property. + +### KTD2: ID Format Change + +Current: `#box-to-500` (selector-method-positionMs) +New: `#box-to-500-position` (selector-method-positionMs-group) + +The group suffix is appended after the position key. Duplicate handling (`-2`, `-3`) comes after the group. This makes IDs stable across property-group edits and unambiguous for mutation routing. + +### KTD3: Legacy Split Strategy — Lazy (Split on First Edit) + +When a user performs a keyframe edit (drag/resize/rotate/property-panel) on a legacy single-tween composition, the system: +1. Reads the existing tween's properties +2. Partitions them into property groups +3. Replaces the single tween with multiple group tweens at the same position +4. Continues with the edit on the correct group tween + +This is a single atomic `replace-with-property-groups` server mutation. The file is not modified until the user actually edits. + +### KTD4: Cache Continues to Merge for Diamond Display + +The `keyframeCache` continues to merge all property-group tweens into a single keyframe stream per element for the timeline diamond view. This matches the current UX — one row of diamonds per element in the collapsed view. The expanded `TimelinePropertyRows` already extracts per-property diamonds. + +A new `propertyGroup` field on each cached keyframe entry tracks which group it belongs to, enabling the property rows to show group-level diamonds. + +### KTD5: Serializer Outputs Multiple Tweens Per Element + +The serializer already iterates an array of `GsapAnimation` objects. With per-property-group tweens, each element produces multiple entries sorted by position. The serializer handles this naturally — no structural change needed, just awareness that multiple tweens at the same position for the same selector is now intentional. + +--- + +## High-Level Technical Design + +``` +User Action (drag/resize/rotate) + │ + ▼ +Intercept (gsapRuntimeBridge.ts) + │ Identifies property group from action type + │ Finds or creates the group-specific tween + ▼ +Mutation (commitMutation → server) + │ Routes to group tween by ID (#box-to-0-position) + │ add-keyframe / replace-with-keyframes / convert-to-keyframes + ▼ +Parser (gsapParser.ts) + │ Assigns group-aware IDs + │ Groups animations by (selector, group) for cache + ▼ +Tween Cache (useGsapTweenCache.ts) + │ Merges per-group keyframes into per-element stream + │ Preserves group tag on each keyframe + ▼ +Timeline UI (TimelineClipDiamonds.tsx) + │ Collapsed: all diamonds merged (current behavior) + │ Expanded: per-property-group rows +``` + +**Legacy split flow:** + +``` +User edits legacy tween + │ + ▼ +Intercept detects single mixed tween (no group suffix in ID) + │ + ▼ +Server: split-into-property-groups mutation + │ Reads all properties from the tween + │ Partitions into groups per KTD1 + │ Removes original tween + │ Adds one tween per non-empty group + │ Returns new IDs + ▼ +Intercept continues with edit on the correct group tween +``` + +--- + +## Scope Boundaries + +### In Scope +- Property group definitions and type changes +- Parser group-aware ID generation +- Server `split-into-property-groups` mutation +- All three intercepts (drag/resize/rotate) routing to correct group +- Tween cache group tracking +- `replace-with-keyframes` and `add-keyframe` group awareness +- Legacy split-on-first-edit +- Property panel edits routing to correct group +- Enable keyframes routing to correct group + +### Deferred to Follow-Up Work +- Per-property-group sub-track rows in timeline UI (the existing `TimelinePropertyRows` already shows per-property diamonds — group-level grouping is a UI enhancement) +- Gesture recording property-group awareness +- Per-group easing UI (different ease per property group) +- Keyframe copy/paste across property groups + +### Outside This Plan's Identity +- Changes to how compositions are authored by hand (the HTML format is additive — new tweens are valid GSAP) +- Changes to the rendering engine or player +- Changes to the linter rules + +--- + +## Implementation Units + +### U1. Property Group Type Definitions and Constants + +**Goal**: Define the property group taxonomy as types and constants shared across parser and studio. + +**Requirements**: R1, R2, R3, R7, R10 + +**Dependencies**: None + +**Files**: +- `packages/core/src/parsers/gsapConstants.ts` — add `PROPERTY_GROUPS` map and `PropertyGroupName` type +- `packages/core/src/parsers/gsapSerialize.ts` — add `propertyGroup?: PropertyGroupName` to `GsapAnimation` +- `packages/core/src/parsers/gsapParser.test.ts` — property group classification tests + +**Approach**: Define a `PROPERTY_GROUPS: Record>` constant mapping group names to their property sets. Add a `classifyPropertyGroup(propName: string): PropertyGroupName` function. Add `propertyGroup` as an optional field on `GsapAnimation` — set during parsing based on the tween's property set. + +**Patterns to follow**: Existing `SUPPORTED_PROPS` in `gsapConstants.ts` + +**Test scenarios**: +- `classifyPropertyGroup("x")` returns `"position"`, `"scale"` for scale, `"rotation"` for rotation, `"visual"` for opacity, `"other"` for unknown props +- A tween with only `{x, y}` gets `propertyGroup: "position"` +- A mixed tween with `{x, y, scale, opacity}` gets `propertyGroup: undefined` (legacy mixed) +- A tween with `{scale, transformOrigin}` gets `propertyGroup: "scale"` (transformOrigin follows the group) + +**Verification**: Types compile, constant is importable from both core and studio + +--- + +### U2. Parser: Group-Aware ID Generation + +**Goal**: `assignStableIds` produces IDs with a group suffix for non-legacy tweens. + +**Requirements**: R7, R10 + +**Dependencies**: U1 + +**Files**: +- `packages/core/src/parsers/gsapParser.ts` — update `assignStableIds`, `tweenCallToAnimation` +- `packages/core/src/parsers/gsapParser.test.ts` — ID generation tests + +**Approach**: In `tweenCallToAnimation`, classify the tween's property group. In `assignStableIds`, append `-{group}` to the base ID when `propertyGroup` is set. Legacy mixed tweens keep the current ID format for backward compatibility. + +**Test scenarios**: +- Single-property-group tween: `#box-to-0-position`, `#box-from-500-scale` +- Legacy mixed tween: `#box-to-0` (no group suffix) +- Multiple groups at same position: `#box-to-0-position`, `#box-to-0-scale` (no count suffix needed since groups differ) +- Duplicate same-group same-position: `#box-to-0-position`, `#box-to-0-position-2` + +**Verification**: Existing golden tests updated to include group suffixes where applicable; new tests for group ID format + +--- + +### U3. Server: `split-into-property-groups` Mutation + +**Goal**: Atomic server mutation that splits a legacy mixed tween into per-property-group tweens. + +**Requirements**: R5, R6, R10 + +**Dependencies**: U1, U2 + +**Files**: +- `packages/core/src/parsers/gsapParser.ts` — add `splitIntoPropertyGroups` function +- `packages/core/src/studio-api/routes/files.ts` — add `split-into-property-groups` mutation type +- `packages/core/src/parsers/gsapParser.test.ts` — split tests + +**Approach**: Given an animation ID, read the tween's properties (flat or keyframed). Partition properties into groups per KTD1. For each non-empty group, create a new tween with only that group's properties, preserving the original position, duration, ease, and method. For keyframed tweens, each group's keyframes contain only the group's properties. Remove the original tween and insert the group tweens. Return the new IDs. + +**Test scenarios**: +- Split flat `to({x:100, y:50, scale:1.5, rotation:45})` → position tween `{x:100, y:50}` + scale tween `{scale:1.5}` + rotation tween `{rotation:45}` +- Split keyframed tween: each group gets only its properties per keyframe; keyframes with no properties for a group are omitted +- Split `from({scale:0.5, opacity:0})` → scale tween `from({scale:0.5})` + visual tween `from({opacity:0})` +- Single-group tween (already pure): no split, return same ID +- Preserve original position, duration, ease, extras on each group tween + +**Verification**: Round-trip: split then serialize produces valid GSAP that visually matches the original + +--- + +### U4. Intercepts Route to Correct Property Group + +**Goal**: Drag/resize/rotate intercepts find or create the correct property-group tween, splitting legacy tweens on first edit. + +**Requirements**: R1, R2, R3, R6 + +**Dependencies**: U1, U2, U3 + +**Files**: +- `packages/studio/src/hooks/gsapRuntimeBridge.ts` — update `tryGsapDragIntercept`, `tryGsapResizeIntercept`, `tryGsapRotationIntercept` +- `packages/studio/src/hooks/gsapDragCommit.ts` — update `commitGsapPositionFromDrag`, remove mixed-property reads +- `packages/studio/src/hooks/gsapRuntimeReaders.ts` — scope `readAllAnimatedProperties` to group + +**Approach**: Each intercept: +1. Checks if the element has a group-specific tween (e.g., `animations.find(a => a.propertyGroup === "position")`) +2. If yes: edit that tween's keyframes +3. If no but a legacy mixed tween exists: call `split-into-property-groups` first, then edit the newly created group tween +4. If no tween at all: create a new group-specific tween + +`readAllAnimatedProperties` gains a `group?: PropertyGroupName` parameter to scope which properties it reads. This eliminates cross-group contamination at the reader level. + +Drag sends only `{x, y}`. Resize sends only `{width, height}` or `{scale}`. Rotate sends only `{rotation}`. No `runtimeProps` spread. + +**Test scenarios**: +- Drag on element with group tweens: only position tween modified +- Resize on element with group tweens: only scale/size tween modified +- Drag on legacy mixed tween: split first, then position group edited +- Drag on element with no animation: creates position-group tween +- Resize on element with from({scale:0.5}): splits, then scale group edited + +**Verification**: After drag, diff shows only position properties changed; scale/rotation/size untouched + +--- + +### U5. Tween Cache Group Awareness + +**Goal**: Cache tracks property group per keyframe while maintaining merged diamond display. + +**Requirements**: R4, R8, R10 + +**Dependencies**: U1, U2 + +**Files**: +- `packages/studio/src/player/store/playerStore.ts` — add `propertyGroup` to keyframe cache entry +- `packages/studio/src/hooks/useGsapTweenCache.ts` — group tag on cached keyframes +- `packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts` — preserve group in cache writes + +**Approach**: Each keyframe in the cache gets a `propertyGroup?: PropertyGroupName` tag. The merge logic in `useGsapAnimationsForElement` preserves the group from the source animation. Diamond rendering continues to show all keyframes merged. The expanded `TimelinePropertyRows` can filter by group. + +**Test scenarios**: +- Cache entry for element with position + scale groups: keyframes have correct group tags +- Diamond display shows union of all group keyframes +- Property rows filter correctly by group +- `tweenPercentage` preserved per-group for correct server targeting + +**Verification**: Diamonds render at correct positions; property panel shows correct values per group + +--- + +### U6. Property Panel and Enable-Keyframes Group Routing + +**Goal**: Property panel edits and enable-keyframes toggle route to the correct property group. + +**Requirements**: R1, R2, R3, R10 + +**Dependencies**: U1, U4 + +**Files**: +- `packages/studio/src/hooks/useAnimatedPropertyCommit.ts` — route by property group +- `packages/studio/src/hooks/useEnableKeyframes.ts` — create group-specific tweens +- `packages/studio/src/components/editor/PropertyPanel.tsx` — display group-aware keyframe nav + +**Approach**: `commitAnimatedProperty` classifies the edited property into a group and targets that group's tween. `useEnableKeyframes` creates a group-specific tween when enabling keyframes for a property. The keyframe navigation diamonds in PropertyPanel use the group-tagged cache entries to show correct state per property. + +**Test scenarios**: +- Edit opacity in property panel: only visual-group tween touched +- Enable keyframes on scale: creates scale-group tween, not mixed tween +- Keyframe diamond state (active/inactive/ghost) per property reflects the correct group + +**Verification**: Property edits don't affect other property groups; keyframe nav shows correct per-property state + +--- + +### U7. Remove Normalization and Backfill Workarounds + +**Goal**: Remove the workarounds that were needed because of bundled keyframes. + +**Requirements**: R1, R2, R3, R4 + +**Dependencies**: U4, U5 + +**Files**: +- `packages/core/src/parsers/gsapParser.ts` — remove `normalizeKeyframeProperties` +- `packages/studio/src/hooks/gsapDragCommit.ts` — remove backfillDefaults for x/y +- `packages/studio/src/hooks/gsapRuntimeBridge.ts` — remove SIZE_PROPS exclusion, IDENTITY_ONE backfill + +**Approach**: With per-property-group tweens, each tween only contains its own properties. No cross-property backfill is needed. The normalization function, identity-value backfill, and width/height exclusion logic are all artifacts of the bundled model and can be removed. + +**Test scenarios**: +- Drag adds keyframe with only `{x, y}` — no scale/width/height backfilled +- Resize adds keyframe with only `{width, height}` — no x/y/scale backfilled +- No `normalizeKeyframeProperties` call in any mutation path + +**Verification**: Diff shows removed complexity; keyframe files contain only the properties the user explicitly set + +--- + +### U8. Golden Test Refresh and Integration Tests + +**Goal**: Update all golden snapshot tests and add integration tests for the property-group flows. + +**Requirements**: All + +**Dependencies**: U1-U7 + +**Files**: +- `packages/core/src/parsers/gsapParser.test.ts` — update existing tests, add group tests +- `packages/core/src/parsers/gsapParser.golden.test.ts` — refresh snapshots +- `packages/core/src/parsers/__goldens__/*.js` — updated golden files +- `packages/studio/src/utils/globalTimeCompiler.test.ts` — verify unchanged + +**Approach**: Refresh all golden snapshots with the new group-aware ID format. Add integration tests that exercise: legacy split → group edit → verify file output. Add round-trip tests: parse → split → serialize → parse → verify. + +**Test scenarios**: +- Golden snapshots match new ID format with group suffixes +- Legacy composition parses with `propertyGroup: undefined` (mixed) +- Split + edit + serialize produces valid GSAP +- Round-trip preserves all animation properties across splits +- Position resolution (`resolvedStart`) still correct after split + +**Verification**: `bun test` passes all parser, golden, and compiler tests + +--- + +## Open Questions + +1. **Should `extras` (stagger, yoyo, repeat) be duplicated across all group tweens or kept on only one?** Current plan: keep on the "primary" group (the one with the most keyframes). Revisit if this causes GSAP playback issues. + +2. **How should `fromTo()` split work?** Each group gets its own `fromTo()` with only the group's from/to properties. If a group has no fromProperties, it becomes a `to()` instead. + +--- + +## Sources & Research + +- GSAP percentage keyframes empirical tests (run in this session via browser DevTools) +- GSAP official docs: https://gsap.com/resources/keyframes/ +- `keyframes-trace-investigation.md` — 6 root causes analysis +- `studio-keyframes-bug-audit.md` — 2026-06-10 audit findings +- Session debugging with `sdk-test.html` reference composition diff --git a/docs/plans/2026-06-11-002-feat-responsive-aspect-ratio-plan.md b/docs/plans/2026-06-11-002-feat-responsive-aspect-ratio-plan.md new file mode 100644 index 000000000..1dc14b240 --- /dev/null +++ b/docs/plans/2026-06-11-002-feat-responsive-aspect-ratio-plan.md @@ -0,0 +1,119 @@ +--- +title: "feat: Responsiveness skill — aspect-ratio change best practices for agents" +type: feat +status: active +date: 2026-06-11 +--- + +# feat: Responsiveness skill — aspect-ratio change best practices for agents + +## Summary + +Give agents a documented, verifiable workflow for adapting a hyperframes composition to a different canvas aspect ratio (16:9 → 9:16 → 1:1) — **without modifying the core framework, CLI, or runtime**. Everything needed already exists: the aspect change is an edit to `data-width`/`data-height` on the root element, ratio-conditional styling is achievable with authored CSS container queries (the composition makes its own root a query container — plain CSS, no runtime support required), and `hyperframes layout` + `hyperframes snapshot` already audit/screenshot at whatever size the file declares, giving the agent a mechanical self-verification loop. The deliverable is a `skills/responsive` skill encoding these best practices plus reconciliation of existing skill guidance that currently teaches anti-responsive fixed-dimension patterns. + +--- + +## Problem Frame + +Compositions declare a fixed canvas; the only adaptation today is uniform scale-to-fit (letterboxing), never reflow. Producing a 9:16 version of a 16:9 video means hand-editing with no guidance and no guardrails. From the Slack thread: layout must genuinely rearrange (like a responsive website), the main risk is an agent silently mangling the layout, so the workflow must include self-verification in an isolated worktree. Per scope decision: no framework changes — best-practices guidance for the agent is the deliverable. + +## Requirements + +- R1: An agent following the skill can retrofit an existing composition to a new aspect ratio using only authored HTML/CSS edits and existing CLI tooling. +- R2: The workflow includes mandatory mechanical verification (`hyperframes layout` clean at the new ratio AND the original ratio) and visual verification (`hyperframes snapshot` frames reviewed at both ratios), performed in an isolated worktree. +- R3: The skill documents ratio-conditional CSS patterns that work identically across preview, studio, producer capture, and player. +- R4: Existing skill guidance that contradicts responsiveness (`skills/tailwind` fixed-dimension advice, `skills/hyperframes` fixed-px patterns) is reconciled to point at the responsive path when multi-ratio output is needed. + +## Key Technical Decisions + +1. **No framework changes — authored CSS container queries.** Media queries key off the viewport, which in studio/player iframes is the scaled container, not the canvas — they'd behave differently across surfaces. Instead the skill teaches the composition to declare its own root a named size container in its own stylesheet (`[data-composition-id] { container-type: size; container-name: canvas; }`) and write `@container canvas (max-aspect-ratio: 1/1) { ... }` rules. This is plain CSS the runtime already honors everywhere (the runtime sizes the root from `data-width`/`data-height` inline, which gives the container a definite size — verified in `packages/core/src/runtime/init.ts` `applyCompositionSizing()`). Container queries are supported in capture Chrome (≥105) and all evergreen browsers. +2. **The aspect change itself is a two-attribute edit.** Rewrite `data-width`/`data-height` on the root composition element (and `data-resolution`/`data-composition-width|height` on `` when present — see `packages/core/src/parsers/htmlParser.ts` resolution chain). The skill documents exactly which attributes to touch and warns that studio snaps arbitrary sizes to the six `CANVAS_DIMENSIONS` presets, so agents should target preset dimensions (1920x1080, 1080x1920, 1080x1080, 4K variants) unless told otherwise. +3. **Verification reuses existing commands unmodified.** `hyperframes layout --json` audits overflow/overlap at the file's declared canvas size; `hyperframes snapshot` captures frames at that size. Checking *both* ratios means running the loop once per ratio with the attributes set accordingly (the new-ratio file in the worktree, the original via the untouched base checkout or by temporarily flipping the attributes back). No `--canvas` flag, no new command. +4. **Worktree isolation is the agent's own primitive.** The skill instructs: do the retrofit in a fresh worktree, gate completion on clean audits + reviewed snapshots, only then merge back. No bespoke tooling. + +--- + +## High-Level Technical Design + +The retrofit loop the skill encodes: + +```mermaid +flowchart LR + A[New worktree] --> B[Edit data-width/height to target ratio] + B --> C[layout --json at new ratio] + C -->|issues| D[Add/adjust @container canvas CSS] + D --> C + C -->|clean| E[Restore original ratio attrs → layout --json again] + E -->|issues| D + E -->|clean| F[snapshot at both ratios → visual review] + F --> G[Done — merge worktree] +``` + +Author-facing CSS convention (directional, lives in the skill): + +```text +[data-composition-id="root"] { container-type: size; container-name: canvas; } + +@container canvas (max-aspect-ratio: 1/1) { + .hero { flex-direction: column; } +} +``` + +--- + +## Implementation Units + +### U1. `skills/responsive` skill + +**Goal:** An agent can take a finished composition to a new ratio safely, end to end. +**Requirements:** R1, R2, R3 +**Dependencies:** none +**Files:** `skills/responsive/SKILL.md` (new), optional `skills/responsive/references/` for the worked example, `CLAUDE.md` (skills paragraph mention). +**Approach:** Skill content, in order: +1. *Conventions*: the container-query setup from KTD 1; the existing `--comp-width`/`--comp-height` CSS variables (already set by the runtime) for `calc()`/JS consumers; prefer %/flex/grid/`cqw`/`cqh` units over canvas-pixel literals. +2. *Aspect change procedure*: exactly which attributes to edit (KTD 2), preset dimension table, studio-snapping caveat. +3. *Rearrangement patterns*: row→column stacks, caption repositioning, crop-vs-reflow decisions for media, safe-area thinking for portrait. +4. *Animation caveat (loud)*: GSAP keyframes recorded in px against one ratio won't follow a reflowed layout — prefer transforms relative to final layout or %-based values; `gsap.matchMedia()` keys off viewport, not canvas, so gate ratio-conditional animation on the `--comp-*` variables or `data-width`/`data-height` instead. +5. *Verification loop (hard gate)*: the worktree flow from the HTD diagram — not done until `hyperframes layout --json` is clean at **both** the target and original ratio and snapshots at both ratios have been visually reviewed. +Follow the structure of existing skills (`skills/hyperframes`: SKILL.md + references); pass `scripts/lint-skills.ts`. +**Test expectation: none — documentation/skill content.** Validated by `bun run lint:skills` plus a manual end-to-end worked example: retrofit one real composition (e.g. a `registry/blocks/` entry) from 16:9 to 9:16 following the skill verbatim, achieving clean audits at both ratios; capture it as the skill's worked example. +**Verification:** `bun run lint:skills` passes; worked example produces clean `layout --json` output at 1920x1080 and 1080x1920. + +### U2. Reconcile existing anti-responsive guidance + +**Goal:** No skill contradicts the new convention. +**Requirements:** R4 +**Dependencies:** U1 +**Files:** `skills/tailwind/SKILL.md`, `skills/hyperframes/SKILL.md`, `skills/gsap/SKILL.md`. +**Approach:** Soften the fixed-dimension advice in `skills/tailwind` (line ~108: fixed dims are fine for single-ratio work; point to `/responsive` for multi-ratio). Update the `skills/hyperframes` container guidance (line ~97) to mention the canvas container-query convention. In `skills/gsap`, add the matchMedia-vs-canvas caveat cross-referencing `/responsive`. +**Test expectation: none — documentation.** `bun run lint:skills` passes. +**Verification:** grep confirms the old fixed-dimension advice now references the responsive path. + +--- + +## Scope Boundaries + +**In scope:** the `skills/responsive` skill and reconciled cross-skill guidance. Nothing else. + +**Out of scope (true non-goals):** +- Any change to `packages/core`, `packages/cli`, runtime, producer, engine, player, or studio — explicit scope decision. +- Automatic content rearrangement — layout decisions stay with the author/agent. +- Bespoke worktree machinery — agents bring their own isolation. + +**Deferred to follow-up work (only if the skill proves insufficient in practice):** +- `--canvas` override flag for `layout`/`snapshot` (audit multiple ratios without attribute flipping). +- `hyperframes aspect` command (deterministic attribute rewrite + damage report). +- Runtime-applied container setup (so compositions don't need the one-line CSS). +- Lint rule warning on ratio-fragile patterns. +- First-class arbitrary canvas sizes in studio (today snapped to six presets). + +## Risks (verified during planning) + +- **Container queries across surfaces:** verified — the runtime sets inline root width/height from the data attributes in every surface (`applyCompositionSizing()`, `packages/core/src/runtime/init.ts:226`), giving the authored container a definite size; the producer's inline-style injection (`packages/producer/src/services/htmlCompiler.ts` ~line 624) only adds width/height px and never touches container properties. Capture Chrome supports container queries. +- **Attribute-flip verification is manual:** checking the original ratio requires flipping `data-width`/`data-height` back (or auditing the base checkout). Slightly clunky but tool-free; the deferred `--canvas` flag removes the clunk later if needed. +- **Animations vs. layout:** GSAP px keyframes won't follow reflowed layouts; the skill's animation section is the mitigation. Tooling support deferred. + +## Sources & Research + +- Slack thread (Sauce/James/Miguel, 2026-06-11): layout must rearrange like a responsive site; agent self-verification in a worktree required; scope decision — no core framework changes, best-practices skill only. +- Repo research: runtime sizing + CSS vars (`packages/core/src/runtime/init.ts`), resolution attribute chain (`packages/core/src/parsers/htmlParser.ts`), audit tooling (`packages/cli/src/commands/layout.ts`, `snapshot.ts`), skills structure (`skills/`), conflicting guidance (`skills/tailwind/SKILL.md` ~108, `skills/hyperframes/SKILL.md` ~97). diff --git a/docs/plans/2026-06-11-002-fix-unclip-overlay-for-offscreen-elements-plan.md b/docs/plans/2026-06-11-002-fix-unclip-overlay-for-offscreen-elements-plan.md new file mode 100644 index 000000000..b5e9ae2e1 --- /dev/null +++ b/docs/plans/2026-06-11-002-fix-unclip-overlay-for-offscreen-elements-plan.md @@ -0,0 +1,79 @@ +--- +title: "fix: Unclip DomEditOverlay for off-screen element interaction" +status: active +created: 2026-06-11 +type: fix +depth: lightweight +origin: null +--- + +## Summary + +Split the NLE preview wrapper so the iframe is clipped but the DomEditOverlay renders unclipped, enabling selection handles, resize, rotation, and drag for GSAP-animated elements positioned outside the composition bounds. + +## Problem Frame + +The NLE layout wraps both `NLEPreview` (iframe) and the `previewOverlay` slot (DomEditOverlay, CaptionOverlay, SnapToolbar, gesture overlays) in a single `overflow-hidden` div at `packages/studio/src/components/nle/NLELayout.tsx` line 369. This clips the overlay, making selection handles unreachable for off-screen elements. The iframe needs clipping (to prevent it from bleeding into the timeline area), but the overlay must NOT be clipped (so handles extend beyond the composition). + +## Requirements + +- **R1**: The iframe/player content is visually clipped to the preview area +- **R2**: The DomEditOverlay renders without overflow clipping — selection handles, resize handles, and drag gestures work for elements outside the composition bounds +- **R3**: Zoom/pan, drop targets, gesture recording overlay, snap guides, grid overlay, and caption overlay continue to function correctly + +## Key Technical Decisions + +### KTD1: Restructure at NLELayout wrapper level + +The `overflow-hidden` lives on the wrapper div at `NLELayout.tsx:369`, not inside `NLEPreview.tsx`. The fix: split this wrapper into two layers — an inner clipped div for `NLEPreview` and the drag-over indicator, and the outer unclipped div for `previewOverlay`. The overlay already uses absolute positioning with `inset-0`, so moving it one level up is safe as long as the parent has `position: relative`. + +## Implementation Units + +### U1. Split the NLE preview wrapper + +**Goal**: Move `previewOverlay` outside the `overflow-hidden` container so it renders unclipped. + +**Requirements**: R1, R2, R3 + +**Dependencies**: None + +**Files**: +- `packages/studio/src/components/nle/NLELayout.tsx` — restructure the preview wrapper + +**Approach**: The current structure at line 367-389 is: +``` +
← clips everything + ← needs clipping + {previewDragOver && } ← needs clipping + {previewOverlay} ← must NOT be clipped +
+``` + +Change to: +``` +
← no overflow, position context +
← clips iframe only + + {previewDragOver && } +
+ {previewOverlay} ← unclipped, absolute, same position context +
+``` + +The outer div keeps `relative` for positioning context and `flex-1 min-h-0 flex flex-col` for layout. The inner div gets `overflow-hidden absolute inset-0` to clip the iframe. The overlay sits as a sibling outside the clipped container. + +**Patterns to follow**: The existing overlay already uses `absolute inset-0 z-10` positioning in `DomEditOverlay.tsx:341`. + +**Test scenarios**: +- Iframe content does not bleed outside the preview area at any zoom level +- Selection handles for an element positioned outside the composition (via GSAP x/y) are visible and interactive +- Drag gesture on an off-screen element moves it (pointer events reach the handle) +- Resize handles on an off-screen element work +- Zoom/pan gestures (wheel, pinch) still function — the wheel handler is on `viewportRef` inside NLEPreview, not on the wrapper +- Block/asset drop onto the preview works — the drag-over indicator and drop handler are on the wrapper; verify `onDragOver`/`onDrop` still fire +- Gesture recording overlay renders correctly over the preview +- Snap guides and grid overlay render correctly +- Caption overlay renders correctly when in caption edit mode +- The zoom HUD and reset button remain visible and functional + +**Verification**: Off-screen elements can be selected, dragged, resized, and rotated from their actual position outside the composition bounds. All existing preview interactions (zoom, pan, drop, recording) work unchanged. diff --git a/docs/plans/handoff-keyframes-session.md b/docs/plans/handoff-keyframes-session.md new file mode 100644 index 000000000..defdbfba7 --- /dev/null +++ b/docs/plans/handoff-keyframes-session.md @@ -0,0 +1,103 @@ +# Session Handoff: Keyframes & Studio UX + +## What was done + +Three stacked PRs shipping keyframe editing, arc motion, design panel redesign, and gesture recording: + +### PR #1217 — `fix/keyframe-stability-border-radius` → main +- Per-corner border-radius editor with visual picker +- GSAP cache stability fixes (bump on code-tab edits, sync on mutations) +- Flat undo/redo/capture buttons in header + +### PR #1232 — `feat/motionpath-arc-motion` → #1217 +- MotionPathPlugin integration (conditional CDN, AST mutations, curviness/auto-rotate) +- Design panel visual redesign (panel-* Tailwind tokens, unified #3CE6AC accent) +- Runtime auto-stamp with timed-ancestor guard (fixes style-13-prod regression) +- Keyframe diamond synthesis from flat tweens (start+end markers) +- Shape-adaptive selection overlays (clip-path mirroring, border-radius matching) +- Export button, render controls redesign, SSR externals fix +- Click-through for styled elements (background, border, shadow detection) + +### PR #1256 — `feat/gesture-to-keyframes` → #1232 +- Gesture recording engine (RAF sampling, RDP simplification, velocity-to-ease inference) +- Record button in Animation section, R keyboard shortcut +- Auto-commit keyframes on stop (no preview panel, no extra step) +- Ghost trail SVG overlay during recording +- Clipboard icon copies element context (selector, position, size, line number, animation) +- Glass-style dismissible toast (bottom-right, backdrop-blur) +- Render queue always-visible download/remove buttons +- Keyframe diamond dedup + React key fix +- Keyframe cache fixes (3 clearing paths eliminated) +- `addKeyframeToScript` auto-converts flat tweens + handles ID change after from→to conversion +- Mintlify docs: `docs/guides/keyframes.mdx` (timeline diamonds, arc motion, gesture recording) + +## Known issues to fix next session + +### Critical: Keyframe creation at scrubbed time doesn't work properly +**Root cause**: GSAP keyframes are percentage-based within a tween's duration (e.g., 0-100% of 0.5s). When the user scrubs to t=1.5s on a 0.5s tween, the computed percentage is based on `data-duration` (4s full composition), not the tween's actual duration. This creates keyframes at meaningless percentages. + +**The gap vs After Effects**: AE has per-property keyframes on a global timeline. GSAP has percentage keyframes within a tween's local duration. Dragging at t=1.5s on a 0.5s tween needs one of: +- (a) Extend the tween duration to cover the scrubbed time, then add keyframe +- (b) Create a new tween starting at the scrubbed time +- (c) Clamp to the tween's end percentage +- (d) Rearchitect to a global-time keyframe model that maps to GSAP behind the scenes + +**Recommended next step**: `/ce-brainstorm` on the global timeline keyframe architecture before implementing. + +### Medium: Gesture recording position baseline +The recording captures pointer deltas but doesn't properly account for the element's GSAP-interpolated position at recording start. The `useGestureRecording` hook's `startPointerRef` captures cursor position, not the element's computed transform. Fix: read `gsap.getProperty(element, "x/y")` at recording start. + +### Low: Timeline keyframe diamonds disappear temporarily +After certain operations (undo/redo, element drag, selection change), diamonds may briefly disappear because the runtime scan polls at 500ms intervals and the cache needs time to repopulate. The cache is no longer cleared incorrectly, but the scan timing creates a visual flicker. + +## Architecture notes + +### Keyframe cache system +The keyframe cache (`playerStore.keyframeCache`) maps element IDs to keyframe data. Three systems populate it: +1. **AST fetch** (`usePopulateKeyframeCacheForFile`) — parses GSAP script for explicit `keyframes: {}` objects +2. **Runtime scan** (`scanAllRuntimeKeyframes`) — reads `timeline.getChildren()` from the iframe's live GSAP instance, synthesizes start+end keyframes from flat tweens +3. **Per-element selection** (`useGsapAnimationsForElement`) — sets cache when an element with explicit keyframes is selected + +**Key invariant**: No code path should clear cache entries for flat tweens. The cache is additive-only for runtime-scanned entries. The three clearing bugs were: +- `usePopulateKeyframeCacheForFile` cleared all entries matching `sourceFile#*` prefix (removed) +- `useGsapAnimationsForElement` wrote `undefined` for elements without explicit keyframes (changed to set-only) +- `useGsapScriptCommits.commitMutation` cleared entries when parsed result had no keyframes (removed) + +### Animation ID changes after conversion +When `convertToKeyframesInScript` converts a `from()` tween to `to()` with keyframes, the animation ID changes (e.g., `#title-from-0` → `#title-to-0`). The parser's `addKeyframeToScript` handles this with a regex fallback: `animationId.replace(/-from-|-fromTo-/, "-to-")`. This fallback runs at both the top-level `locateAnimation` and inside the auto-conversion branch. + +### Auto-stamp and render parity +The runtime's auto-stamp code (`init.ts`) adds `data-start`/`data-duration` to GSAP-targeted elements so they appear in the Studio timeline. The `hasTimedAncestor()` guard prevents stamping elements whose parent already has timing attributes — without this, the style-13-prod regression test fails (elements inside timed clips get their own timing, overriding parent clip visibility). + +## Files of interest + +| Area | Key files | +|------|-----------| +| Keyframe cache | `packages/studio/src/hooks/useGsapTweenCache.ts` | +| Runtime keyframe synthesis | `packages/studio/src/hooks/gsapRuntimeKeyframes.ts` | +| GSAP drag intercept | `packages/studio/src/hooks/gsapRuntimeBridge.ts` | +| Keyframe mutation parser | `packages/core/src/parsers/gsapParser.ts` (lines 1286-1380) | +| Auto-stamp | `packages/core/src/runtime/init.ts` (lines 1002-1060) | +| Gesture recording | `packages/studio/src/hooks/useGestureRecording.ts` | +| RDP simplification | `packages/studio/src/utils/rdpSimplify.ts` | +| Design panel | `packages/studio/src/components/editor/PropertyPanel.tsx` | +| Timeline diamonds | `packages/studio/src/player/components/TimelineClipDiamonds.tsx` | +| Arc path controls | `packages/studio/src/components/editor/ArcPathControls.tsx` | +| Soft reload | `packages/studio/src/utils/gsapSoftReload.ts` | + +## Branches and worktrees + +- Main repo: `/Users/miguel07code/dev/hyperframes-oss` — currently on `feat/motionpath-arc-motion` +- Worktree: `.worktrees/feat/motionpath-arc-motion` — currently on `feat/gesture-to-keyframes` +- Test project: `packages/studio/data/projects/keyframes-test/` (clean copy at `/tmp/keyframes-test/index.html`) +- Dev server: `bun run --cwd packages/studio dev --port 5191` from the worktree +- After changing `packages/core/src/parsers/gsapParser.ts`, must rebuild: `bun run --cwd packages/core build` AND restart the dev server (Vite SSR caches module imports) + +## User preferences (from CLAUDE.md and memory) + +- Stealth mode: never add AI attribution to commits/PRs +- Rebase not merge, squash to 1 commit per PR +- Always write plan.md before code +- Don't commit design specs, only actual code +- Sequential renders only (never parallel — saturates CPU) +- Pacific is private source, never reference in HyperFrames artifacts diff --git a/packages/core/src/parsers/__goldens__/complex.parsed.json b/packages/core/src/parsers/__goldens__/complex.parsed.json index 92c5def44..af592ef9d 100644 --- a/packages/core/src/parsers/__goldens__/complex.parsed.json +++ b/packages/core/src/parsers/__goldens__/complex.parsed.json @@ -1,55 +1,62 @@ { "animations": [ { - "targetSelector": ".ambient-line", + "targetSelector": ".headline span", "method": "from", - "position": 0.16, + "position": 0.05, "properties": { - "scaleX": 0, + "y": 46, "opacity": 0 }, - "duration": 0.42, + "duration": 0.38, + "ease": "back.out(1.35)", "extras": { - "stagger": "__raw:0.08" + "stagger": "__raw:0.055" }, - "id": ".ambient-line-from-160" + "resolvedStart": 0.05, + "id": ".headline span-from-50" }, { - "targetSelector": ".ambient-word", + "targetSelector": ".headline .sub", "method": "from", - "position": 0.08, + "position": 0.2, "properties": { - "scale": 0.92, + "y": 20, "opacity": 0 }, - "duration": 0.5, - "id": ".ambient-word-from-80" + "duration": 0.28, + "ease": "power3.out", + "resolvedStart": 0.2, + "id": ".headline .sub-from-200" }, { - "targetSelector": ".headline .sub", + "targetSelector": ".ambient-word", "method": "from", - "position": 0.2, + "position": 0.08, "properties": { - "y": 20, + "scale": 0.92, "opacity": 0 }, - "duration": 0.28, - "id": ".headline .sub-from-200" + "duration": 0.5, + "ease": "power3.out", + "resolvedStart": 0.08, + "id": ".ambient-word-from-80" }, { - "targetSelector": ".headline span", + "targetSelector": ".ambient-line", "method": "from", - "position": 0.05, + "position": 0.16, "properties": { - "y": 46, + "scaleX": 0, "opacity": 0 }, - "duration": 0.38, - "ease": "back.out(1.35)", + "duration": 0.42, + "ease": "power3.out", "extras": { - "stagger": "__raw:0.055" + "stagger": "__raw:0.08" }, - "id": ".headline span-from-50" + "resolvedStart": 0.16, + "id": ".ambient-line-from-160" } ], "timelineVar": "tl", diff --git a/packages/core/src/parsers/__goldens__/complex.serialized.js b/packages/core/src/parsers/__goldens__/complex.serialized.js index 2a1516fee..16e8313db 100644 --- a/packages/core/src/parsers/__goldens__/complex.serialized.js +++ b/packages/core/src/parsers/__goldens__/complex.serialized.js @@ -3,8 +3,8 @@ gsap.defaults({ force3D: true }); const tl = gsap.timeline({ paused: true, defaults: { duration: 0.45, ease: "power3.out" } }); tl.from(".headline span", { y: 46, opacity: 0, duration: 0.38, ease: "back.out(1.35)", stagger: 0.055 }, 0.05); - tl.from(".ambient-word", { scale: 0.92, opacity: 0, duration: 0.5 }, 0.08); - tl.from(".ambient-line", { scaleX: 0, opacity: 0, duration: 0.42, stagger: 0.08 }, 0.16); - tl.from(".headline .sub", { y: 20, opacity: 0, duration: 0.28 }, 0.2); + tl.from(".ambient-word", { scale: 0.92, opacity: 0, duration: 0.5, ease: "power3.out" }, 0.08); + tl.from(".ambient-line", { scaleX: 0, opacity: 0, duration: 0.42, ease: "power3.out", stagger: 0.08 }, 0.16); + tl.from(".headline .sub", { y: 20, opacity: 0, duration: 0.28, ease: "power3.out" }, 0.2); window.__timelines["vpn-youtube-spot"] = tl; \ No newline at end of file diff --git a/packages/core/src/parsers/__goldens__/fromto.parsed.json b/packages/core/src/parsers/__goldens__/fromto.parsed.json index 1431064c3..8c8637d49 100644 --- a/packages/core/src/parsers/__goldens__/fromto.parsed.json +++ b/packages/core/src/parsers/__goldens__/fromto.parsed.json @@ -14,6 +14,7 @@ }, "duration": 0.6, "ease": "power3.out", + "resolvedStart": 0.1, "id": "#hero-fromTo-100" }, { @@ -29,6 +30,7 @@ "opacity": 0 }, "duration": 0.45, + "resolvedStart": 0.5, "id": "#caption-fromTo-500" } ], diff --git a/packages/core/src/parsers/__goldens__/minimal.parsed.json b/packages/core/src/parsers/__goldens__/minimal.parsed.json index f20fb8fb7..cbfa33de9 100644 --- a/packages/core/src/parsers/__goldens__/minimal.parsed.json +++ b/packages/core/src/parsers/__goldens__/minimal.parsed.json @@ -10,6 +10,7 @@ }, "duration": 0.5, "ease": "power3.out", + "resolvedStart": 0.2, "id": "#notification-to-200" }, { @@ -22,6 +23,7 @@ }, "duration": 0.3, "ease": "power3.in", + "resolvedStart": 4.2, "id": "#notification-to-4200" } ], diff --git a/packages/core/src/parsers/__goldens__/moderate.parsed.json b/packages/core/src/parsers/__goldens__/moderate.parsed.json index 1c4e39127..8edb3f350 100644 --- a/packages/core/src/parsers/__goldens__/moderate.parsed.json +++ b/packages/core/src/parsers/__goldens__/moderate.parsed.json @@ -10,6 +10,7 @@ }, "duration": 0.5, "ease": "power3.out", + "resolvedStart": 0.1, "id": "#card-to-100" }, { @@ -21,7 +22,9 @@ }, "duration": 0.15, "ease": "power2.out", - "id": "#subscribe-btn-to-1000" + "propertyGroup": "scale", + "resolvedStart": 1, + "id": "#subscribe-btn-to-1000-scale" }, { "targetSelector": "#subscribe-btn", @@ -32,7 +35,9 @@ }, "duration": 0.4, "ease": "elastic.out(1, 0.4)", - "id": "#subscribe-btn-to-1150" + "propertyGroup": "scale", + "resolvedStart": 1.15, + "id": "#subscribe-btn-to-1150-scale" }, { "targetSelector": "#btn-subscribe", @@ -43,7 +48,9 @@ }, "duration": 0.08, "ease": "none", - "id": "#btn-subscribe-to-1150" + "propertyGroup": "visual", + "resolvedStart": 1.15, + "id": "#btn-subscribe-to-1150-visual" }, { "targetSelector": "#btn-subscribed", @@ -54,7 +61,9 @@ }, "duration": 0.08, "ease": "none", - "id": "#btn-subscribed-to-1180" + "propertyGroup": "visual", + "resolvedStart": 1.18, + "id": "#btn-subscribed-to-1180-visual" }, { "targetSelector": "#card", @@ -66,6 +75,7 @@ }, "duration": 0.25, "ease": "power3.in", + "resolvedStart": 3.8, "id": "#card-to-3800" } ], diff --git a/packages/core/src/parsers/gsapConstants.ts b/packages/core/src/parsers/gsapConstants.ts index 9cae4cc9f..015362395 100644 --- a/packages/core/src/parsers/gsapConstants.ts +++ b/packages/core/src/parsers/gsapConstants.ts @@ -45,6 +45,46 @@ export const SUPPORTED_PROPS = [ "innerText", ]; +// ── Property Groups ───────────────────────────────────────────────────────── +// Each group maps to an independent GSAP tween so editing one property +// (e.g. drag → x/y) never contaminates another (e.g. scale, rotation). + +export type PropertyGroupName = "position" | "scale" | "size" | "rotation" | "visual" | "other"; + +export const PROPERTY_GROUPS: Record> = { + position: new Set(["x", "y", "xPercent", "yPercent"]), + scale: new Set(["scale", "scaleX", "scaleY"]), + size: new Set(["width", "height"]), + rotation: new Set(["rotation", "skewX", "skewY"]), + visual: new Set(["opacity", "autoAlpha"]), + other: new Set(), +}; + +const PROP_TO_GROUP = new Map(); +for (const [group, props] of Object.entries(PROPERTY_GROUPS) as [ + PropertyGroupName, + ReadonlySet, +][]) { + for (const p of props) PROP_TO_GROUP.set(p, group); +} + +export function classifyPropertyGroup(prop: string): PropertyGroupName { + return PROP_TO_GROUP.get(prop) ?? "other"; +} + +export function classifyTweenPropertyGroup( + properties: Record, +): PropertyGroupName | undefined { + const groups = new Set(); + for (const key of Object.keys(properties)) { + if (key === "transformOrigin") continue; + const g = classifyPropertyGroup(key); + groups.add(g); + } + if (groups.size === 1) return groups.values().next().value; + return undefined; +} + export const SUPPORTED_EASES = [ "none", "power1.in", diff --git a/packages/core/src/parsers/gsapParser.stress.test.ts b/packages/core/src/parsers/gsapParser.stress.test.ts index 95c1ef047..c99b88415 100644 --- a/packages/core/src/parsers/gsapParser.stress.test.ts +++ b/packages/core/src/parsers/gsapParser.stress.test.ts @@ -658,9 +658,9 @@ describe("14. ID collision", () => { const ids = result.animations.map((a) => a.id); // All IDs must be unique expect(new Set(ids).size).toBe(3); - expect(ids[0]).toBe("#el-to-0"); - expect(ids[1]).toBe("#el-to-0-2"); - expect(ids[2]).toBe("#el-to-0-3"); + expect(ids[0]).toBe("#el-to-0-visual"); + expect(ids[1]).toBe("#el-to-0-position"); + expect(ids[2]).toBe("#el-to-0-position-2"); }); it("disambiguated IDs are stable across parses", () => { @@ -932,7 +932,7 @@ describe("Additional edge cases", () => { `; const result = parseGsapScript(script); // ID uses Math.round(position * 1000) for numeric positions - expect(result.animations[0].id).toBe("#el-to--2500"); + expect(result.animations[0].id).toBe("#el-to--2500-position"); }); it("fromTo with no position arg defaults to 0", () => { diff --git a/packages/core/src/parsers/gsapParser.test.ts b/packages/core/src/parsers/gsapParser.test.ts index 85c204118..0ef8797b6 100644 --- a/packages/core/src/parsers/gsapParser.test.ts +++ b/packages/core/src/parsers/gsapParser.test.ts @@ -18,8 +18,10 @@ import { removeAllKeyframesFromScript, addAnimationWithKeyframesToScript, splitAnimationsInScript, + splitIntoPropertyGroups, } from "./gsapParser.js"; import type { GsapAnimation } from "./gsapParser.js"; +import { classifyPropertyGroup, classifyTweenPropertyGroup } from "./gsapConstants.js"; import type { Keyframe } from "../core.types"; import { parseAndSerialize, @@ -260,8 +262,8 @@ describe("parseGsapScript", () => { expect(result1.animations[1].id).toBe(result2.animations[1].id); // IDs encode selector, method, and position - expect(result1.animations[0].id).toBe("#el1-to-0"); - expect(result1.animations[1].id).toBe("#el2-to-1000"); + expect(result1.animations[0].id).toBe("#el1-to-0-visual"); + expect(result1.animations[1].id).toBe("#el2-to-1000-position"); }); it("disambiguates colliding IDs with a suffix", () => { @@ -272,8 +274,8 @@ describe("parseGsapScript", () => { `; const result = parseGsapScript(script); - expect(result.animations[0].id).toBe("#el1-to-0"); - expect(result.animations[1].id).toBe("#el1-to-0-2"); + expect(result.animations[0].id).toBe("#el1-to-0-visual"); + expect(result.animations[1].id).toBe("#el1-to-0-visual-2"); }); it("uses string position in ID for relative positions", () => { @@ -283,7 +285,219 @@ describe("parseGsapScript", () => { `; const result = parseGsapScript(script); - expect(result.animations[0].id).toBe("#el1-to-+=1"); + expect(result.animations[0].id).toBe("#el1-to-+=1-visual"); + }); +}); + +describe("resolvedStart — timeline position resolution", () => { + it("resolves chained from() tweens with relative positions (sdk-test pattern)", () => { + const script = ` + const tl = gsap.timeline({ defaults: { ease: "power3.out" } }); + tl.from("#headline", { duration: 0.6, scale: 0.92, transformOrigin: "left center" }) + .from("#subtext", { duration: 0.5, scale: 0.92, transformOrigin: "left center" }, "-=0.3") + .from("#box", { duration: 0.5, scale: 0.5, transformOrigin: "center center" }, "-=0.3"); + `; + const result = parseGsapScript(script); + + expect(result.animations).toHaveLength(3); + // Execution order: #headline, #subtext, #box + expect(result.animations[0].targetSelector).toBe("#headline"); + expect(result.animations[1].targetSelector).toBe("#subtext"); + expect(result.animations[2].targetSelector).toBe("#box"); + + // #headline: implicit position → starts at 0, ends at 0.6 + expect(result.animations[0].resolvedStart).toBe(0); + expect(result.animations[0].implicitPosition).toBe(true); + + // #subtext: "-=0.3" from cursor (0.6) → 0.6 - 0.3 = 0.3 + expect(result.animations[1].resolvedStart).toBe(0.3); + + // #box: "-=0.3" from cursor (max(0.6, 0.3+0.5=0.8) = 0.8) → 0.8 - 0.3 = 0.5 + expect(result.animations[2].resolvedStart).toBe(0.5); + }); + + it("resolves += and < positions", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { opacity: 1, duration: 0.5 }, "+=1"); + tl.to("#el2", { x: 100, duration: 1 }, "<"); + tl.to("#el3", { y: 50, duration: 0.3 }, "-=0.5"); + `; + const result = parseGsapScript(script); + + // #el1: "+=1" from cursor (0) → 0 + 1 = 1, ends at 1.5 + expect(result.animations[0].resolvedStart).toBe(1); + + // #el2: "<" = previous start → 1 + expect(result.animations[1].resolvedStart).toBe(1); + + // #el3: "-=0.5" from cursor (max(1.5, 1+1=2) = 2) → 2 - 0.5 = 1.5 + expect(result.animations[2].resolvedStart).toBe(1.5); + }); + + it("resolves numeric positions directly", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { opacity: 1, duration: 0.5 }, 0); + tl.to("#el2", { x: 100, duration: 1 }, 2); + `; + const result = parseGsapScript(script); + + expect(result.animations[0].resolvedStart).toBe(0); + expect(result.animations[1].resolvedStart).toBe(2); + }); + + it("resolves implicit sequential positions", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { opacity: 1, duration: 0.5 }) + .to("#el2", { x: 100, duration: 1 }) + .to("#el3", { y: 50, duration: 0.3 }); + `; + const result = parseGsapScript(script); + + // #el1: implicit → cursor=0, ends at 0.5 + expect(result.animations[0].resolvedStart).toBe(0); + expect(result.animations[0].implicitPosition).toBe(true); + + // #el2: implicit → cursor=0.5, ends at 1.5 + expect(result.animations[1].resolvedStart).toBe(0.5); + expect(result.animations[1].implicitPosition).toBe(true); + + // #el3: implicit → cursor=1.5, ends at 1.8 + expect(result.animations[2].resolvedStart).toBe(1.5); + expect(result.animations[2].implicitPosition).toBe(true); + }); + + it("clamps negative resolvedStart to 0", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { opacity: 1, duration: 0.2 }); + tl.to("#el2", { x: 100, duration: 1 }, "-=5"); + `; + const result = parseGsapScript(script); + + expect(result.animations[1].resolvedStart).toBe(0); + }); + + it("uses GSAP default duration (0.5) for tweens with no explicit duration", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { opacity: 1 }) + .to("#el2", { x: 100 }); + `; + const result = parseGsapScript(script); + + // #el1: starts at 0, duration defaults to 0.5 → cursor at 0.5 + expect(result.animations[0].resolvedStart).toBe(0); + // #el2: starts at cursor = 0.5 + expect(result.animations[1].resolvedStart).toBe(0.5); + }); + + it("treats set() as zero-duration", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.set("#el1", { opacity: 0 }); + tl.to("#el2", { opacity: 1, duration: 1 }); + `; + const result = parseGsapScript(script); + + // set() at 0, zero duration → cursor stays at 0 + expect(result.animations[0].resolvedStart).toBe(0); + // next tween starts at cursor = 0 + expect(result.animations[1].resolvedStart).toBe(0); + }); +}); + +describe("timeline defaults inheritance", () => { + it("inherits ease and duration from timeline defaults onto tweens", () => { + const script = ` + const tl = gsap.timeline({ defaults: { ease: "power3.out", duration: 0.6 } }); + tl.from("#headline", { scale: 0.92, transformOrigin: "left center" }) + .from("#subtext", { scale: 0.92 }, "-=0.3"); + `; + const result = parseGsapScript(script); + + expect(result.animations[0].ease).toBe("power3.out"); + expect(result.animations[0].duration).toBe(0.6); + expect(result.animations[1].ease).toBe("power3.out"); + expect(result.animations[1].duration).toBe(0.6); + }); + + it("does not override explicit ease/duration on individual tweens", () => { + const script = ` + const tl = gsap.timeline({ defaults: { ease: "power3.out", duration: 0.6 } }); + tl.to("#el1", { opacity: 1, duration: 1, ease: "none" }); + `; + const result = parseGsapScript(script); + + expect(result.animations[0].ease).toBe("none"); + expect(result.animations[0].duration).toBe(1); + }); + + it("uses inherited duration for position resolution", () => { + const script = ` + const tl = gsap.timeline({ defaults: { duration: 0.8 } }); + tl.from("#a", { scale: 0.5 }) + .from("#b", { scale: 0.5 }); + `; + const result = parseGsapScript(script); + + // #a starts at 0, duration 0.8 → cursor at 0.8 + expect(result.animations[0].resolvedStart).toBe(0); + // #b starts at cursor = 0.8 + expect(result.animations[1].resolvedStart).toBe(0.8); + }); +}); + +describe("property group classification", () => { + it("classifies individual properties into groups", () => { + expect(classifyPropertyGroup("x")).toBe("position"); + expect(classifyPropertyGroup("y")).toBe("position"); + expect(classifyPropertyGroup("xPercent")).toBe("position"); + expect(classifyPropertyGroup("scale")).toBe("scale"); + expect(classifyPropertyGroup("scaleX")).toBe("scale"); + expect(classifyPropertyGroup("width")).toBe("size"); + expect(classifyPropertyGroup("height")).toBe("size"); + expect(classifyPropertyGroup("rotation")).toBe("rotation"); + expect(classifyPropertyGroup("skewX")).toBe("rotation"); + expect(classifyPropertyGroup("opacity")).toBe("visual"); + expect(classifyPropertyGroup("autoAlpha")).toBe("visual"); + expect(classifyPropertyGroup("borderRadius")).toBe("other"); + expect(classifyPropertyGroup("fontSize")).toBe("other"); + }); + + it("classifies a pure position tween", () => { + expect(classifyTweenPropertyGroup({ x: 100, y: 50 })).toBe("position"); + }); + + it("classifies a pure scale tween", () => { + expect(classifyTweenPropertyGroup({ scale: 0.5 })).toBe("scale"); + }); + + it("classifies scale + transformOrigin as scale (transformOrigin follows group)", () => { + expect(classifyTweenPropertyGroup({ scale: 0.5, transformOrigin: "center center" })).toBe( + "scale", + ); + }); + + it("returns undefined for mixed-group tweens", () => { + expect(classifyTweenPropertyGroup({ x: 100, scale: 0.5 })).toBeUndefined(); + expect(classifyTweenPropertyGroup({ x: 100, opacity: 0 })).toBeUndefined(); + }); + + it("classifies tweens during parsing", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#a", { x: 100, y: 50, duration: 1 }, 0); + tl.to("#b", { scale: 0.5, duration: 0.5 }, 0); + tl.to("#c", { x: 100, scale: 0.5, opacity: 0, duration: 1 }, 0); + `; + const result = parseGsapScript(script); + + expect(result.animations[0].propertyGroup).toBe("position"); + expect(result.animations[1].propertyGroup).toBe("scale"); + expect(result.animations[2].propertyGroup).toBeUndefined(); }); }); @@ -1223,11 +1437,12 @@ describe("native GSAP keyframes parsing", () => { const kfs = expectKeyframesFormat(anim, "object-array", 3); // Total duration = 0.5 + 1 + 0.8 = 2.3 - expectKeyframe(kfs[0], 0, { x: 0, opacity: 1 }); - // Second: cumulative = 0.5, pct = round(0.5/2.3 * 100) = 22 - expectKeyframe(kfs[1], 22, { x: 100 }, "power2.out"); - // Third: cumulative = 1.5, pct = round(1.5/2.3 * 100) = 65 - expectKeyframe(kfs[2], 65, { x: 200 }); + // First: cumulative = 0.5, pct = round(0.5/2.3 * 100) = 22 + expectKeyframe(kfs[0], 22, { x: 0, opacity: 1 }); + // Second: cumulative = 1.5, pct = round(1.5/2.3 * 100) = 65 + expectKeyframe(kfs[1], 65, { x: 100 }, "power2.out"); + // Third: cumulative = 2.3, pct = round(2.3/2.3 * 100) = 100 + expectKeyframe(kfs[2], 100, { x: 200 }); }); it("parses simple array keyframes format", () => { @@ -1944,3 +2159,119 @@ tl.to("#el1", { y: 200, duration: 1 }, 3);`; expect(result.skippedSelectors).toEqual([".el1"]); }); }); + +describe("splitIntoPropertyGroups", () => { + const baseScript = `const tl = gsap.timeline({ paused: true });`; + + it("splits flat to({x, y, scale, rotation}) into 3 group tweens", () => { + const script = `${baseScript}\ntl.to("#el", { x: 100, y: 50, scale: 1.5, rotation: 45, duration: 1 }, 0);`; + const parsed = parseGsapScript(script); + const animId = parsed.animations[0]!.id; + + const result = splitIntoPropertyGroups(script, animId); + const reParsed = parseGsapScript(result.script); + + // Should produce 3 tweens: position (x,y), scale, rotation + expect(reParsed.animations).toHaveLength(3); + expect(result.ids).toHaveLength(3); + + const groups = new Set(reParsed.animations.map((a) => a.propertyGroup)); + expect(groups.has("position")).toBe(true); + expect(groups.has("scale")).toBe(true); + expect(groups.has("rotation")).toBe(true); + + const posAnim = reParsed.animations.find((a) => a.propertyGroup === "position")!; + expect(posAnim.properties.x).toBe(100); + expect(posAnim.properties.y).toBe(50); + expect(posAnim.properties.scale).toBeUndefined(); + + const scaleAnim = reParsed.animations.find((a) => a.propertyGroup === "scale")!; + expect(scaleAnim.properties.scale).toBe(1.5); + expect(scaleAnim.properties.x).toBeUndefined(); + + const rotAnim = reParsed.animations.find((a) => a.propertyGroup === "rotation")!; + expect(rotAnim.properties.rotation).toBe(45); + }); + + it("splits flat from({scale, opacity}) into 2 group tweens", () => { + const script = `${baseScript}\ntl.from("#el", { scale: 0.5, opacity: 0, duration: 0.5 }, 1);`; + const parsed = parseGsapScript(script); + const animId = parsed.animations[0]!.id; + + const result = splitIntoPropertyGroups(script, animId); + const reParsed = parseGsapScript(result.script); + + expect(reParsed.animations).toHaveLength(2); + expect(result.ids).toHaveLength(2); + + const groups = new Set(reParsed.animations.map((a) => a.propertyGroup)); + expect(groups.has("scale")).toBe(true); + expect(groups.has("visual")).toBe(true); + }); + + it("returns same ID for single-group tween (no split)", () => { + const script = `${baseScript}\ntl.to("#el", { x: 100, y: 50, duration: 1 }, 0);`; + const parsed = parseGsapScript(script); + const animId = parsed.animations[0]!.id; + + const result = splitIntoPropertyGroups(script, animId); + expect(result.ids).toEqual([animId]); + // Script should be unchanged + const reParsed = parseGsapScript(result.script); + expect(reParsed.animations).toHaveLength(1); + }); + + it("preserves position, duration, ease on split tweens", () => { + const script = `${baseScript}\ntl.to("#el", { x: 100, scale: 2, duration: 0.8, ease: "power2.out" }, 1.5);`; + const parsed = parseGsapScript(script); + const animId = parsed.animations[0]!.id; + + const result = splitIntoPropertyGroups(script, animId); + const reParsed = parseGsapScript(result.script); + + expect(reParsed.animations).toHaveLength(2); + for (const anim of reParsed.animations) { + expect(anim.position).toBe(1.5); + expect(anim.duration).toBe(0.8); + expect(anim.ease).toBe("power2.out"); + } + }); + + it("splits keyframed tween: each group gets only its properties per keyframe", () => { + const script = `${baseScript}\ntl.to("#el", { keyframes: { "0%": { x: 0, scale: 1 }, "50%": { x: 50, scale: 1.5 }, "100%": { x: 100, scale: 2 } }, duration: 2 }, 0);`; + const parsed = parseGsapScript(script); + const animId = parsed.animations[0]!.id; + + const result = splitIntoPropertyGroups(script, animId); + const reParsed = parseGsapScript(result.script); + + expect(reParsed.animations).toHaveLength(2); + expect(result.ids).toHaveLength(2); + + // Both tweens are keyframed — identify them by the properties inside their keyframes. + const xAnim = reParsed.animations.find((a) => + a.keyframes?.keyframes.some((kf) => "x" in kf.properties), + )!; + const scaleAnim = reParsed.animations.find((a) => + a.keyframes?.keyframes.some((kf) => "scale" in kf.properties), + )!; + + expect(xAnim).toBeDefined(); + expect(xAnim.keyframes).toBeDefined(); + expect(xAnim.keyframes!.keyframes).toHaveLength(3); + // Position keyframes should have x but not scale + for (const kf of xAnim.keyframes!.keyframes) { + expect(kf.properties.x).toBeDefined(); + expect(kf.properties.scale).toBeUndefined(); + } + + expect(scaleAnim).toBeDefined(); + expect(scaleAnim.keyframes).toBeDefined(); + expect(scaleAnim.keyframes!.keyframes).toHaveLength(3); + // Scale keyframes should have scale but not x + for (const kf of scaleAnim.keyframes!.keyframes) { + expect(kf.properties.scale).toBeDefined(); + expect(kf.properties.x).toBeUndefined(); + } + }); +}); diff --git a/packages/core/src/parsers/gsapParser.ts b/packages/core/src/parsers/gsapParser.ts index e9f6ea6d8..eaf962675 100644 --- a/packages/core/src/parsers/gsapParser.ts +++ b/packages/core/src/parsers/gsapParser.ts @@ -39,6 +39,14 @@ export { SUPPORTED_PROPS, SUPPORTED_EASES, } from "./gsapSerialize"; +export type { PropertyGroupName } from "./gsapConstants"; +export { + PROPERTY_GROUPS, + classifyPropertyGroup, + classifyTweenPropertyGroup, +} from "./gsapConstants"; +import { classifyPropertyGroup, classifyTweenPropertyGroup } from "./gsapConstants"; +import type { PropertyGroupName } from "./gsapConstants"; export { generateSpringEaseData, SPRING_PRESETS } from "./springEase"; export type { SpringPreset } from "./springEase"; @@ -343,19 +351,47 @@ function isGsapTimelineCall(node: any): boolean { ); } +interface TimelineDefaults { + ease?: string; + duration?: number; +} + interface TimelineDetection { timelineVar: string | null; timelineCount: number; + defaults?: TimelineDefaults; } -function findTimelineVar(ast: any): TimelineDetection { +function extractTimelineDefaults( + callNode: any, + scope: ScopeBindings, +): TimelineDefaults | undefined { + const arg = callNode.arguments?.[0]; + if (!arg || arg.type !== "ObjectExpression") return undefined; + const defaultsProp = arg.properties?.find( + (p: any) => isObjectProperty(p) && propKeyName(p) === "defaults", + ); + if (!defaultsProp?.value || defaultsProp.value.type !== "ObjectExpression") return undefined; + const record = objectExpressionToRecord(defaultsProp.value, scope); + const result: TimelineDefaults = {}; + if (typeof record.ease === "string") result.ease = record.ease; + if (typeof record.duration === "number") result.duration = record.duration; + return Object.keys(result).length > 0 ? result : undefined; +} + +function findTimelineVar(ast: any, scope?: ScopeBindings): TimelineDetection { let timelineVar: string | null = null; let timelineCount = 0; + let defaults: TimelineDefaults | undefined; + const emptyScope: ScopeBindings = scope ?? new Map(); recast.types.visit(ast, { visitVariableDeclarator(path: any) { if (isGsapTimelineCall(path.node.init)) { timelineCount += 1; - if (!timelineVar) timelineVar = path.node.id?.name ?? null; + if (!timelineVar) { + timelineVar = path.node.id?.name ?? null; + defaults = extractTimelineDefaults(path.node.init, emptyScope); + } } this.traverse(path); }, @@ -365,12 +401,13 @@ function findTimelineVar(ast: any): TimelineDetection { if (!timelineVar) { const left = path.node.left; if (left?.type === "Identifier") timelineVar = left.name; + defaults = extractTimelineDefaults(path.node.right, emptyScope); } } this.traverse(path); }, }); - return { timelineVar, timelineCount }; + return { timelineVar, timelineCount, defaults }; } // ── Find All Tween Calls ──────────────────────────────────────────────────── @@ -632,13 +669,13 @@ function parseObjectArrayKeyframes(node: any, scope: ScopeBindings): GsapKeyfram if (totalDuration > 0) { let cumulative = 0; for (const entry of raw) { + cumulative += entry.duration ?? 0; const percentage = Math.round((cumulative / totalDuration) * 100); keyframes.push({ percentage, properties: entry.properties, ...(entry.ease ? { ease: entry.ease } : {}), }); - cumulative += entry.duration ?? 0; } } else { for (let i = 0; i < raw.length; i++) { @@ -872,7 +909,8 @@ function tweenCallToAnimation( } } - const posVal = call.positionArg ? extractLiteralValue(call.positionArg, scope) : 0; + const hasPositionArg = !!call.positionArg; + const posVal = hasPositionArg ? extractLiteralValue(call.positionArg, scope) : 0; const position: number | string = typeof posVal === "number" ? posVal : typeof posVal === "string" ? posVal : 0; let duration = typeof vars.duration === "number" ? vars.duration : undefined; @@ -891,6 +929,16 @@ function tweenCallToAnimation( duration, ease, }; + if (!hasPositionArg) anim.implicitPosition = true; + let group = classifyTweenPropertyGroup(properties); + if (!group && keyframesData) { + const kfProps: Record = {}; + for (const kf of keyframesData.keyframes) { + for (const k of Object.keys(kf.properties)) kfProps[k] = true; + } + group = classifyTweenPropertyGroup(kfProps); + } + if (group) anim.propertyGroup = group; if (Object.keys(extras).length > 0) anim.extras = extras; if (keyframesData) anim.keyframes = keyframesData; if (motionPathResult) anim.arcPath = motionPathResult.arcPath; @@ -899,8 +947,96 @@ function tweenCallToAnimation( return anim; } +// ── Timeline Position Resolution ────────────────────────────────────────── + +const GSAP_DEFAULT_DURATION = 0.5; + +// NOTE: Label-based positions (e.g. "myLabel+=0.5") are not yet resolved — +// they fall through to parseFloat which returns null for non-numeric strings. +function resolvePositionString(pos: string, cursor: number, prevStart: number): number | null { + const trimmed = pos.trim(); + if (trimmed === "") return cursor; + if (trimmed.startsWith("+=")) { + const n = Number.parseFloat(trimmed.slice(2)); + return Number.isFinite(n) ? cursor + n : null; + } + if (trimmed.startsWith("-=")) { + const n = Number.parseFloat(trimmed.slice(2)); + return Number.isFinite(n) ? cursor - n : null; + } + if (trimmed === "<") return prevStart; + if (trimmed === ">") return cursor; + if (trimmed.startsWith("<")) { + const n = Number.parseFloat(trimmed.slice(1)); + return Number.isFinite(n) ? prevStart + n : null; + } + if (trimmed.startsWith(">")) { + const n = Number.parseFloat(trimmed.slice(1)); + return Number.isFinite(n) ? cursor + n : null; + } + const n = Number.parseFloat(trimmed); + return Number.isFinite(n) ? n : null; +} + +function applyTimelineDefaults( + anims: Omit[], + defaults?: TimelineDefaults, +): void { + if (!defaults) return; + for (const anim of anims) { + if (anim.method === "set") continue; + if (anim.duration === undefined && defaults.duration !== undefined) { + anim.duration = defaults.duration; + } + if (anim.ease === undefined && defaults.ease !== undefined) { + anim.ease = defaults.ease; + } + } +} + +function resolveTimelinePositions(anims: Omit[]): void { + let cursor = 0; + let prevStart = 0; + for (const anim of anims) { + const duration = anim.method === "set" ? 0 : (anim.duration ?? GSAP_DEFAULT_DURATION); + let start: number | null; + + if (anim.implicitPosition) { + start = cursor; + } else if (typeof anim.position === "number") { + start = anim.position; + } else if (typeof anim.position === "string") { + start = resolvePositionString(anim.position, cursor, prevStart); + } else { + start = cursor; + } + + if (start != null) { + anim.resolvedStart = Math.max(0, start); + prevStart = anim.resolvedStart; + cursor = Math.max(cursor, anim.resolvedStart + duration); + } + } +} + +function sortBySourcePosition(calls: TweenCallInfo[]): void { + calls.sort((a, b) => { + const aLoc = a.node.callee?.property?.loc?.start; + const bLoc = b.node.callee?.property?.loc?.start; + if (!aLoc || !bLoc) return 0; + return aLoc.line - bLoc.line || aLoc.column - bLoc.column; + }); +} + // ── Stable ID Generation ─────────────────────────────────────────────────── +/** + * IDs are transient — recomputed on every parse, never persisted across sessions. + * They exist only in ephemeral request/response payloads, React component state, + * and the in-memory keyframe cache (rebuilt on every page load). No database, + * localStorage, or file stores animation IDs, so changing the ID format (e.g. + * adding a `-scale`/`-position` suffix) is safe. + */ function assignStableIds(anims: Omit[]): GsapAnimation[] { const counts = new Map(); return anims.map((anim) => { @@ -908,7 +1044,8 @@ function assignStableIds(anims: Omit[]): GsapAnimation[] { typeof anim.position === "number" ? String(Math.round(anim.position * 1000)) : String(anim.position); - const base = `${anim.targetSelector}-${anim.method}-${posKey}`; + const groupSuffix = anim.propertyGroup ? `-${anim.propertyGroup}` : ""; + const base = `${anim.targetSelector}-${anim.method}-${posKey}${groupSuffix}`; const count = (counts.get(base) ?? 0) + 1; counts.set(base, count); const id = count === 1 ? base : `${base}-${count}`; @@ -937,10 +1074,14 @@ function parseGsapAst(script: string): ParsedGsapAst { const ast = parseScript(script); const scope = collectScopeBindings(ast); const targetBindings = collectTargetBindings(ast, scope); - const detection = findTimelineVar(ast); + const detection = findTimelineVar(ast, scope); const timelineVar = detection.timelineVar ?? "tl"; const calls = findAllTweenCalls(ast, timelineVar, scope, targetBindings); - const animations = assignStableIds(calls.map((call) => tweenCallToAnimation(call, scope))); + sortBySourcePosition(calls); + const rawAnims = calls.map((call) => tweenCallToAnimation(call, scope)); + applyTimelineDefaults(rawAnims, detection.defaults); + resolveTimelinePositions(rawAnims); + const animations = assignStableIds(rawAnims); const located = animations.map((animation, i) => ({ id: animation.id, call: calls[i]!, @@ -1300,7 +1441,11 @@ export function removeAnimationFromScript(script: string, animationId: string): console.warn("[gsap-parser] removeAnimationFromScript parse failed:", e); return script; } - const target = parsed.located.find((l) => l.id === animationId); + let target = parsed.located.find((l) => l.id === animationId); + if (!target) { + const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-"); + target = parsed.located.find((l) => l.id === convertedId); + } if (!target) return script; const node = target.call.node; const stmtPath = findStatementPath(target.call.path); @@ -1514,6 +1659,21 @@ function percentageFromKey(key: string): number { return m ? Number.parseFloat(m[1]!) : Number.NaN; } +const PCT_TOLERANCE = 2; + +function findKeyframePropByPct(kfNode: any, percentage: number): { idx: number; prop: any } | null { + const props = kfNode.properties; + for (let i = 0; i < props.length; i++) { + if (!isObjectProperty(props[i])) continue; + const key = propKeyName(props[i]); + if (typeof key !== "string") continue; + const parsed = percentageFromKey(key); + if (Number.isNaN(parsed)) continue; + if (Math.abs(parsed - percentage) <= PCT_TOLERANCE) return { idx: i, prop: props[i] }; + } + return null; +} + /** Build a keyframe value AST node from properties and optional ease. */ function buildKeyframeValueNode(properties: Record, ease?: string): any { const entries = Object.entries(properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); @@ -1571,7 +1731,11 @@ function collapseKeyframesToFlat(varsArg: any, record: Record): * updateKeyframeInScript. */ function locateKeyframeCtx(script: string, animationId: string, percentage: number) { - const loc = locateAnimation(script, animationId); + let loc = locateAnimation(script, animationId); + if (!loc) { + const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-"); + loc = locateAnimation(script, convertedId); + } if (!loc) return null; const kfNode = findKeyframesObjectNode(loc.target.call.varsArg); if (!kfNode) return null; @@ -1613,12 +1777,20 @@ export function addKeyframeToScript( const newValueNode = buildKeyframeValueNode(properties, ease); - // Replace if this percentage already exists - const existingIdx = kfNode.properties.findIndex( - (p: any) => isObjectProperty(p) && propKeyName(p) === pctKey, - ); - if (existingIdx !== -1) { - kfNode.properties[existingIdx].value = newValueNode; + // Merge into existing keyframe at this percentage, or insert new + const existing = findKeyframePropByPct(kfNode, percentage); + if (existing) { + if (existing.prop.value?.type === "ObjectExpression") { + const existingRecord = objectExpressionToRecord(existing.prop.value, loc.parsed.scope); + const merged = { ...existingRecord }; + for (const [k, v] of Object.entries(properties)) merged[k] = v; + existing.prop.value = buildKeyframeValueNode( + merged as Record, + ease ?? (typeof existingRecord.ease === "string" ? existingRecord.ease : undefined), + ); + } else { + existing.prop.value = newValueNode; + } } else { // Build the new property node with a quoted percentage key const newProp = parseExpr(`{ ${JSON.stringify(pctKey)}: {} }`).properties[0]; @@ -1703,12 +1875,11 @@ export function removeKeyframeFromScript( ): string { const ctx = locateKeyframeCtx(script, animationId, percentage); if (!ctx) return script; - const { loc, kfNode, pctKey } = ctx; + const { loc, kfNode } = ctx; - const removeIdx = kfNode.properties.findIndex( - (p: any) => isObjectProperty(p) && propKeyName(p) === pctKey, - ); - if (removeIdx === -1) return script; + const match = findKeyframePropByPct(kfNode, percentage); + if (!match) return script; + const removeIdx = match.idx; kfNode.properties.splice(removeIdx, 1); @@ -1736,14 +1907,12 @@ export function updateKeyframeInScript( ): string { const ctx = locateKeyframeCtx(script, animationId, percentage); if (!ctx) return script; - const { loc, kfNode, pctKey } = ctx; + const { loc, kfNode } = ctx; - const existing = kfNode.properties.find( - (p: any) => isObjectProperty(p) && propKeyName(p) === pctKey, - ); - if (!existing) return script; + const match = findKeyframePropByPct(kfNode, percentage); + if (!match) return script; - existing.value = buildKeyframeValueNode(properties, ease); + match.prop.value = buildKeyframeValueNode(properties, ease); return recast.print(loc.parsed.ast).code; } @@ -1760,32 +1929,47 @@ function cssIdentityValue(prop: string): number { return CSS_IDENTITY[prop] ?? 0; } +/** + * Resolve the 0% (from) and 100% (to) property maps for a tween being + * converted to percentage keyframes. + * + * @param resolvedFromValues — Despite the "from" in the name (historical), these + * are runtime-captured DOM values that override the conversion endpoint: + * - For to(): overrides fromProps (the 0% state / where the element is now). + * - For from(): overrides toProps (the 100% state / where the element rests). + * - For fromTo(): merges into toProps (the 100% endpoint the user is editing). + */ function resolveConversionProps( anim: GsapAnimation, resolvedFromValues?: Record, ): { fromProps: Record; toProps: Record } { if (anim.method === "to") { - if (resolvedFromValues) { - return { fromProps: resolvedFromValues, toProps: { ...anim.properties } }; - } const identityFrom: Record = {}; for (const [key, val] of Object.entries(anim.properties)) { if (val != null) identityFrom[key] = typeof val === "number" ? cssIdentityValue(key) : val; } - return { fromProps: identityFrom, toProps: { ...anim.properties } }; + const fromProps = resolvedFromValues + ? { ...identityFrom, ...resolvedFromValues } + : identityFrom; + return { fromProps, toProps: { ...anim.properties } }; } if (anim.method === "from") { - if (resolvedFromValues) { - return { fromProps: { ...anim.properties }, toProps: resolvedFromValues }; - } const identityTo: Record = {}; for (const [key, val] of Object.entries(anim.properties)) { if (val != null) identityTo[key] = typeof val === "number" ? cssIdentityValue(key) : val; } - return { fromProps: { ...anim.properties }, toProps: identityTo }; + const toProps = resolvedFromValues ? { ...identityTo, ...resolvedFromValues } : identityTo; + return { fromProps: { ...anim.properties }, toProps }; } - // fromTo - return { fromProps: { ...(anim.fromProperties ?? {}) }, toProps: { ...anim.properties } }; + // fromTo(fromVars, toVars): anim.fromProperties = fromVars (0% state), + // anim.properties = toVars (100% state). resolvedFromValues contains the + // current DOM position from a drag — it represents the NEW destination, so + // it merges into toProps (the 100% endpoint the user is editing), NOT into + // fromProps. This is intentional and not inverted. + const toProps = resolvedFromValues + ? { ...anim.properties, ...resolvedFromValues } + : { ...anim.properties }; + return { fromProps: { ...(anim.fromProperties ?? {}) }, toProps }; } /** Strip editable properties and ease/keyframes keys from a varsArg. */ @@ -1827,7 +2011,11 @@ export function convertToKeyframesInScript( animationId: string, resolvedFromValues?: Record, ): string { - const loc = locateAnimation(script, animationId); + let loc = locateAnimation(script, animationId); + if (!loc) { + const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-"); + loc = locateAnimation(script, convertedId); + } if (!loc) return script; const anim = loc.target.animation; @@ -1858,7 +2046,11 @@ export function convertToKeyframesInScript( * last keyframe's properties. */ export function removeAllKeyframesFromScript(script: string, animationId: string): string { - const loc = locateAnimation(script, animationId); + let loc = locateAnimation(script, animationId); + if (!loc) { + const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-"); + loc = locateAnimation(script, convertedId); + } if (!loc) return script; const kfNode = findKeyframesObjectNode(loc.target.call.varsArg); if (!kfNode) return script; @@ -1894,7 +2086,11 @@ export function materializeKeyframesInScript( easeEach?: string, resolvedSelector?: string, ): string { - const loc = locateAnimation(script, animationId); + let loc = locateAnimation(script, animationId); + if (!loc) { + const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-"); + loc = locateAnimation(script, convertedId); + } if (!loc) return script; const varsArg = loc.target.call.varsArg; @@ -2143,6 +2339,156 @@ export function removeArcPathFromScript(script: string, animationId: string): st }); } +// ── Split Into Property Groups ──────────────────────────────────────────── + +/** + * Split a multi-group tween into separate per-group tweens. Each resulting + * tween contains only properties belonging to one property group (position, + * scale, rotation, visual, etc.). `transformOrigin` stays with the group that + * has the most properties. If the tween already belongs to a single group, + * returns the script unchanged with the original ID. + */ +// fallow-ignore-next-line complexity +export function splitIntoPropertyGroups( + script: string, + animationId: string, +): { script: string; ids: string[] } { + let loc = locateAnimation(script, animationId); + if (!loc) { + const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-"); + loc = locateAnimation(script, convertedId); + } + if (!loc) return { script, ids: [animationId] }; + + const anim = loc.target.animation; + + // Collect the properties to partition. For keyframed tweens, gather the + // union of all properties across all keyframes. For flat tweens, use the + // tween's own properties map. + const allPropKeys = new Set(); + if (anim.keyframes) { + for (const kf of anim.keyframes.keyframes) { + for (const k of Object.keys(kf.properties)) allPropKeys.add(k); + } + } else { + for (const k of Object.keys(anim.properties)) allPropKeys.add(k); + } + + // Partition properties into groups (excluding transformOrigin — handled below). + const groupProps = new Map(); + for (const key of allPropKeys) { + if (key === "transformOrigin") continue; + const group = classifyPropertyGroup(key); + let arr = groupProps.get(group); + if (!arr) { + arr = []; + groupProps.set(group, arr); + } + arr.push(key); + } + + // Only one group (or zero) — no split needed. + if (groupProps.size <= 1) return { script, ids: [anim.id] }; + + // Assign transformOrigin to the group with the most properties. + if (allPropKeys.has("transformOrigin")) { + let largestGroup: PropertyGroupName | undefined; + let largestCount = 0; + for (const [group, props] of groupProps) { + if (props.length > largestCount) { + largestCount = props.length; + largestGroup = group; + } + } + if (largestGroup) { + groupProps.get(largestGroup)!.push("transformOrigin"); + } + } + + // Build per-group tweens and insert them, then remove the original. + let result = script; + + // Remove the original tween first. + result = removeAnimationFromScript(result, anim.id); + + // Insert one tween per group. Iteration order of the Map follows insertion + // order, which mirrors the order properties were encountered. + for (const [, props] of groupProps) { + const propSet = new Set(props); + + if (anim.keyframes) { + // Build keyframes containing only this group's properties per keyframe. + const groupKeyframes: Array<{ + percentage: number; + properties: Record; + ease?: string; + auto?: boolean; + }> = []; + + for (const kf of anim.keyframes.keyframes) { + const filtered: Record = {}; + for (const [k, v] of Object.entries(kf.properties)) { + if (propSet.has(k)) filtered[k] = v; + } + // Skip keyframes where this group has zero properties. + if (Object.keys(filtered).length === 0) continue; + groupKeyframes.push({ + percentage: kf.percentage, + properties: filtered, + ...(kf.ease ? { ease: kf.ease } : {}), + }); + } + + if (groupKeyframes.length === 0) continue; + + const addResult = addAnimationWithKeyframesToScript( + result, + anim.targetSelector, + typeof anim.position === "number" ? anim.position : 0, + anim.duration ?? 0.5, + groupKeyframes, + anim.keyframes.easeEach ?? anim.ease, + ); + result = addResult.script; + } else { + // Flat tween — filter properties to this group. + const groupProperties: Record = {}; + for (const [k, v] of Object.entries(anim.properties)) { + if (propSet.has(k)) groupProperties[k] = v; + } + if (Object.keys(groupProperties).length === 0) continue; + + let fromProperties: Record | undefined; + if (anim.method === "fromTo" && anim.fromProperties) { + fromProperties = {}; + for (const [k, v] of Object.entries(anim.fromProperties)) { + if (propSet.has(k)) fromProperties[k] = v; + } + } + + const addResult = addAnimationToScript(result, { + targetSelector: anim.targetSelector, + method: anim.method, + position: anim.position, + duration: anim.duration, + ease: anim.ease, + properties: groupProperties, + fromProperties, + extras: anim.extras, + }); + result = addResult.script; + } + } + + // Re-parse to collect the new IDs. + const reParsed = parseGsapAst(result); + const newIds = reParsed.located + .filter((l) => l.animation.targetSelector === anim.targetSelector) + .map((l) => l.id); + + return { script: result, ids: newIds }; +} + /** * Replace a dynamic loop that generates multiple tween calls with individual * static `tl.to()` calls — one per element. Finds the loop containing the @@ -2213,7 +2559,7 @@ export function unrollDynamicAnimations( kfEntries.push(`easeEach: ${JSON.stringify(el.easeEach)}`); } calls.push( - `tl.to(${JSON.stringify(el.selector)}, { keyframes: { ${kfEntries.join(", ")} }, duration: ${duration}, ease: ${JSON.stringify(ease)} }, ${posCode});`, + `${loc.parsed.timelineVar}.to(${JSON.stringify(el.selector)}, { keyframes: { ${kfEntries.join(", ")} }, duration: ${duration}, ease: ${JSON.stringify(ease)} }, ${posCode});`, ); } diff --git a/packages/core/src/parsers/gsapSerialize.ts b/packages/core/src/parsers/gsapSerialize.ts index b480190e6..fc5a6220a 100644 --- a/packages/core/src/parsers/gsapSerialize.ts +++ b/packages/core/src/parsers/gsapSerialize.ts @@ -7,6 +7,7 @@ * parsing of GSAP source lives in the Node-only `./gsapParser` module. */ import type { Keyframe, KeyframeProperties, ValidationResult } from "../core.types"; +import type { PropertyGroupName } from "./gsapConstants"; export type GsapMethod = "set" | "to" | "from" | "fromTo"; @@ -29,6 +30,13 @@ export interface GsapAnimation { hasUnresolvedKeyframes?: boolean; /** True when the tween's target selector couldn't be statically resolved (dynamic). */ hasUnresolvedSelector?: boolean; + /** Absolute start time computed by walking the timeline chain (handles +=, -=, <, >, labels). */ + resolvedStart?: number; + /** True when no position arg was authored — the tween is sequentially placed by GSAP. */ + implicitPosition?: boolean; + /** Which property group this tween belongs to (position, scale, size, rotation, visual, other). + * Undefined for legacy mixed tweens that bundle multiple groups. */ + propertyGroup?: PropertyGroupName; } export interface GsapPercentageKeyframe { @@ -77,8 +85,10 @@ export function serializeGsapAnimations( options?: { includeMediaSync?: boolean; preamble?: string; postamble?: string }, ): string { const sorted = [...animations].sort((a, b) => { - const aNum = typeof a.position === "number" ? a.position : Number.MAX_SAFE_INTEGER; - const bNum = typeof b.position === "number" ? b.position : Number.MAX_SAFE_INTEGER; + const aNum = + a.resolvedStart ?? (typeof a.position === "number" ? a.position : Number.MAX_SAFE_INTEGER); + const bNum = + b.resolvedStart ?? (typeof b.position === "number" ? b.position : Number.MAX_SAFE_INTEGER); return aNum - bNum; }); const lines = sorted.map((anim) => { diff --git a/packages/core/src/studio-api/routes/files.ts b/packages/core/src/studio-api/routes/files.ts index 9fd909920..31b879bbb 100644 --- a/packages/core/src/studio-api/routes/files.ts +++ b/packages/core/src/studio-api/routes/files.ts @@ -409,6 +409,20 @@ type GsapMutationRequest = }>; ease?: string; } + | { + type: "replace-with-keyframes"; + animationId: string; + targetSelector: string; + position: number; + duration: number; + keyframes: Array<{ + percentage: number; + properties: Record; + ease?: string; + auto?: boolean; + }>; + ease?: string; + } | { type: "split-animations"; originalId: string; @@ -416,6 +430,10 @@ type GsapMutationRequest = splitTime: number; elementStart: number; elementDuration: number; + } + | { + type: "split-into-property-groups"; + animationId: string; }; // ── GSAP mutation executor ────────────────────────────────────────────────── @@ -445,6 +463,7 @@ async function executeGsapMutation( removeArcPathFromScript, addAnimationWithKeyframesToScript, splitAnimationsInScript, + splitIntoPropertyGroups, } = parser; function requireAnimation( @@ -617,6 +636,18 @@ async function executeGsapMutation( ); return result.script; } + case "replace-with-keyframes": { + const script = removeAnimationFromScript(block.scriptText, body.animationId); + const added = addAnimationWithKeyframesToScript( + script, + body.targetSelector, + body.position, + body.duration, + body.keyframes, + body.ease, + ); + return added.script; + } case "split-animations": { if ( typeof body.originalId !== "string" || @@ -647,6 +678,10 @@ async function executeGsapMutation( elementDuration: body.elementDuration, }); } + case "split-into-property-groups": { + const result = splitIntoPropertyGroups(block.scriptText, body.animationId); + return result.script; + } default: return respond({ error: `unknown mutation type: ${(body as { type: string }).type}` }, 400); } @@ -1061,8 +1096,9 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { if (result instanceof Response) return result; const newScript = typeof result === "string" ? result : result.script; - const newHtml = block.replaceScript(newScript); - if (newHtml !== html) { + const changed = newScript !== block.scriptText; + const newHtml = changed ? block.replaceScript(newScript) : html; + if (changed) { writeFileSync(res.absPath, newHtml, "utf-8"); } @@ -1070,6 +1106,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { const freshParsed = parseGsapScript(newScript); const responsePayload: Record = { ok: true, + changed, parsed: freshParsed, before: html, after: newHtml, diff --git a/packages/studio/src/components/StudioPreviewArea.tsx b/packages/studio/src/components/StudioPreviewArea.tsx index 76dc47c96..6e6446d8c 100644 --- a/packages/studio/src/components/StudioPreviewArea.tsx +++ b/packages/studio/src/components/StudioPreviewArea.tsx @@ -108,6 +108,7 @@ export function StudioPreviewArea({ handlePreviewCanvasPointerMove, handlePreviewCanvasPointerLeave, applyDomSelection, + buildDomSelectionFromTarget, handleBlockedDomMove, handleDomManualDragStart, handleDomPathOffsetCommit, @@ -154,25 +155,40 @@ export function StudioPreviewArea({ onRazorSplitAll={handleRazorSplitAll} onSelectTimelineElement={handleTimelineElementSelect} onDeleteAllKeyframes={(_elId) => { - const anim = - selectedGsapAnimations.find((a) => a.keyframes) ?? selectedGsapAnimations[0]; - if (anim) handleGsapDeleteAnimation(anim.id); + for (const anim of selectedGsapAnimations) { + handleGsapDeleteAnimation(anim.id); + } }} onDeleteKeyframe={(_elId, pct) => { - const anim = selectedGsapAnimations.find((a) => a.keyframes); - if (anim) handleGsapRemoveKeyframe(anim.id, pct); + const cacheKey = domEditSelection?.id ?? ""; + const cached = usePlayerStore.getState().keyframeCache.get(cacheKey); + const kf = cached?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2); + const group = kf?.propertyGroup; + const anim = + (group ? selectedGsapAnimations.find((a) => a.propertyGroup === group) : undefined) ?? + selectedGsapAnimations.find((a) => a.keyframes); + if (!anim) return; + handleGsapRemoveKeyframe(anim.id, kf?.tweenPercentage ?? pct); }} onChangeKeyframeEase={(_elId, _pct, ease) => { - const anim = selectedGsapAnimations.find((a) => a.keyframes); - if (anim) handleGsapUpdateMeta(anim.id, { ease }); + for (const anim of selectedGsapAnimations) { + if (anim.keyframes) handleGsapUpdateMeta(anim.id, { ease }); + } }} // fallow-ignore-next-line complexity onMoveKeyframe={(_el, oldPct, newPct) => { - const anim = selectedGsapAnimations.find((a) => a.keyframes); + const cacheKey = domEditSelection?.id ?? ""; + const cached = usePlayerStore.getState().keyframeCache.get(cacheKey); + const cachedKf = cached?.keyframes.find((k) => Math.abs(k.percentage - oldPct) < 0.2); + const group = cachedKf?.propertyGroup; + const anim = + (group ? selectedGsapAnimations.find((a) => a.propertyGroup === group) : undefined) ?? + selectedGsapAnimations.find((a) => a.keyframes); if (!anim?.keyframes) return; + const tweenOldPct = cachedKf?.tweenPercentage ?? oldPct; const kf = anim.keyframes.keyframes.find((k) => k.percentage === oldPct); if (!kf) return; - handleGsapRemoveKeyframe(anim.id, oldPct); + handleGsapRemoveKeyframe(anim.id, tweenOldPct); for (const [prop, val] of Object.entries(kf.properties)) { handleGsapAddKeyframe(anim.id, newPct, prop, val); } @@ -264,6 +280,14 @@ export function StudioPreviewArea({ onRotationCommit={handleDomRotationCommit} gridVisible={snapPrefs.gridVisible} gridSpacing={snapPrefs.gridSpacing} + onSelectElementById={async (id) => { + const iframe = previewIframeRef.current; + const el = iframe?.contentDocument?.getElementById(id); + if (!el) return null; + const sel = await buildDomSelectionFromTarget(el); + if (sel) applyDomSelection(sel, { revealPanel: true }); + return sel; + }} /> {gestureOverlay} diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx index 66129639b..aa6684fb8 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.tsx +++ b/packages/studio/src/components/editor/DomEditOverlay.tsx @@ -13,6 +13,7 @@ import { useDomEditOverlayRects } from "./useDomEditOverlayRects"; import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures"; import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay"; import { GridOverlay } from "./GridOverlay"; +import { useOffScreenIndicators } from "./useOffScreenIndicators"; // Re-exports for external consumers — preserving existing import paths. export { @@ -54,6 +55,7 @@ interface DomEditOverlayProps { ) => void; onBlockedMove: (selection: DomEditSelection) => void; onManualDragStart?: () => void; + onSelectElementById?: (id: string) => Promise; onPathOffsetCommit: ( selection: DomEditSelection, next: { x: number; y: number }, @@ -83,6 +85,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({ gridVisible = false, gridSpacing = 50, onManualDragStart, + onSelectElementById, onPathOffsetCommit, onGroupPathOffsetCommit, onBoxSizeCommit, @@ -212,6 +215,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({ return () => cancelAnimationFrame(frame); }); + const offScreenIndicators = useOffScreenIndicators({ iframeRef, overlayRef, compRect }); + const gestures = createDomEditOverlayGestureHandlers({ overlayRef, iframeRef, @@ -263,6 +268,22 @@ export const DomEditOverlay = memo(function DomEditOverlay({ } const target = event.target as HTMLElement | null; if (target?.closest('[data-dom-edit-selection-box="true"]')) return; + // Don't re-resolve selection when clicking outside the composition bounds — + // the iframe can't resolve elements there, so it would clear the selection. + if (selection && compRect.width > 0) { + const overlayEl = overlayRef.current; + if (overlayEl) { + const overlayRect = overlayEl.getBoundingClientRect(); + const clickX = event.clientX - overlayRect.left; + const clickY = event.clientY - overlayRect.top; + const outsideComp = + clickX < compRect.left || + clickX > compRect.left + compRect.width || + clickY < compRect.top || + clickY > compRect.top + compRect.height; + if (outsideComp) return; + } + } onCanvasMouseDown(event, { preferClipAncestor: false }); if (event.shiftKey) { suppressNextBoxMouseDownRef.current = true; @@ -500,6 +521,64 @@ export const DomEditOverlay = memo(function DomEditOverlay({ }} /> ))} + {offScreenIndicators.length > 0 && + compRect.width > 0 && + offScreenIndicators.map((ind) => { + const isSelected = selection?.id === ind.elementId; + return ( +
{ + if (e.button !== 0) return; + e.stopPropagation(); + e.preventDefault(); + const startX = e.clientX; + const startY = e.clientY; + const el = e.currentTarget; + el.setPointerCapture(e.pointerId); + let deltaX = 0; + let deltaY = 0; + let moved = false; + const onMove = (me: PointerEvent) => { + deltaX = me.clientX - startX; + deltaY = me.clientY - startY; + if (Math.abs(deltaX) > 3 || Math.abs(deltaY) > 3) moved = true; + if (moved) { + el.style.transform = `translate(${deltaX}px, ${deltaY}px)`; + } + }; + const onUp = async (ue: PointerEvent) => { + el.releasePointerCapture(ue.pointerId); + el.removeEventListener("pointermove", onMove); + el.removeEventListener("pointerup", onUp); + el.style.transform = ""; + const sel = await onSelectElementById?.(ind.elementId); + if (moved && sel && onPathOffsetCommit) { + const scale = compRect.scaleX || 1; + onPathOffsetCommit(sel, { x: deltaX / scale, y: deltaY / scale }); + } + }; + el.addEventListener("pointermove", onMove); + el.addEventListener("pointerup", onUp); + } + } + title={isSelected ? undefined : `Drag #${ind.elementId}`} + /> + ); + })} onSeekToTime?.(elStart + (pct / 100) * elDuration); + const animIdForProp = (prop: string): string | null => { + const group = classifyPropertyGroup(prop); + const groupAnim = gsapAnimations?.find((a) => a.propertyGroup === group); + if (groupAnim) return groupAnim.id; + return gsapAnimId; + }; + // Read ALL GSAP-interpolated values at the current seek time. const gsapRuntimeValues = readGsapRuntimeValuesForPanel( gsapAnimId, @@ -395,8 +403,8 @@ export const PropertyPanel = memo(function PropertyPanel({ onCommitAnimatedProperty && void onCommitAnimatedProperty(element, "x", displayX) } - onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} - onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} + onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("x"), pct)} + onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("x"))} /> )}
@@ -420,8 +428,8 @@ export const PropertyPanel = memo(function PropertyPanel({ onCommitAnimatedProperty && void onCommitAnimatedProperty(element, "y", displayY) } - onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} - onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} + onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("y"), pct)} + onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("y"))} /> )}
@@ -445,8 +453,8 @@ export const PropertyPanel = memo(function PropertyPanel({ onCommitAnimatedProperty && void onCommitAnimatedProperty(element, "width", displayW) } - onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} - onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} + onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("width"), pct)} + onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("width"))} /> )} @@ -470,8 +478,8 @@ export const PropertyPanel = memo(function PropertyPanel({ onCommitAnimatedProperty && void onCommitAnimatedProperty(element, "height", displayH) } - onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} - onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} + onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("height"), pct)} + onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("height"))} /> )} @@ -493,8 +501,8 @@ export const PropertyPanel = memo(function PropertyPanel({ onCommitAnimatedProperty && void onCommitAnimatedProperty(element, "rotation", displayR) } - onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} - onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} + onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("rotation"), pct)} + onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("rotation"))} /> )} @@ -503,6 +511,7 @@ export const PropertyPanel = memo(function PropertyPanel({ ) => void } }) + | (Window & { + gsap?: { + set: (el: Element, vars: Record) => void; + getProperty: (el: Element, prop: string) => number; + }; + }) | null; - win?.gsap?.set(element, { x: offset.x, y: offset.y }); + if (win?.gsap) { + const baseX = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-x") ?? ""); + const baseY = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-y") ?? ""); + const origX = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-x") ?? ""); + const origY = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-y") ?? ""); + const gsapBaseX = Number.isFinite(baseX) + ? baseX + : (win.gsap.getProperty(element, "x") as number); + const gsapBaseY = Number.isFinite(baseY) + ? baseY + : (win.gsap.getProperty(element, "y") as number); + if (!Number.isFinite(baseX)) + element.setAttribute("data-hf-drag-gsap-base-x", String(gsapBaseX)); + if (!Number.isFinite(baseY)) + element.setAttribute("data-hf-drag-gsap-base-y", String(gsapBaseY)); + const deltaX = offset.x - (Number.isFinite(origX) ? origX : 0); + const deltaY = offset.y - (Number.isFinite(origY) ? origY : 0); + win.gsap.set(element, { x: gsapBaseX + deltaX, y: gsapBaseY + deltaY }); + } } else { // Non-GSAP elements: use CSS translate as before. element.style.setProperty( diff --git a/packages/studio/src/components/editor/manualEditsSnapshot.ts b/packages/studio/src/components/editor/manualEditsSnapshot.ts index 1cf840ff5..afc8aa30e 100644 --- a/packages/studio/src/components/editor/manualEditsSnapshot.ts +++ b/packages/studio/src/components/editor/manualEditsSnapshot.ts @@ -183,6 +183,22 @@ export function restoreStudioPathOffset( STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR, previous.originalInlineTranslate, ); + + // Restore GSAP x/y if a draft was applied via gsap.set during drag + const baseX = element.getAttribute("data-hf-drag-gsap-base-x"); + const baseY = element.getAttribute("data-hf-drag-gsap-base-y"); + if (baseX != null || baseY != null) { + const win = element.ownerDocument.defaultView as + | (Window & { gsap?: { set: (el: Element, vars: Record) => void } }) + | null; + if (win?.gsap) { + const x = Number.parseFloat(baseX ?? "0") || 0; + const y = Number.parseFloat(baseY ?? "0") || 0; + win.gsap.set(element, { x, y }); + } + element.removeAttribute("data-hf-drag-gsap-base-x"); + element.removeAttribute("data-hf-drag-gsap-base-y"); + } } /* ── Clear functions ──────────────────────────────────────────────── */ diff --git a/packages/studio/src/components/editor/propertyPanel3dTransform.tsx b/packages/studio/src/components/editor/propertyPanel3dTransform.tsx index 058e44fae..210ded863 100644 --- a/packages/studio/src/components/editor/propertyPanel3dTransform.tsx +++ b/packages/studio/src/components/editor/propertyPanel3dTransform.tsx @@ -13,6 +13,7 @@ type KeyframeEntry = Array<{ interface PropertyPanel3dTransformProps { gsapRuntimeValues: Record; gsapAnimId: string | null; + resolveAnimIdForProp?: (prop: string) => string | null; gsapKeyframes: KeyframeEntry; currentPct: number; elStart: number; @@ -31,6 +32,7 @@ interface PropertyPanel3dTransformProps { export function PropertyPanel3dTransform({ gsapRuntimeValues, gsapAnimId, + resolveAnimIdForProp, gsapKeyframes, currentPct, elStart, @@ -41,6 +43,7 @@ export function PropertyPanel3dTransform({ onRemoveKeyframe, onConvertToKeyframes, }: PropertyPanel3dTransformProps) { + const idFor = (prop: string) => resolveAnimIdForProp?.(prop) ?? gsapAnimId; return (
@@ -72,8 +75,14 @@ export function PropertyPanel3dTransform({ void onCommitAnimatedProperty(element, "z", gsapRuntimeValues?.z ?? 0); } }} - onRemoveKeyframe={(pct) => gsapAnimId && onRemoveKeyframe?.(gsapAnimId, pct)} - onConvertToKeyframes={() => gsapAnimId && onConvertToKeyframes?.(gsapAnimId)} + onRemoveKeyframe={(pct) => { + const id = idFor("z"); + if (id) onRemoveKeyframe?.(id, pct); + }} + onConvertToKeyframes={() => { + const id = idFor("z"); + if (id) onConvertToKeyframes?.(id); + }} /> )}
@@ -102,8 +111,14 @@ export function PropertyPanel3dTransform({ void onCommitAnimatedProperty(element, "scale", gsapRuntimeValues?.scale ?? 1); } }} - onRemoveKeyframe={(pct) => gsapAnimId && onRemoveKeyframe?.(gsapAnimId, pct)} - onConvertToKeyframes={() => gsapAnimId && onConvertToKeyframes?.(gsapAnimId)} + onRemoveKeyframe={(pct) => { + const id = idFor("scale"); + if (id) onRemoveKeyframe?.(id, pct); + }} + onConvertToKeyframes={() => { + const id = idFor("scale"); + if (id) onConvertToKeyframes?.(id); + }} /> )}
diff --git a/packages/studio/src/components/editor/useOffScreenIndicators.ts b/packages/studio/src/components/editor/useOffScreenIndicators.ts new file mode 100644 index 000000000..6128a9896 --- /dev/null +++ b/packages/studio/src/components/editor/useOffScreenIndicators.ts @@ -0,0 +1,184 @@ +/** + * Detects GSAP-animated elements whose center is outside the visible composition + * area and returns edge-clamped indicator positions for each. + */ +import { useRef, useState, type RefObject } from "react"; +import { useMountEffect } from "../../hooks/useMountEffect"; + +export interface OffScreenIndicator { + key: string; + elementId: string; + left: number; + top: number; + width: number; + height: number; +} + +interface CompRect { + left: number; + top: number; + width: number; + height: number; + scaleX: number; + scaleY: number; +} + + +type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> }; + +function isHtmlElement(node: unknown): node is HTMLElement { + return ( + typeof node === "object" && + node !== null && + typeof (node as HTMLElement).getBoundingClientRect === "function" && + typeof (node as HTMLElement).tagName === "string" + ); +} + +function collectGsapTargetElements(iframe: HTMLIFrameElement): HTMLElement[] { + const win = iframe.contentWindow as + | (Window & { __timelines?: Record }) + | null; + if (!win) return []; + + let timelines: Record | undefined; + try { + timelines = win.__timelines; + } catch { + return []; + } + if (!timelines) return []; + + const seen = new Set(); + for (const tl of Object.values(timelines)) { + if (!tl?.getChildren) continue; + try { + for (const child of tl.getChildren(true)) { + if (!child.targets) continue; + for (const t of child.targets()) { + if (isHtmlElement(t)) seen.add(t); + } + } + } catch { + // cross-origin or detached timeline — skip + } + } + return Array.from(seen); +} + +function indicatorsEqual(a: OffScreenIndicator[], b: OffScreenIndicator[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + const ai = a[i]!; + const bi = b[i]!; + if (ai.key !== bi.key || Math.abs(ai.left - bi.left) > 0.5 || Math.abs(ai.top - bi.top) > 0.5 || Math.abs(ai.width - bi.width) > 0.5 || Math.abs(ai.height - bi.height) > 0.5) + return false; + } + return true; +} + +export function useOffScreenIndicators({ + iframeRef, + overlayRef, + compRect, +}: { + iframeRef: RefObject; + overlayRef: RefObject; + compRect: CompRect; +}): OffScreenIndicator[] { + const [indicators, setIndicators] = useState([]); + const prevRef = useRef([]); + const compRectRef = useRef(compRect); + compRectRef.current = compRect; + + useMountEffect(() => { + let frame = 0; + + const update = () => { + frame = requestAnimationFrame(update); + + const iframe = iframeRef.current; + const overlayEl = overlayRef.current; + const cr = compRectRef.current; + if (!iframe || !overlayEl || cr.width <= 0 || cr.height <= 0) { + if (prevRef.current.length > 0) { + prevRef.current = []; + setIndicators([]); + } + return; + } + + const iframeRect = iframe.getBoundingClientRect(); + const overlayRect = overlayEl.getBoundingClientRect(); + + const doc = iframe.contentDocument; + const root = + doc?.querySelector("[data-composition-id]") ?? doc?.documentElement ?? null; + if (!root) return; + + const declaredWidth = + Number.parseFloat(root.getAttribute("data-width") ?? "") || iframeRect.width; + const declaredHeight = + Number.parseFloat(root.getAttribute("data-height") ?? "") || iframeRect.height; + const rootScaleX = iframeRect.width / declaredWidth; + const rootScaleY = iframeRect.height / declaredHeight; + + const targets = collectGsapTargetElements(iframe); + if (targets.length === 0) { + if (prevRef.current.length > 0) { + prevRef.current = []; + setIndicators([]); + } + return; + } + + // Composition bounds in overlay coordinates + const compLeft = cr.left; + const compTop = cr.top; + const compRight = compLeft + cr.width; + const compBottom = compTop + cr.height; + + const next: OffScreenIndicator[] = []; + const keyCounts = new Map(); + + for (const el of targets) { + if (!el.isConnected) continue; + + const elRect = el.getBoundingClientRect(); + if (elRect.width <= 0 && elRect.height <= 0) continue; + + // Element rect in overlay coordinates + const elLeft = iframeRect.left - overlayRect.left + elRect.left * rootScaleX; + const elTop = iframeRect.top - overlayRect.top + elRect.top * rootScaleY; + const elW = elRect.width * rootScaleX; + const elH = elRect.height * rootScaleY; + + // Check if the element is fully inside the composition + if ( + elLeft >= compLeft && + elTop >= compTop && + elLeft + elW <= compRight && + elTop + elH <= compBottom + ) { + continue; + } + + const base = el.id || el.getAttribute("data-hf-id") || el.tagName.toLowerCase(); + const count = keyCounts.get(base) ?? 0; + keyCounts.set(base, count + 1); + const key = count > 0 ? `${base}:${count}` : base; + next.push({ key, elementId: el.id || base, left: elLeft, top: elTop, width: elW, height: elH }); + } + + if (!indicatorsEqual(prevRef.current, next)) { + prevRef.current = next; + setIndicators(next); + } + }; + + frame = requestAnimationFrame(update); + return () => cancelAnimationFrame(frame); + }); + + return indicators; +} diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index 00e94b1d6..0f9c37eb7 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -366,25 +366,27 @@ export const NLELayout = memo(function NLELayout({ {/* Preview + player controls */}
- - {previewDragOver && ( -
- )} +
+ + {previewDragOver && ( +
+ )} +
{!isFullscreen && previewOverlay}
diff --git a/packages/studio/src/hooks/gsapDragCommit.ts b/packages/studio/src/hooks/gsapDragCommit.ts index 797376408..6e23936e5 100644 --- a/packages/studio/src/hooks/gsapDragCommit.ts +++ b/packages/studio/src/hooks/gsapDragCommit.ts @@ -11,8 +11,6 @@ import { resolveTweenStart, resolveTweenDuration, } from "../utils/globalTimeCompiler"; -import { readAllAnimatedProperties } from "./gsapRuntimeReaders"; - export interface GsapDragCommitCallbacks { commitMutation: ( selection: DomEditSelection, @@ -114,7 +112,6 @@ async function extendTweenAndAddKeyframe( const newStart = Math.min(targetTime, tweenStart); const newEnd = Math.max(targetTime, tweenEnd); const newDuration = Math.max(0.01, newEnd - newStart); - const existingKfs = anim.keyframes?.keyframes ?? []; const remappedKfs: Array<{ percentage: number; properties: Record }> = []; @@ -126,20 +123,15 @@ async function extendTweenAndAddKeyframe( const targetPct = Math.round(((targetTime - newStart) / newDuration) * 1000) / 10; remappedKfs.push({ percentage: targetPct, properties }); - remappedKfs.sort((a, b) => a.percentage - b.percentage); - await callbacks.commitMutation( - selection, - { type: "delete", animationId: anim.id }, - { label: "Extend tween range", skipReload: true }, - ); + remappedKfs.sort((a, b) => a.percentage - b.percentage); - const selector = anim.targetSelector; await callbacks.commitMutation( selection, { - type: "add-with-keyframes", - targetSelector: selector, + type: "replace-with-keyframes", + animationId: anim.id, + targetSelector: anim.targetSelector, position: Math.round(newStart * 1000) / 1000, duration: Math.round(newDuration * 1000) / 1000, keyframes: remappedKfs, @@ -156,8 +148,9 @@ async function commitKeyframedPosition( callbacks: GsapDragCommitCallbacks, beforeReload?: () => void, ): Promise { - const pct = computeCurrentPercentage(selection, anim); - + const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState(); + const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim); + if (activeKeyframePct != null) setActiveKeyframePct(null); await callbacks.commitMutation( selection, { @@ -182,10 +175,11 @@ async function commitFlatViaKeyframes( callbacks: GsapDragCommitCallbacks, beforeReload?: () => void, ): Promise { + const coalesceKey = `gsap:convert-drag:${anim.id}`; await callbacks.commitMutation( selection, { type: "convert-to-keyframes", animationId: anim.id }, - { label: "Convert to keyframes for drag", skipReload: true }, + { label: "Convert to keyframes for drag", skipReload: true, coalesceKey }, ); const pct = computeCurrentPercentage(selection, anim); @@ -198,7 +192,7 @@ async function commitFlatViaKeyframes( percentage: pct, properties, }, - { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload }, + { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload, coalesceKey }, ); } @@ -243,19 +237,20 @@ export async function commitGsapPositionFromDrag( el.removeAttribute("data-hf-drag-initial-offset-y"); }; + const ct = usePlayerStore.getState().currentTime; if (anim.keyframes) { const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection); const effectiveAnim = newId ? { ...anim, id: newId } : anim; - const runtimeProps = readAllAnimatedProperties(iframe, selector, anim); + const dragProps: Record = { x: newX, y: newY }; - const ct = usePlayerStore.getState().currentTime; const ts = resolveTweenStart(effectiveAnim); const td = resolveTweenDuration(effectiveAnim); - if (ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01)) { + const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); + if (outsideRange) { await extendTweenAndAddKeyframe( selection, effectiveAnim, - { ...runtimeProps, x: newX, y: newY }, + dragProps, ct, ts, td, @@ -263,32 +258,126 @@ export async function commitGsapPositionFromDrag( restoreOffset, ); } else { - await commitKeyframedPosition( + await commitKeyframedPosition(selection, effectiveAnim, dragProps, callbacks, restoreOffset); + } + } else if (anim.method === "from" || anim.method === "fromTo") { + const ct = usePlayerStore.getState().currentTime; + const ts = resolveTweenStart(anim); + const td = resolveTweenDuration(anim); + const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); + const dragProps: Record = { x: newX, y: newY }; + + if (outsideRange && ts !== null) { + // Split the original from() tween into property groups first. + await callbacks.commitMutation( selection, - effectiveAnim, - { ...runtimeProps, x: newX, y: newY }, - callbacks, - restoreOffset, + { type: "split-into-property-groups", animationId: anim.id }, + { label: "Split from() for drag", skipReload: true }, + ); + + // Check if a position-group tween already exists (e.g. from gesture recording). + // If so, extend it instead of creating a duplicate. + const allAnims = await (async () => { + const pid = selection.sourceFile || "index.html"; + try { + const r = await fetch( + `/api/projects/${encodeURIComponent(window.location.hash.match(/project\/([^?/]+)/)?.[1] ?? "")}/gsap-animations/${encodeURIComponent(pid)}`, + ); + if (!r.ok) return []; + const parsed = await r.json(); + return (parsed?.animations ?? []) as GsapAnimation[]; + } catch { + return []; + } + })(); + const existingPosAnim = allAnims.find( + (a) => a.propertyGroup === "position" && a.targetSelector === anim.targetSelector, + ); + + if (existingPosAnim?.keyframes) { + // Extend the existing position tween + const posTs = resolveTweenStart(existingPosAnim); + const posTd = resolveTweenDuration(existingPosAnim); + if (posTs !== null) { + await extendTweenAndAddKeyframe( + selection, + existingPosAnim, + { x: newX, y: newY }, + ct, + posTs, + posTd, + callbacks, + restoreOffset, + ); + return; + } + } + + // No existing position tween — create one + const newStart = Math.min(ct, ts); + const newEnd = Math.max(ct, ts + td); + const newDuration = Math.max(0.01, newEnd - newStart); + const dragBefore = ct < ts; + const origStartPct = Math.round(((ts - newStart) / newDuration) * 1000) / 10; + const origEndPct = Math.round(((ts + td - newStart) / newDuration) * 1000) / 10; + + const keyframes: Array<{ percentage: number; properties: Record }> = + []; + if (dragBefore) { + keyframes.push({ percentage: 0, properties: { x: newX, y: newY } }); + if (origStartPct > 0.5 && origStartPct < 99.5) { + keyframes.push({ percentage: origStartPct, properties: { x: 0, y: 0 } }); + } + keyframes.push({ percentage: 100, properties: { x: 0, y: 0 } }); + } else { + keyframes.push({ percentage: 0, properties: { x: 0, y: 0 } }); + if (origEndPct > 0.5 && origEndPct < 99.5) { + keyframes.push({ percentage: origEndPct, properties: { x: 0, y: 0 } }); + } + keyframes.push({ percentage: 100, properties: { x: newX, y: newY } }); + } + keyframes.sort((a, b) => a.percentage - b.percentage); + + await callbacks.commitMutation( + selection, + { + type: "add-with-keyframes", + targetSelector: anim.targetSelector, + position: Math.round(newStart * 1000) / 1000, + duration: Math.round(newDuration * 1000) / 1000, + keyframes, + }, + { label: "Move layer (from extended)", softReload: true, beforeReload: restoreOffset }, + ); + } else { + // Inside tween range: convert then add keyframe at current time + const coalesceKey = `gsap:convert-drag:${anim.id}`; + await callbacks.commitMutation( + selection, + { + type: "convert-to-keyframes", + animationId: anim.id, + }, + { label: "Convert from() for drag", skipReload: true, coalesceKey }, + ); + const pct = computeCurrentPercentage(selection, anim); + await callbacks.commitMutation( + selection, + { + type: "add-keyframe", + animationId: anim.id, + percentage: pct, + properties: dragProps, + }, + { + label: `Move layer (keyframe ${pct}%)`, + softReload: true, + beforeReload: restoreOffset, + coalesceKey, + }, ); } - } else if (anim.method === "from" || anim.method === "fromTo") { - await callbacks.commitMutation( - selection, - { - type: "convert-to-keyframes", - animationId: anim.id, - resolvedFromValues: { x: newX, y: newY }, - }, - { label: "Move layer (keyframe rest)", softReload: true, beforeReload: restoreOffset }, - ); } else { - const runtimeProps = readAllAnimatedProperties(iframe, selector, anim); - await commitFlatViaKeyframes( - selection, - anim, - { ...runtimeProps, x: newX, y: newY }, - callbacks, - restoreOffset, - ); + await commitFlatViaKeyframes(selection, anim, { x: newX, y: newY }, callbacks, restoreOffset); } } diff --git a/packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts b/packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts index c2ad14b92..4d41eb1f3 100644 --- a/packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts +++ b/packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts @@ -21,18 +21,23 @@ export function updateKeyframeCacheFromParsed( // Convert tween-relative percentages to clip-relative so diamonds // render at the correct position within the timeline clip. - const tweenPos = typeof anim.position === "number" ? anim.position : 0; + const tweenPos = anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0); const tweenDur = anim.duration ?? 1; const timelineEl = elements.find( (el) => el.domId === id || (el.key ?? el.id) === `${targetPath}#${id}`, ); const elStart = timelineEl?.start ?? 0; - const elDuration = timelineEl?.duration ?? 4; + const elDuration = timelineEl?.duration ?? 1; const clipKeyframes = anim.keyframes.keyframes.map((kf) => { const absTime = tweenPos + (kf.percentage / 100) * tweenDur; const clipPct = elDuration > 0 ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10 : kf.percentage; - return { ...kf, percentage: clipPct }; + return { + ...kf, + percentage: clipPct, + tweenPercentage: kf.percentage, + propertyGroup: anim.propertyGroup, + }; }); const existing = merged.get(id); @@ -66,7 +71,7 @@ export function updateKeyframeCacheFromParsed( } } -export function buildCacheKey(sourceFile: string, elementId: string): string { +function buildCacheKey(sourceFile: string, elementId: string): string { return `${sourceFile}#${elementId}`; } diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts index f14d68ed1..1bdfefb21 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts @@ -8,7 +8,7 @@ * absolute positions back into the GSAP script, regardless of tween type, * easing, or seek position. */ -import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { usePlayerStore } from "../player/store/playerStore"; @@ -18,6 +18,7 @@ import { computeCurrentPercentage, materializeIfDynamic, } from "./gsapDragCommit"; +import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler"; import type { GsapDragCommitCallbacks } from "./gsapDragCommit"; // ── Runtime reads ────────────────────────────────────────────────────────── @@ -87,7 +88,7 @@ function findGsapPositionAnimation( if (a.keyframes) score += 5; if (selector && a.targetSelector === selector) score += 8; else if (a.targetSelector.includes(",")) score -= 5; - const pos = typeof a.position === "number" ? a.position : 0; + const pos = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0); const dur = a.duration ?? 0; if (currentTime >= pos - 0.05 && currentTime <= pos + dur + 0.05) score += 4; return { anim: a, score }; @@ -104,6 +105,74 @@ function selectorForSelection(selection: DomEditSelection): string | null { return null; } +// ── Property-group tween resolution ─────────────────────────────────────── + +/** + * Find the tween for a given property group, splitting a legacy mixed tween + * if necessary. Returns the resolved animation or null if none exists. + * + * Resolution order: + * 1. Tween already tagged with `propertyGroup === group` + * 2. Legacy mixed tween (`!propertyGroup`) → split via server mutation, + * re-fetch, then return the group tween + * 3. null — caller must handle the missing-tween case + */ +async function resolveGroupTween( + group: PropertyGroupName, + animations: GsapAnimation[], + selection: DomEditSelection, + commitMutation: GsapDragCommitCallbacks["commitMutation"], + fetchFallbackAnimations?: () => Promise, +): Promise<{ anim: GsapAnimation; animations: GsapAnimation[] } | null> { + // 1. Already-split group tween — prefer the one with the most keyframes + // to avoid targeting a stub when a gesture-recorded tween also exists. + const groupAnims = animations.filter((a) => a.propertyGroup === group); + const groupAnim = + groupAnims.length > 1 + ? groupAnims.sort( + (a, b) => (b.keyframes?.keyframes.length ?? 0) - (a.keyframes?.keyframes.length ?? 0), + )[0] + : (groupAnims[0] ?? null); + if (groupAnim) return { anim: groupAnim, animations }; + + // 2. Legacy mixed tween — split it, then re-fetch + const legacyMixed = animations.find((a) => !a.propertyGroup); + if (legacyMixed) { + await commitMutation( + selection, + { type: "split-into-property-groups", animationId: legacyMixed.id }, + { label: "Split mixed tween into property groups", skipReload: true }, + ); + if (fetchFallbackAnimations) { + const fresh = await fetchFallbackAnimations(); + const freshGroupAnim = fresh.find((a) => a.propertyGroup === group); + if (freshGroupAnim) return { anim: freshGroupAnim, animations: fresh }; + } + } + + // 3. Try fallback fetch (no split needed, just wasn't in the initial list) + if (!legacyMixed && fetchFallbackAnimations) { + const fresh = await fetchFallbackAnimations(); + const freshGroupAnim = fresh.find((a) => a.propertyGroup === group); + if (freshGroupAnim) return { anim: freshGroupAnim, animations: fresh }; + + // Fallback: legacy mixed in the fresh list + const freshLegacy = fresh.find((a) => !a.propertyGroup); + if (freshLegacy) { + await commitMutation( + selection, + { type: "split-into-property-groups", animationId: freshLegacy.id }, + { label: "Split mixed tween into property groups", skipReload: true }, + ); + const reFetched = await fetchFallbackAnimations(); + const reFetchedGroup = reFetched.find((a) => a.propertyGroup === group); + if (reFetchedGroup) return { anim: reFetchedGroup, animations: reFetched }; + } + } + + return null; +} + // ── High-level intercept ─────────────────────────────────────────────────── export type { GsapDragCommitCallbacks }; @@ -127,10 +196,24 @@ export async function tryGsapDragIntercept( const selector = selectorForSelection(selection); if (!selector) return false; - let posAnim = findGsapPositionAnimation(animations, selector); - if (!posAnim && fetchFallbackAnimations) { - const fresh = await fetchFallbackAnimations(); - posAnim = findGsapPositionAnimation(fresh, selector); + // Resolve the position-group tween, splitting legacy mixed tweens if needed. + const resolved = await resolveGroupTween( + "position", + animations, + selection, + commitMutation, + fetchFallbackAnimations, + ); + + // Fallback: use the legacy scoring heuristic for compositions that don't + // have group-tagged tweens at all (e.g. hand-written scripts). + let posAnim = resolved?.anim ?? null; + if (!posAnim) { + posAnim = findGsapPositionAnimation(animations, selector); + if (!posAnim && fetchFallbackAnimations) { + const fresh = await fetchFallbackAnimations(); + posAnim = findGsapPositionAnimation(fresh, selector); + } } if (!posAnim) return false; @@ -151,6 +234,22 @@ export async function tryGsapDragIntercept( export { readGsapProperty, readAllAnimatedProperties }; +// ── Identity-prop synthesis ─────────────────────────────────────────────── + +const IDENTITY_ONE_PROPS = new Set(["opacity", "autoAlpha", "scale", "scaleX", "scaleY"]); + +/** Build identity (zero / one) values for each property in `source`. */ +function synthesizeIdentityProps( + source: Record, +): Record { + const id: Record = {}; + for (const [k, v] of Object.entries(source)) { + if (typeof v === "number") id[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0; + else id[k] = v; + } + return id; +} + // ── Resize intercept ────────────────────────────────────────────────────── export async function tryGsapResizeIntercept( @@ -161,46 +260,155 @@ export async function tryGsapResizeIntercept( commitMutation: GsapDragCommitCallbacks["commitMutation"], fetchFallbackAnimations?: () => Promise, ): Promise { - let anim = animations.find( - (a) => "width" in a.properties || "height" in a.properties || a.keyframes, + // If the element already has a scale-group tween, resize should modify scale + // (the user is resizing something whose visual size is driven by scale). + // Otherwise, use the size group (width/height). + const hasScaleGroup = animations.some((a) => a.propertyGroup === "scale"); + const resizeGroup: PropertyGroupName = hasScaleGroup ? "scale" : "size"; + const resolved = await resolveGroupTween( + resizeGroup, + animations, + selection, + commitMutation, + fetchFallbackAnimations, ); - if (!anim && fetchFallbackAnimations) { - const fresh = await fetchFallbackAnimations(); - anim = fresh.find((a) => "width" in a.properties || "height" in a.properties || a.keyframes); - } - if (!anim) return false; - - const pct = computeCurrentPercentage(selection, anim); - if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) { - const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection); - if (newId) anim = { ...anim, id: newId }; - } else if (!anim.keyframes) { + let anim = resolved?.anim ?? null; + if (!anim) { + // No size-group tween exists — create one. Use the element's timing + // from any existing animation, or fall back to element data attributes. + const refAnim = animations[0]; + const elStart = + refAnim?.resolvedStart ?? (Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0); + const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "5") || 5; + const ct = usePlayerStore.getState().currentTime; + const pct = elDuration > 0 ? Math.round(((ct - elStart) / elDuration) * 1000) / 10 : 0; + const sel = selectorForSelection(selection); + if (!sel) return false; await commitMutation( selection, - { type: "convert-to-keyframes", animationId: anim.id }, - { label: "Convert to keyframes for resize", skipReload: true }, + { + type: "add-with-keyframes", + targetSelector: sel, + position: Math.round(elStart * 1000) / 1000, + duration: Math.round(elDuration * 1000) / 1000, + keyframes: [ + { + percentage: Math.max(0, Math.min(100, pct)), + properties: { width: Math.round(size.width), height: Math.round(size.height) }, + }, + ], + }, + { label: "Resize (new size keyframe)", softReload: true }, ); + return true; } + const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState(); + const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim); + if (activeKeyframePct != null) setActiveKeyframePct(null); + const coalesceKey = `gsap:resize:${anim.id}`; + const selector = selectorForSelection(selection); const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {}; - const backfillDefaults: Record = { ...runtimeProps }; - if (!("width" in runtimeProps)) { - const cssW = readGsapProperty(iframe, selector, "width"); - backfillDefaults.width = cssW ?? Math.round(size.width); + let resizeProps: Record; + if (resizeGroup === "scale") { + const el = iframe?.contentDocument?.querySelector(selector ?? "") as HTMLElement | null; + // The resize draft modifies el.style.width, so read the ORIGINAL width + // saved by the draft system before it ran. + const origW = Number.parseFloat(el?.getAttribute("data-hf-studio-original-width") ?? ""); + const cssW = Number.isFinite(origW) && origW > 0 ? origW : 200; + const newScale = Math.round((size.width / cssW) * 1000) / 1000; + resizeProps = { scale: newScale }; + } else { + resizeProps = { + width: Math.round(size.width), + height: Math.round(size.height), + }; + } + const ct = usePlayerStore.getState().currentTime; + const ts = resolveTweenStart(anim); + const td = resolveTweenDuration(anim); + const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); // Convert flat tweens to keyframes only for in-range resizes. + // Outside-range uses the extend path which handles everything atomically. + if (!outsideRange) { + if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) { + const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection); + if (newId) anim = { ...anim, id: newId }; + } else if (!anim.keyframes) { + const resolvedFromValues = selector + ? readAllAnimatedProperties(iframe, selector, anim) + : undefined; + await commitMutation( + selection, + { type: "convert-to-keyframes", animationId: anim.id, resolvedFromValues }, + { label: "Convert to keyframes for resize", skipReload: true, coalesceKey }, + ); + } } - if (!("height" in runtimeProps)) { - const cssH = readGsapProperty(iframe, selector, "height"); - backfillDefaults.height = cssH ?? Math.round(size.height); + + if (outsideRange && ts !== null) { + // For flat tweens, synthesize the keyframes from the tween's properties + const kfs = + anim.keyframes?.keyframes ?? + (() => { + const fromProps = + anim.method === "from" || anim.method === "fromTo" + ? { ...anim.properties } + : synthesizeIdentityProps(anim.properties); + const toProps = + anim.method === "from" + ? synthesizeIdentityProps(anim.properties) + : { ...anim.properties }; + return [ + { percentage: 0, properties: fromProps }, + { percentage: 100, properties: toProps }, + ]; + })(); + const newStart = Math.min(ct, ts); + const newEnd = Math.max(ct, ts + td); + const newDuration = Math.max(0.01, newEnd - newStart); + const existingKfs = kfs; + const remapped: Array<{ percentage: number; properties: Record }> = []; + for (const kf of existingKfs) { + const absTime = ts + (kf.percentage / 100) * td; + const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10; + const props = { ...kf.properties }; + // Only backfill properties that the animation already had (x, y, scale). + // Don't backfill width/height — they should only appear on the resize keyframe. + for (const k of Object.keys(resizeProps)) { + if (k in props) continue; + if (k === "width" || k === "height") continue; + props[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0; + } + remapped.push({ percentage: newPct, properties: props }); + } + const targetPct = Math.round(((ct - newStart) / newDuration) * 1000) / 10; + remapped.push({ percentage: targetPct, properties: resizeProps }); + remapped.sort((a, b) => a.percentage - b.percentage); + + await commitMutation( + selection, + { + type: "replace-with-keyframes", + animationId: anim.id, + targetSelector: anim.targetSelector, + position: Math.round(newStart * 1000) / 1000, + duration: Math.round(newDuration * 1000) / 1000, + keyframes: remapped, + }, + { label: `Resize (extended to ${ct.toFixed(2)}s)`, softReload: true, coalesceKey }, + ); + return true; } - const properties = { - ...runtimeProps, - width: Math.round(size.width), - height: Math.round(size.height), - }; + const SIZE_PROPS = new Set(["width", "height"]); + const backfillDefaults: Record = {}; + for (const k of Object.keys(runtimeProps)) { + if (SIZE_PROPS.has(k)) continue; + backfillDefaults[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0; + } await commitMutation( selection, @@ -208,10 +416,10 @@ export async function tryGsapResizeIntercept( type: "add-keyframe", animationId: anim.id, percentage: pct, - properties, + properties: resizeProps, backfillDefaults, }, - { label: `Resize (keyframe ${pct}%)`, softReload: true }, + { label: `Resize (keyframe ${pct}%)`, softReload: true, coalesceKey }, ); return true; } @@ -226,10 +434,23 @@ export async function tryGsapRotationIntercept( commitMutation: GsapDragCommitCallbacks["commitMutation"], fetchFallbackAnimations?: () => Promise, ): Promise { - let anim = animations.find((a) => "rotation" in a.properties || a.keyframes); - if (!anim && fetchFallbackAnimations) { - const fresh = await fetchFallbackAnimations(); - anim = fresh.find((a) => "rotation" in a.properties || a.keyframes); + // Resolve the rotation-group tween, splitting legacy mixed tweens if needed. + const resolved = await resolveGroupTween( + "rotation", + animations, + selection, + commitMutation, + fetchFallbackAnimations, + ); + + // Fallback: legacy heuristic for hand-written scripts + let anim = resolved?.anim ?? null; + if (!anim) { + anim = animations.find((a) => "rotation" in a.properties || a.keyframes) ?? null; + if (!anim && fetchFallbackAnimations) { + const fresh = await fetchFallbackAnimations(); + anim = fresh.find((a) => "rotation" in a.properties || a.keyframes) ?? null; + } } if (!anim) return false; @@ -261,14 +482,17 @@ export async function tryGsapRotationIntercept( const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection); if (newId) anim = { ...anim, id: newId }; } else if (!anim.keyframes) { + const resolvedFromValues = selector + ? readAllAnimatedProperties(iframe, selector, anim, "rotation") + : undefined; await commitMutation( selection, - { type: "convert-to-keyframes", animationId: anim.id }, + { type: "convert-to-keyframes", animationId: anim.id, resolvedFromValues }, { label: "Convert to keyframes for rotation", skipReload: true }, ); } - const runtimeProps = readAllAnimatedProperties(iframe, selector, anim); + const runtimeProps = readAllAnimatedProperties(iframe, selector, anim, "rotation"); const backfillDefaults: Record = { ...runtimeProps }; if (!("rotation" in runtimeProps)) { diff --git a/packages/studio/src/hooks/gsapRuntimeReaders.ts b/packages/studio/src/hooks/gsapRuntimeReaders.ts index 1f2898716..0cac04524 100644 --- a/packages/studio/src/hooks/gsapRuntimeReaders.ts +++ b/packages/studio/src/hooks/gsapRuntimeReaders.ts @@ -2,6 +2,7 @@ * Low-level GSAP runtime property readers shared by gsapRuntimeBridge and gsapDragCommit. */ import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import { classifyPropertyGroup, type PropertyGroupName } from "@hyperframes/core/gsap-parser"; interface IframeGsap { getProperty: (el: Element, prop: string) => number; @@ -19,7 +20,8 @@ export function readGsapProperty( const el = iframe.contentDocument?.querySelector(selector); if (!el) return null; const val = Number(gsap.getProperty(el, prop)); - return Number.isFinite(val) ? Math.round(val) : null; + if (!Number.isFinite(val)) return null; + return POSITION_PROPS.has(prop) ? Math.round(val) : Math.round(val * 1000) / 1000; } catch { return null; } @@ -51,6 +53,7 @@ export function readAllAnimatedProperties( iframe: HTMLIFrameElement | null, selector: string, anim: GsapAnimation, + group?: PropertyGroupName, ): Record { const result: Record = {}; if (!iframe?.contentWindow) return result; @@ -81,6 +84,13 @@ export function readAllAnimatedProperties( for (const p of Object.keys(anim.properties)) propKeys.add(p); } + // When a group filter is specified, only keep properties belonging to that group. + if (group) { + for (const p of propKeys) { + if (classifyPropertyGroup(p) !== group) propKeys.delete(p); + } + } + for (const prop of propKeys) { const val = Number(gsap.getProperty(el, prop)); if (Number.isFinite(val)) { @@ -147,9 +157,13 @@ export function readAllAnimatedProperties( sepia: 0, invert: 0, }; + // Collect all properties that ANY tween on this element explicitly targets. + // Only capture baseline values for these — GSAP reports non-default values + // (scaleZ=0, brightness=0) for untouched properties, polluting keyframes. + const allTweenedProps = new Set([...propKeys, ...otherTweenProps]); for (const [prop, defaultVal] of Object.entries(UNIVERSAL_BASELINE)) { if (prop in result) continue; - if (otherTweenProps.has(prop)) continue; + if (!allTweenedProps.has(prop)) continue; const val = Number(gsap.getProperty(el, prop)); if (Number.isFinite(val) && Math.round(val * 1000) !== Math.round(defaultVal * 1000)) { result[prop] = Math.round(val * 1000) / 1000; diff --git a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts index a25064e5e..75a3d42a0 100644 --- a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts +++ b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts @@ -8,6 +8,7 @@ */ import { useCallback } from "react"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { usePlayerStore } from "../player/store/playerStore"; import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeBridge"; @@ -38,7 +39,7 @@ interface CommitAnimatedPropertyDeps { function computePercentage(selection: DomEditSelection, anim?: GsapAnimation): number { const currentTime = usePlayerStore.getState().currentTime; - const tweenPos = typeof anim?.position === "number" ? anim.position : 0; + const tweenPos = anim?.resolvedStart ?? (typeof anim?.position === "number" ? anim.position : 0); const tweenDur = anim?.duration ?? 0; if (tweenDur > 0) { return Math.max( @@ -56,18 +57,19 @@ function computePercentage(selection: DomEditSelection, anim?: GsapAnimation): n function pickBestAnimation( animations: GsapAnimation[], selector: string | null, + property?: string, ): GsapAnimation | undefined { if (animations.length <= 1) return animations[0]; const currentTime = usePlayerStore.getState().currentTime; + const targetGroup = property ? classifyPropertyGroup(property) : undefined; const scored = animations.map((a) => { let score = 0; + if (targetGroup && a.propertyGroup === targetGroup) score += 20; if (a.keyframes) score += 10; - // Prefer single-element selectors over comma-separated groups if (selector && a.targetSelector === selector) score += 5; else if (a.targetSelector.includes(",")) score -= 3; - // Prefer tweens active at the current time - const pos = typeof a.position === "number" ? a.position : 0; + const pos = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0); const dur = a.duration ?? 0; if (currentTime >= pos - 0.05 && currentTime <= pos + dur + 0.05) score += 8; return { anim: a, score }; @@ -102,7 +104,11 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) { const iframe = previewIframeRef.current; const selector = selectorFor(selection); - let anim: GsapAnimation | undefined = pickBestAnimation(selectedGsapAnimations, selector); + let anim: GsapAnimation | undefined = pickBestAnimation( + selectedGsapAnimations, + selector, + property, + ); // Case 3: No animation — create one first if (!anim) { diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 242e2ced1..febdb7a46 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -329,7 +329,11 @@ export function useDomEditSession({ // GSAP-aware: intercept offset/resize/rotation to commit via script mutation when animated. const handleGsapAwarePathOffsetCommit = useCallback( async (selection: DomEditSelection, next: { x: number; y: number }) => { - if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) { + if ( + STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && + gsapCommitMutation && + usePlayerStore.getState().autoKeyframeEnabled + ) { const handled = await tryGsapDragIntercept( selection, next, @@ -375,7 +379,11 @@ export function useDomEditSession({ const handleGsapAwareBoxSizeCommit = useCallback( async (selection: DomEditSelection, next: { width: number; height: number }) => { - if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) { + if ( + STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && + gsapCommitMutation && + usePlayerStore.getState().autoKeyframeEnabled + ) { const handled = await tryGsapResizeIntercept( selection, next, @@ -399,7 +407,11 @@ export function useDomEditSession({ const handleGsapAwareRotationCommit = useCallback( async (selection: DomEditSelection, next: { angle: number }) => { - if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) { + if ( + STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && + gsapCommitMutation && + usePlayerStore.getState().autoKeyframeEnabled + ) { const handled = await tryGsapRotationIntercept( selection, next.angle, diff --git a/packages/studio/src/hooks/useEnableKeyframes.ts b/packages/studio/src/hooks/useEnableKeyframes.ts index 3978ff871..ca6ea8e19 100644 --- a/packages/studio/src/hooks/useEnableKeyframes.ts +++ b/packages/studio/src/hooks/useEnableKeyframes.ts @@ -52,10 +52,12 @@ function readElementPosition( const element = sel.element; if (!element?.isConnected || !gsap?.getProperty) return result; + const POSITION_PROPS = new Set(["x", "y", "xPercent", "yPercent"]); const props = anim ? Object.keys(anim.properties) : ["x", "y", "opacity"]; for (const prop of props) { const val = Number(gsap.getProperty(element, prop)); - if (Number.isFinite(val)) result[prop] = Math.round(val); + if (!Number.isFinite(val)) continue; + result[prop] = POSITION_PROPS.has(prop) ? Math.round(val) : Math.round(val * 1000) / 1000; } return result; diff --git a/packages/studio/src/hooks/useGestureCommit.ts b/packages/studio/src/hooks/useGestureCommit.ts index 82e488246..4d3a8846d 100644 --- a/packages/studio/src/hooks/useGestureCommit.ts +++ b/packages/studio/src/hooks/useGestureCommit.ts @@ -7,10 +7,13 @@ import { useGestureRecording } from "./useGestureRecording"; import { simplifyGestureSamples } from "../utils/rdpSimplify"; import { usePlayerStore } from "../player"; import type { DomEditSelection } from "../components/editor/domEditing"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser"; // Minimal subset of the session used by gesture commit interface GestureSessionRef { domEditSelection: DomEditSelection | null; + selectedGsapAnimations?: GsapAnimation[]; commitMutation?: ( mutation: Record, options: { label: string; softReload?: boolean }, @@ -43,6 +46,9 @@ export function useGestureCommit({ const recordingAutoStopRef = useRef>(undefined); const recordingStartTimeRef = useRef(0); const commitInFlightRef = useRef(false); + // Capture selection at recording start so commit always targets the recorded element, + // even if the user's selection changes mid-recording. + const capturedSelectionRef = useRef(null); // Unmount: clear auto-stop interval useEffect(() => () => clearInterval(recordingAutoStopRef.current), []); @@ -59,7 +65,7 @@ export function useGestureCommit({ store.setIsPlaying(false); try { const liveSession = domEditSessionRef.current; - const sel = liveSession.domEditSelection; + const sel = capturedSelectionRef.current; if (!sel) { if (frozenSamples.length > 2) { showToast("Selection lost during recording", "error"); @@ -77,7 +83,13 @@ export function useGestureCommit({ return; } - const simplified = simplifyGestureSamples(frozenSamples, duration, 5); + // Per-property epsilon: small-range properties (opacity 0–1, scale ~0.01–10) + // need a much tighter tolerance than positional properties (x/y in px). + const simplified = simplifyGestureSamples(frozenSamples, duration, (key) => { + if (key === "opacity") return 0.01; + if (key === "scale" || key === "scaleX" || key === "scaleY") return 0.01; + return 5; + }); const sortedPcts = Array.from(simplified.keys()).sort((a, b) => a - b); // Ensure a 0% keyframe exists with the element's start-of-recording position @@ -98,16 +110,42 @@ export function useGestureCommit({ properties: simplified.get(pct) as Record, })); - await liveSession.commitMutation( - { - type: "add-with-keyframes", - targetSelector: selector, - position: Math.round(recStart * 1000) / 1000, - duration: Math.round(duration * 1000) / 1000, - keyframes, - }, - { label: "Gesture recording", softReload: true }, + // Check if the recorded gesture contains position properties. + // If so, and a position-group tween already exists for this element, + // replace it atomically instead of adding a duplicate. + const hasPositionProps = keyframes.some((kf) => + Object.keys(kf.properties).some((k) => classifyPropertyGroup(k) === "position"), ); + const existingPositionTween = hasPositionProps + ? liveSession.selectedGsapAnimations?.find( + (a) => a.propertyGroup === "position" && a.targetSelector === selector, + ) + : undefined; + + if (existingPositionTween) { + await liveSession.commitMutation( + { + type: "replace-with-keyframes", + animationId: existingPositionTween.id, + targetSelector: selector, + position: Math.round(recStart * 1000) / 1000, + duration: Math.round(duration * 1000) / 1000, + keyframes, + }, + { label: "Gesture recording (replace)", softReload: true }, + ); + } else { + await liveSession.commitMutation( + { + type: "add-with-keyframes", + targetSelector: selector, + position: Math.round(recStart * 1000) / 1000, + duration: Math.round(duration * 1000) / 1000, + keyframes, + }, + { label: "Gesture recording", softReload: true }, + ); + } } showToast(`Recorded ${sortedPcts.length} keyframes`, "info"); } finally { @@ -139,6 +177,7 @@ export function useGestureCommit({ const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0; const elDur = Number.parseFloat(sel.dataAttributes?.duration ?? "0") || 0; const elementEnd = elDur > 0 ? elStart + elDur : undefined; + capturedSelectionRef.current = sel; gestureRecording.startRecording(sel.element, iframe, elementEnd); gestureStateRef.current = "recording"; isGestureRecordingRef.current = true; diff --git a/packages/studio/src/hooks/useGestureRecording.ts b/packages/studio/src/hooks/useGestureRecording.ts index cdc7fd2f5..73bfd94ca 100644 --- a/packages/studio/src/hooks/useGestureRecording.ts +++ b/packages/studio/src/hooks/useGestureRecording.ts @@ -126,8 +126,12 @@ function applyRuntimePreview( function recordSample(r: RecordingRefs, time: number, properties: Record): void { const sampleProps = { ...properties }; - if ("x" in sampleProps) sampleProps.x -= r.cssVarOffset.x; - if ("y" in sampleProps) sampleProps.y -= r.cssVarOffset.y; + // Subtract both the CSS var offset AND the pointer-element snap offset + // so the first sample doesn't include the snap-to-cursor jump. + if ("x" in sampleProps) + sampleProps.x -= r.cssVarOffset.x + r.pointerElementOffset.x / (r.scale || 1); + if ("y" in sampleProps) + sampleProps.y -= r.cssVarOffset.y + r.pointerElementOffset.y / (r.scale || 1); r.samples.push({ time, properties: sampleProps }); r.trail.push({ x: r.pointer.x, y: r.pointer.y }); } @@ -307,6 +311,18 @@ export function useGestureRecording() { }; const handleWheel = (e: WheelEvent) => { + // Capture startPointer on first wheel if no pointermove has fired yet, + // preventing an enormous bogus first keyframe from stale startPointer. + if (!r.hasMoved) { + r.startPointer = { x: r.pointer.x, y: r.pointer.y }; + r.pointerElementOffset = { + x: r.pointer.x - elCenterViewport.x, + y: r.pointer.y - elCenterViewport.y, + }; + r.basePosition.x += r.pointerElementOffset.x / iframeScale; + r.basePosition.y += r.pointerElementOffset.y / iframeScale; + r.hasMoved = true; + } r.scrollDelta += e.deltaY; r.modifiers = { shift: e.shiftKey, alt: e.altKey, meta: e.metaKey || e.ctrlKey }; }; diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 70eee5215..f082ef5ee 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -51,6 +51,7 @@ function ensureElementAddressable(selection: DomEditSelection): { interface MutationResult { ok: boolean; + changed?: boolean; parsed?: ParsedGsap; before?: string; after?: string; @@ -131,9 +132,16 @@ export function useGsapScriptCommits({ const pid = projectIdRef.current; if (!pid) return; const targetPath = selection.sourceFile || activeCompPath || "index.html"; - const result = await mutateGsapScript(pid, targetPath, mutation); - if (!result?.ok) return; + if (!result) { + if (options.skipReload) return; + throw new Error(`Mutation failed: ${mutation.type}`); + } + + if (result.changed === false) { + if (options.skipReload) return; + return; + } domEditSaveTimestampRef.current = Date.now(); @@ -441,7 +449,9 @@ export function useGsapScriptCommits({ apply: () => { const prev = readKeyframeSnapshot(sf, elementId); if (prev) { - const newKeyframes = prev.keyframes.filter((kf) => kf.percentage !== percentage); + const newKeyframes = prev.keyframes.filter( + (kf) => Math.abs((kf.tweenPercentage ?? kf.percentage) - percentage) > 0.2, + ); writeKeyframeCache(sf, elementId, { ...prev, keyframes: newKeyframes }); } return prev; diff --git a/packages/studio/src/hooks/useGsapTweenCache.ts b/packages/studio/src/hooks/useGsapTweenCache.ts index 1a3c1eeed..17103e622 100644 --- a/packages/studio/src/hooks/useGsapTweenCache.ts +++ b/packages/studio/src/hooks/useGsapTweenCache.ts @@ -264,9 +264,11 @@ export function useGsapAnimationsForElement( (el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`, ); const elStart = timelineEl?.start ?? 0; - const elDuration = timelineEl?.duration ?? 4; + const elDuration = timelineEl?.duration ?? 1; - const allKeyframes: GsapKeyframesData["keyframes"] = []; + const allKeyframes: Array< + GsapKeyframesData["keyframes"][0] & { tweenPercentage?: number; propertyGroup?: string } + > = []; let format: GsapKeyframesData["format"] = "percentage"; let ease: string | undefined; let easeEach: string | undefined; @@ -275,7 +277,8 @@ export function useGsapAnimationsForElement( if (!kf) continue; // Convert tween-relative percentages to clip-relative so diamonds // render at the correct position within the timeline clip. - const tweenPos = typeof anim.position === "number" ? anim.position : 0; + const tweenPos = + anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0); const tweenDur = anim.duration ?? elDuration; for (const k of kf.keyframes) { const absTime = tweenPos + (k.percentage / 100) * tweenDur; @@ -283,7 +286,12 @@ export function useGsapAnimationsForElement( elDuration > 0 ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10 : k.percentage; - allKeyframes.push({ ...k, percentage: clipPct }); + allKeyframes.push({ + ...k, + percentage: clipPct, + tweenPercentage: k.percentage, + propertyGroup: anim.propertyGroup, + }); } format = kf.format; if (kf.ease) ease = kf.ease; @@ -305,6 +313,9 @@ export function useGsapAnimationsForElement( }; const { setKeyframeCache } = usePlayerStore.getState(); setKeyframeCache(`${sourceFile}#${elementId}`, merged); + // PropertyPanel reads the cache by bare elementId (without sourceFile prefix), + // so write a duplicate entry under the bare key for cross-component lookups. + setKeyframeCache(elementId, merged); }, [elementId, sourceFile, animations]); return { animations, multipleTimelines, unsupportedTimelinePattern }; @@ -327,13 +338,14 @@ export function usePopulateKeyframeCacheForFile( version: number, iframeRef?: React.RefObject, ): void { + const elementCount = usePlayerStore((s) => s.elements.length); const lastFetchKeyRef = useRef(""); const runtimeScanDoneRef = useRef(""); const astFetchDoneRef = useRef(""); useEffect(() => { - const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}`; + const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}:${elementCount}`; if (fetchKey === lastFetchKeyRef.current) return; lastFetchKeyRef.current = fetchKey; runtimeScanDoneRef.current = ""; @@ -358,21 +370,26 @@ export function usePopulateKeyframeCacheForFile( if (!id) continue; const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim); if (!kfData) continue; - // Convert tween-relative percentages to clip-relative. - const tweenPos = typeof anim.position === "number" ? anim.position : 0; + const tweenPos = + anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0); const tweenDur = anim.duration ?? 1; const timelineEl = elements.find( (el) => el.domId === id || (el.key ?? el.id) === `${sf}#${id}`, ); const elStart = timelineEl?.start ?? 0; - const elDuration = timelineEl?.duration ?? 4; + const elDuration = timelineEl?.duration ?? 1; const clipKeyframes = kfData.keyframes.map((kf) => { const absTime = tweenPos + (kf.percentage / 100) * tweenDur; const clipPct = elDuration > 0 ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10 : kf.percentage; - return { ...kf, percentage: clipPct }; + return { + ...kf, + percentage: clipPct, + tweenPercentage: kf.percentage, + propertyGroup: anim.propertyGroup, + }; }); const existing = mergedByElement.get(id); if (existing) { @@ -388,7 +405,10 @@ export function usePopulateKeyframeCacheForFile( } astFetchDoneRef.current = fetchKey; }); - }, [projectId, sourceFile, version]); + // elementCount is in the deps because new timeline elements (e.g. after a + // sub-composition expand) need their keyframe cache populated immediately; + // without it the effect won't re-run when elements appear/disappear. + }, [projectId, sourceFile, version, elementCount]); // Separate effect for runtime keyframe discovery — polls until the iframe // has loaded GSAP timelines, independent of the AST fetch lifecycle. diff --git a/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx b/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx index 8f16cceec..29dcd10b9 100644 --- a/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx +++ b/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx @@ -7,6 +7,7 @@ export interface KeyframeDiamondContextMenuState { y: number; elementId: string; percentage: number; + tweenPercentage?: number; currentEase?: string; } @@ -113,7 +114,7 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe type="button" className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left" onClick={() => { - onDelete(state.elementId, state.percentage); + onDelete(state.elementId, state.tweenPercentage ?? state.percentage); onClose(); }} > diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 6df0d41bd..699ebd4d3 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -448,6 +448,9 @@ export const Timeline = memo(function Timeline({ onSelectElement?.(el); const absTime = el.start + (pct / 100) * el.duration; onSeek?.(absTime); + const kfData = keyframeCache?.get(elKey); + const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.5); + usePlayerStore.getState().setActiveKeyframePct(kf?.tweenPercentage ?? null); }} onShiftClickKeyframe={(elId, pct) => { toggleSelectedKeyframe(`${elId}:${pct}`); @@ -464,12 +467,13 @@ export const Timeline = memo(function Timeline({ onSeek?.(absTime); } const kfData = keyframeCache.get(elId); - const kf = kfData?.keyframes.find((k) => k.percentage === pct); + const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2); setKfContextMenu({ - x: e.clientX, - y: e.clientY, + x: e.clientX + 4, + y: e.clientY + 2, elementId: elId, percentage: pct, + tweenPercentage: kf?.tweenPercentage, currentEase: kf?.ease ?? kfData?.ease, }); }} diff --git a/packages/studio/src/player/components/TimelineClipDiamonds.tsx b/packages/studio/src/player/components/TimelineClipDiamonds.tsx index acad691e1..bbe8b0ac9 100644 --- a/packages/studio/src/player/components/TimelineClipDiamonds.tsx +++ b/packages/studio/src/player/components/TimelineClipDiamonds.tsx @@ -123,7 +123,8 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ const kfKey = `${elementId}:${kf.percentage}`; const isKfSelected = selectedKeyframes.has(kfKey); const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5; - const color = isKfSelected || atPlayhead ? accentColor : "#a3a3a3"; + const isHighlighted = isKfSelected || atPlayhead; + const color = isHighlighted ? accentColor : "#a3a3a3"; return (