fix(studio): resize an element whose scale is an instant hold - #3092
Conversation
…cale Resizing a scale-driven element failed with "animation not found", and the element could not be saved again at all. The tween resolved for the resize's group is, for such an element, the one carrying `scale`. When it is an instant hold the code handed it straight to the size commit, which wrote `width` and `height` into it. One tween now spanned two property groups, so the parser classified it as neither — it lost its group suffix, and its id with it. Every later edit looked for a scale tween and a size tween, found a tween with no group at all, and had nothing it could address. Size goes to a size hold of its own now; the scale hold is left alone. Where the damage has already happened it is repairable: splitting the mixed tween into property groups gives back a `scale` tween and a `size` tween.
Resizing an element whose scale is an instant hold saved the new size and then snapped the element back to its authored position, every drag. Whether the caller persists the drag offset was inferred from the element's tweens: a scale-group tween meant "the resize settles its own position, hold the offset back". That is true of the scale route, which commits a scale and then measures where centre-scaling put the box. It is not true of an element whose scale is an instant hold — that has a scale-group tween and still commits width/height. So the offset was withheld, nobody wrote it, and the position tween re-asserted the authored value a frame later. The outcome carries the answer now. A resize that moved the element says so; everything else leaves the anchor to the drag, which is what already handles it.
Both faults on this branch were found one composition at a time, which is a bad way to find the third. Drives the real intercept across the cross-product of what an element's tweens can look like — scale absent, an instant hold, a real tween, longhands; size absent, a hold, a tween; position absent, a static hold, a tween; plus the 3D and rotation set a card carries and a tween that already spans two groups — and holds all 108 to the two rules that were broken: never address an animation the source does not have, and never leave a tween spanning two property groups. The server stand-in answers the way the real one does, rejecting an id it cannot find, and applies what it is told, so a run that corrupts the animation list is caught by the next mutation in the same run.
A free corner drag whose two axes happened to land within 0.01 of each other was committed as one `scale` value for both, and gave back a box shorter than the one dropped — 326x213 became 326x211. The threshold was a fixed amount of scale. That is invisible on a 40px box and two pixels of height on a 408px one, and the question was never about scale: it is only ever whether using one value for both axes would move an edge. So it asks that, in pixels, against the axis the collapse would distort. Found by a geometry sweep added alongside: 120 runs over the routes a resize can take, six rotations from none to 180 degrees, and four drops from near-zero to an aspect flip, each checking the committed scale or size reproduces the RENDERED box the user dropped — and, where the resize reports it owns the drag offset, that the box lands on the drop point too. Six runs failed before this change, all of them the near-uniform shrink, at every rotation including none. Rotation was the suspect and turned out to be innocent.
…eral A composition animates the same property more than once — a scale-in early, a scale-out late — and the one the user means is the one under the playhead. Editing the wrong one changes a moment they are not looking at and leaves the moment they are looking at unchanged, which reads as "the resize did nothing". Six playheads across two scale tweens, including both sides of the midpoint between them and a time past the end of both. Verified against a stubbed selection that always takes the first tween: three of the six fail.
vanceingalls
left a comment
There was a problem hiding this comment.
APPROVE @ 1aa0fdb
Three targeted fixes to the resize intercept, each with a matching test that would fail with the fix reverted. The sizeSet guard (fix 1), the ownsDragOffset outcome channel (fix 2), and the pixel-scoped uniformity check (fix 3) are all correct in their primary scenarios, and the coordinate contract in computeDraggedGsapPosition is untouched so preview/commit still agree by construction. New sweeps (gsapResizeSweep.test.ts structural + gsapResizeGeometrySweep.test.ts geometric) harvest commitMutation.mock.calls and reconstruct the AABB against the drop point — CORRECT persisted-state assertions, guard semantics not presence.
-
[NOTE]packages/studio/src/hooks/gsapResizeIntercept.ts:432,544,566— the threeownsDragOffset: truereturns fire regardless ofresizeGroup. PR body says "the three scale-route returns that run the centre-scaling correction set it, and every other route leaves it unset", but the returns are reached wheneveranimis a real non-instant-hold tween — including the size-tween route (element has no scale group, only a real width/height tween). In that casescaleDraftElisnull,finalizeScaleResizeCommitis a no-op, and the caller inuseGsapAwareEditing.ts:275will now withhold the drag offset — regressing the pre-existing behaviour wherescaleRoute=falseforwarded the offset throughtryGsapDragIntercept.settle()still writes to the live DOM, so the release frame looks right; the persisted state does not, so a re-seek reverts. NeithergsapResizeGeometrySweep.test.ts(mock'slive.posstarts atdrop.x/y, and nothing in the size branch moves it, so the position check passes trivially) noruseGsapAwareEditing.test.tsx(both new cases use a{ propertyGroup: "scale" }fixture) exercises this. Easiest guard:const ownsDragOffset = resizeGroup === "scale"and spread it into the three returns. Size-tween-only elements are uncommon in HeyGen compositions, so this is a follow-up rather than a blocker for the instant-hold win. -
[NIT]packages/studio/src/hooks/gsapResizeIntercept.ts:261-262— the pixel-drift check usescssHon the assumption the shorthand takesnewScaleX(verified at :266 and :282-284 — correct). Nothing wrong, but a one-line comment tying the choice ofcssHback to that assumption would save the next reader the derivation ("shorthand = newScaleX applied to Y, so the collapse distorts the Y edge by |Δscale| × cssH").
CI (required per ruleset 14211637 — 8 checks): Semantic PR title / Build / regression / Test: runtime contract green; Typecheck / Test / Render on windows-latest / Tests on windows-latest still pending. Non-required Lint/Format/Preflight/Producer/SDK/Studio-load/File-size all green.
— Via
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at 1aa0fdba6.
Three faults, each traced to a real corruption on real compositions, each fix pointed and understood; the fault-1 diagnosis (one tween misclassified because it spans two groups, ID suffix and repair path both) and the fault-3 reframing (the question was never scale, it was pixels) are especially clean. The two sweep suites (108 structural shapes × 120 geometry cases across five routes / six rotations / four drops) turn "found one composition at a time" into a guardrail. This is the shape I want more HF fixes to take.
One blocker on fault 2's inverse. The three return { status: "persisted", ownsDragOffset: true } sites at gsapResizeIntercept.ts:432, :544, and :566 are all reachable from BOTH the scale route AND the size route. For a real (non-hold) size tween — auto-keyframe on or off, in-range or outside-range — resizeGroup === "size", scaleDraftEl stays null, and finalizeScaleResizeCommit is a no-op, but ownsDragOffset: true is returned anyway. The caller at useGsapAwareEditing.ts:275 then skips tryGsapDragIntercept(offset), and the anchor never lands in the script — same class of "an offset nobody wrote" the PR body flags as the fault-2 anti-pattern, just inverted (declared instead of inferred). Any corner-drag on a size-tween element persists the new width/height at the OLD anchor position after soft-reload. Inline at gsapResizeIntercept.ts:432 with the trace + suggested fix; the same shape covers the other two returns.
One concern — the geometry sweep silently accepts this. runCase initializes live.pos to the drop point (gsapResizeGeometrySweep.test.ts:203) and never has commitMutation update it (the vi.fn is a bare stub), so the "committed size or scale reproduces the rendered box" assertion is met even when the commit persists no position, and the "when ownsDragOffset is true, the box lands on the drop point" assertion is met trivially because live.pos was pre-positioned there. Same-file inline with a modelling suggestion.
What lands cleanly:
- Fault 1's diagnosis is exact and the repair path lands in the same call.
gsapResizeIntercept.ts:120-122detects any animation without apropertyGroupand hands the split-capable fetch toresolveGroupTween; the#cardreproduction ingsapResizeMixedTween.test.ts:57-102drives an actual 404-throwing server stand-in against the exact five-tween shape and verifies no stale id reaches it. This is the "found this by A/B'ing on a real composition" work that Miguel's memory writes reward. Every "damaged composition is auto-repaired" claim in the PR body maps to a specific line. sizeSetguard at :162-165 — when the resize is scale-route (resizeGroup === "scale"), the resolvedanim(the scale hold) is deliberately NOT reused as the sizeSet target;findSizeSetAnimationruns and falls back tocommitStaticGsapSizecreating a freshaddif nothing matches (gsapDragCommit.ts— verified). This is the fault-1 fix's actual mechanism, and the "size hold of its own" comment matches the code.- Pixel uniformity check at :261-263 —
uniformDrift = |ΔScale| × cssH > 0.5is the RIGHT question given the code commitsscale: newScaleXat :266. The commit's height error IScssH × |newScaleX - newScaleY|, so the threshold is measuring exactly what the collapse would distort. And> 0.5matches the position-rounding tolerance elsewhere in the file — a distortion below rounding is invisible by construction. The bug-report numbers (630×408 dropped at 326×213 coming back 326×211) prove the old 0.01-of-scale threshold was scale-dimensional; the new one is pixel-dimensional. useScaleLonghandstriggers the full-rewrite path at :472 — the "GSAP animates each name independently" note in the comment matches how a{scale, scaleX, scaleY}mix silently discards the shorthand. Normalizing every existing keyframe'sscaletoscaleX/scaleYat :501-508 (only whennonUniformScale) keeps a purely-uniform drag on the shorthand and only rewrites when the axes actually diverge.- Playhead-under-cursor tween pinning at
gsapResizeSweep.test.ts:239-274— six playheads across twoscaleAttweens, three per side of the boundary at t=3/3.1. This is the "the resize did nothing because it edited the moment you weren't looking at" bug expressed as a test, and the sweep failed 3-of-6 against a stubbed selection that always takes the first — a real regression guard. ownsDragOffsetas reported truth vs. inferred guess is the right shape. The caller comment atuseGsapAwareEditing.ts:261-265is worth its ink; keepingscaleRoutearound only for the LIVEsettle()step (:239) and reading the reported flag for the persist decision is the correct split. The regression above is the wiring of the flag, not the shape.- Idempotence-by-construction is present in each of the three fault mechanisms — the split-repair, the size-hold-of-its-own path, and the pixel-uniformity check all leave a re-applied resize deterministic. The suites cover it as a rule rather than a test each.
The blocker is small — one predicate on each of three returns, or lift the flag into finalizeScaleResizeCommit's output — and the sweep concern is a shape suggestion, not a rewrite. With those closed, this is a clean piece of work.
| ); | ||
| await finalizeScaleResizeCommit(); | ||
| return { status: "persisted" }; | ||
| return { status: "persisted", ownsDragOffset: true }; |
There was a problem hiding this comment.
🔴 Blocker — fault 2's inverse. The three ownsDragOffset: true return sites are reachable from the size route, so a resize on a real (non-hold) size tween drops the anchor offset.
The three sites — this line, :544, and :566 — are shared between the scale-route path and the size-route path. Trace for a size-tween element (selectedGsapAnimations = [size-tween with duration > 0], no scale group, auto-keyframe on, drag with offset={x, y}):
hasScaleGroup = falseat:110→resizeGroup = "size".animresolves to the size tween.isInstantHold(anim) = false, so we pass over the fault-1 branch at:149.resizeGroup === "scale"at:218is false → we take the size branch at:295:resizeProps = { width, height }, andscaleDraftElstaysnull(only set inside the scale branch at:277).- Fall through to the add-keyframe path at
:554(or:472outside-range, or this line if auto-keyframe is off). finalizeScaleResizeCommitruns butscaleDraftEl === nullshort-circuits it at:314— no position write.- This line returns
ownsDragOffset: true.
The caller then reads outcome.ownsDragOffset === true at useGsapAwareEditing.ts:266, sees !ownsDragOffset = false at :275, and skips tryGsapDragIntercept(offset). The gesture's live setElementGsapPosition at :246 renders the offset in the iframe's GSAP runtime — but as the comment on elementGsap.ts:6-7 calls out, that's a live touch, not persistence. The softReload: true on the commit at :563 re-runs the timeline from the source script, which still holds the OLD position — the live gsap.set is overwritten. Element sits at the pre-gesture position after every drag. Same UX symptom as fault 2 (element "snaps back to authored position"), same class of "an offset nobody wrote".
The PR body says it correctly:
the three scale-route returns that run the centre-scaling correction set it, and every other route leaves it unset so the caller forwards the offset exactly as it does for a plain size resize.
That's the intent, but the code doesn't distinguish. All three returns set the flag regardless of route.
Reproduction case in the geometry sweep:
- Route "size tween" (
gsapResizeGeometrySweep.test.ts:94) plus rotation 0 plus shrink drop ({w: 326, h: 213, x: 60, y: 40}). - Real element (no
commitMutationstub applying mutations): base position stays at whatever the pre-gesture value was, commit writes only{width, height}, nogsap.set(x, y)reaches the timeline, soft-reload lands the box at the OLD anchor.
The sweep doesn't catch it because runCase at gsapResizeGeometrySweep.test.ts:200-204 pre-positions live.pos to the drop point — inline on that separately.
Suggested fix. The simplest safe form is to key the flag off whether the scale draft actually ran, which is already tracked by scaleDraftEl. At each of the three returns:
return { status: "persisted", ownsDragOffset: scaleDraftEl !== null };Equivalent: resizeGroup === "scale" — but reading it off scaleDraftEl keeps the flag colocated with the mutation it describes ("did finalize's correction actually run?") rather than with a routing input.
A slightly nicer version threads the flag out of finalizeScaleResizeCommit's return (currently void), so the return-site can't get out of sync with what finalize actually did. Not required — one predicate per return is fine.
Missing test. No existing test catches this because useGsapAwareEditing.test.tsx mocks tryGsapResizeIntercept end-to-end. The two new tests at :316 and :334 cover mocked ownsDragOffset: true and ownsDragOffset: undefined respectively, but nothing wires up an unmocked resize of a size-tween-with-duration to verify the flag comes back false. One test would fail today against the real intercept:
it("does not claim the anchor when the size route commits width/height", async () => {
document.body.innerHTML = "";
const el = document.createElement("div");
el.id = "el";
el.setAttribute("data-hf-studio-original-box-width", "300");
el.setAttribute("data-hf-studio-original-box-height", "200");
document.body.append(el);
const sizeTween = tween("#el-size", { width: 300, height: 200 }, 2);
const outcome = await tryGsapResizeIntercept(
{ id: "el", selector: "#el", element: el } as DomEditSelection,
{ width: 400, height: 260 },
[sizeTween],
null,
vi.fn() as never,
async () => [sizeTween],
);
expect(outcome.status).toBe("persisted");
expect(outcome.status === "persisted" && outcome.ownsDragOffset).not.toBe(true);
});Same shape as the third gsapResizeMixedTween.test.ts test at :149 — natural next-door test.
— Review by Rames D Jusso
| usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null }); | ||
| const live: Pose = { | ||
| box: { ...testCase.box }, | ||
| pos: { x: testCase.drop.x, y: testCase.drop.y }, |
There was a problem hiding this comment.
🟡 Concern — the geometry sweep pre-positions live.pos to the drop point, so the sweep passes even when the commit persists no position.
runCase initializes live.pos at this line to {x: testCase.drop.x, y: testCase.drop.y}, which is where the DRAFT would have visually placed the element in a real gesture. commitMutation is a bare vi.fn() (:209), so it never dispatches to the fake gsap.set in mountCase (:174-178) that would otherwise update live.pos — the only way live.pos moves during the run.
That means:
settled.x/y = renderRect({pos: live.pos, ...}).x/yat:222is computed from the pre-set drop point.dropped.x/y = el.getBoundingClientRect().x/yat:207is computed from the SAMElive.pos(same fakegetBoundingClientRectclosure).off(settled.x, dropped.x)is trivially false.
So the ownership assertion at :234 ("when the resize reports ownsDragOffset, the box must also land on the drop point") is satisfied whenever live.pos was pre-set to the drop point — which is always — REGARDLESS of whether the commit actually wrote a position to the script. The blocker above is a live regression that the sweep declares clean.
To make the sweep model persistence:
- Initialize
live.posto the BASE position (testCase.base.x/y), not the drop point — that's where the element sits before the gesture's draft renders. - Have
commitMutationapply position writes:{x, y}in theproperties(or in thekeyframes[].properties), plus the specificadd-with-gsap.setandcommitStaticGsapPosition-shaped mutations that the finalize step actually emits. Thenlive.posmoves only when the commit actually persists a position. - Optionally cross-check by asserting the SIZE route with a
size tweensetsownsDragOffset: false— the routes list already carries the intent ("size tween"should not own the drop point).
Alternatively (lighter-touch): keep live.pos initialized to the drop point but ADD an assertion — when owns === false, some position mutation must have reached commitMutation.mock.calls, or the harness accepts that the caller (not the intercept) owns the offset and doesn't assert on landing. As-is, the sweep can't distinguish "commit put the box on the drop point" from "harness pre-put the box on the drop point and the commit did nothing".
The commitMutation.mock.calls inspection already exists in committed() at :126-151 — extending it to catch position writes is small.
Non-blocking, but worth closing: this is the invariant the sweep advertises, and today it doesn't hold.
— Review by Rames D Jusso
Review caught the inverse of the fault above it. The three returns that report `ownsDragOffset` hardcoded `true`, and they are reached by the size-tween route too — a real, non-hold size tween with no scale group. That route never captures the element, so the finalize step no-ops, nothing writes the position, and the caller withholds an offset it would otherwise have forwarded. The release frame looks right because the live DOM was already settled; the persisted state reverts on the next seek. Fixed the same way the fault above it was: the finalize step reports whether it settled the drop point rather than the caller assuming from where it was called. It answers false when it is not the scale route, false when it cannot measure, and TRUE when the box is already on the point with nothing to write — forwarding an offset on top of that would move it off. The geometry sweep accepted this silently, and the reviewer said why: its live pose starts at the drop, which is where the gesture leaves it, so a route that moves nothing trivially "lands" there. Each route now declares whether it settles the drop point and the sweep holds it to that, which fails on 24 of the 120 runs with the old hardcoded `true`.
vanceingalls
left a comment
There was a problem hiding this comment.
APPROVE @ 85c796b6eb0e059b57789f49cfc12d89f2f77614 — R2 (delta on top of R1 1aa0fdba6).
One new commit — 85c796b, "only claim the drop point on the route that settles it" — folds in the R1 NOTE cleanly. Two files touched: gsapResizeIntercept.ts (+15 / -12) and gsapResizeGeometrySweep.test.ts (+53 / -11). No other delta.
-
NOTE fold-in — polarity & coverage verified.
finalizeScaleResizeCommitnow returnsPromise<boolean>—falsewhenscaleDraftEl == null(size-only route) or when the finite / draft-point / selector safety-nets bail,truewhen the scale route actually settled the drop (including the "already on drop point" short-circuit,residual < 0.5, where nothing gets written but scale still owns the anchor — Miguel's comment reasons that correctly). All three previously-literalownsDragOffset: truereturn sites (now lines 437 / 548 / 569, previously 432 / 544 / 566) propagate that boolean:return { status: "persisted", ownsDragOffset: await finalizeScaleResizeCommit() };. This is equivalent to the suggestedresizeGroup === "scale"guard —scaleDraftElis set only on the scale route — and arguably tidier because the runtime signal is the same authority the finalizer already keys off. Consumer atuseGsapAwareEditing.ts:266-275readsoutcome.ownsDragOffset === true(strict equal), sofalseandundefinedcollapse to the same "caller forwards the offset" branch — clean. -
Test hits the exact critical property from the NOTE. ROUTES gained per-route
settles: booleanmetadata —scale tween/scale longhands=true;scale instant hold/size tween/size instant hold=false. The judge assertsowns === testCase.settlesand fails withownsDragOffset <actual>, expected <settles>. Thesize tweencase (real non-instant-hold size tween, 6 rotations × 3 drops = 18 shapes) is exactly the R1 NOTE case — pre-fold that route reportedownsDragOffset: trueand the caller withheld an offset nobody wrote; post-fold it reportsfalseand the drag intercept persists the anchor. Not just an "instant hold" cover. -
No other regressions in the delta.
computeDraggedGsapPositionuntouched.commitMutationmock behaviour untouched. The test-scaffolding change is a data-shape widen (() => Anim[]→{ animations, settles }) plus extracting the judge into a named function — theofftolerance, thepersistedPositions/persistedScale/renderRectreconstruction, and the box-size assertion path are all preserved. Nothing shrunk.GsapEditOutcome.ownsDragOffsetstays optional, so the type also permitsfalsein the "persisted" variant. -
CI: Preflight, Lint, Format, Fallow audit, Producer unit tests, Studio load smoke, Studio timeline viewport gate, SDK unit+contract+smoke, Semantic PR title, player-perf, Analyze (actions/python) all green. Build, Typecheck, CLI smoke, Producer integration tests, regression-shards, Test: runtime contract, Preview parity, Analyze (js-ts), Tests/Render on windows-latest still pending (freshly pushed). No failures.
R1 NOTE folded cleanly, tests catch it, no new issues. Ship.
— Via
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at 85c796b6e — delta from 1aa0fdba6: gsapResizeIntercept.ts (net +4 lines across three return sites and one helper signature) + gsapResizeGeometrySweep.test.ts (route-model widened, judge rewritten).
Both R1 findings closed, and each with a shape stronger than I asked for.
🔴 Blocker — closed at gsapResizeIntercept.ts:437 / :548 / :569. Rather than reading a call-site proxy (scaleDraftEl !== null) the way I suggested, finalizeScaleResizeCommit now returns Promise<boolean> and each of the three finalize-capable return sites reads it directly: return { status: "persisted", ownsDragOffset: await finalizeScaleResizeCommit() }. That's the right place for the truth to live — the helper knows whether it actually wrote a position or bailed out — and it catches two failure modes my one-liner would have missed:
- Non-finite residual (
:357). WithscaleDraftElset (scale route) butresidual.xorresidual.ynon-finite, the pre-PR code returned void and the caller still declared ownership; the new code returnsfalseso ownership follows the write. - "Already on the drop point" (
:361). Scale route, residual absorbed under half a pixel — the helper skips the commit but still returnstrue, and the comment (Settled, with nothing to write. Still ours: forwarding the drag offset on top would move the box off the point it is already sitting on.) names exactly why. That's the case a naive predicate would have gotten wrong in the other direction.
Between those two, the flag now reflects "did finalize actually settle the drop point", not "does this call site look like the scale route" — the distinction I was groping at in R1 stated cleanly in code.
🟡 Concern — closed at gsapResizeGeometrySweep.test.ts:83, :98-115, :270-278. ROUTES now declares settles: boolean per route, buildCases propagates it onto every Case, and judge asserts owns === testCase.settles before the position check runs — so a run whose ownsDragOffset disagrees with the route's declared truth fails the sweep even when live.pos starts at the drop. The comment at :270-275 calls out exactly the fixture shape I flagged (the fixture's live pose starts at the drop, which is where the gesture leaves it, so a size route trivially "lands" there). Ownership is a first-class invariant in the sweep now instead of a byproduct.
Two smaller touches worth naming:
"scale instant hold"is markedsettles: false, which is the right encoding of fault-1 from this PR (the instant-hold scale tween commits via the size route). If a future fix accidentally routes it through scale, the sweep will catch the drift.- The extracted
judgefunction (:255-283) makes each shard-check independent, which will read cleanly if this fixture grows.
Runtime contract + Windows tests still finishing at the time of this review; regression shards were green on the previous SHA so I don't expect surprises. LGTM from my side.
What
Three faults in resizing an element whose size is driven by a scale animation. The first two are the same element shape, an instant hold: a
duration: 0tween carryingscale, which is how a composition pins a static scale.1. The resize wrote size into the tween carrying scale. That left one tween spanning two property groups. The parser classifies such a tween as neither, so it loses its group suffix and its id along with it:
#card-to-0-scalebecomes plain#card-to-0. Every later edit went looking for a scale tween and a size tween, found a tween with no group at all, and had nothing it could address. The element failed to save withanimation not foundand could not be edited again.2. The drag offset was withheld from a resize that never moved the element. The size saved and the element then snapped back to its authored position on release, every drag.
3. A near-uniform drag silently lost up to one percent on one axis. Drop a 630x408 box at 326x213 and it came back 326x211.
Why
1. The tween resolved for the resize's group is, for a scale-driven element, the one carrying
scale. When it is an instant hold the code handed it straight to the size commit, which wrotewidthandheightinto it. Size and scale are separate property groups for a reason; putting both in one tween makes it addressable as neither.2. Whether the caller persists the drag offset was inferred from the element's tweens: a scale-group tween meant "the resize settles its own position, hold the offset back". That is true of the scale route, which commits a scale and then measures where centre-scaling put the box. It is not true of an element whose scale is an instant hold, which has a scale-group tween and still commits width/height, moving nothing. So the offset was withheld, nobody wrote it, and the position tween re-asserted the authored value a frame later.
3. The test for "is this drag uniform enough to commit one
scalevalue" was a fixed 0.01 of scale. That is invisible on a 40px box and two pixels of height on a 408px one, and the question was never about scale: it is only ever whether using one value for both axes would move an edge.How
1. Size goes to a size hold of its own; the scale hold is left alone.
2. The resize outcome carries the answer instead of the caller guessing.
GsapEditOutcomegainsownsDragOffseton the persisted case: the three scale-route returns that run the centre-scaling correction set it, and every other route leaves it unset so the caller forwards the offset exactly as it does for a plain size resize.3. The uniformity test asks its real question, in pixels, against the axis a collapse would distort.
Where fault 1 has already damaged a composition it is repairable without hand-editing: splitting the mixed tween into property groups gives back a
scaletween and asizetween.Test plan
How was this tested?
Three targeted tests in
gsapResizeMixedTween.test.ts, driving the real intercept against the five-tween shape that surfaced this, with a server stand-in that rejects an id it does not have. Each verified to fail with its fix reverted:width/heightinto the tween carryingscaleTwo sweeps, because both faults were found one composition at a time and that is a bad way to find the third:
gsapResizeSweep.test.ts: 108 shapes, the cross-product of scale absent / instant hold / tween / longhands, size absent / hold / tween, position absent / static hold / tween, and either nothing extra, the 3D-and-rotation set a card carries, or a tween that already spans two groups. Every run held to two rules: never address an animation the source does not have, never leave a tween spanning two property groups. Plus six playheads across two scale tweens, pinning which tween a resize edits when the element has several; three of the six fail against a stubbed selection that always takes the first.gsapResizeGeometrySweep.test.ts: 120 runs over the five routes, six rotations from none to 180 degrees, and four drops from near-zero to an aspect flip, checking the committed scale or size reproduces the rendered box that was dropped, and that the box lands on the drop point where the resize reports it owns the offset. Six failed before fix 3, all of them the near-uniform shrink, at every rotation including none. Rotation was the suspect and came out clean.Verified in Studio against a real composition, and A/B'd rather than assumed: with the fix in, the element holds its drop point; with it reverted, the snap-back to the authored transform returns on every drag.
Suites green: studio 3507, studio-server 416.
Not covered