Skip to content

fix(studio): edits on GSAP-animated layers keep their keyframes and file positions - #4869

Merged
miguel-heygen merged 16 commits into
mainfrom
fix/studio-resize-gsap-positioned-css-size
Oct 2, 2026
Merged

miguel-heygen merged 16 commits into
mainfrom
fix/studio-resize-gsap-positioned-css-size

Conversation

@miguel-heygen

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

Copy link
Copy Markdown
Collaborator

What

This PR carries several Studio fixes for edits on GSAP-animated layers, one item per section. Items 1 (resize), 2 (cause A, undo), 3 (cause C, xPercent centring), 4 (font pick writes once), 5 (a focused field counts as an unsaved edit), 6 (a comment-ratchet test line) and 7 (an inspector edit during a preview reload) are in; later items are added as they land.

Item 1: a resize of a layer GSAP positions but never sizes

Resizing an element that GSAP positions but never sizes (for example one placed by a gsap.set({ x, y }) hold) now saves its new size as the element's own CSS width and height. The position part of the resize still goes through GSAP, as the compensating x/y, in the same undo step. Where the script already writes the size (a tween, a keyframe, a from/fromTo, or a size set), the resize still edits that animation, as before.

Why

Before this change, the size of a GSAP-positioned box went down the GSAP route too, which wrote a new tl.set("#target", { width, height }, 0). On a composition whose timeline has no tween, that set leaves the root timeline with a duration of 0, and the Studio preview never renders it. After a reload the box snapped back to its old size while keeping its new position. The file and the render had the new size; the reloaded preview did not.

The rule is one write channel per property: a gsap.set only where GSAP already animates that property, and a resize writes width/height, never scale.

Related work

Refs the edit-accuracy bench's resize-hold-*-root-* rows. The preview not rendering a set inside a zero-duration paused root timeline is a separate runtime issue in @hyperframes/core and is not touched here.

