Skip to content

feat(studio): patchRuntimeTweenInPlace — update a tween's values in place#1612

Open
miguel-heygen wants to merge 2 commits into
feat/studio-gsap-keyframe-editingfrom
feat/instant-patch-helper
Open

feat(studio): patchRuntimeTweenInPlace — update a tween's values in place#1612
miguel-heygen wants to merge 2 commits into
feat/studio-gsap-keyframe-editingfrom
feat/instant-patch-helper

Conversation

@miguel-heygen

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

Copy link
Copy Markdown
Collaborator

Summary

Adds patchRuntimeTweenInPlace, a defensive helper that updates a single tween's values directly in the preview iframe's runtime timeline (window.__timelines) and re-seeks to the current playhead — so a value-only manual edit (a tl.set position/rotation/scale, or a keyframe value/position change) is reflected instantly, without re-running the whole composition.

This is the foundation for flicker-free instant editing. It doesn't change any commit path on its own; runCommit opts in later in the stack and falls back to the existing soft reload whenever the helper declines. The new module is fully unit-tested (12 scenarios), and the supporting gsapRuntimeKeyframes exports are widened so read and write resolve "which tween" identically.

What's changed

packages/studio/src/hooks/gsapRuntimePatch.ts (new)

  • patchRuntimeTweenInPlace(iframe, selector, change, compositionId?) — resolves the live tween, applies the change in place, invalidate()s the tween + its owning timeline (so GSAP re-reads vars on next render), then re-seeks the player to the current playhead. Returns true on a confident patch, false otherwise. Wrapped in a top-level try/catch so it never throws — any internal failure becomes false.
  • RuntimeTweenChange — discriminated union: { kind: "set"; props: SetPatchProps } and { kind: "keyframes"; keyframes: KeyframeStep[] }. SetPatchProps covers x, y, rotation, scaleX, scaleY, scale, opacity; KeyframeStep is the numeric GSAP array-keyframe form.
  • patchSet — writes only the provided numeric channels onto tween.vars. Returns false if vars is missing, the set carries a motionPath, or any provided channel is non-finite; true only if it touched a channel.
  • patchKeyframes — replaces vars.keyframes with a deep-copied static array. Declines when vars is missing, the tween carries a motionPath (arc motion is a path, not channel keyframes), the tween isn't already array-keyframe-shaped (shape-match guard), or any step value isn't finite.
  • playerOf — reads __player behind a try/catch so cross-origin/teardown access can't throw.
  • The re-seek — pulls time from __player.getTime()timeline.time()0, guards non-finite, then __player.seek(...) to re-render at the playhead.

packages/studio/src/hooks/gsapRuntimeKeyframes.ts

  • resolveRuntimeTween(iframe, selector, kind, compositionId?) (new export) — resolves the live tween using the same all-timelines scan readRuntimeKeyframes uses, so reader and writer always agree on which tween is edited. kind: "keyframe" skips zero-duration sets and prefers the tween straddling the playhead; kind: "set" picks the zero-duration set/hold. Falls back to the first match; returns null when nothing matches. Returns the tween + its owning timeline.
  • Widened exports: RuntimeTween, RuntimeTimeline, timelinesOf, new ResolvedRuntimeTween; RuntimeTween/RuntimeTimeline gain an optional invalidate?().

Defensive false-returns

The caller falls back to the soft reload on false, so declining is always safe — far safer than a silent mis-patch. Returns false (never throws, never mutates) when the tween can't be confidently located, the change can't be safely expressed (motionPath arc, dynamic/computed values, set-vs-keyframes shape mismatch), or any internal error occurs.

Why

Today applying a manual value edit re-runs the composition timeline, which causes a visible flash — the playhead resets and the scene rebuilds before settling. For incremental value-only edits that flash breaks the direct-manipulation feel. patchRuntimeTweenInPlace mutates the one tween that changed, invalidates it, and re-seeks in place — no teardown, no rebuild, no flash. Because it's strictly opt-in and falls back to the proven soft-reload path whenever it can't apply confidently, it carries zero regression risk for the cases it doesn't handle.

Testing

