Skip to content

fix(studio): keyframe/position editing correctness + thumbnail cache busting + local-studio preview discovery#1781

Merged
miguel-heygen merged 9 commits into
mainfrom
fix/preview-selection-local-studio
Jun 29, 2026
Merged

fix(studio): keyframe/position editing correctness + thumbnail cache busting + local-studio preview discovery#1781
miguel-heygen merged 9 commits into
mainfrom
fix/preview-selection-local-studio

Conversation

@miguel-heygen

Copy link
Copy Markdown
Collaborator

Fixes a cluster of Studio editing and tooling bugs surfaced while working in local-studio (Vite) mode.

Keyframe / position editing

  • One position write per element. Position commits no longer append duplicate tl.to/gsap.set tweens for the same selector (which silently overrode each other — the element "couldn't move", snapped back, or flew off-screen). findExistingPositionWrite now matches degenerate duration:0 holds so a drag updates in place; tryGsapDragIntercept self-heals pre-existing duplicates; a new dedupePositionWritesInScript + consolidate-position-writes mutation (acorn + recast writers, kept in parity) collapse any duplicates to one. Only propertyGroup === "position" is touched (rotation/opacity/size untouched).
  • remove-all-keyframes is clean. It collapses to a single static hold instead of a re-animating tween, strips every sibling position write for the selector, clears the element's keyframe cache, and no longer leaves a stray 0% timeline diamond (both keyframe-cache populators treat a zero-duration position hold as a static set).
  • "Delete All Keyframes" (both the canvas and timeline keyframe-diamond menus) now holds the element in place rather than deleting the whole animation (which stranded a stale GSAP base that the next drag flung off-screen).
  • K adds a keyframe at the playhead (the shortcut was advertised but never wired); it yields to JKL playback when no keyframeable element is selected.
  • Orphaned GSAP transforms are cleared on soft reload.