How

  • tryGsapResizeIntercept: on the hold/no-tween branch, if no known animation for the element writes width or height (properties, from properties or keyframes), it returns a new { status: "element-size" } outcome instead of adding a size set.
  • handleGsapAwareBoxSizeCommit: on that outcome, the size is saved by the existing plain box-size writer (handleDomBoxSizeCommit, the one non-GSAP elements already use) under the gesture's undo key, and the drag offset is still forwarded to the GSAP position writer. The rescaled crop comes with it.
  • The size is saved before the buffered GSAP writes and asks for no render of its own (deferRender), so the GSAP reload stays the gesture's last render. Saved after them, the gesture stayed open for the extra write, and a nested layer showed its timeline's start position for about 5 frames after release (the bench's teleport metric, 40 px). Saved before them with its own reseek, the reseek rendered the old tween over the settled position.
  • handleDomBoxSizeCommit takes an optional undo key so its write joins that step.
  • The plain box-size patch no longer saves GSAP's inline scale: none mask on an element whose transform GSAP renders. Saved, it would override a stylesheet scale on the next load (checked with a scale: 1.5 element: it keeps 1.5x after a reload).

Keyframe rule rows. A resize writes CSS width/height only when nothing in the script animates or sets the element's size; otherwise it still edits that animation. On the keyframes grid: resize under size, scale (a real scale tween) and keys (its keyframes animate width) is unchanged by this item. Resize under spin (GSAP animates rotation only) and fromto (GSAP animates x only) now saves the size as CSS, because nothing animates it there, as decided; their position or rotation still goes through GSAP.

Test plan

  • gsapResizeElementSize.test.tsx (new): runs the real resize and drag intercepts with the real plain writer. A GSAP-positioned, CSS-sized element saves width: 340px; height: 227px as inline CSS, makes no GSAP write of width/height, and moves its position hold under the same undo key. Control: with a size tween, the size still goes through GSAP and no CSS width/height is written.

  • gsapResizeIntercept.test.ts: the fade-only case now returns element-size with no mutation.

  • The new test also pins that the CSS size is saved before the GSAP write and with deferRender.

  • Both new assertions fail with the fix reverted (main writes { type: "add", method: "set", properties: { width, height } }; without the mask skip the patch carries scale: none).

  • Edit-accuracy bench, --filter '^resize-hold-' (48 rows): drop, reload and render pass on all 48. The 24 nested rows pass every metric, as on main. On the 24 root rows, reload and render were the failing metrics on main (reload 60-120 px) and now pass. Undo still fails on 21 of the 24 root rows (undone box off by 44-196 px); that is the separate in-place undo restore issue, not this writer.

  • Unit tests added/updated

  • Manual testing performed

  • Documentation updated (if applicable)

  • Comments follow CONTRIBUTING.md "Comments": they say why, not what, and a bug fix says what the code must do and how to reproduce the bug

Before

The hold fixture (gsap.set("#target", { x: 40, y: 20 }), CSS size 240x160) resized to 340x227, then reloaded: the box snaps back to 240x160.

Before: resized
Before: after reload

After

Same fixture and gesture: the reloaded box keeps 340x227. The file has width: 340px; height: 227px on the element and gsap.set("#target", { x: -10, y: -13.5 }).

After: resized, still selected, before any reload
After: after reload


Item 2: Cause A, an undo of a style-only edit on a layer GSAP parsed

An undo or redo of a crop (or any style-only edit) on a layer GSAP animates now puts the layer back where its file and script put it. Before, a gsap.set hold lost its position (10 px off on the edit-accuracy fixture), a tween counted the stylesheet translate twice, and a CSS rotate was applied twice.

Why. The in-place undo copies the file's attributes onto the live element. That wipes what GSAP wrote inline when it first parsed the element (translate: none; rotate: none; scale: none, and for an off-timeline gsap.set the transform itself), while GSAP's cache keeps the parsed values. When the script did not change, the restore only seeked, so GSAP never parsed the element again.

How.

  • applyUndoRestoreToPreview: when a synced element was parsed by GSAP and the document has one GSAP script, re-run the restored script in place, even if its text is unchanged. A document with several GSAP scripts cannot re-run one in place, so that case reloads.
  • applySoftReload takes those elements as reparse and resets GSAP's cache for exactly them.

Proof.

  • gsapUndoRestore.browser.test.ts drives real Chrome with the edit-accuracy fixtures (hold, tween, xPercent tween with CSS rotate: 30deg), crops the live layer, undoes and compares the box with the one before the edit. Without the fix 3 cases fail (hold y 330 vs 320; tween 660,367.5 vs 620,337.5; rotated box swapped); with it all pass.
  • gsapUndoRestore.test.ts adds the several-scripts branch: a style restore on a GSAP-parsed element in a two-script file reloads. Without the branch it returns soft; with it, full.

Keyframe rule rows. This item writes nothing; it changes only how the preview shows an undo or redo.

Before (cause A)

crop-hold-px-r0-root-z100: crop, then Cmd+Z. The layer lands 10 px below where its file puts it; redo is off by the same 10 px.

Before: after Cmd+Z the layer sits below the red line

After (cause A)

Same case: undo and redo land exactly (box error 0).

After: after Cmd+Z the layer sits on the red line


Item 3: Cause C, a move on a layer centred by an authored xPercent set

A move, nudge or resize of a layer centred by an authored gsap.set({ xPercent: -50, yPercent: -50 }) with an x/y tween now keeps that centring set in the saved file. Before, the position dedupe treated the centring set and the x/y tween as two conflicting position writes and deleted the set, so the saved layer landed half its size off (+120, +80 on the fixture).

How.

  • One predicate, isXYPositionWrite in packages/parsers/src/gsapConstants.ts: only writes of x/y count as position writes that conflict. Both writers' dedupe (acorn and recast), Studio's self-heal before a move commit and the stale-id rematch use it.
  • The drag's clearProps probe now restores every position channel, so a drag on a centred layer no longer flashes off-centre on release.

Proof.

  • gsapRuntimeBridge.test.ts and gsapWriter.parity.test.ts keep the centring set through a move; each fails with its part reverted.
  • The release flash has no unit test (the test DOM does not render it); its proof is the bench's teleport check: without that part, 19 rows failed teleport at 144 px.
  • CI on main plus this item alone: 34 newly passing (move, nudge and resize -tween-xpercent- rows), 0 regressed.

Keyframe rule rows. The x/y keyframe a move writes is unchanged (the existing position writer); this item only stops deleting a set nothing replaces.

Before (cause C)

Release, then reload: the layer reloads half its size off where it was dropped.

Before: dropped and reloaded positions differ

After (cause C)

Same gesture: the reloaded layer sits where it was dropped.

After: dropped and reloaded positions match


Item 4: Font pick writes once

Picking an imported font for a text layer now saves the style edit and its @font-face rule in one file write. Before, the edit and the @font-face were two writes, so the file could hold one without the other and undo needed two steps.

How. The patch request carries the imported font (importedFont); the server adds its @font-face rule to the file's Studio font block (studioFontFace.ts, creating the block in <head> the first time) in the same write as the edit. The client-side second write is removed.

Proof. On the base code the route test, the helper test and 5 client tests fail; with the fontFaceCss handling deleted, the route test, canvasQuickSaves and both one-request tests fail; with the fix, studio-server and studio pass on the touched files and both typecheck.

The @font-face check. The server accepts fontFaceCss only as exactly one @font-face { ... } rule: braces only inside quoted strings, < nowhere (the HTML parser ends the style block at </style whatever the CSS quoting says), and no LF, CR or form feed inside a string or after a backslash (CSS ends a string at any of the three, so a following } would close the rule). Route and helper tests: a trailing second rule, two rules, </style> inside quotes, an unterminated quote, escaped quotes, and a CR or a form feed inside the family name each give a 400; a file path with {} in its name is accepted and written. Each new rejection test fails on the check before it (a trailing rule passed a startsWith check, braces failed a no-braces pattern, CR and FF passed a pattern that excluded only LF).

