Skip to content

fix(studio): keyframed animations keep their ease through every Studio edit - #5051

Merged
miguel-heygen merged 11 commits into
mainfrom
fix/keyframe-drag-keeps-ease
Oct 5, 2026
Merged

miguel-heygen merged 11 commits into
mainfrom
fix/keyframe-drag-keeps-ease

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

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:

  • drag a keyframe diamond;
  • edit a value with the playhead past the animation, which stretches it and adds a keyframe;
  • record a gesture over the animation, which merges into it (the recording also plays at the times you recorded it);
  • split the animation into property groups.

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 none gets 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.inOut for percentage keyframes, none for array steps, or the easeEach). It used to show the tween's run ease, or none. 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 easeEach into the keyframes object (update-meta with { easeEach, resetKeyframeEases: true }). A diamond drag is moveKeyframeInScript. Both writers rebuilt the keyframes object from its percentage steps alone, so its other keys, easeEach and ease, were silently dropped on every drag:

tl.to("#a", { duration: 2, keyframes: { "0%": { x: 0 }, "50%": { x: 5 }, "100%": { x: 10 }, easeEach: "power2.out" } }, 0);
// after dragging the 50% diamond to 60%, before this fix
tl.to("#a", { duration: 2, keyframes: { "0%": { x: 0 }, "60%": { x: 5 }, "100%": { x: 10 } } }, 0);

Splitting a keyframed tween into property groups (SDK splitIntoPropertyGroups and the server's split-into-property-groups route) had the same loss. It passed easeEach in the outer ease slot and dropped the keyframes' own ease, which changes how the tween plays.

Two Studio rewrites sent a replace-with-keyframes with no eases at all: the extend path in useAnimatedPropertyCommit and the gesture merge in useGestureCommit. 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, moveKeyframeInScript re-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 skips ease/easeEach when 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's ease and easeEach as easeEach, in both writers.

Both Studio rewrites now spread keyframeEases(anim), the helper the other rewrites already use, and the extend path keeps each keyframe's ease. 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 with progressAtTime, 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 with roundTo3 only 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 get ease: "none", the same constant speed a stand-alone recording gets from its easeEach: "none"; without this, they would take the animation's easeEach. The exception is the first recorded keyframe: its segment comes from the old motion, so it keeps the animation's easeEach. keyframeEases now takes the tween ease from runEaseOf, which is the keyframes' own ease before the tween's, as GSAP plays it. replace-with-keyframes has no slot for the keyframes object's own ease, so that ease was lost before; it moves to the tween ease and plays the same.

buildTweenSummary gets 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 from keyframedTweenEases, 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 from keyframedTweenEases(animation).run. A flat tween's header is unchanged.

Both writers and the unroller now write every percentage key through one owner, plainPercentKey in gsapSerialize. 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 easeEach into the keyframes. An easeEach inside the keyframes object is read as before.

Tests

Parsers, for both writers:

  • An ease set through Studio's own call (updateAnimationInScript with easeEach and resetKeyframeEases) survives a 50% to 60% drag.
  • An ease authored on the keyframes object survives a drag.
  • Splitting a tween whose keyframes carry ease and easeEach keeps both on every group.
  • A tween-level easeEach is not reported as the keyframes' ease.
  • A keyframe at 1.286e-7% is written as a decimal key, kept apart from 0%, and read back by add-with-keyframes in both writers and by replace-with-keyframes. With the old template, all three fail.

Studio:

  • The extend path's replace-with-keyframes carries the tween ease, the easeEach and a keyframe's own ease. Played in real GSAP under power2.inOut, the old end keyframe still lands at 2 s after the tween stretches to 3 s.
  • The gesture merge carries the tween ease and easeEach. Its recorded segments after the first are none, and without a run ease it writes rounded percentages (a 1 s recording over a 3 s tween gives 16.667 and 33.333).
  • Played in real GSAP, a recording over the last second of a 10 s power4.out tween reaches x 50 half-way, as recorded; rounding there had merged its keyframes. A keyframe that plays at 8.5 s of a 10 s power2.out tween survives a recording over 9-10 s.
  • Played in real GSAP, a 1 s recording merged into a 3 s power2.out tween 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.
  • keyframeEases carries the keyframes' own ease.
  • Segment rows show percentages rounded to 3 decimals, and <0.001 for a tiny one; the old rows printed 1.2860082304526747e-7%.
  • The segment list labels unset segments of a power1.out tween power1.inOut, as GSAP plays them.
  • The card header reads power1.inOut for 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.out and none).
  • buildTweenSummary describes 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.ts plays real GSAP. It shows a tween-level easeEach changes 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 easeEach inside 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-level easeEach behaviour; they now put easeEach inside 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 to from 0 to 2 s). Step 1 is the same everywhere: select the box, Design > Motion, Per-keyframe easing > Set all > power3.out. Studio writes easeEach: "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):