gsapRuntimePatch.test.ts — 12 scenarios against fixtures mimicking the runtime timeline shape (a timeline with getChildren(deep), child tweens with vars/targets/duration/startTime/invalidate, and a __player clock):

  • Sets: patches x/y (re-seek reflects new values; invalidate + seek fire once); patches only rotation; patches only scale (leaving opacity).
  • Keyframes: rebuilds keyframes (moved middle keyframe updates, others stay); preserves ease.
  • Defensive false: no matching tween (seek never called); selector resolves to no element; motionPath arc (no invalidate/seek); dynamic/computed value; keyframes-vs-set shape mismatch; never throws (exploding vars/getChildren).
  • Composition isolation: patches only the owning timeline's tween; a same-channel tween in another composition is untouched and never invalidated.

Stack

Part of the GSAP keyframe/motion-path stack: #1553 → #1554 → #1555 → #1607 → #1608 → #1609 → #1610 → #1611 → #1567 → #1612 → #1613 → #1605. First instant-editing PR, on #1567. Builds independently; the combined diff across the stack 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 dc79a2a6. Solid foundation slice; defensive-by-default contract is clean and the 12-scenario test suite covers the decline paths well. Couple of concerns + a few nits.

Concerns

  • gsapDragCommit.ts callers select existingSet via findPositionSetAnimation (matches on "x" in props || "y" in props) and findRotationSetAnimation (matches on "rotation") — these can pick different tweens for the same selector when the source has separate tl.set("#el",{x,y}) and tl.set("#el",{rotation}) calls. But patchRuntimeTweenInPlace resolves by selector + kind:"set" only — no channel disambiguation — and resolveRuntimeTween returns the first zero-duration tween for that target. So a position-fast-path can land on the rotation set's tween (and vice versa): patchSet then writes new x/y channels INTO the rotation tween's vars, leaving the position tween's vars.x/y stale. The source file write is correct (separate update-property on the right animationId), but the runtime is now contradictory until the next full reload. Worth either (a) accepting a channels hint to resolveRuntimeTween and preferring the tween that already carries the channel, or (b) explicitly documenting "fast path expects merged-set shape; multi-set elements fall back to soft reload" and gating accordingly. This bites #1613, not this PR per se, but the helper enables it.

  • patchSet writes channels onto vars only if Number.isFinite(next). But it doesn't check whether vars[ch] was previously a string expression (e.g. x: "+=10" or x: "random(0,50)") — overwriting that with a number silently drops dynamic intent. keyframesAreStatic rejects non-finite VALUES in the patch input; the equivalent for patchSet would be "decline if the existing vars[ch] is a string and we're about to replace it with a number." Today's caller path (drag from a static element) won't hit this, but the foundation is general; cheap to add a guard so the helper stays safe for keyframe-value-edit callers later in the stack.

Nits

  • playerOf try/catchs cross-origin access, but the surrounding patchRuntimeTweenInPlace has its own top-level try/catch — the inner one is belt-and-braces, fine but redundant. (nit)
  • vars[ch] = next mutates a Record<string, unknown> typed object via a typed-channel key; the assignment is sound but reads slightly awkward. Optional: (vars as Record<string, number>)[ch] = next. (nit)

What I didn't verify

  • GSAP's documented contract for mutating vars on a live tween + invalidate() — I trusted the comments and tests. If GSAP caches plugin-resolved values somewhere other than vars (e.g. an internal _pt linked list for the recompiled tween), invalidate() should rebuild it, but I didn't read the GSAP source to confirm scale/scaleX/scaleY interactions.
  • Whether resolveRuntimeTween's "first match" preference is acceptable for the multi-set case mentioned above — depends on whether the studio source writer can ever produce two separate sets for the same selector. Worth confirming with Miguel.

— 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 dc79a2a6. Foundation slice — concur with @james-russo-rames-d-jusso that the helper itself meets its declared contract and the 12-scenario decline matrix is comprehensive. The channel-blind tween resolution concern Rames flags as biting at #1613 is the live one; this PR alone is NIT.

Verified at HEAD — full decline-path test coverage:

patchRuntimeTweenInPlace's 6 documented decline paths each have explicit tests in gsapRuntimePatch.test.ts:

  • no matching tween: :315 "returns false when the selector has no matching tween"
  • no element: :329 "returns false when the selector resolves to no element"
  • motion-path arc: :338 "returns false for a motionPath arc tween (defers to soft reload)"
  • dynamic values: :374 "returns false for a dynamic/computed keyframe value"
  • set-vs-keyframes shape mismatch: :407 "returns false for a keyframes change against a set-only tween (shape mismatch)"
  • exploding vars/getChildren: :422 "never throws — returns false on internal error" (covers both vars-throw and getChildren-throw in one fixture)

