Repository navigation
fix(studio): keyframed animations keep their ease through every Studio edit - #5051
Conversation
Edit accuracy: accurate 2055 (base branch 2055), smooth 1563 of thoseThe gate passes. Quarantined, measured but not gated (0) Unstable (1)
|
c92f9c5 to
53278a1
Compare
Extending a keyframed tween to the playhead and merging a gesture recording into it now carry the tween's eases and each keyframe's own ease. The parsers no longer report a tween-level easeEach, which GSAP ignores, as the keyframes' ease.
…it was recorded Merged keyframes are placed through the tween's run ease, as the extend path already does, and recorded constant-speed segments stay linear.
…es and eases The summary line read 'no properties yet using a none curve' for every keyframed tween. It now names the keyframed properties, the keyframe count, the segment ease and the run ease that GSAP plays.
…mes and the ones before it The merge keeps or replaces old keyframes by when they play, and rounds percentages only when the run ease does not warp time.
The header shows the one ease every segment plays, or Mixed, and a run ease other than none gets its own line.
…rsers read back A percentage below 1e-6 printed as '1.28e-7%', which both parsers reject. One plainPercentKey owner now writes every percentage key in both writers and the unroller.
jrusso1020
left a comment
There was a problem hiding this comment.
Review at c5afa030. The ease handling now matches what GSAP actually plays, and every Studio rewrite carries the ease through. Approving.
Checked against GSAP 3.15's source (gsap-core.js, keyframes branch):
- The run ease is
keyframes.ease || vars.ease || "none". Each percentage segment eases withobj.ease || keyframes.easeEach || "power1.inOut".easeEachis only read inside the keyframes object, and array steps inherit the timeline defaultease: "none". keyframedTweenEases(utils/gsapKeyframeEases.ts:12) encodes exactly those rules. So dropping the tween-leveleaseEachcopy from both parsers is right: GSAP never reads that key.- The split fix (
gsapParser.ts:3361,gsapWriterAcorn.ts:2009) now puts each ease back in the slot GSAP reads it from: the keyframes'easeas the run ease andeaseEachaseaseEach.
Round trip:
moveKeyframeInScriptre-keys only the percentage props.- The Acorn writer keeps every other prop as its source text.
- The recast writer keeps the AST nodes.
- So
easeandeaseEachsurvive a drag, and the rest of the script is untouched.
- The parity test covers both writers.
Split and merge:
- Each half of a split gets the same eases.
- The merge now:
- places each recorded time through
progressAtTime; - keeps or replaces existing keyframes by when they play (
timeAtProgress); - gives recorded segments without a fitted ease
ease: "none", except the first recorded keyframe. That segment comes from the old motion, so it keeps the animation'seaseEach.
- places each recorded time through
- The extend path already timed its keyframes through
progressAtTime(runEase, ...)but then wrote no ease. Adding...keyframeEases(anim)makes the written tween play the way it was timed.
Reuse / simpler:
- Both Studio rewrites spread the existing
keyframeEaseshelper rather than adding a new one. warpsTimereuses the existingtimingEasecheck instead of a second definition of "this ease warps time".plainPercentKeyreplaces twelve inline`${pct}%`templates across the two writers, the parser and the unroller with one owner. The threePERCENTAGE_KEY_REcopies (/^(\d+(?:\.\d+)?)%$/) reject exponent notation, so writing plain decimals is the right side to fix.
Tests at this head:
-
packages/parserspasses 1194 of 1194. -
The six changed Studio test files pass 93 of 93.
-
Each of these mutants fails at least one test:
Mutant Tests failed Acorn moveKeyframeInScriptdrops the non-percentage props4 Recast moveKeyframeInScriptdrops the non-percentage props4 plainPercentKeywrites exponent notation3 Acorn parser copies the tween-level easeEachagain1 Acorn split goes back to easeEach ?? easein the run-ease slot1 Gesture merge skips progressAtTime2 Gesture merge drops the ease: "none"on recorded segments1 Gesture merge keeps old keyframes by percentage instead of play time 1 Segment list goes back to the easeEach ?? ease ?? "none"fallback1
Nits (not blocking):
gsapParser.ts:1055-1056still says tween-leveleaseEachis "handled below", but this PR removed that handler. It should now say GSAP ignores the key.easeDisplayName(AnimationCard.tsx:35) andeaseLabel(gsapAnimationHelpers.ts:11) are the samecustom(/EASE_LABELSlookup with different custom wording ("Custom curve" vs "custom"). One shared helper that takes the custom label would keep them from drifting.
Verdict: APPROVE
Reasoning: The eases Studio reads and writes now follow GSAP's own resolution rules. Every rewrite path keeps them, and a test fails if any of the fixes is undone.
— Rames Jusso
What changes for users
Studio no longer throws away a keyframed animation's ease when it rewrites the animation. Set an ease with Studio's ease control, then do any of these, and the ease is still there:
The ease card's header now shows the ease its segment rows play, or "Mixed" when they differ, so it never disagrees with the rows below it. A run ease other than
nonegets its own line, "Run ease: power1.out". The segment list labels a segment with no ease of its own by the ease GSAP plays (power1.inOutfor percentage keyframes,nonefor array steps, or theeaseEach). It used to show the tween's run ease, ornone. The rows also round their percentage labels to 3 decimals (display only; the file keeps its values). The summary line now describes a keyframed animation by what it plays: "animate #box's move x through 3 keyframes, each segment eased power3.out." It used to read "no properties yet using a none curve" for every keyframed animation.A gesture merged into an animation with no run ease also writes rounded keyframe percentages (
"33.333%") instead of float noise ("86.19999999999999%"), in the file and in the segment list. Under a run ease that warps time, the merge keeps full precision. A percentage that would print in exponent notation ("1.28e-7%", from a recording that starts near the start of a strong ease-in) is now written as a plain decimal key the parsers read back.Studio's ease control writes
easeEachinto the keyframes object (update-metawith{ easeEach, resetKeyframeEases: true }). A diamond drag ismoveKeyframeInScript. Both writers rebuilt the keyframes object from its percentage steps alone, so its other keys,easeEachandease, were silently dropped on every drag:Splitting a keyframed tween into property groups (SDK
splitIntoPropertyGroupsand the server'ssplit-into-property-groupsroute) had the same loss. It passedeaseEachin the outereaseslot and dropped the keyframes' ownease, which changes how the tween plays.Two Studio rewrites sent a
replace-with-keyframeswith no eases at all: the extend path inuseAnimatedPropertyCommitand the gesture merge inuseGestureCommit. The extend path also dropped each keyframe's own ease while remapping. The gesture merge also wrote its remapped percentages unrounded.The parsers also reported a tween-level
easeEach({ keyframes: {...}, easeEach }) as the keyframes' ease. GSAP ignores that key, so Studio showed an ease that does not play.The fix
In both writers,
moveKeyframeInScriptre-keys and re-sorts the percentage steps as before, then keeps every other property of the keyframes object verbatim after them. Acorn keeps the source text; recast keeps the AST nodes. GSAP skipsease/easeEachwhen it reads steps and sorts the steps itself, so key order does not matter.Splitting into property groups now passes the keyframes'
ease(or the tween's) as the new tween'seaseandeaseEachaseaseEach, in both writers.Both Studio rewrites now spread
keyframeEases(anim), the helper the other rewrites already use, and the extend path keeps each keyframe'sease. Keeping the run ease means the merge must also place the recording through it. Keyframe percentages are progress through the run ease, not time, so the merge now converts each recorded time withprogressAtTime, as the extend path already does. It keeps or replaces old keyframes by when they play (timeAtProgress), not by their percentage. Under a strong ease-out, the last second of a long tween can span a few thousandths of a percent. So the merge rounds percentages withroundTo3only when the run ease does not warp time (warpsTime, the same test the timing math uses), and keeps full precision otherwise. Recorded segments the velocity fitter left unset getease: "none", the same constant speed a stand-alone recording gets from itseaseEach: "none"; without this, they would take the animation'seaseEach. The exception is the first recorded keyframe: its segment comes from the old motion, so it keeps the animation'seaseEach.keyframeEasesnow takes the tween ease fromrunEaseOf, which is the keyframes' owneasebefore the tween's, as GSAP plays it.replace-with-keyframeshas no slot for the keyframes object's ownease, so that ease was lost before; it moves to the tween ease and plays the same.buildTweenSummarygets a keyframed branch. It names the properties across the keyframes, the keyframe count, the segment ease (or "per-keyframe easing" when they differ) and the run ease when there is one. Both eases come fromkeyframedTweenEases, the same owner Studio's timing math uses, and ease names keep GSAP's case, since the line can be copied. The flat-tween sentence is unchanged. The segment list takes its fallback from the same owner,keyframedTweenEases(animation).segment.uniformSegmentEase(the one ease every segment plays, or null) feeds both the header and the summary sentence. The run ease comes fromkeyframedTweenEases(animation).run. A flat tween's header is unchanged.Both writers and the unroller now write every percentage key through one owner,
plainPercentKeyingsapSerialize. It writes a value below 1e-6 as a plain decimal instead of in exponent notation, which no parser reads, and leaves every other value exactly as before.Both parsers stop copying a tween-level
easeEachinto the keyframes. AneaseEachinside the keyframes object is read as before.Tests
Parsers, for both writers:
updateAnimationInScriptwitheaseEachandresetKeyframeEases) survives a 50% to 60% drag.easeauthored on the keyframes object survives a drag.easeandeaseEachkeeps both on every group.easeEachis not reported as the keyframes' ease.add-with-keyframesin both writers and byreplace-with-keyframes. With the old template, all three fail.Studio:
replace-with-keyframescarries the tweenease, theeaseEachand a keyframe's ownease. Played in real GSAP underpower2.inOut, the old end keyframe still lands at 2 s after the tween stretches to 3 s.easeandeaseEach. Its recorded segments after the first arenone, and without a run ease it writes rounded percentages (a 1 s recording over a 3 s tween gives 16.667 and 33.333).power4.outtween reaches x 50 half-way, as recorded; rounding there had merged its keyframes. A keyframe that plays at 8.5 s of a 10 spower2.outtween survives a recording over 9-10 s.power2.outtween reaches x 50 at 0.5 s and x 100 at 1 s, as recorded. Keeping the ease without the conversion gives x 100 at 0.5 s.keyframeEasescarries the keyframes' ownease.<0.001for a tiny one; the old rows printed1.2860082304526747e-7%.power1.outtweenpower1.inOut, as GSAP plays them.power1.inOutfor that tween, with the line "Run ease: power1.out". With mixed segment eases and a linear run it reads "Mixed", and there is no run line. With the old header logic, both cases fail on behaviour (power1.outandnone).buildTweenSummarydescribes a keyframed tween by its properties, keyframe count and segment ease. With mixed segment eases and a run ease it reads "with per-keyframe easing, across a sine.inOut run."keyframeEase.realGsap.test.tsplays real GSAP. It shows a tween-leveleaseEachchanges nothing and one inside the keyframes does, then checks that the parser reports exactly that.Every new case fails on the base, with two exceptions that pass on both. One is the control that an
easeEachinside the keyframes still reads. The other is the merge timing check, since the base drops the ease and so plays in linear time; it fails when the ease is kept without the conversion. Two existing parser tests ("parses three-level easing") asserted the tween-leveleaseEachbehaviour; they now puteaseEachinside the keyframes, where GSAP reads it. The parsers suite passed 3 runs in a row (1,194 tests), and the changed Studio files passed 3 runs in a row. The full Studio suite passed (7,519 tests; the hooks and utils folders, 2,284 tests, again after the last commit), along with the SDK GSAP mutation tests and the server files routes. tsc, oxfmt, oxlint, Fallow and the comment checks are clean.Before
Real Studio, a fixture built for this PR (one box, one keyframed
tofrom 0 to 2 s). Step 1 is the same everywhere: select the box, Design > Motion, Per-keyframe easing > Set all > power3.out. Studio writeseaseEach: "power3.out".Edit X with the playhead at 3 s, past the animation. The animation stretches to 3 s, and every segment drops to
none(full-window and dark-theme versions of every capture were checked too):Record a gesture over the animation. It merges, every segment drops to
none, and the segment list shows float noise (28.333333333333332%):Record a gesture over an animation with its own
power2.outease. The segment rows print raw percentages:In every Before capture, the summary line under the card header reads "animate #box's no properties yet using a none curve".
Before any ease is set, the segment list reads
none(A), or the tween's own ease,power1.out(B). GSAP playspower1.inOuton every segment in both:After
The same steps on this branch. The ease is kept after the stretch and after the merge:
In the merge After, segments inside the recording read
none(constant speed) or the curve the velocity fitter chose, and segments outside it keep power3.out.The summary line describes the keyframed animation, and the segment list names the ease GSAP plays: