Skip to content

feat(studio): runtime-first dynamic keyframe system [8/10]#1190

Open
miguel-heygen wants to merge 1 commit into
feat/keyframes-5-split-clipfrom
feat/keyframes-6-dynamic-keyframes
Open

feat(studio): runtime-first dynamic keyframe system [8/10]#1190
miguel-heygen wants to merge 1 commit into
feat/keyframes-5-split-clipfrom
feat/keyframes-6-dynamic-keyframes

Conversation

@miguel-heygen
Copy link
Copy Markdown
Collaborator

Summary

  • Read GSAP keyframes from live runtime (tween.vars.keyframes) instead of AST only
  • hasUnresolvedKeyframes / hasUnresolvedSelector flags on GsapAnimation
  • scanAllRuntimeKeyframes discovers all keyframed tweens in the iframe
  • materializeKeyframesInScript + unrollDynamicAnimations — on first edit, dynamic loops are replaced with individual static tl.to() calls
  • Keyframe cache: interval-based runtime scan, dual-write keys, immediate update from mutation response
  • easeEach placement fix (inside keyframes object, not tween vars level)
  • Retry effect for cold-load animation fetch race

+773 LOC

@miguel-heygen miguel-heygen force-pushed the feat/keyframes-6-dynamic-keyframes branch from 6e391b2 to eca150f Compare June 4, 2026 04:57
@miguel-heygen miguel-heygen force-pushed the feat/keyframes-5-split-clip branch 2 times, most recently from 0dbfe93 to 925397e Compare June 4, 2026 16:15
@miguel-heygen miguel-heygen force-pushed the feat/keyframes-6-dynamic-keyframes branch from eca150f to dcbe8da Compare June 4, 2026 16:15
@miguel-heygen miguel-heygen force-pushed the feat/keyframes-5-split-clip branch from 925397e to 4711c17 Compare June 4, 2026 16:40
@miguel-heygen miguel-heygen force-pushed the feat/keyframes-6-dynamic-keyframes branch from dcbe8da to 3a8e22f Compare June 4, 2026 16:40
@miguel-heygen miguel-heygen force-pushed the feat/keyframes-5-split-clip branch from 4711c17 to 32dd0bb Compare June 4, 2026 17:44
@miguel-heygen miguel-heygen force-pushed the feat/keyframes-6-dynamic-keyframes branch from 3a8e22f to 0eb2b93 Compare June 4, 2026 17:44
@miguel-heygen miguel-heygen force-pushed the feat/keyframes-5-split-clip branch from 32dd0bb to 7f991c4 Compare June 5, 2026 03:22
@miguel-heygen miguel-heygen force-pushed the feat/keyframes-6-dynamic-keyframes branch from 0eb2b93 to 626983d Compare June 5, 2026 03:22
Copy link
Copy Markdown
Collaborator

@jrusso1020 jrusso1020 left a comment

Choose a reason for hiding this comment

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

The most architecturally consequential PR in the back half of the stack. +773/-38. Reshapes keyframe discovery from "AST-only" to "runtime-first": Studio reads tween.vars.keyframes from the live iframe rather than relying solely on the AST parse. Plus materializeKeyframesInScript / unrollDynamicAnimations to convert dynamic loops into individual tl.to() calls on first edit.

The key risk: AST↔runtime divergence

The "runtime-first" model means there are now two sources of truth for what a tween is:

  1. AST parse of the source script.
  2. Live tween.vars from the running iframe.

Some keyframes (the "dynamic" ones — generated by a JS loop like for (let i = 0; i < 10; i++) tl.to(...)) only exist in the runtime view, not the AST. The hasUnresolvedKeyframes / hasUnresolvedSelector flags + materializeKeyframesInScript are the bridge.

Things to verify:

1. Materialization is idempotent

Calling materializeKeyframesInScript on a script that's already been materialized should be a no-op. Otherwise repeated edits compound the unroll and the script grows linearly with edit count.

2. Runtime scan interval

PR body says "interval-based runtime scan". What interval? If it's too tight (< 200ms), it's a perf cost on every Studio session. If too loose (> 2s), the panel feels stale during animation creation. Worth a comment in the code explaining the chosen value.

3. Cold-load race + retry effect

"Retry effect for cold-load animation fetch race" — this hints at a known timing issue. The retry strategy matters:

  • Bounded retries: if the runtime never produces tween data, the retry should eventually give up (not loop infinitely).
  • Exponential backoff or fixed interval: either is fine, but document.
  • User-visible feedback: if retry is exhausted, the user should see "couldn't load keyframes" rather than an empty panel.

4. unrollDynamicAnimations and edit semantics

When a dynamic loop like for (let i = 0; i < 10; i++) tl.to("#item-" + i, ...) is unrolled into 10 static tl.to() calls on first edit:

  • The unroll is destructive — you lose the original loop expression. Confirm this is documented as an intentional, irreversible transformation (user can undo at the AST level, but they can't get the original loop back without writing it again).
  • Selector resolution: each unrolled call gets a concrete selector. If the loop used "#item-" + i, the unrolled output should be "#item-0", "#item-1", etc. Verify against tests.
  • Comment / docstring: the unroll comment ideally says "originally generated by loop at line N" so the human reader understands what happened.

5. easeEach placement fix

The fix is the right call — easeEach belongs inside the keyframes: {} object, not at the tween-vars level. This is consistent with GSAP's documented placement. The fix in this PR likely corrects a regression from earlier in the stack (looks like #1167's tween-level easeEach propagation logic had it at the wrong level for emit but right level for parse). Worth a quick parse↔serialize round-trip test that asserts easeEach ends up in the same JSON shape on both sides.

6. Dual-write cache keys

"Dual-write keys" in the keyframe cache — what's the second key for? Migration from old key shape? Coexistence with the legacy code path? Worth a comment explaining the dual-write window (and when one of them can be removed).

This is a meaty PR — I'd push for a screenshot or video showing the live-runtime keyframe panel responding to a scrub before merging.

Review by Jerrai (hyperframes specialist)

…lization

Read GSAP keyframe data from the live runtime instead of only the AST parser.
Dynamic keyframes (loops, variables, computed selectors) now show diamonds
on timeline clips and animation cards in the design panel.

On first edit, dynamic code is automatically materialized:
- Unresolved keyframes (keyframes: kf) replaced with static object
- Unresolved selectors (tl.to(sel, ...)) entire loop unrolled into
  individual static tl.to() calls per element

Key changes:
- Parser: hasUnresolvedKeyframes/hasUnresolvedSelector flags
- Runtime bridge: scanAllRuntimeKeyframes reads tween.vars from iframe
- Tween cache: interval-based runtime scan for dynamic animations
- materializeKeyframesInScript + unrollDynamicAnimations parser functions
- Keyframe cache dual-writes both sourceFile#id and index.html#id keys
- commitMutation updates cache from mutation response
- easeEach placement fix (inside keyframes object, not tween vars)
@miguel-heygen miguel-heygen force-pushed the feat/keyframes-6-dynamic-keyframes branch from 626983d to 7149806 Compare June 5, 2026 05:55
@miguel-heygen miguel-heygen force-pushed the feat/keyframes-5-split-clip branch from 7f991c4 to 524dc79 Compare June 5, 2026 05:55
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.

2 participants