Keyframe rule rows. Not applicable: no GSAP write.

No captures: this item changes no pixels, only how many writes a font pick makes.


Item 5: a focused combobox, switch or plain-text editor counts as an unsaved edit

studioPendingEdits.ts had its own "the user is typing" selector, which missed contenteditable="plaintext-only" (inline text editing), combobox, searchbox and switch. It now asks isTypingTarget(document.activeElement) from utils/typingTarget.ts, the one owner of that question.

Proof. studioPendingEdits.test.ts focuses each of the four and expects a pending edit: all 4 fail with the old selector, all pass with the shared one.

Keyframe rule rows. Not applicable. No captures: no pixels change.


Item 6: comment ratchet, i-- before a quoted slash

scripts/comment-ratchet.test.mjs adds const a = i-- / 2 + 'a/b'; // it's (one comment line). With -- dropped from stripLiterals' value-end rule the test fails (23 pass, 1 fail); with it, 24 pass. Before this line, dropping -- kept every test green.


Item 7: an inspector edit saved while a preview reload loads stays on screen

persistDomEditOperations counts every DOM-patch save as a manual-edit save (start and settle), so a preview reload that is loading while the save lands does not promote a stale shadow preview over it. A selection without an element skips the count.

Proof. inspectorSaveReload.test.tsx passes with the fix, fails on main plus item 4 without the count, and fails with the count deleted (the stale shadow is promoted). studio-server and studio pass; typecheck clean.

Keyframe rule rows. Not applicable. No captures: the race has no UI path to drive.


Edit-accuracy gate and bank

