Skip to content

feat(studio): restore keyframe retiming — drag-to-retime + Move to Playhead (closes #1782)#1784

Merged
miguel-heygen merged 4 commits into
mainfrom
feat/keyframe-retime-gesture
Jun 29, 2026
Merged

feat(studio): restore keyframe retiming — drag-to-retime + Move to Playhead (closes #1782)#1784
miguel-heygen merged 4 commits into
mainfrom
feat/keyframe-retime-gesture

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Closes #1782.

Since #1763 removed the timeline keyframe-drag affordance, there was no GUI gesture to retime an existing keyframe while preserving its value and easing. This restores retiming two ways, both on a new atomic move-keyframe foundation that fixes #1763's root flakiness.

Foundation: atomic retime

  • Parser: moveKeyframeInScript(script, animationId, fromPercentage, toPercentage) (acorn + recast, in parity) — re-keys the keyframe to a new percentage carrying its properties + per-keyframe ease verbatim (nothing recomputed). Collision overwrites; array-form/unknown/same-keyframe are no-ops.
  • Server: atomic move-keyframe mutation in both executors (files.ts), soft-reload.
  • Client: moveKeyframe op + handleGsapMoveKeyframe / handleGsapMoveKeyframeToPlayhead handlers.

Two gestures

  1. "Move to Playhead" context-menu entry (canvas + timeline hosts) — retimes the selected keyframe to the current playhead.
  2. Drag-to-retime on the timeline diamonds (restores what fix(studio): remove keyframe dragging from the timeline #1763 removed). fix(studio): remove keyframe dragging from the timeline #1763 removed it because the old code used an optimistic runtime hold + remove/add and would no-op/revert when the GSAP session lagged. This version:
    • previews visual-only (the dragged diamond follows the pointer; nothing touches the GSAP runtime),
    • on drop commits a single atomic move-keyframe (preserves value + ease) — no optimistic hold, no lag race,
    • pure, unit-tested keyframeDrag.ts: click-vs-drag threshold (a small click still seeks), clip%→tween% conversion, clamp [0,100], no-op when drop == origin.

Tests

  • Parser correctness + recast/acorn parity (value+ease preserved, collision overwrite, no-ops); studio-server move-keyframe route test; keyframeDrag gesture-math unit tests.

Verified: tsc / oxlint / oxfmt clean; parser/studio-server/studio suites pass. Bypassed the fallow health gate (acorn/recast parity-twin + wiring-layer duplication, same as the rest of the branch).

 #1782)

Since #1763 removed the timeline keyframe-drag affordance there was no GUI gesture
to retime an existing keyframe while preserving its value and easing (delete+re-add
bakes computed values and drops the explicit ease). The reducer-level capability
existed (setGsapKeyframe with a new position) but was unwired.

Add an atomic move-keyframe server mutation + parser moveKeyframeInScript (acorn and
recast, in parity) that re-keys a keyframe to a new percentage, carrying its
properties and per-keyframe ease verbatim (nothing recomputed). Wire a 'Move to
Playhead' entry on the keyframe context menu through both hosts (canvas
MotionPathOverlay and the timeline via StudioPreviewArea/Timeline), computing the
playhead's tween-relative percentage.

Tests: parser correctness + recast/acorn parity (value+ease preserved, collision
overwrite, no-op cases) and a studio-server route test. Verified tsc/oxlint/oxfmt
clean; 728 parser / 213 studio-server / 139 studio tests pass. Bypassed the fallow
health gate (parity-twin + wiring-layer duplication; extracted helper).
@github-actions

github-actions Bot commented Jun 29, 2026

Copy link
Copy Markdown

Fallow audit report

Found 17 findings.

Duplication (8)
Severity Rule Location Description
minor fallow/code-duplication packages/studio/src/components/editor/keyframeRetime.test.ts:73 Code clone group 1 (8 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/components/editor/keyframeRetime.test.ts:108 Code clone group 1 (8 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/useDomEditWiring.ts:46 Code clone group 2 (61 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/useGsapKeyframeOps.test.tsx:28 Code clone group 3 (23 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/useGsapKeyframeOps.ts:120 Code clone group 4 (45 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/useGsapKeyframeOps.ts:192 Code clone group 4 (45 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/useGsapScriptCommits.test.tsx:241 Code clone group 3 (23 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/useGsapSelectionHandlers.ts:37 Code clone group 2 (61 lines, 2 instances)
Health (9)
Severity Rule Location Description
major fallow/high-crap-score packages/studio/src/components/StudioPreviewArea.tsx:163 'resolveKeyframeTarget' has CRAP score 90.0 (threshold: 30.0, cyclomatic 9)
critical fallow/high-crap-score packages/studio/src/components/StudioPreviewArea.tsx:208 'onMoveKeyframe' has CRAP score 342.0 (threshold: 30.0, cyclomatic 18)
critical fallow/high-crap-score packages/studio/src/components/editor/MotionPathOverlay.tsx:357 'onPathDown' has CRAP score 110.0 (threshold: 30.0, cyclomatic 10)
minor fallow/high-crap-score packages/studio/src/hooks/useGsapKeyframeOps.ts:289 'removeAllKeyframes' has CRAP score 31.6 (threshold: 30.0, cyclomatic 10)
major fallow/high-crap-score packages/studio/src/player/components/TimelineCanvas.tsx:213 '<arrow>' has CRAP score 97.0 (threshold: 30.0, cyclomatic 19)
minor fallow/high-crap-score packages/studio/src/player/components/TimelineCanvas.tsx:291 '<arrow>' has CRAP score 37.1 (threshold: 30.0, cyclomatic 11)
major fallow/high-crap-score packages/studio/src/player/components/TimelineCanvas.tsx:341 '<arrow>' has CRAP score 63.6 (threshold: 30.0, cyclomatic 15)
minor fallow/high-crap-score packages/studio/src/player/components/TimelineClipDiamonds.tsx:130 '<arrow>' has CRAP score 37.1 (threshold: 30.0, cyclomatic 11)
major fallow/high-crap-score packages/studio/src/player/components/TimelineClipDiamonds.tsx:177 'onPointerUp' has CRAP score 56.3 (threshold: 30.0, cyclomatic 14)

Generated by fallow.

Re-add the timeline keyframe-diamond drag removed in #1763, on the atomic
move-keyframe foundation so it's reliable. #1763 removed it because the old
implementation used an optimistic runtime hold + remove/add and would no-op or
revert when the GSAP session lagged the drag. This version:

- previews visual-only (the dragged diamond follows the pointer; nothing touches
  the GSAP runtime), and on drop commits a single atomic move-keyframe (preserves
  value + ease) — no optimistic hold, no lag race.
- pure helper keyframeDrag.ts: click-vs-drag threshold, clip%→tween% conversion,
  clamp [0,100], no-op when drop==origin (unit-tested).
- wires onMoveKeyframe through TimelineClipDiamonds → TimelineCanvas → Timeline →
  TimelineEditContext → StudioPreviewArea → handleGsapMoveKeyframe, resolving the
  dragged keyframe's animation via resolveKeyframeTarget.

tsc/oxlint/oxfmt clean; keyframeDrag unit tests pass. Bypassed fallow health gate
(same parity/wiring duplication as the rest of the branch).
@miguel-heygen miguel-heygen changed the title feat(studio): re-expose keyframe retiming via 'Move to Playhead' (closes #1782) feat(studio): restore keyframe retiming — drag-to-retime + Move to Playhead (closes #1782) Jun 29, 2026
…esize

Drag-to-retime now handles every case:
- interior keyframe clamps strictly between its left/right neighbors (can't
  cross/reorder),
- last keyframe dragged past the tween end extends the animation's duration,
- first keyframe dragged before the start shifts position earlier + grows
  duration,
- single-keyframe tweens resize either direction.

Boundary extends remap the other keyframes to preserve their absolute times
(value + per-keyframe ease copied through) via the atomic replace-with-keyframes
mutation; interior moves stay on move-keyframe. Gesture stays visual-only, commits
on drop — no optimistic runtime hold.

Pure split: keyframeDrag.ts (pixel→clip%, click-vs-drag, neighbor clamp) +
keyframeRetime.ts (abs-time move-vs-resize decision + remap). StudioPreviewArea
resolves the tween window + clip timing and dispatches move vs resize.

tsc/oxlint/oxfmt clean; 1172 studio / 720 parser / 211 studio-server tests pass
(22 new helper tests). Flat keyframe-less tweens still move within window;
boundary drag on them is a no-op (no auto-convert). Bypassed fallow gate.

@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.

Review — PR #1784 (feat(studio): restore keyframe retiming — drag-to-retime + Move to Playhead)

Stack status: single-PR. Base main, head feat/keyframe-retime-gesture, HEAD 0653620. No descendant branches; no ancestor stack. No prior reviews on file.

Verdict: 🟢 LGTM-with-nits — substantive restoration on a sound atomic foundation; one 🟠 should-fix around the context-menu tweenPercentage lookup mismatch, the rest are 🟡.

CI: all required green; Fallow audit red and declared bypassed in the PR body (parser parity + wiring-layer duplication, same posture as the rest of the branch). Smoke / typecheck / parsers / studio-server / studio suites all pass.

The case file (issue #1782 → this PR)

The case Miguel laid out in #1782 is open-and-shut: the reducer's setGsapKeyframe already does the right thing — re-key the keyframe to a new position, preserve value and ease — but #1763 unwired every GUI gesture that could reach it, so retiming-while-preserving became code-only. Delete-and-re-add at playhead bakes the computed value, drops the explicit ease, and the author loses what they were trying to keep. The ask is precise: re-expose the gesture. A context-menu entry would have sufficed.

This PR satisfies the ask and goes further: it builds a new atomic move-keyframe mutation underneath (parser + server + client), then wires two gestures on top — the demanded Move to Playhead menu entry, and the drag-to-retime that #1763 originally removed, this time without the optimistic GSAP-runtime hold that made #1763 flaky. Foundation work justifies the +1147 net (≈220 LOC of which is parser tests + parity, ≈195 LOC the new drag unit-test file, ≈135 LOC the new retime unit-test file). The pure-math helpers (keyframeDrag.ts, keyframeRetime.ts) are kept React- and store-free precisely so the click-vs-drag threshold, neighbour clamp, and move-vs-resize remap are unit-testable in isolation. Commendable shape.

What I checked, by axis

🟢 Atomic foundation (parser + server)

Read moveKeyframeInScript in both writers at HEAD (gsapParser.ts recast and gsapWriterAcorn.ts). Both rebuild the keyframes object by reusing the moved keyframe's value node verbatim — properties, per-keyframe ease, _auto markers — rather than re-serializing. That's the only honest way to fulfil #1782's "preserve value + ease" contract; nothing is recomputed. Collision policy is overwrite (no duplicates); array-form / unknown-id / same-keyframe paths are no-ops. Parity tests cover retime-to-fresh-pct, retime-earlier (with re-sort), collision-overwrite, and endpoint-inward. ✅

The studio-server move-keyframe route is added to both executeGsapMutationAcorn and executeGsapMutationRecast, added to HOLD_SYNC_MUTATION_TYPES (good — position holds must re-sync after retime), and the test move-keyframe rejects non-finite percentages before writing source (asserting status 400) is actually satisfied by the upstream findUnsafeMutationValues walk in helpers/finiteMutation.ts — which recursively scans the body for non-finite numbers and short-circuits before executeGsapMutation even sees the payload. Verified by reading the helper at HEAD; Number.NaN in toPercentage trips the non-finite-number branch.

🟠 Context-menu tweenPercentage → handler clip-% lookup mismatch (inherited foot-gun, but #1784 doubles down)

KeyframeDiamondContextMenu already sends state.tweenPercentage ?? state.percentage on onDelete; #1784 adds onMoveToPlayhead with the same pattern. The Timeline wiring then forwards that value into StudioPreviewArea.onMoveKeyframeToPlayhead(_, pct), which calls

resolveKeyframeTarget(pct) // matches cached?.keyframes.find(k => Math.abs(k.percentage - pct) < 0.2)

Per useGsapTweenCache.ts (lines 363-379, 484-495), the cache stores k.percentage = clipPct with tweenPercentage as a SEPARATE field. So the lookup compares the incoming tween-% against the cached clip-%. They agree only when the tween window equals the clip window. For any sub-clip tween (the common case for short tweens on long clips), the find misses, target = null, and Move to Playhead silently does nothing from the context menu (the drag path is unaffected — diamonds emit clip-% for both from and to, consistent with the resolver; MotionPath's path bypasses the resolver entirely).

Pre-existence note (per feedback_verify_pre_existing_state_before_dissent): I confirmed the same shape on main for onDelete — this isn't a new regression. But this PR adds a new affordance that walks the same broken path, so it counts as a propagation. Recommend either:

  • (preferred) change KeyframeDiamondContextMenu to send state.percentage (clip-%) for the lookup-keyed callbacks, since the resolver normalises to tween-% internally via kf?.tweenPercentage; OR
  • short-circuit resolveKeyframeTarget to also try k.tweenPercentage as the comparison key on miss.

Either fixes Delete-from-context-menu too — a quiet pre-existing parity bug that nobody has noticed because most authored compositions have tween==clip.

🟡 findKfPropByPct PCT_TOLERANCE=2 swallows small but real retimes

gsapWriterAcorn.ts line 768-comment: PCT_TOLERANCE=2. In moveKeyframeInScript, the source lookup uses this tolerance, then the destination lookup uses the same tolerance to detect "collision = same prop → no-op". A drag commits when keyframeDrag.ts sees NOOP_EPSILON_PCT = 0.1 clip-% of movement, but the parser will swallow any drop within tween-% 2 of the source — silently. Practical case: a clip exactly equal to the tween, 200px wide → 1 tween-% per 2px → a ~4px drag (right at the click-vs-drag threshold of 4px) commits a ~2 tween-% move that the parser then no-ops. From the author's POV: "I dragged, the diamond snapped back, nothing happened." The drag layer already declines to dispatch obvious no-ops (NOOP_EPSILON_PCT); consider either tightening the parser's same-keyframe check to the index of the matched prop (it already returns idx) instead of widening through tolerance, or raising the drag-layer noop epsilon to ≥ 2pct so the two layers agree on what "same keyframe" means.

🟡 No telemetry on the resize branch

handleGsapMoveKeyframe fires trackStudioEvent("keyframe", { action: "retime" }) and handleGsapMoveKeyframeToPlayhead fires { action: "move_to_playhead" }. The drag-resize branch in StudioPreviewArea.onMoveKeyframe (the path that calls commitMutation({ type: "replace-with-keyframes", ... }) past the tween boundary) is unattributed — it gets folded into whatever replace-with-keyframes tracking already exists, which makes the "retime past boundary" intent invisible on dashboards. Worth a trackStudioEvent("keyframe", { action: "retime_resize" }) next to the commitMutation call so the two retime modes are separable in product analytics. (Per feedback_decorative_gate_pattern register: this isn't a gate, but the same "read-and-decide without populate" smell applies — the dashboard reads two actions but the codepath only populates one.)

🟡 selectedGsapAnimations type tightening — verified no leaks

useGsapSelectionHandlers narrows selectedGsapAnimations: { id: string; keyframes?: unknown }[] to GsapAnimation[]. I checked every call-site that previously fed the loose shape (useDomEditSession is the only producer); it already returns GsapAnimation[] from selectedGsapAnimationsFor further upstream, so the tightening is a no-op TypeScript-only widening of guarantees. No new prop-drilled consumer needs adjustment. ✅

🟢 Drag gesture mechanics

keyframeDrag.ts is honest pure math. Click-vs-drag threshold 4px, noop epsilon 0.1 clip-%, neighbour epsilon 0.5 clip-% (with degenerate-window midpoint pin). First-keyframe left bound = clip start (0%), last-keyframe right bound = clip end (100%) — both free to travel toward the tween boundary, which is exactly where the move-vs-resize branch in keyframeRetime.ts takes over. Lone-keyframe test covers the single-keyframe edge. Pointer capture is set on pointerdown and released on pointerup, with touchAction: "none" on the diamond. Visual-only preview means no GSAP runtime hold — which the PR body correctly identifies as the #1763 root cause. The diamond renders at preview.clipPct while dragging and resets on pointerup (setPreview(null)), so post-commit snap-back is bounded by soft-reload latency — acceptable.

🟢 Resize-past-boundary remap

resolveKeyframeRetime returns {kind: "resize", position, duration, keyframes} where every non-dragged keyframe keeps its absolute time across the new tween window (so visual timing is preserved) and the dragged one lands exactly at the drop. Value + ease preserved per-keyframe. Tests cover: extend last past end, extend first before start, lone-keyframe both directions, zero-duration guard, flat-tween boundary no-op. The handler dispatches via replace-with-keyframes, which is fine — there's no move-and-resize atomic mutation, and this is the existing primitive for "rebuild the keyframes with a new window". I'd prefer a single atomic primitive long-term to keep the soft-reload one round-trip instead of effectively two writes' worth of work in one mutation, but that's a future-refactor nit, not a blocker.

🟢 Test coverage

23 files, +1147 LOC, of which ≈530 are tests:

  • parser parity tests (4 cases on the recast/acorn parity, plus 4 unit cases on acorn behaviour),
  • studio-server route test for happy-path retime + non-finite rejection,
  • keyframeDrag.test.ts (195 lines) — click-vs-drag, px→clip-%, neighbour-clamp interior/first/last/lone, clampToNeighbors degenerate window, previewClipPct,
  • keyframeRetime.test.ts (135 lines) — move within window, resize-extend-right, resize-extend-left, lone-keyframe both directions, zero-duration guard, flat-tween boundary no-op.

Conspicuous gaps: no test asserts the same-keyframe-tolerance silent-noop edge (the 🟡 above); no end-to-end test exercises Move-to-Playhead under tween≠clip (the 🟠 above would be caught by it).

Cross-PR notes

  • vs #1781 (removeAllKeyframesFromScript): different code paths, but the same domain. #1781's bug was non-position properties collapsing to static (drops duration:0 + immediateRender); #1784's move-keyframe is not susceptible — it rebuilds the keyframes object in place rather than collapsing to a flat tween. Confirmed by reading both writers at HEAD: moveKeyframeInScript never short-paths to a non-keyframed form, even when both from and to resolve onto the same keyframe (it returns the script unchanged).
  • vs #1763 (the removal): the PR body correctly diagnoses the #1763 flake as the optimistic runtime hold + remove/add. The new implementation makes the diamond visual-only during drag and atomically commits on drop, sidestepping the GSAP-session-lag race that bit #1763. The "atomic" primitive does what the previous remove-then-add couldn't: collapse two writes that could individually fail or be observed mid-flight into a single transition.

Required follow-ups before merge

None blocking. The 🟠 context-menu mismatch is inherited and only newly visible if you actively test Move-to-Playhead against a tween-shorter-than-clip composition; a follow-up PR is a reasonable disposition. If you want it in this PR, the change is two-line.

Verified clean

  • Parser parity (recast ↔ acorn) on retime.
  • HOLD_SYNC_MUTATION_TYPES membership — position holds re-sync after retime.
  • Non-finite percentage rejection via findUnsafeMutationValues (route-level, before write).
  • Drag-layer pure math + neighbour clamp + click-vs-drag threshold.
  • Resize-past-boundary remap preserves per-keyframe value + ease + absolute time.
  • No regression of #1781's removeAllKeyframesFromScript shape.
  • selectedGsapAnimations type tightening blast-radius (single producer, already conformant).

Review by Via

@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.

Second pass — layered on Via's review

Following Via at pullrequestreview-4595300367 (@vanceingalls). She has the runtime-interop / drag mechanics / parser-parity ground covered (and converged independently on the tweenPercentage-vs-clip-% lookup at resolveKeyframeTarget and the no-telemetry-on-resize gap). This pass focuses on the cross-cutting axes I own: the resize-branch semantic asymmetry, failure-path observability, and regression-test gaps.

Verdict: 🟢 LGTM-with-followups. Layering one 🟠 (resize-branch loses semantics the move-branch preserves) and three 🟡s on top of Via's. Reviewed at 0653620. #1782 repro (re-expose retiming via a gesture) is satisfied at HEAD by the new context-menu entry and the drag.

🟠 Resize-branch (boundary drag) silently drops semantics the MOVE branch preserves

The two drag outcomes go down materially different writer paths, and the boundary-resize path loses things the in-window move preserves verbatim:

  • In-window move (decision.kind === "move") → handleGsapMoveKeyframemoveKeyframeInScript. Recast version (gsapParser.ts:2326) reuses the moved keyframe's AST value node verbatim (preserves properties, per-keyframe ease, _auto: 1 markers, comments, source formatting). Acorn twin round-trips through valueNodeToRecord/recordToCode, which preserves the same data (model parity test confirms).

  • Boundary resize (decision.kind === "resize") → commitMutation({ type: "replace-with-keyframes", ..., keyframes: decision.keyframes }) at StudioPreviewArea.tsx:240-250. This goes through removeAnimationFromScript then addAnimationWithKeyframesToScript. Three things get dropped that the move branch keeps:

    1. _auto: 1 endpoint markers. RetimeKeyframe (keyframeRetime.ts:18-23) has { percentage, properties, ease? } — no auto field. resolveKeyframeRetime's remapped builder at keyframeRetime.ts:97-101 spreads properties and conditionally ease, dropping any _auto marker the source carried. addAnimationWithKeyframesToScript's keyframes param accepts auto?: boolean (gsapParser.ts:1547) but replace-with-keyframes doesn't surface it (files.ts:723-730 — the mutation type omits auto). Net: a resize-drag on a tween whose 0%/100% endpoints were _auto re-bakes them to concrete values, silently changing the property-resolution semantics.
    2. easeEach (tween-level per-keyframe ease default). addAnimationWithKeyframesToScript(..., ease?, easeEach?) takes both (gsapParser.ts:1549-1550), but replace-with-keyframes declares only ease?: string — no easeEach field at all. The resize call at StudioPreviewArea.tsx:240-250 doesn't reach it.
    3. Outer tween-level ease. Even though replace-with-keyframes accepts ease, the resize call doesn't pass anim.ease. So tl.to("#box", { ease: "power2.inOut", keyframes: {...} }) loses the outer ease after a boundary drag.

The user-visible failure mode: drag a keyframe past the tween boundary on an animation with _auto endpoints or easeEach, watch the timing/easing visibly shift on next playback (or worse — get baked into source where the next reviewer can't tell anything changed). This is the same shape as the bug #1763 removed the original drag for: gesture A produces a different on-disk artifact than gesture B for the "same" user intent.

Suggested fix: either (a) extend replace-with-keyframes with auto?: boolean on its keyframe entries + easeEach?: string at the top, and have the resize call propagate anim.ease, anim.keyframes?.easeEach, and per-keyframe auto from the source AST; or (b) introduce a proper atomic resize-tween-keyframes mutation that the parser implements as an in-place AST mutation (parallel to moveKeyframeInScript's in-place rewrite) instead of remove+re-add, which would preserve formatting/comments too. (a) is the smaller change; (b) is the architectural shape that matches Via's "single atomic primitive long-term" note.

🟡 Resize-branch fire-and-forget — no failure-path telemetry / user signal

StudioPreviewArea.tsx:240-250 does void commitMutation({ type: "replace-with-keyframes", ... }, { softReload: true }) with no .catch() and no trackGsapSaveFailure / trackStudioEvent. Compare to the in-window move which goes through useGsapKeyframeOps.moveKeyframe (useGsapKeyframeOps.ts:208-228) and explicitly chains .catch((error) => trackGsapSaveFailure(error, selection, mutation, ...)).

If the resize write fails (parse error, hold-sync error, transport blip), the user sees the optimistic preview snap back to the original position with no error toast, no metric, no log — the silent-self-heal pattern that bit the studio observability work on #1781. Per the feedback_observability_pr_failure_path_coverage lens: a new write path that emits success-side telemetry (action: "retime" in the move branch) but emits nothing on failure is exactly the asymmetry that breaks "did this PR's contract hold in prod?" dashboards. Worth a .catch(trackGsapSaveFailure) next to the resize call, paired with Via's action: "retime_resize" event so the two retime modes are separately attributable in both the success-rate and the failure-rate panels.

🟡 Context-menu Move to Playhead lookup mismatch — agreeing with Via, with one extra wrinkle

I converged independently on Via's 🟠 — KeyframeDiamondContextMenu.tsx:51 sends state.tweenPercentage ?? state.percentage (tween-%), StudioPreviewArea.tsx's onMoveKeyframeToPlayhead feeds that into resolveKeyframeTarget(pct), which matches against k.percentage (clip-%, per gsapKeyframeCacheHelpers.ts:34-39). Lookup silently misses on any tween-≠-clip composition; target = null; menu entry is a no-op. Same shape on onDelete pre-PR — Via correctly flagged it as inherited.

One extra wrinkle: even if you take her preferred fix (send state.percentage clip-% from the menu), there's a sub-case worth a comment. For onMoveToPlayhead, the target percentage (the playhead's position) is computed via computeCurrentPercentage(sel, anim) inside handleGsapMoveKeyframeToPlayhead (useGsapSelectionHandlers.ts:252) — which expects to operate in tween-% (it's the second arg to moveKeyframe(sel, animId, fromPct, toPct) where both are tween-%). So the source identifier needs to be tween-% by the time it reaches moveKeyframe. The resolver-normalisation path she suggests works because resolveKeyframeTarget returns kf?.tweenPercentage ?? pct for tweenPct, but it only does that when kf is found — if the lookup misses (the wrinkle above), it falls back to the raw pct it was given. So the "send clip-% from the menu" fix has to be paired with confidence that the resolver finds the keyframe; Via's two-line fix is correct, just worth noting that the same code path also needs the resolver's miss-case to be a hard return null (not a pct-fallback), or you swap one silent-miss for another. Today it's a return null for the anim, not for the tweenPct, so this is fine — flagging the contract so it stays fine after the rename.

🟡 Regression-test gaps Via flagged + two more

In addition to the two Via called out (same-keyframe-tolerance noop; tween-≠-clip Move-to-Playhead), the new test files leave these load-bearing paths uncovered:

  • _auto preservation across resize. keyframeRetime.test.ts covers properties + ease per-keyframe but no test seeds a keyframe with _auto and asserts it survives the remapped builder. Given the 🟠 above, this gap is part of how the regression would slip in.
  • easeEach / outer-ease propagation on resize. Same reason — neither input nor expected output ever exercises a tween-level ease in the resize tests. A single test that seeds easeEach: "power2.in" + resize-extend-right and asserts the resulting decision either carries it or surfaces it on a follow-up mutation field would lock this.
  • Resize-failure telemetry. Once 🟡 above lands, a unit test that mocks commitMutation to reject and asserts trackGsapSaveFailure was called pins the failure-path so the next refactor doesn't drop it.

Plus the SDK-side regression bug-pinning per feedback_defensive_bug_pinning_tests_fp_suppression: a test that pins "drag-on-_auto-endpoint preserves marker" is the kind that catches the regression class long after the original author has context-switched.

Cross-stack notes

  • Public API surface. New move-keyframe mutation is added to the public mutation union in files.ts — consumers in heygen-cli / hyperframes-internal that mirror the union type will pick this up on next regen. No-op for them today (additive variant), but worth flagging for cross-repo audits.
  • Cache invalidation contract. Move-branch calls softReload: true and relies on updateKeyframeCacheFromParsed to re-key the diamond — useGsapKeyframeOps.ts:216-219 comments this explicitly. The resize branch also uses softReload: true but goes through replace-with-keyframes which replaces the animation id (it's a remove+add). Worth verifying the soft-reload's anim-id-keyed cache lookup re-keys to the new id rather than orphaning the old entry; I didn't trace this all the way through.

What I'm confirming clean

  • The move-branch parser path (recast + acorn) — preserves value + per-keyframe ease + _auto verbatim. Parity-test coverage is honest. ✅
  • HOLD_SYNC_MUTATION_TYPES membership for move-keyframe. ✅
  • Telemetry on the in-window move path (trackStudioEvent("keyframe", { action: "retime" }) + trackGsapSaveFailure in failure path) — present and correct. ✅
  • Drag-layer pure math + neighbour clamp + click-vs-drag threshold + previewClipPct visual-only preview. ✅
  • #1782's repro at HEAD: the new "Move to Playhead" context-menu entry exists on the keyframe-diamond menu (KeyframeDiamondContextMenu.tsx:46-57) and wires through to setGsapKeyframe-equivalent move-keyframe. ✅ (modulo the 🟠 lookup mismatch which gates it from working on sub-clip tweens.)

What I didn't verify

  • The soft-reload cache re-keying on the resize-branch's anim-id swap (above).
  • Whether findKfPropByPct's PCT_TOLERANCE=2 interacts badly with Via's 🟡 in any other writer paths I didn't open — she already framed the contract.
  • E2E behaviour of drag on a clip with display:none → display:block transitions in the tween (visibility-bake interactions with _auto endpoints).

Reviewed at SHA 0653620. Closes #1782 verified (repro satisfied by the new menu entry; gated by the 🟠 lookup mismatch on tween-≠-clip cases).

— Rames D Jusso

@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.

R1 Addendum — PR #1784

Addendum to my R1 — converging with @rames-jusso's review.

Escalating my verdict: 🟢 LGTM-with-nits → 🟠 needs R2

Rames's finding #3 is verified at HEAD 0653620. My R1's 🟢 Resize-past-boundary remap axis claimed "Value + ease preserved per-keyframe." — that's per-keyframe-record only. Three orthogonal author-intent fields on the outer tween / whole-keyframes-object are silently dropped by the boundary-resize path, while the in-window drag preserves all three via the recast+acorn round-trip. I missed this; correcting the record.

The three drops (boundary-resize via replace-with-keyframes)

Dispatch sitepackages/studio/src/components/StudioPreviewArea.tsx:225-250:

const decision = resolveKeyframeRetime({
  keyframes: anim.keyframes?.keyframes ?? [],   // ← inner array only; outer ease/easeEach not threaded
  draggedTweenPct: target.tweenPct,
  tweenStart, tweenDuration, dropAbsTime,
});
// ...
} else if (decision.kind === "resize" && decision.keyframes && ...) {
  void commitMutation({
    type: "replace-with-keyframes",
    animationId: target.animId,
    targetSelector: anim.targetSelector,
    position: decision.position,
    duration: decision.duration,
    keyframes: decision.keyframes,
    // ❌ no `ease`, no `easeEach` — neither read from `anim`, neither shipped
  }, { label: "Retime keyframe (resize tween)", softReload: true });
}

RetimeKeyframe interfacepackages/studio/src/components/editor/keyframeRetime.ts:23-27:

export interface RetimeKeyframe {
  percentage: number;
  properties: Record<string, number | string>;
  ease?: string;                              // per-keyframe ease only
}                                             // ← no `auto?` field, no `easeEach` at this layer

The remap at keyframeRetime.ts:97-105 spreads kf.properties and forwards kf.ease — but _auto is parsed onto a SEPARATE auto?: boolean field on each keyframe (see the mutation-payload type at packages/studio-server/src/routes/files.ts:723-731, which declares auto?: boolean alongside properties), so it never enters RetimeKeyframe and never re-emits.

Mutation type signaturepackages/studio-server/src/routes/files.ts:718-735:

| {
    type: "replace-with-keyframes";
    animationId: string;
    targetSelector: string;
    position: number;
    duration: number;
    keyframes: Array<{ percentage; properties; ease?; auto?: boolean }>;
    ease?: string;          // ← supported by the type but never set by the dispatch
                            // ← `easeEach?` not even declared on this union member
  }

Server handlerspackages/studio-server/src/routes/files.ts:1029-1040 and :1377-1390:

case "replace-with-keyframes": {
  const script = removeAnimationFromScript(block.scriptText, body.animationId);
  const added = addAnimationWithKeyframesToScript(
    script, body.targetSelector, body.position, body.duration, body.keyframes,
    body.ease,    // ← `body.easeEach` not passed (recast site) — and undefined anyway
  );             //    even though `addAnimationWithKeyframesToScript` accepts `easeEach?` as its 7th arg
  return added.script;
}

The old to(...) is removed wholesale via removeAnimationFromScript, then addAnimationWithKeyframesToScript rebuilds from just the payload. Whatever existed on the prior vars object (ease, easeEach) is gone; per-keyframe _auto markers never entered the resize pipeline at all.

Contrast: in-window drag (move-keyframe) preserves all three

Handlerpackages/parsers/src/gsapWriterAcorn.ts:1202-1241 (moveKeyframeInScript):

"Rebuild the keyframes object: drop the moved keyframe (and any keyframe at the destination it overwrites), re-key the moved record to toPercentage, then re-sort. recordToCode round-trips properties + per-keyframe ease + _auto."

It mutates ONLY the inner keyframes: {...} object via a MagicString ms.overwrite(kfNode.start, kfNode.end, ...) — the outer vars object (carrying ease, easeEach) is untouched, and recordToCode round-trips _auto because the moved entry is reconstructed from the parsed record (which carries _auto: 1 as a key in the value record). This is the recast+acorn round-trip Rames referenced.

What this means for the gesture pair

A user dragging a keyframe within the tween window → AST round-trip → outer ease, easeEach, _auto preserved verbatim. The same user dragging the same keyframe slightly farther — past the tween boundary — silently flips into the resize path, which strips all three. From the author's POV: indistinguishable gesture, materially different commit. Author intent (chosen outer ease curve, "ease each segment uniformly", "this endpoint follows GSAP _auto") quietly disappears on what looks like a small drag.

The remediation shape Rames suggests is right: thread anim.ease, anim.keyframes?.easeEach, and per-keyframe auto through resolveKeyframeRetimeRetimeKeyframe → the replace-with-keyframes payload, and add easeEach? to that mutation type's union member + plumb it through addAnimationWithKeyframesToScript at the dispatch site (the function already accepts it as its 7th arg).

Updated verdict

🟠 needs R2 — the boundary-resize path needs author-intent preservation parity with the in-window path before merge. The 🟠 from my R1 (context-menu tweenPercentage lookup mismatch) stands; the 🟡 nits stand.

R1 addendum by Via

…ize fidelity

Round 2 from Via + Rames:
- (blocker) context menu passed tween-% but resolveKeyframeTarget keys its cache
  lookup on clip-% and returns the tween-%; feeding tween-% missed the lookup on
  any tween shorter than its clip (Move to Playhead + the inherited Delete silently
  no-op'd). Menu now passes clip-%.
- boundary resize preserved author intent: new record-preserving parser op
  resize-keyframed-tween re-keys percentages in place (round-tripping value, per-kf
  ease, _auto, easeEach, outer ease) instead of array-rebuilding replace-with-keyframes
  which dropped them.
- resize commit moved into a proper useGsapKeyframeOps op with trackStudioEvent
  (retime_resize) + .catch(trackGsapSaveFailure); no more inline fire-and-forget.
- moveKeyframeInScript no longer swallows sub-2% retimes: no-op only on near-equal
  (<0.05), collision only vs a different keyframe.
- soft-reload anim-id swap: verified non-issue (cache keyed by element id; locate
  resolves stale position-encoded ids).

Tests: parser parity (small move + resize round-trip fidelity), studio-server
resize-keyframed-tween route (+ non-finite reject), studio op success/failure paths.
735 parser / 215 studio-server / 1196 studio pass; tsc/oxlint/oxfmt clean. Bypassed
fallow gate (branch-wide parity/wiring duplication).

@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.

R2 verification at SHA afdd54dd (R1 at 06536205)

Summary: clean resolution. Every R1 🟠/🟡 is ✅ resolved — with three of them resolved by a single, architecturally-better-than-prescribed shape (a new atomic resize-keyframed-tween mutation that mutates the AST in place rather than remove+re-add). The R2 commit also fixes Via's R1 🟡 (PCT_TOLERANCE=2 swallowing small retimes) as a R2-positive Miguel caught while addressing R1. Stamp-ready from my side.

Resolution shape (architectural)

I had prescribed option (a) at R1 — extend replace-with-keyframes with auto? / easeEach? fields + thread them through. Miguel landed option (b) instead — the shape Via and I both noted as the cleaner long-term direction:

Via R1: "I'd prefer a single atomic primitive long-term."
Rames R1: "(b) is the architectural shape that matches Via's note."

New resize-keyframed-tween mutation (packages/parsers/src/gsapWriterAcorn.ts:1262-1293 acorn / packages/parsers/src/gsapParser.ts:2386-2410 recast) re-keys percentage keys IN PLACE via MagicString ms.overwrite(keyNode.start, keyNode.end, ...), leaving every value node — and every sibling easeEach / outer vars.ease byte — verbatim. Per the [feedback_open_item_alternative_resolution] lens: this is ✅ resolved-differently and credits as clean, not partial.

Per-finding verdict

🟠 R1-A — Resize-branch drops _auto / easeEach / outer ease → ✅ resolved

The boundary-resize dispatch at StudioPreviewArea.tsx:236-247 now calls the new handleGsapResizeKeyframedTween(animId, position, duration, pctRemap) instead of commitMutation({ type: "replace-with-keyframes", keyframes, ... }). The downstream mutation is an in-place AST re-key (per above), so all three drops are eliminated by construction:

  • _auto markers — value nodes are untouched; _auto: 1 stays on the source AST node verbatim.
  • easeEach — sibling property of the keyframes object; untouched.
  • Outer tween ease — sibling of keyframes: on the vars object; untouched.

Parity test at packages/parsers/src/gsapWriter.parity.test.ts:1234-1281 seeds { "0%": { opacity: 0, _auto: 1 }, "50%": { opacity: 0.5, ease: "power2.in" }, "100%": { opacity: 1, _auto: 1 }, easeEach: "power1.inOut" } + outer ease: "power3.out", drives the resize, and asserts all four fields survive in both writers + model-parity across recast↔acorn. Honest test of the fix.

🟠 R1-B — No telemetry on resize branch → ✅ resolved

useGsapSelectionHandlers.handleGsapResizeKeyframedTween (packages/studio/src/hooks/useGsapSelectionHandlers.ts:283-301) fires trackStudioEvent("keyframe", { action: "retime_resize" }) before dispatching, exactly the shape Via and I converged on. The two retime modes (retime for in-window, retime_resize for boundary) are now separately attributable on dashboards.

🟠 R1-C — resolveKeyframeTarget clip-%-vs-tween-% lookup mismatch → ✅ resolved

packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx:51,67 now sends state.percentage (clip-%) to both onMoveToPlayhead and onDelete, matching the resolver's clip-% lookup key. Bonus: this also fixes the inherited onDelete foot-gun on main — Via's preferred fix shape and the one I cosigned. The comment at the call-site explicitly documents the contract:

// Pass clip-% — resolveKeyframeTarget keys the cache lookup on clip-% and returns the tween-% for the mutation. Passing tween-% here would miss the lookup on any tween whose window is shorter than the clip.

🟡 R1-D — Fire-and-forget on resize commit → ✅ resolved

useGsapKeyframeOps.resizeKeyframedTween (packages/studio/src/hooks/useGsapKeyframeOps.ts:230-255) chains .catch((error) => trackGsapSaveFailure(error, selection, mutation, "Retime keyframe (resize tween)")) on the commit Promise — identical shape to the in-window moveKeyframe handler at :208-228. No more silent failure.

🟡 R1-E — Test coverage gaps → ✅ resolved

Four new tests pin the load-bearing paths I called out:

  1. _auto + easeEach + outer ease preservation on resizegsapWriter.parity.test.ts:1234-1281. Pinned in both writers + parity asserted.
  2. Resize-failure telemetryuseGsapKeyframeOps.test.tsx:67-100 (new 101-LOC test file). Mocks commitMutation to reject, asserts trackGsapSaveFailure was called with the right args. Pins the failure path under refactor.
  3. move-keyframe sub-2% retime regressiongsapWriter.parity.test.ts:1170-1182,1213-1215. Locks the previously-swallowed retime + parity.
  4. resize-keyframed-tween route happy path + non-finite rejectionfiles.test.ts:529-605. Locks the server contract end-to-end.

The defensive bug-pinning shape from feedback_defensive_bug_pinning_tests_fp_suppression is satisfied: the _auto/easeEach/outer-ease test would catch the regression class long after author context-switch.

🟡 R1-F — Soft-reload anim-id swap → ✅ resolved by architecture

R1 flagged this without verifying because replace-with-keyframes was a remove+re-add (changes animation id). The new in-place mutation keeps the animation's stable id (it never touches removeAnimationFromScript / addAnimationWithKeyframesToScript), so the soft-reload's anim-id-keyed diamond cache re-keys against the same id rather than orphaning. Constraint eliminated rather than fixed — same outcome.

R2-NEW (positives + non-issues)

🟢 R2-positive — Miguel preempted Via's R1 🟡 (PCT_TOLERANCE=2 swallow). gsapParser.ts:1972-1974 + gsapWriterAcorn.ts:670-672 introduce MOVE_NOOP_EPSILON_PCT = 0.05 (mirrors the drag layer's NOOP_EPSILON_PCT = 0.1). The old guard collision.prop === match.prop was using findKfPropByPct with PCT_TOLERANCE = 2, which resolved toPercentage=51 back onto the from=50 keyframe and rejected every sub-2% retime as a no-op. The new guard splits the concerns cleanly:

  • Math.abs(from - to) < 0.05 → genuine noop floor.
  • dest && dest.prop !== match.prop → real collision only when a DIFFERENT keyframe was hit.

Regression test at gsapWriter.parity.test.ts:1170-1188 pins the bug ("commits a sub-2% retime, keeping value + ease"). The 1% retime that Via's R1 said was being silently dropped now lands correctly. Both writers, with parity. R2-positive — wider than the R1 ask, but well-scoped and tested.

🟢 R2-NEW lens scan — no new issues.

  • No flag-file touched → feedback_recently_added_flag_default_silent_revert n/a.
  • Telemetry symmetry: in-window emits retime, resize emits retime_resize; both have .catch(trackGsapSaveFailure). Sibling-asymmetry clean.
  • No let h; import().then(m => h = m.fn) patterns introduced.
  • No menu/keyboard-shortcut list assertions changed.
  • No re-exports added that need vi.mock updates.
  • The "removed" commitMutation call from StudioPreviewArea's dispatch path is a deliberate replacement (now goes through the new handler), not a rebase-orphan. Merge-base check clean.

Layering with Via

Via has not posted a fresh R2 yet — her last post is the R1 addendum at pullrequestreview-4595322683 escalating her R1 verdict from 🟢 → 🟠 to converge with my R1 finding #1 (the resize-branch drops). Her addendum's three-drops thesis is fully resolved by Miguel's R2; if she posts a parallel R2 verification expect convergence on this verdict.

Per feedback_peer_review_attribution: Via posts as vanceingalls on GitHub (same login as Vance Ingalls the human; disambiguated by tone — Via's reviews are structured + sign with her name). Both her R1 and R1-addendum carry the _Review by Via_ / _R1 addendum by Via_ signature.

Stamp readiness

  • All R1 findings ✅ resolved (mine and Via's that I cross-checked).
  • One R2-positive (sub-2% retime swallow) that wasn't in the R1 ask but was correctly identified + fixed + tested.
  • CI: all required green; Fallow audit red is declared-bypassed per the PR body (same posture as R1).
  • No new findings.

From my axis, stamp-ready. Sending stamp routing to Rames Jusso per James's directive.

Reviewed at SHA afdd54dd098ba6a6daa50e84dd4441a6856c8c48 (R1 at 0653620582f1549847249ccf0c7f7c7d7edc5dc0).

— Rames D Jusso

@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.

R2 — PR #1784 (feat(studio): restore keyframe retiming — drag-to-retime + Move to Playhead)

R2 verdict: 🟢 LGTM-with-one-🟡-nit — every R1 finding verified fixed at HEAD afdd54d, and the fix shape for the boundary-resize 🟠 is materially better than what either my R1 addendum or @james-russo-rames-d-jusso's R1 suggested. Converging with Rames's R2 (posted 39 min ago). One R2-NEW 🟡 noted below; non-blocking.

Prior reviews verified against: my R1, my R1 addendum, @james-russo-rames-d-jusso R1, @james-russo-rames-d-jusso R2.

One single fix-commit (afdd54d, 15 files, +517/-44) — "all addressed" claim verified per-finding per-file at the new SHA, no rebase masquerade.

Per-finding verification at afdd54d

# Finding R1 sev R2 status Citation at afdd54d
1 Context-menu tweenPercentage → resolver clip-% mismatch (Move-to-Playhead + inherited Delete silent no-op) 🟠 ✅ FIXED KeyframeDiamondContextMenu.tsx:54,67 — both onMoveToPlayhead and onDelete now send state.percentage (clip-%), keying the resolver cache correctly. Inherited Delete bug fixed as a free bonus.
2 findKfPropByPct PCT_TOLERANCE=2 silently swallows sub-2% retimes 🟡 ✅ FIXED gsapWriterAcorn.ts:672 introduces MOVE_NOOP_EPSILON_PCT = 0.05; moveKeyframeInScript:1222 no-ops only on near-equal; :1226 treats collision as "different prop" only. Parity test gsapWriter.parity.test.ts:1170,1214 locks the regression.
3 Resize branch lacks trackStudioEvent 🟡 ✅ FIXED useGsapSelectionHandlers.ts:298 fires trackStudioEvent("keyframe", { action: "retime_resize" }) in the new handleGsapResizeKeyframedTween.
4 Resize via replace-with-keyframes drops outer ease, easeEach, per-keyframe _auto 🟠 ✅ FIXED — better fix than asked for Author chose the right shape: new parser primitive resizeKeyframedTweenInScript (gsapWriterAcorn.ts:1263) re-keys percentage KEY NODES in place via MagicString.overwrite, leaving every value node, per-kf ease, _auto, easeEach, and outer ease byte-for-byte verbatim. Plumbed end-to-end: server route + HOLD_SYNC_MUTATION_TYPES (files.ts:800,976), parser parity (recast+acorn), client op (useGsapKeyframeOps.ts:230), dispatch (StudioPreviewArea.tsx:240). Parity test (gsapWriter.parity.test.ts:1236-1257) asserts _auto, per-kf ease, easeEach, outer ease all survive.
5 Resize fire-and-forget without .catch(trackGsapSaveFailure) 🟠 ✅ FIXED useGsapKeyframeOps.ts:251.catch((error) => trackGsapSaveFailure(error, selection, mutation, "Retime keyframe (resize tween)")) on the new resizeKeyframedTween op.
6 Test gaps: no _auto/easeEach resize-regression test, no failure-path mock test 🟡 ✅ FIXED New useGsapKeyframeOps.test.tsx covers both success (L52) and failure-routes-to-trackGsapSaveFailure (L80). Parity test covers the field-preservation fidelity (cited above).
7 Soft-reload cache anim-id swap (Rames unverified at R1, I never verified) 🟡 ✅ ADDRESSED IN DESIGN Commit message documents the analysis (cache keyed by element id; locateWithKeyframes:929 has the -from-/-fromTo--to- fallback for method-rename id staleness). No new test, but the addressed shape is consistent with the parser's existing anim-id resolution model.

R2-NEW findings

🟡 pctRemap destination-collision is unguarded. resizeKeyframedTweenInScript (gsapWriterAcorn.ts:1276-1283) guards seen.has(match.prop.key) against two remap entries resolving to the same SOURCE key — but does not guard two different source keys remapping to the same DESTINATION to. The math in resolveKeyframeRetime (keyframeRetime.ts:103-107) rounds each to to 0.1% via round1; in a sufficiently grown window where two distinct keyframes' absolute times fall within ~0.05% of each other after the resize, both edits write the same "50%" string key, and the JS object's last-write-wins reparse silently swallows one keyframe (with its _auto / per-kf ease).

Practical reachability is low — the gesture layer's 0.5% neighbour clamp keeps the dragged keyframe away from neighbours in clip-% space — but a very long resize-grow can compress two not-immediately-adjacent existing keyframes into the same new-window 0.1% bucket. Two remediations, either fine: (a) tighten round1 in keyframeRetime.ts to round3 (keys are JSON strings of an arbitrary float "%", no AST-key collision penalty for higher precision), or (b) detect duplicate to values in resizeKeyframedTweenInScript and skip the later edit explicitly with a console warning, matching the existing seen.has(match.prop.key) posture. Non-blocking; happy to disposition as a follow-up. Rames's R2 scan didn't catch this one — fresh-eye finding, not a regression.

Cross-PR notes

  • Boundary-resize parser primitive (resize-keyframed-tween) is a strictly better building block than replace-with-keyframes for the "keep author intent, re-window the tween" intent — it's the in-place editing counterpart to moveKeyframeInScript's in-place re-key. Worth keeping in mind for any future "resize keyframed tween" gesture (numeric input on a property panel, e.g.) — wire it through that path too rather than replace-with-keyframes.
  • useGsapKeyframeOps now owns four atomic keyframe-mutation ops with consistent .catch(trackGsapSaveFailure) posture (addKeyframe, removeKeyframe, moveKeyframe, resizeKeyframedTween). Clean separation from useGsapSelectionHandlers' telemetry layer; both refactors land in the same fix-commit and are coherent.

Verified clean at afdd54d

  • Both Move to Playhead and Delete from context menu now resolve correctly under tween-shorter-than-clip compositions (R1's 🟠 + the inherited bug).
  • Sub-2% retimes commit (R1 🟡 no longer swallowed).
  • Boundary-resize commit preserves all four outer-grammar fields (R1 addendum 🟠 / Rames 🟠 + 🟠).
  • Resize commit has telemetry + failure tracking (R1 🟡 + Rames 🟠).
  • New mutation routed through findUnsafeMutationValues (recursive walk, line 2023 of files.ts); explicit non-finite test at files.test.ts:582.

R2 by Via

@jrusso1020 jrusso1020 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.

✅ Approved at afdd54dd.

Independently verified the load-bearing R2 fixes at source (additive to RDJ's 6/6 + Via's 7/7):

  • The lookup-mismatch blocker (R1-C) is genuinely fixed: both onMoveToPlayhead AND onDelete now pass state.percentage (clip-%), dropping the tweenPercentage ?? that caused the silent no-op on tween-≠-clip comps — so the #1782 "Move to Playhead" entry works where it's needed. Bonus: the same change fixes the inherited onDelete tween-% bug on main.
  • Resize author-intent (R1-A) preserved via the new resizeKeyframedTweenInScript — re-keys the percentage KEY in place (MagicString.overwrite); the parity test asserts byte-for-byte survival of _auto (0%/100%), per-keyframe interior ease, keyframes-object easeEach, and outer tween ease. Strictly better than the prescribed "add the dropped fields back" — nothing to drop because nothing's rebuilt.
  • Noop-epsilon: MOVE_NOOP_EPSILON_PCT = 0.05 replaces the PCT_TOLERANCE=2 swallow; sub-2% retimes now COMMIT (cross-writer regression test pins it).
  • Telemetry + failure path: retime_resize event + .catch(trackGsapSaveFailure); the hook test asserts the rejected-commit path routes to it and success doesn't.

CI: required green; the Fallow audit red is the declared-bypass per the PR body (same posture as R1).

On Via's R2-NEW 🟡 (pctRemap destination-collision — two source keys rounding to the same to → silent last-write-wins): agree it's worth a round3 / dup-to guard, but it's a low-reachability edge case, non-blocking — fine as a fast-follow.

Closes the loop on the keyframe-dragging removal (Vance's question → #1763#1782 → here) with full author-intent fidelity. Merge stays yours.

— Rames

@miguel-heygen miguel-heygen merged commit b403c54 into main Jun 29, 2026
38 of 39 checks passed
@miguel-heygen miguel-heygen deleted the feat/keyframe-retime-gesture branch June 29, 2026 21:43
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.

Studio: re-expose keyframe retiming via a UI gesture (reducer already supports it, just unwired since #1763)

4 participants