Bonus: composition isolation at :456. The "never throws" test is the right defensive-by-default contract; band-aid pattern #4 (catches-its-own-throw) is NOT present because the catch's behavior is observable (return false) and tested.

RuntimeTweenChange.SetPatchProps channel scope: verified x, y, rotation, scaleX, scaleY, scale, opacity — no silent expansion.

Concur with @james-russo-rames-d-jusso (foundation concerns that bite at #1613):

  • Channel-blind tween resolution: resolveRuntimeTween(iframe, selector, "set") returns the first zero-duration tween matching the selector — no channel match. But sibling callers findPositionSetAnimation / findRotationSetAnimation (in gsapDragCommit.ts:319/389) discriminate by "x" in props || "y" in props vs "rotation" in props. For a selector with separate tl.set("#el",{x,y}) and tl.set("#el",{rotation}) calls, source-side writer picks correct animationId, but runtime patcher writes into whichever zero-duration tween came first — potentially the rotation tween for a position drag. Source file is correct; runtime contradictory until next full reload. Maps to band-aid pattern #2 (contradictory rules: source picks by channel, runtime picks by position-in-list) + #4 (defensive premise is wrong).

    The concern is foundation-level — this PR enables the failure, but it can't fire until a caller wires instantPatch (which #1613 does). I'd take Rames's fix-option (a): accept a channels hint to resolveRuntimeTween and prefer the tween that already carries the channel. Cheap and closes the gap before it bites.

  • patchSet dynamic-string-channel overwrite: patchSet writes channels onto vars only if Number.isFinite(next), but doesn't reject existing dynamic-string channels (e.g. vars.x = "+=10" → numeric overwrite drops dynamic intent). keyframesAreStatic has the analog for keyframes; missing here. Today's caller (drag from static element) doesn't hit this, but the foundation is general; cheap guard for keyframe-value-edit callers later.

Rest of nits (inner try/catch redundancy; type-cast readability) concur as cosmetic.

Verdict: NIT (foundation-level). Per Rames's own framing — "this bites #1613, not this PR per se, but the helper enables it" — I agree. The decline-matrix is complete as documented; the missing clause (channel discrimination) is what makes the wiring at #1613 reach a wrong-tween write. Fix the channel-hint at the resolver level here OR strengthen the caller at #1613. Either-or, not both.

Review by Via

…lace

Defensive runtime helper: locate the element's tween in window.__timelines via the
shared resolveRuntimeTween scan, update its set/keyframe vars, invalidate, and re-seek
the playhead — without re-running the whole composition. Returns false (caller falls
back to soft reload) for any shape it can't safely patch (no tween, dynamic/computed
keyframes, motionPath arc, channel mismatch, or any error). Foundation for instant,
flicker-free manual edits.
…cline dynamic-expression patches

- resolveRuntimeTween gains an optional channels[] hint; for kind:set it prefers the set whose
  vars carry one of the patched channels and never returns a disjoint-only set (e.g. won't write
  {x,y} into a co-located {rotation} set). patchRuntimeTweenInPlace derives channels from the props.
- patchSet declines (returns false → soft reload) when overwriting a string/dynamic vars[ch],
  instead of silently dropping the computed expression.
@miguel-heygen miguel-heygen force-pushed the feat/studio-gsap-keyframe-editing branch from c3ec03a to 5ef68d0 Compare June 20, 2026 21:20
@miguel-heygen miguel-heygen force-pushed the feat/instant-patch-helper branch from dc79a2a to 2a0df60 Compare June 20, 2026 21:20
miguel-heygen added a commit that referenced this pull request Jun 20, 2026
…tion, patch both coalesced commits, wire onAsyncFailure

- commitStaticGsapPosition/Rotation derive instantPatch.change.props from the actual
  update-property mutation(s) sent (one source of truth → findUnsafeMutationValues-validated
  values flow into the patch; can't drift).
- Coalesced x/y: the intermediate x commit also carries instantPatch{x}, the y commit {x,y},
  so a second-POST failure still leaves the preview patched for what persisted.
- applyPreviewSync passes reloadPreview as onAsyncFailure (plugin-CDN load error → full reload);
  per U4 the synchronous false still does NOT escalate.
- (channel disambiguation from #1612 verified end-to-end: {x,y}→position set, {rotation}→rotation set.)
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