feat(studio): GSAP runtime read layer + shared helpers#1607
feat(studio): GSAP runtime read layer + shared helpers#1607miguel-heygen wants to merge 2 commits into
Conversation
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
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) — thefirstReadfallback 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 ofObject.keys(...)[0]). Good. -
gsapRuntimeKeyframes.ts:200(zero-duration skip inaddScanEntry) — symmetric withreadRuntimeKeyframesskip 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 tinyisHoldOrZeroSet(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)*100correctly guardsn==1with the ternary. One subtle behavior worth flagging: if an array entry is{ease: "power1"}-only (ease stripped, no other props), it's silently skipped — butistill 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 realkeyframes: [{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 onfetchreject vs cold-but-warming parse — fetch rejection means "endpoint dead, don't retry,"parsed.animations.length === 0means "warm but empty, retry." Right nowfetchParsedAnimationsreturnsnullon both —selectElementAnimationsOrRetrycan't tell them apart and retries either way. Concern. -
useGsapTweenCache.ts:115(isStudioHoldSetfilter) — clean. One question: doesisStudioHoldSetfrom@hyperframes/core/gsap-parsermatchdata: "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 ingsapRuntimeKeyframes.test.tsusesdata: "hf-hold"literally — keeping the sentinel in one place (@hyperframes/core/gsap-parserconst export) would prevent drift. Verify both ends import the same constant. -
useGsapAnimationFetchFallback.test.ts— coverage is good for the three branches ofselectElementAnimationsOrRetry, but the actual retry loop (5x 120ms with the integration shape ofuseGsapAnimationFetchFallback) isn't tested. The retry timing + early-return onattempt >= COLD_PARSE_RETRIESis 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.getPropertyinteraction in #1608'sapplyStudioPathOffsetViaGsap— covered there. - Live behavior under real preview iframe with
__timelinespopulated by the producer — the fake-iframe tests cover the read path mechanically. - That
isStudioHoldSetandparsePercentageKeyframes'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
left a comment
There was a problem hiding this comment.
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-192and:245-247both skipduration <= 0tweens. Comments call out thedata:"hf-hold"case explicitly. Test coverage atgsapRuntimeKeyframes.test.tslines 24-55 hits both "read past the hold" and "return null when ONLY a hold exists" paths. - Multi-tween playhead disambiguation: captures
firstReadbefore any range check, prefers in-range, falls back tofirstRead. Three-case test coverage matches behavior. PREEMPT-class race fix. - Array-form keyframe parsing in
gsapShared.parsePercentageKeyframescovered for object form, GSAP array form as evenly-distributed steps, null for no-positional-props. useGsapAnimationFetchFallbackcold-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.tlIdnon-timeline marker skip at:165-167: when no explicit composition id, picks the first key whose value hasgetChildren, skipping markers like__proxied. Reasonable.
Concur with @james-russo-rames-d-jusso on:
gsapShared.ts:118array-form ease-only entries —i/(n-1)*100advancesieven 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 realkeyframes: [{x:0}, {ease:"none"}, {x:100}]source round-trips correctly through the studio source-mutation API.useGsapAnimationFetchFallback.ts:30cold-parse retry conflatesfetch-reject vs warm-but-empty — both returnnull, both trigger retry. If the parse endpoint is genuinely down, every drag eats 600ms before CSS fallback fires. Fast-fail onfetchreject + retry only on cold-but-warming would tighten this. NIT-level but real.useGsapTweenCache.ts:115isStudioHoldSetfilter — confirm the sentinel string (data:"hf-hold") is exported from@hyperframes/core/gsap-parseras 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.
b79f553 to
1612463
Compare
f87ce08 to
e28b305
Compare

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.tsreadRuntimeKeyframesnow resolves the timeline robustly and picks the right tween under the playhead:window.__timelines(e.g. the studio's__proxiedflag) by selecting the first key whose value actually has agetChildrenfunction, instead of blindly takingObject.keys(...)[0].time()and, when an element has more than one keyframed tween at disjoint time ranges (two non-overlapping gesture recordings → twoto()s), returns the tween whose[start, start+duration]range contains the playhead (with a1e-3epsilon). If the playhead is outside every range (or the timeline exposes no clock), it falls back to the first keyframed tween held infirstRead.readRuntimeKeyframesandaddScanEntrynow skip zero-duration tweens (tl.set(...), including the studio position-holddata:"hf-hold"). These sit before the real keyframed tween and otherwise shadow it —readTweenwould fall back to a degenerate 2-point flat path from the set's values, hiding the actual multi-keyframe motion.RuntimeTimelinegains an optionaltime?: () => number;ReadTweenis now exported (for the test + downstream consumers).packages/studio/src/hooks/gsapShared.tsparsePercentageKeyframesnow handles GSAP array-form keyframes (keyframes: [{x,y}, {x,y}, ...]) in addition to the object/percentage form. Array steps are evenly distributed: stepiofnmaps toi/(n-1)*100%(rounded to one decimal), numeric props rounded to 3 decimals,easeskipped. Previously array-keyframed tweens (e.g. a multi-point shuttle path) parsed asnull→ no motion path drawn.getIframeDocumenthelper.packages/studio/src/hooks/useGsapAnimationFetchFallback.tsselectElementAnimationsOrRetry(parsed, target): returnsnullto 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).useGsapAnimationFetchFallbackcallback 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.tsfetchParsedAnimationsnow filters studio-emitted pre-keyframe holdsets out of the parsed animations viaisStudioHoldSet(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 holdsetshadows 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 holdsets 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 afakeIframebuilder that stubs a runtime timeline (getChildren,duration, optionaltime) and selector resolution. Covers: a zero-duration hold-set must not shadow the keyframed tween (reads all 4 keyframes; returnsnullwhen 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.ts—parsePercentageKeyframes: parses the object/percentage form; parses array-form keyframes as evenly-distributed steps ([0, 33.3, 66.7, 100], properties preserved); returnsnullfor empty array and empty object.useGsapAnimationFetchFallback.test.ts—selectElementAnimationsOrRetry: returnsnull(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.