feat(core): add GSAP keyframe + motion-path source mutations#1554
feat(core): add GSAP keyframe + motion-path source mutations#1554miguel-heygen wants to merge 2 commits into
Conversation
|
Warning This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
This stack of pull requests is managed by Graphite. Learn more about stacking. |
99da598 to
e5e8d40
Compare
085862a to
c8aee05
Compare
jrusso1020
left a comment
There was a problem hiding this comment.
Approved at c8aee055 per Rames D Jusso "stamp-ready with 1 nit + 1 question." Deferring his specific items (posted in his review) to Miguel for resolution inline — non-blocking from my side.
PR body is the unfilled template; please fill in even a one-paragraph description before merge so the change history names what landed (Rames D Jusso + Via both flagged this as a stack-wide process concern across all 9 PRs).
terencecho
left a comment
There was a problem hiding this comment.
Test-coverage + backwards-compat axis (complementary to @rames-jusso's structural pass)
Reviewed with a narrower lens: do the new mutation APIs ship with tests, and is anything signature-broken on the existing parser/writer surface?
Verified
- Test coverage strong on the new surface:
gsapParser.test.tsadds units forupdateMotionPathPointInScript(4 cases),addMotionPathPointInScript(2 cases),removeMotionPathPointInScript(2 cases),addMotionPathToScript(1 case),syncPositionHoldsBeforeKeyframes(6 cases),addKeyframeToScriptbackfill on/off (2 cases),_autoexclusion (1 case).gsapWriter.parity.test.tsaddsremoveKeyframeFromScriptarray-form parity (3 cases: implicit-% removal, collapse-on-fewer-than-2, no-op on mismatched %).
- Writer parity scope is correct. Motion-path mutations are intentionally recast-only (live in
gsapParser.ts, no acorn counterpart) because #1555 routes them throughstudio-api(server-side). The browser/SDK acorn writer doesn't need them — the studio calls them via HTTP. So the parity test absence for motion-path is by design, not a gap. - No export signature breaks: every export touched in
gsapParser.tsandgsapWriterAcorn.tsis an ADDITION (verified bygit difffilter on^[-+]\s*export function— no-lines).
Note
- The commit message describes the surface well, but the PR body is the unfilled template — see consolidated note on #1561. For the most critical PR in the stack (+651/-4 core mutations), the empty body makes the change harder to justify to a future archaeologist.
— Review by tai (pr-review)
c8aee05 to
80d84c7
Compare
e5e8d40 to
5ce82a2
Compare
80d84c7 to
ba09f10
Compare
5ce82a2 to
cd4308b
Compare
ba09f10 to
4ee9b9f
Compare
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at 4ee9b9fb (stack-aware, layered on tai's R1 test-coverage pass).
✅ Stamp-ready on the structural axis. Foundation PR for the keyframe + motion-path editor — pure addition surface, no signature breaks, parity-tested where it matters. Anchoring on tai's verified test-coverage pass (gsapParser.test.ts + gsapWriter.parity.test.ts cover the new ops at 4/2/2/1/6/2/1 + 3 cases respectively); I'm layering structural + downstream-consumer findings.
Verified
- Pure addition surface.
gsapParser.ts+373/-4,gsapWriterAcorn.ts+130/-4 — every new export (updateMotionPathPointInScript,addMotionPathPointInScript,removeMotionPathPointInScript,addMotionPathToScript,syncPositionHoldsBeforeKeyframes) is additive. The 4-line deletions on each side are loosening type checks (theObjectExpression-only bail) to handle array-form keyframes — the existing object-form path still runs as before. - Array-form keyframes are the load-bearing fix.
keyframes: [{x,y}, …]previously silently no-op'd on update/remove because both writers bailed on theObjectExpressioncheck. NewremoveArrayKeyframe+updateArrayKeyframeByPct(acorn) andarrLocbranches in the recast path handle it by even-distribution percentage matching, withconvertArrayKeyframesToObjectnormalizing in-place when a new percentage must be inserted. Parity test atgsapWriter.parity.test.ts:162-211covers the recast/acorn equivalence — exactly the right shape. syncPositionHoldsBeforeKeyframesis the right place for the hold invariant. "Element holds first keyframe's value before tween start" is universal-NLE behavior; implementing it as a taggedtl.set(…, 0)kept in sync via a single re-derive pass is the right primitive. Idempotency test atgsapParser.test.tscovers the canonical regression (re-call after move).isStudioHoldSetre-export throughgsapParserExports.tsis the clean way to let studio filter the synthetic sets out of user-visible animation lists.- Position-drift fallback in
locateAnimationWithFallback(gsapParser.ts:1856-1900). Animation ids encode timeline position (#puck-to-1200-position); a gesture that changes position changes the id, so a stale client cache no-ops. Falling back to selector+method+group + nearest-position is the right shape. ANIM_ID_RE is tight (^(.*)-(fromTo|from|to|set)-(\d+)-([a-z]+)$) — selector permissive enough to include#/./ quoted strings, method enum-closed, position numeric, group lowercase-alpha.
Concerns
gsapConstants.ts:80—_autoANDdataexcluded from classification. The exclusion list now has three entries:transformOrigin,_auto,data. The_autoexclusion is well-defended (regression test added:{ x: 100, y: 50, _auto: 1 }must classify asposition). Thedataexclusion is justified in the comment as "Studio hold-set tag" but has no regression test. If a future hold-set or annotation ever writes a property literally nameddata, that classification path silently drops it. Suggest: add a one-line test mirroring the_autoregression fordata, or rename the marker to_datafor symmetry with_auto.splitAnimationsInScriptfrom-tween fix (gsapParser.ts:1700-1716). The fix correctly handles a completed.from()by NOT inheriting its recorded properties (they're the hidden start state). Two questions:.fromTo()is not in the branch — does a completed.fromTo()revert to thefromstate or end at thetostate? GSAP's behavior is the latter (ends attolike.to()), so this is probably fine, but worth a comment line stating it.- This only fires for
animEnd <= opts.splitTime(animation ends BEFORE split). What about a.from()that ends AFTER the split? Today the inherited-props code path doesn't run for those, so this is silent — but worth pinning with a test that a mid-flight.from()split doesn't inherit hidden-start values.
addMotionPathToScript2-anchor default authors(0,0) → (x,y)withposition(gsapParser.ts:842-851). If the caller invokes withposition > 0, the fresh tween's0%snaps to(0,0)at the moment the tween starts — i.e. the element jumps from its CSS-baseline to (0,0). If the studio invokes this BEFORE the hold-sync runs (which it does — hold-sync is gated byHOLD_SYNC_MUTATION_TYPESinroutes/files.ts:594-611of #1555, andadd-motion-pathis NOT in that set), the user sees a one-frame jump. This is the same class of bug #1555 R2 calls out forHOLD_SYNC_MUTATION_TYPES. Suggest landing the hold-sync set expansion in #1555 BEFORE this PR merges, oraddMotionPathToScriptitself should callsyncPositionHoldsBeforeKeyframeson its output.
Nits
- (nit)
gsapParser.ts:1856-1862—ANIM_ID_REmatchessetas a method butsplitIntoPropertyGroups/ position-tween logic typically excludesset. If asetever lands in the located list, the fallback could pick asetneighbor for atotween. Probably fine in practice (ids are method-disambiguated) but a comment noting the assumption would help future archaeologists. - (nit) PR body is well-written here — thank you. The unfilled-template note tai + jrusso1020 flagged earlier no longer applies at the current commit.
Questions
- The new
addMotionPathToScriptreturns{ script, id: "" }on parse failure or empty-located+null-timelineVar.id: ""is a sentinel; consumers in #1555 (routes/files.ts:1086-1098) only useresult.script, so they don't observe the empty id. But if anything downstream (studio store?) ever callsaddMotionPathToScriptdirectly and chains onid, the silent""will cause a phantom "no animation" branch. Worth either returning{ script, id: null }or asserting at the call site.
What I didn't verify
- The acorn-vs-recast parity for the new
splitAnimationsInScriptfrom-tween branch — only the array-keyframes ops have explicit parity tests. The from-tween fix is recast-only on the surface I read; if acorn has its own split path, both need the same conditional. - That
_auto: 1is the only sentinel — if studio ever adds_hold: 1or similar, the exclusion list needs a regex (_prefix) rather than a literal list.
— Rames D Jusso
vanceingalls
left a comment
There was a problem hiding this comment.
Reviewed at 4ee9b9fb. @jrusso1020's stamp at c8aee055 stands — independently verified that the 28 commits between c8aee055..4ee9b9fb are unrelated merges into the base chain (slideshow #1580-1594, SDK WS-B/C/D #1569-1574, releases v0.6.113/.114), not #1554-attributable. @james-russo-rames-d-jusso's R1 layered concerns are fair; concurring + adding two net-new findings.
Concur with @james-russo-rames-d-jusso:
gsapConstants.ts:80—dataexclusion has no regression test mirroring the_autoregression. Either add the one-line test or rename to_datafor symmetry with the_sentinel convention. Cheap.splitAnimationsInScriptfrom-tween fix atgsapParser.ts:1700-1716:.fromTo()not in the branch + mid-flight.from()split untested.addMotionPathToScript2-anchor default authors(0,0) → (x,y)(gsapParser.ts:842-851); if called atposition > 0withoutHOLD_SYNC_MUTATION_TYPEScoveringadd-motion-path, the user sees a one-frame jump-to-(0,0). This converges with my own concern on #1555's missing hold-sync types (below) — same bug class, two surface areas. Strongest cross-PR finding in the stack.
Net-new (parity-test discipline gaps — HF #1500 pattern):
gsapWriter.parity.test.ts at HEAD adds the array-form removeKeyframeFromScript parity block (lines 168-214, real expect(modelOf(acornOut)).toEqual(modelOf(recastOut)) assertions). Good. But two sibling array-form ops added in this PR have only acorn output correctness coverage in gsapWriter.acorn.test.ts, NOT a parity: describe block:
updateKeyframeInScript(array-form, the#shuttlecase) — covered atgsapWriter.acorn.test.ts:250-262. No parity-test.addKeyframeToScript(array-form normalization) — covered atgsapWriter.acorn.test.ts:264-275. No parity-test.
This is the HF #1500 silent-opt-out pattern — a new op variant added with acorn output correctness only, leaving the recast↔acorn equivalence un-pinned. Cheap fix: mirror the removeKeyframeFromScript block for the other two array-form ops.
Net-new (PR-body framing nit):
Body says "Extend the Acorn-based GSAP writer with keyframe/motion-path ops." But motion-path ops (updateMotionPathPointInScript, addMotionPathPointInScript, removeMotionPathPointInScript, addMotionPathToScript) + syncPositionHoldsBeforeKeyframes ship in gsapParser.ts (recast) only — acorn writer doesn't gain them in this PR. Same for the .from() empty-revert fix. This widens the recast/acorn cutover surface silently. Worth a one-line PR-body clarification ("motion-path ops + sync-position-holds + .from() split-fix are recast-only this PR; acorn cutover follow-up tracked separately") so future archaeologists don't have to re-verify which writer got what.
Stamp stands. Above are NIT-level; not blocking the merge but worth one fix-up pass.
Review by Via
Array-form keyframe removal in both the recast and acorn writers, plus update/add/remove-motion-path-point and add-motion-path. Exclude _auto and data from tween property-group classification.
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Layering on @via's review — net-new from my R1:
Parity-test asymmetry on new keyframe ops. updateKeyframeInScript + addKeyframeToScript (array form) ship with acorn output correctness describe blocks but no parity: blocks. Sibling removeKeyframeFromScript did get a proper parity block. Mirror that for the other two — recast↔acorn equivalence is un-pinned for the two ops that the new motion-path / keyframe UX will exercise most heavily.
Scope clarification ask. Motion-path ops + syncPositionHoldsBeforeKeyframes + .from() split-fix appear to be recast-only this PR (acorn cutover follow-up). One-line PR-body note prevents downstream confusion about scope of the acorn cutover work.
Both are non-blocking but worth landing while the ops are fresh.
— Rames D Jusso
… motion-path sentinel, parity blocks - Regression test for the `data` GSAP-key exclusion (parallel to _auto). - splitAnimationsInScript: documented that .fromTo()/.to() correctly stay out of the from-branch (only .from() reverts) and the <= boundary; added mid-flight straddle tests. - addMotionPathToScript failure path returns id: null (was empty-string sentinel); caller updated. - Parity blocks for addKeyframeToScript array-form + updateKeyframeInScript (mirroring removeKeyframeFromScript). Surfaced a latent acorn array-form partial-props merge bug — documented as it.skip with a ready assertion (acorn cutover follow-up).
4ee9b9f to
fd0a505
Compare
* chore(producer): shim __filename/__dirname in the CJS banner Bundled CJS deps like wawoff2 call __dirname; without the shim they throw "__dirname is not defined in ES module" at render time. Also ignore .zed/. * chore(producer): use a template literal for the CJS banner (review nit) * feat(core): add GSAP keyframe + motion-path source mutations Array-form keyframe removal in both the recast and acorn writers, plus update/add/remove-motion-path-point and add-motion-path. Exclude _auto and data from tween property-group classification. * fix(core): address #1554 review — data-exclusion test, split-fix doc, motion-path sentinel, parity blocks - Regression test for the `data` GSAP-key exclusion (parallel to _auto). - splitAnimationsInScript: documented that .fromTo()/.to() correctly stay out of the from-branch (only .from() reverts) and the <= boundary; added mid-flight straddle tests. - addMotionPathToScript failure path returns id: null (was empty-string sentinel); caller updated. - Parity blocks for addKeyframeToScript array-form + updateKeyframeInScript (mirroring removeKeyframeFromScript). Surfaced a latent acorn array-form partial-props merge bug — documented as it.skip with a ready assertion (acorn cutover follow-up). * feat(core): route motion-path mutations through studio-api + fix clip stamping Wire the new mutations into the file save route. Only authored clips suppress descendant stamping, so auto-stamped animated scenes can inline-expand. Hide in-flow timed clips with `display:none` only when they are LEAF clips (no nested timed clips). `display:none` on a container removes its whole subtree, hiding descendants that are still inside their own visibility window — e.g. an in-flow composition root whose effective window clamps to the timeline end would black out a child video that should still show (the hdr-hlg regression). Containers keep `visibility:hidden`, which a visible descendant can override; only leaves leave the flow, which is all the split-overlap case needs. * feat(core): strip legacy path-offset/rotation + drop obsolete studio lint rule A position or rotation add/set mutation makes the GSAP timeline the single source of truth for that channel, so any lingering --hf-studio-offset / --hf-studio-rotation CSS var must be cleared to avoid double-applying. stripStudioEditsFromTarget now clears both channels, and the add-strip fires for the position AND rotation property groups. Also removes the obsolete `gsap_studio_edit_blocked` lint rule: it warned that Studio cannot save drag/resize edits to elements in a registered timeline — the exact premise the single-source work inverts (the timeline is now the edit target). Removed the rule, its now-unused TIMELINE_REGISTRY_ASSIGN_PATTERN import, and its 5 tests. * fix(core): address #1555 review — complete hold-sync, invalidate clip cache, strip rotation channel - HOLD_SYNC_MUTATION_TYPES: add add-motion-path (load-bearing — addMotionPathToScript authors past t=0 → first-frame snap-to-(0,0) without the hold), update-meta, shift-positions, scale-positions, split-animations. (add stays out: flat tweens only, syncPositionHoldsBeforeKeyframes is a no-op for non-keyframed tweens.) - init.ts: timedClip in-flow/leaf WeakMaps now invalidate on clipTreeSignature change; visible/hidden branches both go through isTimedClipInFlow (was .get() by accident). - keyframesWriteRotation mirrors keyframesWritePosition so a rotation-only keyframe set strips the stale --hf-studio-rotation channel. * feat(studio): GSAP runtime read layer + shared helpers * fix(studio): address #1607 review — cold-parse vs fetch-error budgets, isZeroDurationSet, array-ease tests - useGsapAnimationFetchFallback: discriminate resolved/fetch-error/cold; only the cold (warm-but-zero) race gets the full ~600ms retry budget — a hard fetch error retries once. - Extract isZeroDurationSet (was !(duration>0) duplicated); rejects NaN, documents intent. - parsePercentageKeyframes: cite GSAP even-index spread; tests that a per-entry/interior ease is stripped without shifting the other keyframes' percentages. * feat(studio): GSAP drag/commit/bridge editing infra * fix(studio): address #1608 review — facade awaits commit, strict stale-parse guard, clearProps restore BLOCKER: useSafeGsapCommitMutation now RETURNS the (.catch-chained) commit promise and the commitMutation facade awaits it — so await session.commitMutation(...) resolves AFTER the server save, fixing both consumers (useEnableKeyframes + useGestureCommit's showToast/requestSeek/idle, which were firing before the save landed). SafeGsapCommitMutation return type widened void→Promise<void> (fire-and-forget consumers ignore it). - stale-parse guard uses hasNonHoldTweenForElement (a leftover hold set no longer counts as live). - commitFlatViaKeyframes snapshots dragged gsap values before clearProps + restores after seek, so a failed commit leaves the dropped pose, not a cleared element. * feat(studio): motion-path geometry + commit helpers * docs(studio): address #1609 review — document occlusion fade-in invariant, donut limit, nearestPointOnPath t-semantics * feat(studio): on-canvas motion-path overlay * fix(studio): address #1610 review — scope dblclick to pan-surface, kind-aware geometry guard, gate createMode, screen-space drag threshold * feat(studio): keyframes flag, gesture recording + timeline/selection refinements * fix(studio): address #1611 review — fetch-first keyframe path, gated hydration, dev-gated debug + gesture warn, per-group gesture tweens - useEnableKeyframes: parse current source first (null-vs-[] distinction) so a delete-all's empty parse isn't overridden by a stale selectedGsapAnimations cache. - useStudioUrlState: freeze the hydration effect's time dep once hydrated (was re-running every tick). - useGestureRecording: dev-gated console.warn when the live-preview runtime throws (was silent). - playerStore: gate window.__playerStore behind dev (guarded import.meta.env.DEV). - useGestureCommit: partition recorded keyframes by property group → one add-with-keyframes per group, so a mixed gesture no longer yields an untagged legacy tween. * feat(studio): single-source manual offset + rotation via the GSAP timeline Dragging or rotating an element writes into the GSAP timeline (the single source of truth) instead of a parallel --hf-studio-offset / --hf-studio-rotation CSS var: static elements commit a tl.set (idempotent on re-edit), tweened elements edit keyframes, and the live preview moves via gsap.set so what you see equals what is written and renders. Removes the dual-channel CSS-var/transform reconciliation behind the fling / disappear / runaway / double-stack / wrong-start bug class — for BOTH position and rotation (gesture base read from the gsap transform, gsap.set live preview, tl.set/ keyframe commit, dropped the handleDom*Commit CSS fallbacks). Subcompositions edit the same single-source way, which surfaced and fixes: - resolve a subcomp element's source file via the composition-id map (the runtime drops the source linkage when inlining the subcomposition); - a selected element's selection box AND motion path use basic visibility, not the occlusion heuristic (a backgroundless opacity-1 scene above it is not an opaque cover); - soft reload rebuilds ONLY the committed composition's timeline, leaving other compositions' timelines intact (no cross-composition revert); - read keyframes from the element's OWN composition timeline (scan all timelines, not the first unstable key); - delete-all uses a soft reload too, so editing no longer hard-reloads the iframe. * fix(studio): address #1567 review — drop drag-intercept flag, harden softReload onerror, tighten runtime ladder, per-group gestures - DROP STUDIO_GSAP_DRAG_INTERCEPT_ENABLED: single-source GSAP intercept is the only position/rotation channel; the false branch silently killed drag+rotate (and let GSAP elements into the keyframe-corrupting CSS path). Removed flag + dead branch + env def + tests. - gsapSoftReload: plugin onerror no longer fakes success — signals onAsyncFailure so the caller full-reloads; honors __hfMotionPathPluginLoading so a concurrent reload can't queue a dup script. - gsapDragCommit: resolveDragRuntime narrows the as-any ladder; a mid-seek throw logs + drops partial reads (no phantom identity) and re-applies the drag override in finally. - MotionPathOverlay: park-timer cleanup keyed on animId change. - useGestureCommit: partitionKeyframesByGroup wraps the add-with-keyframes sites (per #1611 review). * feat(studio): patchRuntimeTweenInPlace — update a tween's values in place Defensive runtime helper: locate the element's tween in window.__timelines via the shared resolveRuntimeTween scan, update its set/keyframe vars, invalidate, and re-seek the playhead — without re-running the whole composition. Returns false (caller falls back to soft reload) for any shape it can't safely patch (no tween, dynamic/computed keyframes, motionPath arc, channel mismatch, or any error). Foundation for instant, flicker-free manual edits. * fix(studio): address #1612 review — channel-aware set resolution + decline dynamic-expression patches - resolveRuntimeTween gains an optional channels[] hint; for kind:set it prefers the set whose vars carry one of the patched channels and never returns a disjoint-only set (e.g. won't write {x,y} into a co-located {rotation} set). patchRuntimeTweenInPlace derives channels from the props. - patchSet declines (returns false → soft reload) when overwriting a string/dynamic vars[ch], instead of silently dropping the computed expression. * feat(studio): instantPatch fast path in runCommit A commit carrying an instantPatch option tries patchRuntimeTweenInPlace first; on success the preview updates in place with NO reload (instant), on false it falls back to the existing soft reload. Extracts the preview-sync tail into a testable applyPreviewSync helper. No behavior change when instantPatch is absent. * feat(studio): route static position/rotation set drags through instantPatch Static-element position and rotation set commits now attach instantPatch{selector, change:{kind:set}} so the drag updates in place with no reload. Structural ops (new tween add, delete-all, convert/split/materialize) and keyframe edits deliberately omit it and keep the soft reload — keyframe instant-patch needs object-form keyframe support in patchRuntimeTweenInPlace (deferred). * fix(studio): address #1613 review — derive instantPatch from the mutation, patch both coalesced commits, wire onAsyncFailure - commitStaticGsapPosition/Rotation derive instantPatch.change.props from the actual update-property mutation(s) sent (one source of truth → findUnsafeMutationValues-validated values flow into the patch; can't drift). - Coalesced x/y: the intermediate x commit also carries instantPatch{x}, the y commit {x,y}, so a second-POST failure still leaves the preview patched for what persisted. - applyPreviewSync passes reloadPreview as onAsyncFailure (plugin-CDN load error → full reload); per U4 the synchronous false still does NOT escalate. - (channel disambiguation from #1612 verified end-to-end: {x,y}→position set, {rotation}→rotation set.) * feat(studio): no full iframe remount for soft-reloadable edits A softReload edit (and the SDK single-script refresh) no longer escalates to a full reloadPreview() iframe remount when applySoftReload returns false — the live gsap.set already shows the value, and a remount is the worst flash + re-inlines subcomps (reverting their keyframes). verifyTimelinesPopulated now checks the expected target keys the re-run registers, so a correct scoped re-run doesn't spuriously report empty. Full reload stays only for the structural (no-softReload) and ambiguous-script paths. * feat(studio): pre-load MotionPathPlugin so motion-path edits don't async-flash ensureMotionPathPluginLoaded() runs once at the preview iframe-load seam (NLELayout onIframeLoad), eagerly loading + registering MotionPathPlugin without killing the timeline. So when a user adds a motion path to a composition that didn't originally use one, the soft reload runs synchronously instead of taking the kill-then-await-CDN async path (the flash). Idempotent + defensive; the existing async fallback stays for genuine cold-start/CDN-failure. * fix(studio): don't re-save + reload when source editor syncs externally The SourceEditor's CodeMirror update listener fired onChange on ANY docChanged — including the programmatic dispatch that syncs external content (e.g. a manual-edit commit writing the source back into the open editor). That made the editor re-save the file and bump refreshKey, fully reloading the preview iframe on every drag/keyframe edit — defeating the in-place instant patch and causing the flash. Annotate the programmatic sync (ExternalSync) and skip onChange for it, so only real keystrokes save. * fix(core): inject MotionPathPlugin into preview when a composition uses motionPath A studio-created motion path writes a gsap motionPath tween into the single-source timeline, but the preview HTML only loaded gsap core — so the first render threw "Invalid property motionPath ... Missing plugin?". Detect motionPath usage and inject MotionPathPlugin right after the composition's gsap script, version-matched to it. * fix(studio): dedup __hfMotionPathPluginLoading type decl (restack artifact) * fix(studio): address #1605 review — distinguish soft-reload failure modes + observability, SourceEditor focus guard BLOCKER: applySoftReload now returns SoftReloadResult ('applied' | 'verify-failed' | 'cannot-soft-reload') instead of a bare bool. applyPreviewSync + sdkRefresh escalate to a full reloadPreview() on the PERMANENT 'cannot-soft-reload' (no gsap/rebind hook/scopable key/script, or sync re-run threw) — fixing the silent-stale-preview U4 dropped — but still suppress the TRANSIENT 'verify-failed' (live gsap.set is correct). Telemetry: gsap_soft_reload_outcome (origin/result/escalated) + gsap_instant_patch_fallback, so the U4 invariant is enforced, not asserted. - SourceEditor: skip the programmatic external-sync replace while the editor is focused, so an in-flight commit doesn't clobber the user's uncommitted keystrokes (ExternalSync kept for unfocused). - Verified ensureMotionPathPluginLoaded already guards __hfMotionPathPluginLoading (no double-append). * fix(core): align __clipTree and __clipManifest ids via stableClipId Timeline inline expansion was dead for nested children inside index.html: the tree keyed id-less elements by a synthetic __clip-N while the manifest keyed them null, so parent<->child never joined. Both now resolve identity through stableClipId (id || data-hf-id), which every generated element has. * fix(core): strip baked runtime + tag comp root in preview assembly Comps that ship a baked inline runtime were double-loaded (preview injects its own) and the baked copy failed to parse inline (Unexpected token '<'). Strip it in buildSubCompositionHtml + the disk-fallback preview path. Also tag the comp root with data-composition-file so the studio resolves a comp's top-level elements to the right source file instead of defaulting to index.html (which made the GSAP panel parse the wrong, multi-timeline file). * feat(studio): set motion-path destination from a toolbar toggle Replaces the double-click-on-canvas UX (which painted text over the preview) with a 'Set motion destination' toggle next to Snap/Grid, shown only when the selected element can take a path. While armed, one canvas press places the destination. Also removes the dead TimelinePropertyRows component. * fix(studio): center timeline keyframe diamonds on their percentage Dropped clampDiamondLeft, which forced boundary keyframes fully inside the clip so a 0% diamond sat half a diamond right of the 0% point. Each diamond's midpoint now sits exactly on its % (the clip is overflow-visible). * fix(studio): resize static elements via tl.set, not a single-stop keyframes tween Resizing an element with no size animation wrote keyframes:{ <playhead%>: {width,height} } — one mid-point stop GSAP can't interpolate, so it rendered NaN/0 dimensions at every other frame and the element vanished (worst off 0%). Added commitStaticGsapSize (mirrors commitStaticGsapPosition): a static resize now writes tl.set({width,height}), held at all frames; re-resizing updates it in place. * fix(studio): negative-cache failed media probes Only successful probes were cached, so CORS/404 cross-origin media was re-probed every rAF-driven timeline re-derive, flooding the console. Remember failed URLs and skip them. * fix(studio): type window.setTimeout handle as number ReturnType<typeof window.setTimeout> infers NodeJS.Timeout when @types/node is present and clashes with the DOM number the call returns. Type it number. * fix(studio): drag/resize disappearance, stale-ID duplicates, soft-reload clearProps - Fix soft-reload clearProps destroying element inline styles — save cssText, clear, restore, strip only transform - Fix resize no-op on re-resize: delete+add instead of two update-property - Route set tweens through static resize path (convertToKeyframes skips sets) - Re-fetch animation ID before drag commit to prevent stale-ID duplicates - Guard editDebugLog for Node test environments - Fix NLELayout setState-during-render (move reset to useEffect) - Stop SnapToolbar pointer events propagating to canvas deselect handler - Enable click-to-add waypoints on cubic motion paths - Add whole-path drag offset (Alt+drag shifts all keyframes together) - Add Canvas shortcuts section to ShortcutsPanel - Extract useMotionPathData + commitGsapPositionFromDrag (filesize compliance) - Delete dead code (getElementDepth, isElementVisibleInPreview, unused exports)

Stack: GSAP keyframe + motion-path editing — core read/write layer (#1553 → #1561).
What
Add core parser + writer support for keyframe and motion-path source mutations on GSAP tweens: add / remove / update keyframes, convert a flat tween to keyframes, edit motion-path waypoints, and classify tween properties into groups (position / scale / rotation / visual).
Why
The Studio keyframe/motion-path editor needs deterministic, AST-level read and write of GSAP timelines so that edits round-trip through the composition source without reformatting unrelated code.
How
gsapParser.ts,gsapConstants.ts).Note on the write path (recast vs acorn)
The keyframe/motion-path write ops apply through recast, not the acorn writer — recast reprints only the touched nodes, preserving the surrounding source formatting (the property of these mutations we care about). The parity tests assert the recast output round-trips through the acorn-based parser.
There is one known acorn-vs-recast divergence: an array-form keyframe update with partial props — recast replaces the keyframe object, acorn would merge it. This is captured as an
it.skipwith the ready assertion ingsapWriter.parity.test.ts, pending the writer fix; it does not affect the object-form path used by the Studio editor today.Test plan
addKeyframeToScriptarray-form +updateKeyframeInScript)bun run test(core) green