Skip to content

feat(studio): GSAP runtime read layer + shared helpers#1607

Open
miguel-heygen wants to merge 2 commits into
feat/core-motion-path-routefrom
feat/studio-read-layer
Open

feat(studio): GSAP runtime read layer + shared helpers#1607
miguel-heygen wants to merge 2 commits into
feat/core-motion-path-routefrom
feat/studio-read-layer

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

This PR establishes the GSAP runtime read layer for the Studio editor — the code that reads back live keyframed motion from the preview iframe's running GSAP timelines and reconciles it with the parsed source. It hardens three read paths (live tween reads, percentage-keyframe parsing, and the parse-fetch fallback) against the failure modes that surface once an element can carry multiple gesture-recorded tweens, array-form keyframes, and studio-emitted hold sets. It also introduces small, tested helper functions so the read logic is unit-testable in isolation.

What's changed

packages/studio/src/hooks/gsapRuntimeKeyframes.ts

  • readRuntimeKeyframes now resolves the timeline robustly and picks the right tween under the playhead:
    • When no explicit composition id is passed, it skips non-timeline markers on window.__timelines (e.g. the studio's __proxied flag) by selecting the first key whose value actually has a getChildren function, instead of blindly taking Object.keys(...)[0].
    • It reads the timeline clock via the new optional time() and, when an element has more than one keyframed tween at disjoint time ranges (two non-overlapping gesture recordings → two to()s), returns the tween whose [start, start+duration] range contains the playhead (with a 1e-3 epsilon). If the playhead is outside every range (or the timeline exposes no clock), it falls back to the first keyframed tween held in firstRead.
    • Both readRuntimeKeyframes and addScanEntry now skip zero-duration tweens (tl.set(...), including the studio position-hold data:"hf-hold"). These sit before the real keyframed tween and otherwise shadow it — readTween would fall back to a degenerate 2-point flat path from the set's values, hiding the actual multi-keyframe motion.
  • Type surface: RuntimeTimeline gains an optional time?: () => number; ReadTween is now exported (for the test + downstream consumers).

packages/studio/src/hooks/gsapShared.ts

  • parsePercentageKeyframes now handles GSAP array-form keyframes (keyframes: [{x,y}, {x,y}, ...]) in addition to the object/percentage form. Array steps are evenly distributed: step i of n maps to i/(n-1)*100% (rounded to one decimal), numeric props rounded to 3 decimals, ease skipped. Previously array-keyframed tweens (e.g. a multi-point shuttle path) parsed as null → no motion path drawn.
  • Removed the now-unused getIframeDocument helper.

packages/studio/src/hooks/useGsapAnimationFetchFallback.ts

  • New exported pure helper selectElementAnimationsOrRetry(parsed, target): returns null to signal a retry only when the parse is cold (missing or zero total animations — the initial-load race), returns the matched animations on a warm parse, and returns [] (no retry) when a warm parse has no match for the element (it genuinely has no animation).
  • The useGsapAnimationFetchFallback callback now retries the async parse fetch (COLD_PARSE_RETRIES = 5, COLD_PARSE_DELAY_MS = 120) on a cold parse instead of falling straight through to the no-animation path. This closes the race where a drag fires before the parse is warm and the empty result causes the tween to be duplicated rather than edited.

packages/studio/src/hooks/useGsapTweenCache.ts

  • fetchParsedAnimations now filters studio-emitted pre-keyframe hold sets out of the parsed animations via isStudioHoldSet (from @hyperframes/core/gsap-parser). These holds are an internal runtime detail (they hold an element's first keyframe before its tween) and must not surface as user animations, where they would pollute the keyframe cache and timeline diamonds.

Why

Once the editor lets a user record multiple gestures, edit array-form keyframes, and relies on studio-emitted hold sets, the naive "read the first matching tween" logic breaks in several concrete ways: recording a second gesture leaves the motion path stuck on the first; a zero-duration hold set shadows the real keyframed tween and collapses the path to a flat 2-point line; array-form keyframes don't parse at all; a drag that fires before the async parse is warm duplicates the tween instead of editing it; and internal hold sets leak into the user-facing animation list. This PR makes the runtime read layer correct and playhead-aware, so the overlay always reflects the actual motion under the cursor.

Testing

Three vitest files, all reading the real exported helpers:

  • gsapRuntimeKeyframes.test.ts — adds a fakeIframe builder that stubs a runtime timeline (getChildren, duration, optional time) and selector resolution. Covers: a zero-duration hold-set must not shadow the keyframed tween (reads all 4 keyframes; returns null when the element has only a zero-duration set); multiple tweens / playhead selection (inside the second reads the second, inside the first reads the first, outside every range falls back to the first).
  • gsapShared.test.tsparsePercentageKeyframes: parses the object/percentage form; parses array-form keyframes as evenly-distributed steps ([0, 33.3, 66.7, 100], properties preserved); returns null for empty array and empty object.
  • useGsapAnimationFetchFallback.test.tsselectElementAnimationsOrRetry: returns null (retry) for a cold parse; returns the matching animations from a warm parse; returns [] (no retry) for a warm parse with no match.

Stack

Part of the GSAP keyframe/motion-path stack, split into reviewable PRs: #1553 → #1554 → #1555 → #1607 → #1608 → #1609 → #1610 → #1611 → #1567 → #1612 → #1613 → #1605. This is the bottom studio PR (on #1555). Each PR builds independently; the combined diff of the split is byte-identical to the originally-reviewed work.

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at f87ce080e74fa1ff9712e7fac91728bc1a7aad7c (Group 2 of the stable-keyframes stack — sibling to #1608). Read in context of #1555 (source-mutation API consumer) and the bridge in #1608.

This is the read-layer half of the studio-runtime pair. Three independent failure modes — multi-tween playhead selection, array-form keyframes, cold-parse retry — each fixed at the right layer with tests that read real exports. Clean PR.

Findings

  • gsapRuntimeKeyframes.ts:178 (playhead-aware tween pick) — the firstRead fallback when the playhead is outside every range is the right semantic ("element still has motion, just nothing under the cursor"). Race-fix classification per the race-fix preempt-vs-narrow rubric: this is PREEMPT for the "second gesture stuck on the first tween" symptom (per-element playhead-range walk instead of Object.keys(...)[0]). Good.

  • gsapRuntimeKeyframes.ts:200 (zero-duration skip in addScanEntry) — symmetric with readRuntimeKeyframes skip at line 188. Sibling-asymmetry as evidence rubric: matches. The duplication is fine for now but the predicate (!(duration > 0)) appears 3x in this file — a tiny isHoldOrZeroSet(tween) helper would make intent more grep-able and harden against a future fourth call site forgetting it. Nit.

  • gsapShared.ts:118 (array-form keyframes) — i/(n-1)*100 correctly guards n==1 with the ternary. One subtle behavior worth flagging: if an array entry is {ease: "power1"}-only (ease stripped, no other props), it's silently skipped — but i still advances, so the percentages of the surviving entries follow the original index spacing (not redistributed). That's the right call for GSAP semantics (ease is per-segment, not a keyframe) but please verify against a real keyframes: [{x:0}, {ease:"none"}, {x:100}] source — does GSAP itself accept that shape, and if so does the studio source-mutation API round-trip it? If not, dead code path; if yes, the dropped middle ease is silently lost.

  • useGsapAnimationFetchFallback.ts:30 (cold-parse retry) — COLD_PARSE_RETRIES=5 * COLD_PARSE_DELAY_MS=120 = up to 600ms blocking the drag-commit chain before fallback fires. For an initial-load race this is fine, but if the parse endpoint is genuinely down (5xx, network out), every drag eats 600ms before the CSS path runs. Consider a separate fast-fail on fetch reject vs cold-but-warming parse — fetch rejection means "endpoint dead, don't retry," parsed.animations.length === 0 means "warm but empty, retry." Right now fetchParsedAnimations returns null on both — selectElementAnimationsOrRetry can't tell them apart and retries either way. Concern.

  • useGsapTweenCache.ts:115 (isStudioHoldSet filter) — clean. One question: does isStudioHoldSet from @hyperframes/core/gsap-parser match data: "hf-hold" exactly, or also catch other studio-internal markers? If the sentinel string changes in the parser, this filter silently goes stale. Sibling test in gsapRuntimeKeyframes.test.ts uses data: "hf-hold" literally — keeping the sentinel in one place (@hyperframes/core/gsap-parser const export) would prevent drift. Verify both ends import the same constant.

  • useGsapAnimationFetchFallback.test.ts — coverage is good for the three branches of selectElementAnimationsOrRetry, but the actual retry loop (5x 120ms with the integration shape of useGsapAnimationFetchFallback) isn't tested. The retry timing + early-return on attempt >= COLD_PARSE_RETRIES is the load-bearing addition. Not a blocker — pure helper is tested, retry shape is mechanically simple — but a fake-timers test of the loop would lock it in.

What I didn't verify

  • The gsap.set / gsap.getProperty interaction in #1608's applyStudioPathOffsetViaGsap — covered there.
  • Live behavior under real preview iframe with __timelines populated by the producer — the fake-iframe tests cover the read path mechanically.
  • That isStudioHoldSet and parsePercentageKeyframes's array branch handle the same shape the source-mutation API in #1555 emits (cross-stack).

LGTM modulo the isStudioHoldSet cross-import check and the cold-parse fetch-vs-warm-but-empty distinction. Stamp-routable to Rames Jusso after those are confirmed.

— Rames D Jusso

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at f87ce080. Concur with @james-russo-rames-d-jusso — clean read-layer slice, the three independent fixes (multi-tween playhead disambiguation, array-form keyframes, cold-parse retry) each land at the right layer with tests reading real exports.

Verified at HEAD:

  • Zero-duration set hold-set shadowing: gsapRuntimeKeyframes.ts:188-192 and :245-247 both skip duration <= 0 tweens. Comments call out the data:"hf-hold" case explicitly. Test coverage at gsapRuntimeKeyframes.test.ts lines 24-55 hits both "read past the hold" and "return null when ONLY a hold exists" paths.
  • Multi-tween playhead disambiguation: captures firstRead before any range check, prefers in-range, falls back to firstRead. Three-case test coverage matches behavior. PREEMPT-class race fix.
  • Array-form keyframe parsing in gsapShared.parsePercentageKeyframes covered for object form, GSAP array form as evenly-distributed steps, null for no-positional-props.
  • useGsapAnimationFetchFallback cold-parse retry: null (cold, retry) vs [] (warm, no match, don't retry) — the distinction IS the explicit design, not masking. Max 5 × 120ms = 600ms ceiling. Documented + tested.
  • tlId non-timeline marker skip at :165-167: when no explicit composition id, picks the first key whose value has getChildren, skipping markers like __proxied. Reasonable.

Concur with @james-russo-rames-d-jusso on:

  • gsapShared.ts:118 array-form ease-only entries — i/(n-1)*100 advances i even when the entry is silently skipped, so percentages of surviving entries follow original index spacing not redistributed. Right call for GSAP semantics (ease is per-segment, not a keyframe) but worth verifying against a real keyframes: [{x:0}, {ease:"none"}, {x:100}] source round-trips correctly through the studio source-mutation API.
  • useGsapAnimationFetchFallback.ts:30 cold-parse retry conflates fetch-reject vs warm-but-empty — both return null, both trigger retry. If the parse endpoint is genuinely down, every drag eats 600ms before CSS fallback fires. Fast-fail on fetch reject + retry only on cold-but-warming would tighten this. NIT-level but real.
  • useGsapTweenCache.ts:115 isStudioHoldSet filter — confirm the sentinel string (data:"hf-hold") is exported from @hyperframes/core/gsap-parser as a single const, not duplicated string-literal across both sides. Drift here would silently filter wrong tweens.

LGTM. No band-aid patterns, no dispatch-chain gaps, no spurious console additions. Clean slice.

Review by Via

…, isZeroDurationSet, array-ease tests

- useGsapAnimationFetchFallback: discriminate resolved/fetch-error/cold; only the cold
  (warm-but-zero) race gets the full ~600ms retry budget — a hard fetch error retries once.
- Extract isZeroDurationSet (was !(duration>0) duplicated); rejects NaN, documents intent.
- parsePercentageKeyframes: cite GSAP even-index spread; tests that a per-entry/interior
  ease is stripped without shifting the other keyframes' percentages.
@miguel-heygen miguel-heygen force-pushed the feat/core-motion-path-route branch from b79f553 to 1612463 Compare June 20, 2026 21:20
@miguel-heygen miguel-heygen force-pushed the feat/studio-read-layer branch from f87ce08 to e28b305 Compare June 20, 2026 21:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants