fix(core): a cropped element's edge stays exact while GSAP tweens it - #5010
Conversation
Edit accuracy: accurate 1562 (base branch 1556), smooth 1333 of thoseThe gate passes. Newly passing (6)
Quarantined, measured but not gated (0) |
…sitions set elements
…r 2D GSAP transforms
jrusso1020
left a comment
There was a problem hiding this comment.
Approving. Setting force3D: false through the public gsap.config is the fix at the cause. The install order is what makes it work for elements that are set() before their timeline.
Install and chaining
installFlatGsapTransformsruns at script evaluation, next to the other evaluation-time installs inentry.ts. It configures a GSAP that is already loaded and trapswindow.gsapfor one that loads later, so the setting is in place before a composition'sset()/from()stores it on an element.- It chains to the producer's early stub (
hf-early-stub-inline.ts), which defines a configurable getter and setter onwindow.gsap. Assignments still reach the stub's setter, so the timeline proxy wrapping is kept, and reads go through the stub's getter. - An author's own
gsap.config({ force3D: true }), or a tween-levelforce3D, still wins, because it runs later or is per tween. parseTransformMatrixin the engine already handles the 2Dmatrix(...)form that computed styles now report, so nothing downstream relies onmatrix3d.
Goldens and baseline: these are intended baselines, not stray render output.
- The png-sequence and mov-prores outputs are LFS files already tracked on main.
- png-sequence is a byte-strict gate (
maxFrameFailures: 0), and itsmeta.jsonnamesdocker:test:updateas the way to regenerate it. baseline.jsonis re-banked per PR, as on #4935 / #4934 / #4889.
Reuse: the only other window.gsap trap is the producer's stub. It only exists in renders, and this fix has to reach Studio preview too, so the runtime is the right owner, and it chains to the stub rather than duplicating it. Nothing in core configures GSAP globally today.
Simplicity: 25 lines, and I don't see a smaller version that still covers the set()-first case. Applying the setting at DOMContentLoaded or at timeline bind is the simpler-looking alternative, and the body's second table shows it misses that case. The hfFlat install-once marker and the non-configurable guard are each pinned by a test.
Tests and mutations (core, 221 tests, green): each of seven single-line mutations of installFlatGsapTransforms turns at least one test red:
- dropping the config of an already-loaded GSAP (2 red)
- dropping the config on assignment (6)
- the setter skipping the stub's setter (1)
- the getter ignoring the stub's getter (1)
- removing the non-configurable guard (1)
- removing the install-once marker (1)
force3D: "auto"(7)
The entry.ts call itself has no unit test. The producer and edit-accuracy CI results cover it.
CI at this head: no red and nothing pending, including the Perf jobs (fps, scrub, load, drift, parity), player-perf and all ten regression shards.
Not blocking:
- The render change reaches every 2D GSAP transform, not only cropped ones. The body measures preview main-thread work but not producer render wall time, and repainting in-page instead of compositing could cost more on heavy scenes. The Perf jobs passing is some evidence. One timed render of a heavy fixture, before and after, would settle it.
- The known limits (runtime added after the composition script: the bundler-error fallback, a composition loaded by URL, an inline head timeline) are stated in the body. Worth tracking alongside the existing note on the other evaluation-time installs.
— Rames
What this fixes
A crop on an element that GSAP scales or moves shimmered in renders: while a tween ran, the crop edge jumped by up to 1 px from frame to frame, then settled when the tween stopped. The Studio preview geometry was exact, so the export disagreed with what the author set. Six edit accuracy cases failed on this (
crop-scale-px-*-mid, render 0.99 px and 0.77 px).Cause
Mid-tween, GSAP's default
force3D: "auto"writestranslate3d(...)instead oftranslate(...)(CSSPlugin_renderCSSTransforms). A 3D transform gives the element its own compositing layer, and Chrome paints aclip-pathedge on such a layer hard, snapped to whole pixels. At a tween boundary GSAP writes the 2D form again, so the edge snaps back to the exact position. The same static page shows it without any HyperFrames code:clip-path: inset(0 35.55px 0 0)scale(1.125)translate3d(0,0,0) scale(1.125)scale(1.225)translate3d(0,0,0) scale(1.225)Change
The runtime sets GSAP's
force3Dtofalsethrough the publicgsap.config, the moment GSAP loads.installFlatGsapTransformsruns while the runtime script evaluates (besideinstallAuthoredMediaCaptureinentry.ts), configures a GSAP that is already there, and wraps thewindow.gsapassignment so a GSAP bundle that loads later is configured on arrival. Either way the setting is in place before a composition's own script runsset(),from()orfromTo(), which is when GSAP stores it on each element. The wrapper chains to an accessor already onwindow.gsap(the producer's early stub traps the same property) and installs once.z,rotationX,rotationY,transformPerspective) is still written as 3D by GSAP.gsap.config({ force3D: true })itself keeps its own setting.window.gsapnon-configurable before the runtime (a globalvar gsapahead of it) is not trapped: a GSAP already there is configured, one that loads after it is not and keeps 3D tweens.Evidence
Per-frame crop edge in a producer render of a bench case (crop on a scale tween, 30 fps, t = 1.8 to 3.2 s, 43 frames, JPEG as the bench renders, Chrome headless shell 152):
The same case, with and without a
gsap.set()on the element before its timeline (crop edge error at frames 30 to 66, producer render):gsap.set()firstcrop-scale-px-*-midcases are newly passing and no case lost its pass.baseline.jsonis the gate artifact's, committed as the gate asks. The render error itself moves both ways; see the trade below.product-promo,kinetic-type), 5 alternating rounds against main: no separable difference in per-frame main-thread work (medians 3.02 vs 3.21 ms and 3.30 vs 3.08 ms) or dropped frames. Measured with an earlier build that applied the same setting at timeline bind.heygen-promo-preview-assets) and most ofstyle-17-prod. In the rest, the change is fine texture and outline sharpness inside layers that move (video included), because they are now painted in the page rather than resampled as a 3D layer. Their worst frames are visually the same. Nothing moves, appears or disappears. One fixture (hf2550-video-subcomposition-ghost) has no renderable media in that environment and was not compared.init.test.ts, real GSAP, in page order: runtime script, GSAP bundle, composition script, DOMContentLoaded):to(),from(),fromTo()andset()-then-to()draw in 2D mid-tween; aset()then moved element draws in 2D when GSAP loaded before the runtime; a composition's ownforce3D: trueis kept; an opacity-only tween writes no transform; a second install configures GSAP once; an accessor already onwindow.gsapstill receives GSAP; a non-configurablewindow.gsapis configured without the runtime throwing (init.flatGsap.test.ts, its own file because the property cannot be removed). Seven single-line mutations ofinstallFlatGsapTransformseach turn at least one test red. 3 green runs; the runtime folder (69 files, 1815 tests) passes.Render change: transformed layers stay crisp mid-motion
Every element GSAP moves, scales or rotates in 2D now paints in the page instead of on its own compositor layer while the tween runs. On main such an element is drawn as a resampled texture mid-tween and snaps back to a sharp paint when the tween ends; on this branch it is painted sharp throughout.
Two producer goldens are byte-strict (
maxFrameFailures: 0) over a GSAP-rotated icon and are regenerated here withdocker:test:updatefromDockerfile.test(Chrome 148):png-sequence: 59 of 60 frames change, every changed pixel inside the 44 x 44 px icon, only on the ring and dot edges.mov-prores: on main this fixture already scored 30.04 to 30.06 dB against its June golden, just over the 30 dB bar, because its text renders about 1 px differently today; the icon change took it to 29.88. The regenerated golden matches today's render.compiled.htmlsnapshots are left as they were: the update also rewrites them, but only for drift unrelated to this change (they do not contain the runtime), and the compile check passes either way.Render accuracy trade
Chrome snaps a box's painted background to whole pixels when its layout position is fractional, in both modes. On a 3D layer the crop moves with that snap, but the crop edge itself snaps (the 1 px pop above). In 2D the crop clips at its exact position on top of the snapped background, so a cropped box at a half-pixel position paints up to 0.5 px short on an uncropped edge. Static page, Chrome 152, box at
top: 100.5px,height: 160px, cropped: 2D paints 101.0 to 260.5, 3D paints 101.0 to 261.0.Render error across the 1562 edit accuracy cases, this branch's banked
baseline.jsonvs main's (the same counts on two CI runs at different heads of this branch):crop-tween-px-r0-*, 0.02 to 0.26 px)The largest render error is now 0.37 px (
crop-spin-px-r0-nested-*-mid, 0.32 on main); on main it was 0.99 px.Known limits
The setting reaches only elements GSAP first touches after the runtime script has run. Three paths load the runtime after a composition's own script, and there an element
set()before its timeline still tweens in 3D:<body>(reported separately; it also affects the other script-time installs inentry.ts).composition-probe.ts).<head>before the runtime.Before
After