feat(studio): patchRuntimeTweenInPlace — update a tween's values in place#1612
feat(studio): patchRuntimeTweenInPlace — update a tween's values in place#1612miguel-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 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.tscallers selectexistingSetviafindPositionSetAnimation(matches on"x" in props || "y" in props) andfindRotationSetAnimation(matches on"rotation") — these can pick different tweens for the same selector when the source has separatetl.set("#el",{x,y})andtl.set("#el",{rotation})calls. ButpatchRuntimeTweenInPlaceresolves by selector +kind:"set"only — no channel disambiguation — andresolveRuntimeTweenreturns the first zero-duration tween for that target. So a position-fast-path can land on the rotationset's tween (and vice versa):patchSetthen writes newx/ychannels INTO the rotation tween'svars, leaving the position tween'svars.x/ystale. The source file write is correct (separateupdate-propertyon the right animationId), but the runtime is now contradictory until the next full reload. Worth either (a) accepting achannelshint toresolveRuntimeTweenand 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. -
patchSetwrites channels ontovarsonly ifNumber.isFinite(next). But it doesn't check whethervars[ch]was previously a string expression (e.g.x: "+=10"orx: "random(0,50)") — overwriting that with a number silently drops dynamic intent.keyframesAreStaticrejects non-finite VALUES in the patch input; the equivalent forpatchSetwould be "decline if the existingvars[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
playerOftry/catchs cross-origin access, but the surroundingpatchRuntimeTweenInPlacehas its own top-leveltry/catch— the inner one is belt-and-braces, fine but redundant. (nit)vars[ch] = nextmutates aRecord<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
varson a live tween +invalidate()— I trusted the comments and tests. If GSAP caches plugin-resolved values somewhere other thanvars(e.g. an internal_ptlinked 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-setcase 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
left a comment
There was a problem hiding this comment.
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 bothvars-throw andgetChildren-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 callersfindPositionSetAnimation/findRotationSetAnimation(ingsapDragCommit.ts:319/389) discriminate by"x" in props || "y" in propsvs"rotation" in props. For a selector with separatetl.set("#el",{x,y})andtl.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 achannelshint toresolveRuntimeTweenand prefer the tween that already carries the channel. Cheap and closes the gap before it bites. -
patchSetdynamic-string-channel overwrite:patchSetwrites channels ontovarsonly ifNumber.isFinite(next), but doesn't reject existing dynamic-string channels (e.g.vars.x = "+=10"→ numeric overwrite drops dynamic intent).keyframesAreStatichas 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.
c3ec03a to
5ef68d0
Compare
dc79a2a to
2a0df60
Compare
…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.)

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 (atl.setposition/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;
runCommitopts 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 supportinggsapRuntimeKeyframesexports 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. Returnstrueon a confident patch,falseotherwise. Wrapped in a top-leveltry/catchso it never throws — any internal failure becomesfalse.RuntimeTweenChange— discriminated union:{ kind: "set"; props: SetPatchProps }and{ kind: "keyframes"; keyframes: KeyframeStep[] }.SetPatchPropscoversx,y,rotation,scaleX,scaleY,scale,opacity;KeyframeStepis the numeric GSAP array-keyframe form.patchSet— writes only the provided numeric channels ontotween.vars. Returnsfalseifvarsis missing, the set carries amotionPath, or any provided channel is non-finite;trueonly if it touched a channel.patchKeyframes— replacesvars.keyframeswith a deep-copied static array. Declines whenvarsis missing, the tween carries amotionPath(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__playerbehind atry/catchso cross-origin/teardown access can't throw.__player.getTime()→timeline.time()→0, guards non-finite, then__player.seek(...)to re-render at the playhead.packages/studio/src/hooks/gsapRuntimeKeyframes.tsresolveRuntimeTween(iframe, selector, kind, compositionId?)(new export) — resolves the live tween using the same all-timelines scanreadRuntimeKeyframesuses, 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; returnsnullwhen nothing matches. Returns the tween + its owning timeline.RuntimeTween,RuntimeTimeline,timelinesOf, newResolvedRuntimeTween;RuntimeTween/RuntimeTimelinegain an optionalinvalidate?().Defensive
false-returnsThe caller falls back to the soft reload on
false, so declining is always safe — far safer than a silent mis-patch. Returnsfalse(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.
patchRuntimeTweenInPlacemutates 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 withgetChildren(deep), child tweens withvars/targets/duration/startTime/invalidate, and a__playerclock):x/y(re-seek reflects new values; invalidate + seek fire once); patches onlyrotation; patches onlyscale(leavingopacity).ease.motionPatharc (no invalidate/seek); dynamic/computed value; keyframes-vs-set shape mismatch; never throws (explodingvars/getChildren).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.