CI gate on e83ed76 (items 1-7 plus the font check, on main c3b6e55): 951 passing vs 852, 0 regressed, 0 unstable, 99 newly passing, banked byte for byte from that run's artifact as the last commit: 20 resize-hold-*-root (item 1), 24 crop-hold + 21 crop-tween (item 2), 12 move-tween-xpercent + 12 nudge-tween-xpercent + 10 resize-tween-xpercent (item 3). The carrier was then rebased onto main d218cec, which changes only CI workflow and scripts, so the banked file is unchanged; the gate on this head re-checks it. All 36 resize-tween-{center,pct,px} rows pass every gated metric, resize-tween-center-r0-root-z200's undo included.

@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: 951 passing here, 852 on the base branch

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

Newly passing (99)

  • move-tween-xpercent-r0-root-z50
  • nudge-tween-xpercent-r0-nested-z100
  • resize-hold-center-r30-root-z100
  • resize-hold-pct-r30-root-z100
  • resize-hold-px-r30-root-z100
  • resize-hold-xpercent-r30-root-z100
  • resize-tween-xpercent-r30-root-z100
  • crop-hold-center-r30-root-z50
  • crop-hold-pct-r30-root-z50
  • crop-hold-px-r30-root-z50
  • crop-hold-xpercent-r30-root-z50
  • crop-tween-center-r30-root-z50
  • crop-tween-pct-r30-root-z50
  • crop-tween-px-r30-root-z50
  • crop-tween-xpercent-r30-root-z50
  • move-tween-xpercent-r30-nested-z50
  • nudge-tween-xpercent-r0-root-z100
  • resize-tween-xpercent-r0-nested-z100
  • move-tween-xpercent-r0-root-z200
  • nudge-tween-xpercent-r30-root-z50
  • resize-tween-xpercent-r30-nested-z50
  • move-tween-xpercent-r30-root-z100
  • nudge-tween-xpercent-r30-nested-z200
  • resize-hold-pct-r0-root-z200
  • resize-hold-px-r0-root-z200
  • crop-hold-center-r0-root-z200
  • crop-hold-pct-r0-root-z200
  • crop-hold-px-r0-root-z200
  • crop-hold-xpercent-r0-root-z200
  • crop-tween-center-r0-root-z200
  • ...

Quarantined, measured but not gated (2)

@miguel-heygen
miguel-heygen force-pushed the fix/studio-resize-gsap-positioned-css-size branch from c7110d4 to ea2aa43 Compare October 1, 2026 16:58
@miguel-heygen miguel-heygen changed the title fix(studio): resizing an element GSAP only positions saves its CSS width and height fix(studio): edits on GSAP-animated layers keep their keyframes and file positions Oct 1, 2026
@miguel-heygen
miguel-heygen force-pushed the fix/studio-resize-gsap-positioned-css-size branch from 669e188 to 99c2027 Compare October 1, 2026 19:43
@miguel-heygen
miguel-heygen force-pushed the fix/studio-resize-gsap-positioned-css-size branch 4 times, most recently from 18c58b5 to 05b37d2 Compare October 1, 2026 22:34
@miguel-heygen
miguel-heygen force-pushed the fix/studio-resize-gsap-positioned-css-size branch from 62044fb to 06649d5 Compare October 1, 2026 23:39
@miguel-heygen
miguel-heygen marked this pull request as ready for review October 2, 2026 00:22
@miguel-heygen
miguel-heygen merged commit d4756f5 into main Oct 2, 2026
75 of 76 checks passed
@miguel-heygen
miguel-heygen deleted the fix/studio-resize-gsap-positioned-css-size branch October 2, 2026 00:24
meefs pushed a commit to meefs/hyperframes that referenced this pull request Oct 2, 2026
…their content step

heygen-com#4869 dropped prepareContent from the persist options and the server path
while heygen-com#4813's attribute batch commit (mediaAudioEdits) still passes it, so
main fails typecheck and the audio edit would lose its prepared content.
Restore the option, take the server patch when it is set, and write the
prepared content over the patch under the same file queue.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
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.

1 participant