Before, extend path, ease card at 2x

Record a gesture over the animation. It merges, every segment drops to none, and the segment list shows float noise (28.333333333333332%):

Before, gesture merge, ease card at 2x

Record a gesture over an animation with its own power2.out ease. The segment rows print raw percentages:

Before, gesture merge into a power2.out animation: raw percentages in the rows

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 plays power1.inOut on every segment in both:

Before, segment list with no eases (A) and with a power1.out tween ease (B)

After

The same steps on this branch. The ease is kept after the stretch and after the merge:

After, extend path: the animation stretches to 3 s and every segment keeps power3.out

After, extend path, ease card at 2x

After, gesture merge, ease card at 2x

After, gesture merge into a power2.out animation: rows rounded to 3 decimals

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:

After, ease card summary line at 2x

After, card in states A, B and C: header matches the rows, run ease on its own line in B, Mixed in C

After, segment list, dark theme

@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Edit accuracy: accurate 2055 (base branch 2055), smooth 1563 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (0)

Unstable (1)

  • seqplainkeys-none-pct-r0-nested-z100: tracking 248.42, pressJump 0, drop 248.42, reload 0, render 0.02, renderKey -, undo true, teleport false / tracking 0.26, pressJump 0, drop 0, reload 0, render 0.02, renderKey -, undo true, teleport true / tracking 0.26, pressJump 0, drop 0, reload 0, render 0.02, renderKey -, undo true, teleport true

@miguel-heygen
miguel-heygen force-pushed the fix/keyframe-drag-keeps-ease branch from c92f9c5 to 53278a1 Compare October 5, 2026 08:36
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.
@miguel-heygen miguel-heygen changed the title fix(parsers): dragging a keyframe keeps the keyframes' ease fix(studio): keyframed animations keep their ease through every Studio edit Oct 5, 2026
…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.
@miguel-heygen
miguel-heygen marked this pull request as ready for review October 5, 2026 19:02

@jrusso1020 jrusso1020 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.

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 with obj.ease || keyframes.easeEach || "power1.inOut". easeEach is only read inside the keyframes object, and array steps inherit the timeline default ease: "none".
  • keyframedTweenEases (utils/gsapKeyframeEases.ts:12) encodes exactly those rules. So dropping the tween-level easeEach copy 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' ease as the run ease and easeEach as easeEach.

Round trip:

  • moveKeyframeInScript re-keys only the percentage props.
    • The Acorn writer keeps every other prop as its source text.
    • The recast writer keeps the AST nodes.
    • So ease and easeEach survive 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's easeEach.
  • 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 keyframeEases helper rather than adding a new one.
  • warpsTime reuses the existing timingEase check instead of a second definition of "this ease warps time".
  • plainPercentKey replaces twelve inline `${pct}%` templates across the two writers, the parser and the unroller with one owner. The three PERCENTAGE_KEY_RE copies (/^(\d+(?:\.\d+)?)%$/) reject exponent notation, so writing plain decimals is the right side to fix.

Tests at this head:

  • packages/parsers passes 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 moveKeyframeInScript drops the non-percentage props 4
    Recast moveKeyframeInScript drops the non-percentage props 4
    plainPercentKey writes exponent notation 3
    Acorn parser copies the tween-level easeEach again 1
    Acorn split goes back to easeEach ?? ease in the run-ease slot 1
    Gesture merge skips progressAtTime 2
    Gesture merge drops the ease: "none" on recorded segments 1
    Gesture merge keeps old keyframes by percentage instead of play time 1
    Segment list goes back to the easeEach ?? ease ?? "none" fallback 1

Nits (not blocking):

  • gsapParser.ts:1055-1056 still says tween-level easeEach is "handled below", but this PR removed that handler. It should now say GSAP ignores the key.
  • easeDisplayName (AnimationCard.tsx:35) and easeLabel (gsapAnimationHelpers.ts:11) are the same custom( / EASE_LABELS lookup 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

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 5, 2026
Merged via the queue into main with commit 0d5e395 Oct 5, 2026
94 checks passed
@miguel-heygen
miguel-heygen deleted the fix/keyframe-drag-keeps-ease branch October 5, 2026 19:34
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