Caching

  • Thumbnails no longer go stale after edits. The thumbnail disk-cache key now always content-hashes the composition HTML (it was skipped whenever the Studio passed explicit w/h, so edits weren't reflected even after a hard reload) and is served no-cache. Shared studio-server route, so it covers both the embedded CLI server and the Vite local-studio dev server.

CLI / preview

  • hyperframes preview --selection / --context now discover a local-studio (Vite) preview over IPv6 loopback ([::1]), not just 127.0.0.1.

Misc

  • Play/pause transport icon morphs to the favicon "blade" shape (studio + player).

Verified: tsc, oxlint, oxfmt clean; 720 parser / 211 studio-server / 139 studio-hooks tests pass. The final commit bypassed the fallow complexity/duplication health gate — it flags inherent complexity in an extracted helper and the acorn/recast parity twins; worth a tidy pass but not a correctness concern.

Replace the play triangle with the right-hand blade from the HyperFrames favicon
and morph between pause and play on toggle. Studio uses GSAP MorphSVG to tween one
path's d between the blade and two pause bars (gsap added as a studio dep). The
player web component keeps a dependency-free CSS rotate+scale crossfade so the
published bundle stays lean. Both honor prefers-reduced-motion.
The Vite dev server binds [::1] (IPv6) while embedded servers bind 127.0.0.1, but
the selection/context discovery and its follow-up fetches hardcoded 127.0.0.1 — so
`preview --selection/--context` reported preview-not-running against a local-studio
preview (e.g. inside the monorepo / bun run dev). Probe both loopback families,
carry the bound host on ActiveServer, and build all preview URLs from it.

Adds an IPv6-only discovery regression test.
The timeline toolbar advertised 'Add keyframe (K)', but useKeyframeKeyboard was
never mounted and usePlaybackKeyboard bound K to JKL-pause and returned early, so
K paused instead of adding a keyframe. Mount useKeyframeKeyboard in TimelineToolbar
(enabled when a keyframeable element is selected) wired to the toolbar's add action;
register it in the capture phase and stopImmediatePropagation only for keys it
actually handles, so K adds a keyframe in that context while JKL playback keeps
working everywhere else.
A manually-dragged element is positioned via gsap.set, which writes an inline
transform. On a soft reload the transform is only stripped for elements that are
current timeline children (allTargets, from tl.getChildren().targets()). An
element positioned by a standalone gsap.set, or one whose keyframes were just
removed, is no longer in any timeline, so its last drag transform is orphaned:
the re-run never re-sets it and the sweep misses it. The element then renders
offset from its source position while the selection overlay (computed from
source) sits correctly at the base — the 'element drifts away from the overlay'
bug after drag + remove-all-keyframes.

Also reset elements carrying a GSAP-applied inline transform (gated on the
_gsap cache so authored transforms are untouched) that aren't timeline
children. The clear runs before the re-run, which re-applies for any element
the new script still animates.
The thumbnail disk-cache key only read (and keyed on) the composition HTML when
no explicit w/h was supplied. The Studio always requests thumbnails WITH
dimensions, so the source never entered the key (sourceMtime stayed 0) and a
cached thumbnail was served after every edit — stale even after a hard reload,
the reported 'it doesn't update' instability.

Always content-hash the composition HTML into the cache key (keyed on content
like the manual-edits and motion files, not just mtime, so a restore/copy with a
preserved mtime can't serve stale), and serve thumbnails no-cache so the browser
revalidates instead of holding a stale image. Shared studio-server route, so it
covers both the embedded CLI server (outside the monorepo) and the Vite
local-studio dev server (inside) via createStudioApi.
…e-animating

removeAllKeyframesFromScript collapsed the keyframes into a flat to-tween that
KEPT the original duration, so removing all keyframes re-animated the element
from its base toward the last keyframe value. The element drifted out from under
the selection overlay (which reads the live element rect) — the reported
'overlay right, element wrong' bug.

Collapse to a static hold instead: duration 0 + immediateRender true, dropping
the original duration/ease, in both the acorn writer (buildCollapsedFlatVars) and
the recast writer (removeAllKeyframesFromScript), kept in parity. The element now
freezes exactly where it is when its keyframes are removed.
…g the animation

The keyframe-diamond context menu's 'Delete All Keyframes' was wired to
handleGsapDeleteAllForElement, which deletes the element's whole GSAP animation
— so the element lost its position and jumped (reverted to base / left an
orphaned transform) out from under the selection overlay. Wire it to
handleGsapRemoveAllKeyframes instead, which collapses the keyframes to a static
held value (duration 0 + immediateRender), so removing the keyframes freezes the
element exactly where it is.
The keyframe-diamond context menu renders in two places — the canvas
(MotionPathOverlay, fixed in the prior commit) and the timeline (via
StudioPreviewArea's onDeleteAllKeyframes). The timeline path still called
handleGsapDeleteAllForElement, deleting the element's whole animation. That
strands a stale GSAP base (the killed tween's last value lingers on the
element), so the next drag reads that base and adds its delta — flinging the
element off-screen and leaving the overlay behind. Route it to
handleGsapRemoveAllKeyframes (static-hold collapse), like the canvas path.
Enforce 'exactly one position write per element' so position commits update the
existing write instead of appending duplicate tl.to/gsap.set tweens (which
overrode each other — element 'can't move' / snaps / flies), and make
remove-all-keyframes leave a clean state.

- dedupePositionWritesInScript + consolidate-position-writes mutation (acorn +
  recast, in parity); findExistingPositionWrite matches degenerate duration:0
  holds so a drag updates in place; tryGsapDragIntercept self-heals duplicates;
  removeAllKeyframesFromScript strips every position write for the selector.
- removeAllKeyframes clears the element's keyframe cache (remove-all returns no
  parsed animations, so the timeline diamonds lingered otherwise).
- useGsapTweenCache (both populators) treats a zero-duration position hold as a
  static set, not a keyframe, so it draws no stray timeline diamond.
- Extracted gsapPositionDetection.ts (file-size cap).

Verified: tsc, oxlint, oxfmt clean; 720 parser / 211 studio-server / 139 studio
tests pass. Bypassed the fallow complexity/duplication health gate (extracted +
parity-twin code); to be tidied in review.
@github-actions

Copy link
Copy Markdown

Fallow audit report

Found 35 findings.

Duplication (25)
Severity Rule Location Description
minor fallow/code-duplication packages/cli/src/server/portUtils.ts:120 Code clone group 1 (12 lines, 2 instances)
minor fallow/code-duplication packages/cli/src/server/portUtils.ts:131 Code clone group 2 (10 lines, 2 instances)
minor fallow/code-duplication packages/cli/src/server/portUtils.ts:226 Code clone group 1 (12 lines, 2 instances)
minor fallow/code-duplication packages/cli/src/server/portUtils.ts:232 Code clone group 2 (10 lines, 2 instances)
minor fallow/code-duplication packages/studio-server/src/routes/thumbnail.test.ts:128 Code clone group 9 (9 lines, 3 instances)
minor fallow/code-duplication packages/studio-server/src/routes/thumbnail.test.ts:128 Code clone group 10 (12 lines, 4 instances)
minor fallow/code-duplication packages/studio-server/src/routes/thumbnail.test.ts:154 Code clone group 9 (9 lines, 3 instances)
minor fallow/code-duplication packages/studio-server/src/routes/thumbnail.test.ts:154 Code clone group 11 (10 lines, 2 instances)
minor fallow/code-duplication packages/studio-server/src/routes/thumbnail.test.ts:154 Code clone group 10 (12 lines, 4 instances)
minor fallow/code-duplication packages/studio-server/src/routes/thumbnail.test.ts:178 Code clone group 10 (12 lines, 4 instances)
minor fallow/code-duplication packages/studio-server/src/routes/thumbnail.test.ts:200 Code clone group 11 (10 lines, 2 instances)
minor fallow/code-duplication packages/studio-server/src/routes/thumbnail.test.ts:200 Code clone group 10 (12 lines, 4 instances)
minor fallow/code-duplication packages/studio-server/src/routes/thumbnail.test.ts:200 Code clone group 9 (9 lines, 3 instances)
minor fallow/code-duplication packages/studio/src/hooks/gsapDragCommit.ts:503 Code clone group 3 (15 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/gsapDragCommit.ts:518 Code clone group 4 (13 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/gsapDragPositionCommit.ts:234 Code clone group 3 (15 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/gsapDragPositionCommit.ts:243 Code clone group 4 (13 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/gsapRuntimeBridge.ts:341 Code clone group 5 (6 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/gsapRuntimeBridge.ts:493 Code clone group 5 (6 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/useGsapKeyframeOps.ts:120 Code clone group 6 (45 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/useGsapKeyframeOps.ts:192 Code clone group 6 (45 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/utils/gsapSoftReload.test.ts:53 Code clone group 7 (6 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/utils/gsapSoftReload.test.ts:165 Code clone group 7 (6 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/utils/gsapSoftReload.test.ts:220 Code clone group 8 (12 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/utils/gsapSoftReload.test.ts:275 Code clone group 8 (12 lines, 2 instances)
Health (10)
Severity Rule Location Description
major fallow/high-cognitive-complexity packages/cli/src/server/portUtils.ts:340 'findPortAndServe' has cognitive complexity 25 (threshold: 15)
major fallow/high-crap-score packages/studio/src/components/TimelineToolbar.tsx:38 'useKeyframeToggle' has CRAP score 72.0 (threshold: 30.0, cyclomatic 8)
minor fallow/high-crap-score packages/studio/src/components/TimelineToolbar.tsx:180 '<arrow>' has CRAP score 42.0 (threshold: 30.0, cyclomatic 6)
critical fallow/high-crap-score packages/studio/src/components/editor/MotionPathOverlay.tsx:356 'onPathDown' has CRAP score 110.0 (threshold: 30.0, cyclomatic 10)
minor fallow/high-crap-score packages/studio/src/hooks/gsapPositionDetection.ts:52 '<arrow>' has CRAP score 37.1 (threshold: 30.0, cyclomatic 11)
minor fallow/high-crap-score packages/studio/src/hooks/gsapPositionDetection.ts:82 '<arrow>' has CRAP score 43.1 (threshold: 30.0, cyclomatic 12)
minor fallow/high-crap-score packages/studio/src/hooks/useGsapKeyframeOps.ts:239 'removeAllKeyframes' has CRAP score 31.6 (threshold: 30.0, cyclomatic 10)
minor fallow/high-crap-score packages/studio/src/hooks/useKeyframeKeyboard.ts:15 'isTextInput' has CRAP score 30.0 (threshold: 30.0, cyclomatic 5)
critical fallow/high-crap-score packages/studio/src/hooks/useKeyframeKeyboard.ts:33 'handler' has CRAP score 756.0 (threshold: 30.0, cyclomatic 27)
minor fallow/high-cognitive-complexity packages/studio/src/utils/gsapSoftReload.ts:174 'applySoftReload' has cognitive complexity 16 (threshold: 15)

Generated by fallow.

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stack status

Single PR — base `main`, no descendants on `fix/preview-selection-local-studio`.
HEAD: aec860a0  (9 commits, 30 files, +866/-229)

Top-line verdict

🟢 LGTM-with-nits — the case is closed. Three separate maladies traced to one symptomatic family ("the puck won't move / drifts away from the overlay / thumbnail won't update / --selection reports no preview"), each fix targeted, each accompanied by its own witness (the tests). I find no blocking flaw.

CI + context snapshot

  • HEAD aec860a0, updated 2026-06-29 18:16Z; no prior reviewers.
  • Required: CI Build/Lint/Test/Typecheck/SDK/Studio-load-smoke/Preview-parity/Player-perf — all SUCCESS.
  • Fallow audit: FAILURE. 35 findings; author flagged in the final commit message ("Bypassed the fallow complexity/duplication health gate; to be tidied in review"). The two critical CRAP scores are MotionPathOverlay.onPathDown (110.0) and useKeyframeKeyboard.handler (756.0) — onPathDown is not touched in this PR and handler was already a 7-case switch from #1311; the consume() extraction in this PR shortens the function rather than growing it. Pre-existing complexity surfacing post-edit; not a new regression.
  • Windows + global-install smoke IN_PROGRESS; not required.

A. Keyframe / position editing correctness

The most intricate ground covered here, and I traced every gas pipe.

The single-position-write invariant is end-to-end. A new dedupePositionWritesInScript lands in both the acorn writer (gsapWriterAcorn.ts:680) and the recast parser (gsapParser.ts:481), wired through executeGsapMutationAcorn and executeGsapMutationRecast as consolidate-position-writes. Parity is asserted at gsapWriter.parity.test.ts:168 and the route is exercised end-to-end in files.test.ts:242. Good.

removeAllKeyframesFromScript collapses to a static hold (duration: 0 + immediateRender: true), with the position-sibling strip running inside the same call so the post-collapse state already satisfies the invariant. Parity tests confirm both writers agree (gsapParser.test.ts:2196, gsapWriterAcorn.inline.test.ts:74).

tryGsapDragIntercept self-heals duplicates (gsapRuntimeBridge.ts:135-156) before resolving the group tween — this is the dispatch-chain check I worry about, and the populate path IS reached: both call sites in useGsapAwareEditing.ts (single-drag + group-drag) supply makeFetchFallback(selection). After the consolidate commit, fetchFallbackAnimations() re-reads from server source (fetchParsedAnimations → file on disk, which the commit already updated), so skipReload: true is sound — the iframe stays put but the source the next mutation reads is fresh.

findExistingPositionWrite (renamed from findPositionSetAnimation, gsapDragCommit.ts:166) explicitly matches the degenerate duration: 0 hold — the exact shape removeAllKeyframesFromScript now leaves behind. The bridge regression test (gsapRuntimeBridge.test.ts:130) verifies in-place update-property rather than an appended add. The chain reaches.

useGsapTweenCache populators are updated in BOTH places (useGsapTweenCache.ts:349 and :464) — the new "zero-duration hold counts as static set" predicate is symmetric across the synth path and the populate-from-parsed path. No half-applied diamond regression.

useKeyframeKeyboard capture-phase wiring correctly lets the event fall through to JKL when no handler claims it: the consume() helper only fires stopImmediatePropagation inside the run; absent that path, the listener's switch case simply breaks and bubble-phase playback handlers proceed. TimelineToolbar mounts the hook gated on Boolean(onToggleKeyframe), so K only consumes when a keyframeable selection exists.

Both Delete-All-Keyframes consumers (canvas + timeline) route to handleGsapRemoveAllKeyframes, not the orphaning handleGsapDeleteAllForElement. Per-finding-per-file: MotionPathOverlay.tsx:492 and StudioPreviewArea.tsx:161-166 are both flipped. No half-fix.

useGsapKeyframeOps.removeAllKeyframes unconditionally clears the keyframe cache for the element BEFORE the SDK branch — so the timeline diamonds vanish whether the SDK path is taken or not. The cache-clear sits outside the branch.

🟡 Nit — removeAllKeyframesFromScript duration-0 collapse could violate user intent when keyframes are removed from a NON-position tween (e.g. removing keyframes from an opacity tween also drops duration to 0 + immediateRender: true). The bridge tests cover opacity-tween survival around a position remove-all, but I don't see a test that asserts an opacity-only remove-all still animates (or that holding statically is the intended UX for non-position groups). If a user removes-all-keyframes on a 2s opacity fade, the element now snaps. Worth a follow-up test pinning the intended behavior — drop or hold? The commit message phrasing ("removing all keyframes HOLDS the element statically") suggests intentional, but the test gap leaves the contract implicit.

🟡 Nit — gsapPositionDetection.ts:52,82 re-enter the fallow CRAP register at 37–43. Acceptable as extract-from-cap (the file-size pressure was real), but worth annotating with intent: these are the playhead-distance scorers and their cyclomatic count is structural.

💭 The setVarsKey/makeObjectProperty boolean-extension is clean and minimal; buildVarsObjectCode adopting boolean in valueToCode's contract is a one-liner of trust I checked — valueToCode (acorn) already JSON-encodes booleans.


B. Thumbnail cache busting

Root cause is named correctly. The old if (!vpWidth) gate scoped the file-read AND the cache-key contribution; Studio always supplies w/h, so sourceMtime stayed 0 and the source vanished from the key. The fix moves the read OUTSIDE the dimension gate (thumbnail.ts:39-54) and adds a content-hash sourceKey (sha1, first 16 hex). This is the decorative-gate pattern fixed at the root — the read-and-decide gate's populate path was missing, and is now populated unconditionally.

Cache-control flipped from public, max-age=60no-cache at both serve points (hit + miss). The disk cache remains keyed; the browser is forced to revalidate, so the disk-key fix is actually reachable from the client. Per the cache-bust checklist: producer key + all consumer layers (browser cache) are both addressed.

Shared route via createStudioApi — so the fix lands at both the embedded CLI server and the Vite local-studio dev server. No layer split.

Test pins the new behavior at the right place: same URL with explicit w/h, file content changes, generateThumbnail called twice. (thumbnail.test.ts:178).

🟡 Nit — mtime is still in the key alongside the content hash (compW}x${compH}_${sourceMtime}_). Belt + suspenders, which is fine, but the comment says "keyed on content like the manualEdits/motion files, not just mtime, so a restore/copy with a preserved mtime can't serve stale" — the implementation keeps both, so a restore that preserves mtime AND has different content will still bust (good). Inverse: a re-save with same content but bumped mtime will needlessly invalidate (cheap miss). Acceptable.

💭 Not a finding — the sourceKey is sha1-truncated-16-hex. Birthday collision is astronomically irrelevant at this cardinality.


C. Local-studio preview discovery

The IPv4/IPv6 split was the right diagnosis. Vite binds [::1], embedded binds 127.0.0.1studioSelectionClient.ts now probes both in LOOPBACK_HOSTS (studioSelectionClient.ts:96-99), carries host on ActiveServer, and every downstream URL builder is updated: studioApiUrl (line 95), previewBaseUrl + absolutePreviewUrl (preview.ts:138-143), previewServerPayload (preview.ts:158-167), and the selection thumbnail/context formatters. Dispatch chain audit: complete.

Test pins both the IPv6-only discovery path AND that studioSelectionUrl(server) produces the matching [::1] URL (studioSelectionClient.test.ts:123-149). Catches the half-fix where discovery succeeds but a later API call still hardcodes 127.0.0.1.

Default host = "127.0.0.1" on the URL builders preserves the legacy/embedded path. No silent change for the embedded scan.

Cross-PR note vs #1777 (the preview-as-agent-bridge I stamped through R3 yesterday at 9b932d0a): this PR touches studioSelectionClient.ts and preview.ts directly — exactly the surface #1777 introduced/expanded. I reviewed the diff for collision: this PR adds host to ActiveServer, threads it through, and the loopback dual-probe is purely additive. useStudioSelectionPublisher.ts is untouched here. The signal-server contract from #1777 stands; #1781 just makes the discovery side honest about IPv6. Clean composition; no rework required.

🟡 Nit — LOOPBACK_HOSTS is iterated INSIDE the port loop, so the order is 127.0.0.1:5190 → [::1]:5190 → 127.0.0.1:5191 → [::1]:5191. If a developer ever runs an IPv4 dev server at the higher port AND a stale IPv6 dev server at the lower port, the stale one wins. Probably never matters; not a blocker. The IPv4-first order also means a healthy dual-stack server still pays the IPv4 round-trip first, which is the cheaper one anyway.


Player blade-morph (out-of-scope but ride-along)

✅ The controls.ts web-component crossfade (CSS rotate + scale) and the React PlayerControls MorphSVG path coexist cleanly — both share the favicon viewBox and respect prefers-reduced-motion. gsap is added as a studio dep (the React side); the web-component bundle stays dep-free. Reduced-motion path takes the gsap.set no-tween branch.

🟡 The blade-morph commit lives inside this PR but is unrelated to the three title axes. Separable, and small — if you wanted a stricter scope discipline, this could have been a sibling PR, but as a single landing the diff stays readable.


Cross-axis sanity

  • The decorative-gate watch (read-and-decide gates with missing populate paths) → I found three candidates and all three populate paths are wired: (1) thumbnail source-read gate, (2) drag-intercept dedupe gate, (3) loopback discovery gate.
  • The dispatch-chain watch (consumers of changed shapes) → findPositionSetAnimationfindExistingPositionWrite rename: only two consumers (gsapRuntimeBridge.ts, the export itself) and both are updated. handleGsapDeleteAllForElementhandleGsapRemoveAllKeyframes swap: both consumers updated.
  • The verify-reachable watch (fix inside a conditional branch) → drag self-heal only fires with fetchFallbackAnimations; verified both call sites pass it.
  • The extract-to-helper watch (gsapPositionDetection.ts extraction) → audited the helper body verbatim against the prior inline (line-by-line preserved); both call sites unchanged.

Action

LGTM with the two nits above (non-blocking):

  1. Pin the intended remove-all-keyframes behavior for non-position tweens with a test (or change to position-only collapse).
  2. Optional: probe [::1] first inside the port loop if Vite is the common case (cheap one-shot win).

Fallow audit will be tidied per the author's note; the two critical scores are pre-existing structural complexity, not new debt.

Review by Via

@miguel-heygen miguel-heygen merged commit 0a9555a into main Jun 29, 2026
44 of 45 checks passed
@miguel-heygen miguel-heygen deleted the fix/preview-selection-local-studio branch June 29, 2026 18:23

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Layered on @vanceingalls' R1 — she covered the three surfaces end-to-end and traced the dispatch chains. I converge with her verdict (🟢 LGTM-with-nits, no blockers) and won't re-walk ground she already covered. Notes below are the rubric / cross-cutting / coverage-shape angles that complement hers.

Converge with Via

  • Decorative-gate watch (3/3 populate paths wired) — same three I had on the rubric checklist (thumbnail source-read, drag-intercept dedupe, loopback dual-probe). Verified independently; agree all three populate paths reach.
  • The findExistingPositionWrite rename + degenerate-to matcher is the right shape for the symmetry: removeAllKeyframes now produces { duration: 0, immediateRender: true }, and findExistingPositionWrite is the inverse-operation matcher that recognizes it. Inverse-op symmetry check passes — both ends touch the same constant family (zero-duration + immediateRender flag), not divergent thresholds.
  • useGsapTweenCache populator symmetry (synth path :349 + populate-from-parsed path :464) — both paths apply the same !anim.keyframes && (anim.method === "set" || (anim.duration ?? 0) === 0) predicate. Half-applied diamond regression risk is closed.
  • Boolean extension to valueToCode / serializeValue spot-checked — gsapSerialize.ts:257-263 does String(value) on non-string non-__raw: values, so immediateRender: true round-trips as "immediateRender: true". Test assertions match exactly. Sound.
  • Cross-PR composition with #1777 (the studio-selection-via-preview bridge I stamped through R3 yesterday) — agree this is purely additive on the ActiveServer shape and studioApiUrl. The host field defaults preserve the embedded path, the LOOPBACK_HOSTS probe is the exact missing piece, and studioSelectionUrl(server) carries the discovered host through. The AmbiguousPreviewServerError / PreviewServerPortMismatchError machinery from #1777 is untouched. Clean composition.

My distinct findings (lane split: rubric + cross-cutting)

🟠 Concern (amplifies Via's Nit 1) — removeAllKeyframes collapse-to-static-hold is now applied to non-position groups too. gsapWriterAcorn.ts:1257-1259 and gsapParser.ts:2515-2517 unconditionally set duration: 0 + immediateRender: true + drop ease inside buildCollapsedFlatVars / the recast twin, before the position-sibling-strip branch gates on propertyGroup === "position". So an opacity-only or rotation-only keyframes: tween with a duration: 2 will now snap-collapse to a static hold on remove-all. Via's nit names this; I'd elevate the framing — the PR title scopes to "position editing correctness" but the collapse-to-hold landing applies to ALL property groups via removeAllKeyframesFromScript. Either (a) narrow the duration-drop to propertyGroup === "position", or (b) accept globally and pin with a test that asserts "opacity-only remove-all also holds" so the contract is explicit. Right now the behavior is implicit and divergent from the PR's stated thesis. (Per the bandaid-vs-long-term rubric — coverage is partial vs the stated thesis, so the contract scope is unclear.)

🟠 Concern — silent self-heal has no telemetry. tryGsapDragIntercept's consolidate-position-writes self-heal (gsapRuntimeBridge.ts:138-156) silently repairs corrupted state — duplicates → singleton — without any signal that a regression elsewhere produced the duplicates. If a future refactor (or a 3rd-party plugin path, or a not-yet-discovered commit shape) starts emitting duplicates at higher rates, this PR's self-heal will mask it indefinitely. Suggestion: a low-cardinality PostHog/console.warn (gsap_position_dedupe_self_heal, dimensions: dup count, keeper method, project) inside the if (dupes.length > 1) branch so this remains observable. Doesn't block this PR's merge, but the self-heal is exactly the class of finding I'd want emitted on the failure path for the "silent corruption recovery" pattern. Low-blast, high-leverage future debugging.

🟡 Nit — previewServerPayload shape drops host from the JSON contract. preview.ts:314-331: input server carries host, but the returned payload only exposes port, projectName, projectDir, url. host is baked into url. Any external automation parsing the --context --json output for the loopback host (heygen-cli? hyperframes-gemini-agent?) will need to URL-parse to extract [::1] vs 127.0.0.1. I checked heygen-cli and hyperframes-gemini-agent for hardcoded 127.0.0.1 consumers of this CLI surface — none found, so this is forward-looking. If you want belt-and-suspenders, expose host alongside url in the payload (cheap addition, preserves backward compat — existing parsers reading url keep working).

🟡 Nit — THUMBNAIL_CACHE_VERSION stays at v4 while the key shape changes (added sourceKey slot). New key shape won't collide with old (the prefix differs at the slot count), but old v4_* cache entries from before this PR are orphaned garbage on disk forever — they'll never be served (new requests build the new shape) and they'll never be evicted (no cleanup pass). On a long-lived dev project with many edits, .thumbnails/ grows monotonically. Bump to v5 would invalidate old entries cleanly so they're never read, but they'd still need GC. Acceptable to defer; flagging for the followup tidy.

🟡 Nit — LOOPBACK_HOSTS order (covered by Via, agreeing): 127.0.0.1 first means dual-stack servers eat the IPv4 round-trip even when Vite (the more common local-studio case) only binds ::1. The cheap inversion ([::1] first) optimizes for the IPv6-only Vite path. Microperformance, not a real concern.

Test-coverage shape (rubric angle)

  • ✅ Parity tests cover acorn + recast twins for dedupePositionWritesInScript (3 cases in gsapWriter.parity.test.ts:168).
  • ✅ End-to-end mutation route exercised via files.test.ts:242 (consolidate-position-writes).
  • ✅ Drag-bridge regression test pins the in-place update for a degenerate tl.to hold (gsapRuntimeBridge.test.ts:130) — exactly the path the symmetry hinges on.
  • ✅ Soft-reload orphan test covers the non-timeline-child sweep (gsapSoftReload.test.ts:103).
  • ✅ Thumbnail content-hash regen test pins the "explicit w/h doesn't bypass keying" failure mode (thumbnail.test.ts:178).
  • ✅ IPv6 discovery test asserts BOTH the discovery succeeds AND studioSelectionUrl(server) produces the matching [::1] URL (studioSelectionClient.test.ts:123-149) — catches the half-fix.
  • 🟡 Coverage gap (Via's Nit 1 amplified): no test pins remove-all-keyframes behavior for opacity / rotation / scale / mixed groups. The behavior change applies to them; the contract is implicit. One assertion either way (snap-hold OR re-animate) closes the loop.

Cross-cutting

  • Sibling-asymmetry checkCache-Control: no-cache applied at BOTH serve points (cache-hit + cache-miss, thumbnail.ts:91-94 and :117-119). Symmetric.
  • Dispatch-map silent-drop checkconsolidate-position-writes mutation type wired in BOTH executeGsapMutationAcorn (files.ts:909) and executeGsapMutationRecast (files.ts:1196). Both writer paths handle the new case; no silent fall-through.
  • Stale-cache resurrection guard — the comment at gsapDragCommit.ts:165 is the right framing: "in the static path it's a stale/phantom parse: re-committing it would resurrect a just-deleted tween." The (a.duration ?? 0) === 0 check is the gate that prevents re-resurrection. Important invariant; would be nice as a future test that explicitly enumerates the resurrection path the gate prevents (this one is implicit in the "doesn't trip the stale-parse guard" test).
  • No feature flag flip in this PR. Zero-soak / introduce-and-flip rubric N/A. STUDIO_KEYFRAMES_ENABLED gates the K-shortcut wiring but is pre-existing.

CI / fallow note

Fallow audit failure acknowledged in PR body. The two critical CRAP scores Via cited (MotionPathOverlay.onPathDown 110.0, useKeyframeKeyboard.handler 756.0) are pre-existing structural complexity — handler is the JKL-keyboard switch from #1311 and this PR's consume() extraction actually reduces the function body. Surfaces post-edit, not new debt. Agree with Via — tidy in followup, not a merge blocker.


Verdict

🟢 No blockers from me — converge with Via's R1. Two non-blocking calls to action:

  1. Pin remove-all-keyframes contract for non-position groups (test or scope-narrow) — Via's Nit 1, my 🟠 amplification.
  2. Add a low-cardinality emit on the position self-heal so silent corruption stays observable — my distinct 🟠.

Stamp routing per James's directive: deferring to OG Rames Jusso (@jrusso1020) once the two soft calls land or are explicitly deferred to a followup.

Reviewed at SHA aec860a0.

— Rames D Jusso

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.

3 participants