Repository navigation
fix(studio): edits on GSAP-animated layers keep their keyframes and file positions - #4869
Merged
Merged
Conversation
Contributor
Edit accuracy: 951 passing here, 852 on the base branchThe gate passes. Newly passing (99)
Quarantined, measured but not gated (2)
|
miguel-heygen
force-pushed
the
fix/studio-resize-gsap-positioned-css-size
branch
from
October 1, 2026 16:58
c7110d4 to
ea2aa43
Compare
3 of 4 tasks
miguel-heygen
force-pushed
the
fix/studio-resize-gsap-positioned-css-size
branch
from
October 1, 2026 19:43
669e188 to
99c2027
Compare
3 tasks done
miguel-heygen
force-pushed
the
fix/studio-resize-gsap-positioned-css-size
branch
4 times, most recently
from
October 1, 2026 22:34
18c58b5 to
05b37d2
Compare
…SAP write renders
…s an unsaved edit
…nd nothing after it
miguel-heygen
force-pushed
the
fix/studio-resize-gsap-positioned-css-size
branch
from
October 1, 2026 23:39
62044fb to
06649d5
Compare
miguel-heygen
marked this pull request as ready for review
October 2, 2026 00:22
This was referenced Oct 2, 2026
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>
3 of 4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 CSSwidthandheight. The position part of the resize still goes through GSAP, as the compensatingx/y, in the same undo step. Where the script already writes the size (a tween, a keyframe, afrom/fromTo, or a sizeset), 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.setonly 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/coreand is not touched here.How
tryGsapResizeIntercept: on the hold/no-tween branch, if no known animation for the element writeswidthorheight(properties,fromproperties 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.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.handleDomBoxSizeCommittakes an optional undo key so its write joins that step.scale: nonemask on an element whose transform GSAP renders. Saved, it would override a stylesheetscaleon the next load (checked with ascale: 1.5element: 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) andkeys(its keyframes animatewidth) is unchanged by this item. Resize underspin(GSAP animates rotation only) andfromto(GSAP animatesxonly) 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 saveswidth: 340px; height: 227pxas 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 returnselement-sizewith 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 carriesscale: 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.After
Same fixture and gesture: the reloaded box keeps 340x227. The file has
width: 340px; height: 227pxon the element andgsap.set("#target", { x: -10, y: -13.5 }).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.sethold lost its position (10 px off on the edit-accuracy fixture), a tween counted the stylesheettranslatetwice, and a CSSrotatewas 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-timelinegsap.setthe 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.applySoftReloadtakes those elements asreparseand resets GSAP's cache for exactly them.Proof.
gsapUndoRestore.browser.test.tsdrives real Chrome with the edit-accuracy fixtures (hold, tween, xPercent tween with CSSrotate: 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.tsadds the several-scripts branch: a style restore on a GSAP-parsed element in a two-script file reloads. Without the branch it returnssoft; 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.After (cause A)
Same case: undo and redo land exactly (box error 0).
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.
isXYPositionWriteinpackages/parsers/src/gsapConstants.ts: only writes ofx/ycount 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.Proof.
gsapRuntimeBridge.test.tsandgsapWriter.parity.test.tskeep the centring set through a move; each fails with its part reverted.move,nudgeandresize-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.
After (cause C)
Same gesture: the reloaded layer sits where it was dropped.
Item 4: Font pick writes once
Picking an imported font for a text layer now saves the style edit and its
@font-facerule in one file write. Before, the edit and the@font-facewere 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-facerule 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
fontFaceCsshandling deleted, the route test,canvasQuickSavesand both one-request tests fail; with the fix,studio-serverandstudiopass on the touched files and both typecheck.The
@font-facecheck. The server acceptsfontFaceCssonly as exactly one@font-face { ... }rule: braces only inside quoted strings,<nowhere (the HTML parser ends the style block at</stylewhatever 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 astartsWithcheck, 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.tshad its own "the user is typing" selector, which missedcontenteditable="plaintext-only"(inline text editing),combobox,searchboxandswitch. It now asksisTypingTarget(document.activeElement)fromutils/typingTarget.ts, the one owner of that question.Proof.
studioPendingEdits.test.tsfocuses 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 slashscripts/comment-ratchet.test.mjsaddsconst a = i-- / 2 + 'a/b'; // it's(one comment line). With--dropped fromstripLiterals' 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
persistDomEditOperationscounts 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.tsxpasses with the fix, fails on main plus item 4 without the count, and fails with the count deleted (the stale shadow is promoted).studio-serverandstudiopass; 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), 24crop-hold+ 21crop-tween(item 2), 12move-tween-xpercent+ 12nudge-tween-xpercent+ 10resize-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 36resize-tween-{center,pct,px}rows pass every gated metric,resize-tween-center-r0-root-z200's undo included.