Skip to content

feat(studio): add timeline keyframe retiming interactions#2783

Open
miguel-heygen wants to merge 3 commits into
codex/studio-timeline-b-variable-layout-v2from
codex/studio-timeline-b-keyframe-retiming-v2
Open

feat(studio): add timeline keyframe retiming interactions#2783
miguel-heygen wants to merge 3 commits into
codex/studio-timeline-b-variable-layout-v2from
codex/studio-timeline-b-keyframe-retiming-v2

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

What

Adds direct timeline keyframe retiming interactions.

Why

Editors need to drag keyframes while preserving element identity, clip-relative timing, occupied-destination safety, and deterministic source updates.

How

Routes retime gestures through the shared timing model and mutation callbacks, with focused coverage for drag lifecycle and destination rules. This is B3 of the Family B Graphite stack.

Review fix folded in: onMoveKeyframe returns Promise<boolean> across the whole callback chain (timelineCallbacks -> TimelineLanes -> useTimelineEditCallbacks). This commit is where TimelineClipDiamonds starts awaiting the commit result, so the widening belongs here; without it the intermediate commits do not typecheck.

Test plan

  • Unit tests added/updated
  • Manual testing performed
  • Documentation updated (not applicable)

Every commit in the stack typechecks independently. Drag-to-retime verified in a browser: dragging a keyframe rewrites its percentage on disk while preserving both its value and its ease.

Deferred review findings

Every blocker and high finding raised on this PR is fixed in the stack. The 3 remaining low/nit findings are parked, verbatim, in .scratch/studio-timeline-family-b/issues/03-pr-2685-deferred-review-findings.md:

  • 🟡 packages/studio/src/player/components/TimelineClipDiamonds.tsx:139 — useEffect clears pending entries by clip-% only, ignoring keyframe identity
  • 🟢 packages/studio/src/player/components/TimelineClipDiamonds.tsx:424 — data-keyframe-percentage attribute is polysemous
  • 🟢 packages/studio/src/player/components/TimelineClipDiamonds.tsx:456 — onPointerCancel clears drag state but never fires the click-fallback

Supersedes #2685, which was closed when main was rewritten to unwind an early landing of this stack. Same head commit, same review history.

R1 review follow-ups

Fixed in this PR:

  • Escape cancels an in-flight retime, matching clip and element drags. The pointerup that follows is swallowed rather than falling through to the click branch.
  • Pointer moves are throttled to one preview render per frame, so a 120Hz trackpad no longer re-evaluates every diamond's memo per event.

Scoped out, documented at the dispatch site: multi-select drag would have to move every selected keyframe as one mutation, which the script ops do not express yet.

The 2 remaining low findings (edge auto-scroll, post-drop snap-back window) are parked in .scratch/studio-timeline-family-b/issues/09-family-b-v2-r1-deferred.md.

R2 review follow-ups

Fixed in this PR:

  • The diamond connector reached its previous keyframe through a non-null assertion. CONTRIBUTING.md asks for a guard clause outside already-checked paths, so the index check and the lookup are now the same guard.

miguel-heygen commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

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

Verdict: COMMENT — retime mechanism is sound (pointer capture correct, cancel path clean, single-write-per-drag, gesture-layer neighbour clamp, rapid-second-drag identity via pendingRetimeRef is well-tested), and the widened Promise<boolean> chain in timelineCallbacks.ts / TimelineLanes.tsx / useTimelineEditCallbacks.ts matches. The observations below are polish, not blockers; the deferred findings in the PR body correctly cover the pending-clear-by-clip-%-only + data-attr semantics, so I'm not repeating them.

Head SHA: 50a90772cabc4709e36d4fdd07758aac3e0a2e57.

Adversarial lenses

Lens A — drag lifecycle — mostly clean. setPointerCapture on pointerdown, releasePointerCapture on both pointerup (TimelineClipDiamonds.tsx:351) and pointercancel (:451); touchAction: "none" on the button (:439) gets touch parity; the grab-offset is captured once on pointerdown via d.startX + d.fromClipPct (:311-315), and previewClipPct is delta-based so mid-drag re-renders don't snap-to-cursor. Two gaps:

  • P2 — Escape does not cancel an in-flight drag. No key handler is armed on pointerdown. Users can back out of clip/element drags elsewhere in the app with Escape; this diamond can't. Pressing Escape here just leaves the drag running until pointerup commits it. Suggest a document.addEventListener('keydown', …) scoped to dragRef.current !== null that mirrors the pointercancel branch at TimelineClipDiamonds.tsx:444-452 (null the ref, setPreview(null), release capture).
  • P3 — no auto-scroll when the pointer nears the timeline viewport edge. Long clips scrolled off-screen can't be retimed to their edges without releasing and re-scrolling.

Lens B — constraint enforcement — solid. sortedClipPcts (:190) feeds both previewClipPct and resolveKeyframeDrag, so the gesture layer clamps to immediate neighbours + clip bounds before this file ever sees the drop. The subsequent clipToTweenPercentage extrapolation is defensively clamped to the animation's own tween-% range at TimelineClipDiamonds.tsx:378-385, so a boundary drag can't reselect an out-of-range tween-% (150%) even though the mutation would clamp back. Nice.

Lens C — undo integration — clean. One onMoveKeyframe per drag, fired only on pointerup (:404); setPreview during pointermove is visual-only, never persisted. resolveKeyframeRetime returns noop at keyframeRetime.ts:74/125 when the drop resolves onto the source (< 0.1% tween epsilon or within EPSILON_TIME on flat tweens), so a click with a few px of trackpad jitter can't sneak a phantom undo entry. Nothing to add.

Lens D — multi-select drag parity — flag.

  • P2 — multi-select retime is not implemented. The lane accepts selectedKeyframes: ReadonlySet<string> and isKfSelected at TimelineClipDiamonds.tsx:300-302 reads it, but onPointerDown/onPointerUp only operate on the pressed diamond (d.kfKey, target). If two or more keyframes are selected and the user drags one, only that one moves; the rest stay put. If Family-B's plan is "multi-select drag comes in a later stack PR" that's fine — just call it out here so reviewers don't assume the shipped selection primitive implies group retime. If multi-drag is intentionally scoped out of this stack, that's worth a short comment near the retime dispatch (:369-415).

Lens E — perf & throttling — mild.

  • P2 — setPreview on every pointermove without rAF throttling. onPointerMove at TimelineClipDiamonds.tsx:318-337 calls setPreview per event. React 18 batches, but on a 120Hz trackpad + a densely populated expanded lane row (each diamond's React.memo still evaluates its props), this is one render per event. A requestAnimationFrame gate (accumulate the latest clientX, flush once per rAF) would drop most of them and matches how the timeline's clip drag path throttles elsewhere.
  • P3 — post-drop snap-back window. renderPct at :293 reads only from preview (cleared on pointerup at :350) or kf.percentage (stale cache until the mutation round-trips). Between pointerup and the next cache tick, the diamond visually snaps back to its old %. The pendingRetimeRef map fixes the identity problem for rapid double-drags but doesn't influence rendering. If the GSAP mutation is synchronous in practice this is invisible; if it isn't, adding pendingRetimeRef.current.get(kfKey)?.clipPct into the renderPct fallback would remove the flicker for free. Not a blocker — worth a quick check on a real clip.

Facade blast-radius

onMoveKeyframe widened to Promise<boolean> across four surfaces (timelineCallbacks.ts, TimelineLanes.tsx, useTimelineEditCallbacks.ts, TimelineClipDiamonds.tsx) — the ?? Promise.resolve(false) fallback in the TimelineClipDiamonds facade (:501) preserves the "undefined-callback → non-committing" semantic. handleGsapMoveKeyframe / handleGsapResizeKeyframedTween remain fire-and-forget on the callee side; the boolean is used only by pendingRetimeRef.clearPending and by the test that asserts a false result clears pending. Consistent.

PR-body claims audit

  • "Every commit in the stack typechecks independently" + "the widening belongs here" — matches: this PR is where TimelineClipDiamonds starts awaiting the commit result (:404), so pulling the return-type change forward into #2781/#2782 would produce a mismatched signature on the callee. Correct placement.
  • Test coverage claims match diff: 5 new gesture tests (click-armed no-op, retimed reselect, rapid second retime, failed-pending clear, ambiguous ease segment) + 2 flat-tween tests in the pure resolver.
  • Deferred findings section correctly matches the code (I re-verified :139/:424/:456).

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

Reviewed at 50a90772cabc.

R2 adversarial pass on the retiming interactions slice. Two blockers flagged: (1) the Promise committed signal introduced in this PR is cosmetic — the mutation is fire-and-forget through commitMutation.catch, so committed always resolves true regardless of whether the server persist actually succeeded; (2) rapid-second retime is silently discarded because resolveKeyframeTarget(fromClipPct) looks up against the still-stale cache — a keyframe with pending clipPct=75 can't be located when the cache still shows 50. These interact: the pendingRetimeRef cleanup depends on !committed, which the first blocker breaks — so a silent persist failure leaves the pending entry permanently set to a nonexistent destination.

🟢 Verified clean

  • keyframeRetime.ts round3 replacing round1 is consistent with test-file updates (33.3→33.333, 63.6→63.636)
  • onPointerCancel handler clearing dragRef + preview and releasing pointer capture matches the drag armed by onPointerDown
  • timelineKeyframeSelectionKey degenerate cases (no propertyGroup / no animationId) round-trip through diamond render and useTimelineKeyframeHandlers.toggleSelectedKeyframe with consistent shape
  • The easeAmbiguous && animationId !== undefined gate correctly withholds the inline ease button on collided merged segments, per new tests in TimelineClipDiamonds.test.tsx
  • handleGsapMoveKeyframeToPlayhead / handleGsapRemoveKeyframe are (still) invoked with correct animId+tweenPct pair by useTimelineEditCallbacks for KeyframeDiamondContextMenu delete + move-to-playhead

Complements Via's parallel adversarial pass (Family B rollup). Where Via found 0 P1 / 17 P2 / 18 P3, the adversarial subagent pass here surfaced additional depth on the mutation-authority thread and the Promise chain.

Review by Rames D Jusso

const anim = selectedGsapAnimations.find((a) => a.id === target.animId);
const tweenStart = anim ? resolveTweenStart(anim) : null;
if (!anim || tweenStart === null) return;
if (!anim || tweenStart === null) return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Promise committed signal is cosmetic — mutation is fire-and-forget

if (decision.kind === "move" && decision.toTweenPct != null) {
  handleGsapMoveKeyframe(target.animId, target.tweenPct, decision.toTweenPct);
} else if (...) {
  ...
    handleGsapResizeKeyframedTween(...);
  } else {
    handleGsapUpdateMeta(...);
  }
} else {
  return false;
}
return true;

The entire PR feature (pendingRetimeRef + clearPending on !committed) rests on onMoveKeyframe returning a Promise reflecting whether the mutation actually persisted. But handleGsapMoveKeyframe, handleGsapResizeKeyframedTween, handleGsapUpdateMeta in useGsapSelectionHandlers.ts return void (line 342: void moveKeyframe(sel, animId, fromPercentage, toPercentage);), and moveKeyframe/resizeKeyframedTween in useGsapKeyframeOps.ts use void commitMutation(...).catch(error => trackGsapSaveFailure(...)) — fire-and-forget with a catch that only fires telemetry. onMoveKeyframe: async always resolves to true immediately after firing, regardless of whether server commit rejects, selection is stale, schema validator refuses, etc. The .then((committed) => if (!committed) clearPending()) in TimelineClipDiamonds is effectively dead code for anything except the two synchronous early-returns in useTimelineEditCallbacks itself.

Fix: Await the underlying handler in a variant that returns its commitMutation promise (e.g. moveKeyframeAsync on the actions context returning Promise) and thread it through handleGsapMoveKeyframe → useTimelineEditCallbacks. Or explicitly document that committed only reflects preflight validity — but then rename it so consumers stop treating it as a persist signal.

Review by Rames D Jusso

? keyframesData.keyframes
: keyframesData.keyframes.filter((k) => k.animationId === target.animationId);
// Clamp to the mapped tween range: clipToTweenPercentage extrapolates
// linearly, so a boundary drag past the range would otherwise reselect

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Rapid second retime is silently discarded — pending FROM identity doesn't survive resolveKeyframeTarget

const fromTarget = pendingBefore
  ? {
      ...target,
      percentage: pendingBefore.clipPct,
      tweenPercentage: pendingBefore.tweenPct,
    }
  : target;
...
void onMoveKeyframe?.(fromTarget, res.toClipPct).then((committed) => {

The diamond sends fromTarget.percentage = pendingBefore.clipPct (e.g. 75%) into the callback. But useTimelineEditCallbacks.onMoveKeyframe locates the keyframe by calling resolveKeyframeTarget(fromClipPct), which queries the still-stale keyframeCache via resolveTimelineKeyframeTarget → keyframes.find(item => Math.abs(item.percentage - pct) < 0.2). Cache still shows pre-first-retime position (e.g. 50%), so no keyframe within 0.2 of 75% exists → returns null → if (!target || !sel) return false;. The composed retime the TimelineClipDiamonds.test.tsx test verifies at the diamond boundary never actually completes at the mutation layer. Test only asserts onMoveKeyframe was called with percentage: 75, not that anything landed.

Fix: Serialize the second mutation behind the first (chain the promises), or thread animationId + tween-% from fromTarget directly into a mutation that keys on animation identity + tween-% rather than resolving clip-% back through the stale cache. TimelineKeyframeTarget.animationId is already available end-to-end for exactly this reason.

Review by Rames D Jusso

// position; the mutation locates the source keyframe by this identity.
const pendingBefore = pendingRetimeRef.current.get(kfKey);
const fromTarget = pendingBefore
? {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 Optimistic seek + selection fires synchronously before commit → ghost playhead/selection on failure

void onMoveKeyframe?.(fromTarget, res.toClipPct).then((committed) => {
  if (!committed) clearPending();
}, clearPending);
// A retime still targeted this exact diamond — park/select it at its
// new position, same as a plain click, or a drag that actually moved
// something looks identical to one that silently did nothing.
onClickKeyframe?.({
  ...target,
  percentage: res.toClipPct,
  tweenPercentage: newTweenPct,
});

onClickKeyframe executes synchronously, immediately after the async onMoveKeyframe is fired. Both Timeline.tsx's inline onClickKeyframe (onSeek?.(absTime) + toggleSelectedKeyframe(...)) and useTimelineKeyframeHandlers' equivalent perform seek + selection based on ATTEMPTED destination — before commit resolves. If mutation later fails (via any of the two paths above, or committed=false from the sync early-return), diamond's next re-render snaps back to original clip-%, but playhead sits at res.toClipPct and selectedKeyframes still contains phantom elId:res.toClipPct key that no longer maps to any diamond. Nothing rolls that back.

Fix: Defer seek + selection into the same .then((committed) => committed && onClickKeyframe(...)) continuation, or (for immediate visual response) roll back seek/selection in .then((committed) => !committed && rollback()) branch.

Review by Rames D Jusso

pointerEvents: "auto",
}}
onMouseEnter={() => setHoveredSegment(i)}
onMouseLeave={() => setHoveredSegment((h) => (h === i ? null : h))}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 Segment ease button is not keyboard-reachable

onMouseEnter={() => setHoveredSegment(i)}
onMouseLeave={() => setHoveredSegment((h) => (h === i ? null : h))}
>
  {hoveredSegment === i && (
    <button
      type="button"
      data-keyframe-ease-button=""
      aria-label={`Edit ${ease} easing`}

Ease button is only conditionally rendered when hoveredSegment === i, and hoveredSegment is set exclusively by onMouseEnter/onMouseLeave. No keyboard-driven code path sets hoveredSegment; button doesn't exist in DOM until pointer hovers. Keyboard-only users (Tab traversal, screen-reader) cannot open segment ease editor from inline diamond row. aria-label is set correctly but only on element that never mounts without mouse hover.

Fix: Render the button always (behind opacity: 0 / focus:opacity-100 / group-hover:opacity-100 treatment) so it participates in tab order, or add onFocus/onBlur handlers on wrapper mirroring onMouseEnter/onMouseLeave.

Review by Rames D Jusso

const dragRef = useRef<DragState | null>(null);
// Pending retime destination (clip + tween %) per keyframe key, so a rapid
// second drag composes from where the first move left the keyframe (whose
// cache entry has not rebuilt yet) instead of the stale rendered value.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 pendingRetimeRef leaks permanently on silent persist failure

useEffect(() => {
  // Clear a pending entry once the authoritative cache reflects a keyframe at
  // ~its destination. Match by tolerance, not equality: cache writers round
  // clip %s, so an exact check would leak an entry after every successful retime.
  for (const [key, pending] of pendingRetimeRef.current) {
    if (keyframesData.keyframes.some((k) => Math.abs(k.percentage - pending.clipPct) < 0.2)) {
      pendingRetimeRef.current.delete(key);
    }
  }
}, [keyframesData.keyframes]);

Pending entry is cleared in only two places: (a) !committed branch of promise callback, (b) this effect when cache reflects a keyframe at destination. Combined with finding #1 (committed always true) and finding #2 (rapid second retime fails at resolver), a silent persist failure leaves the pending entry set to a nonexistent destination. Cache never gains a keyframe at that %, so this effect never fires. On next drag of same diamond, fromClipPct: pendingRetimeRef.current.get(kfKey)?.clipPct ?? kf.percentage uses dead destination — mutation resolves against a keyframe that isn't there, hanging the diamond in a permanently-broken drag state until unmount.

Fix: Add a bounded lifetime for pending entries (e.g. drop after N ms with no cache update), or clear pending in the .then continuation regardless of committed once promise resolves (accepting small race with a follow-up drag).

Review by Rames D Jusso

}: UseTimelineKeyframeHandlersInput) {
const onClickKeyframe = useCallback(
(el: TimelineElement, pct: number) => {
(el: TimelineElement, target: TimelineKeyframeTarget, options?: { seek?: boolean }) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 useTimelineKeyframeHandlers refactor lands with no production consumer

export function useTimelineKeyframeHandlers({
  expandedElements,
  keyframeCache,
  onSelectElement,
  onSeek,
  setSelectedElementId,
  setKfContextMenu,
  toggleSelectedKeyframe,
}: UseTimelineKeyframeHandlersInput) {

At pr-2783-r3, the only files that reference useTimelineKeyframeHandlers are the hook itself and its test file. Timeline.tsx (the actual consumer of TimelineClipDiamonds) inlines its own onClickKeyframe={(el, pct) => { ... toggleSelectedKeyframe(${elKey}:${pct}) ... }} (lines 496-509 at pr-2783-r3) and doesn't call this hook at all. Hook's Target-aware refactor, new onSelectSegment path, and setActiveKeyframePct-from-target logic have no runtime effect. Tests exercise dead code — TimelineDiamondLane never calls the hook's handlers because shim TimelineClipDiamonds always down-projects targets to plain percentages, and parent Timeline.tsx wiring uses legacy percentage-only callback signature.

Fix: Either wire useTimelineKeyframeHandlers into Timeline.tsx (replacing inline handlers) in this PR so the abstraction has a live consumer, or split hook + tests into a separate follow-up that lands with its consumer. Landing tested-but-unused abstractions makes future refactors deceptively riskier.

Review by Rames D Jusso

// shrink to fit under the strip.
const diamondSize = beatsActive
? Math.round(clipHeightPx * 0.45)
: Math.round(LANE_H * DIAMOND_RATIO);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Neighbour clamp uses stale cache positions, allowing reorder across not-yet-persisted neighbour

const sorted = keyframesData.keyframes
  .filter((kf) => kf.percentage >= KF_MIN_PCT && kf.percentage <= KF_MAX_PCT)
  .sort((a, b) => a.percentage - b.percentage);
// Clip-%s of the sorted keyframes — the neighbour clamp (preview + drop) needs
// the whole row to bound the dragged diamond between its immediate siblings.
const sortedClipPcts = sorted.map((k) => k.percentage);

sortedClipPcts (used by clampToNeighbors in resolveKeyframeDrag / previewClipPct) is computed from cached clip-%s, not from pending destinations. Suppose keyframe A has pending clipPct=75 and neighbour B is at cached clipPct=60 (B is really to the LEFT of A per the pending state, but cache still shows A@50, B@60). Now the user drags A: d.fromClipPct = 75 (from pending), but sortedClipPcts = [..., 50, 60, ...] with A at index for 50, so right-neighbour clamp bounds A to 59.5% max — dragging A rightward from real position of 75 immediately snaps it BEFORE B, silently reordering. NEIGHBOR_EPSILON_PCT comment says clamp prevents equal/cross, but with stale positions can force cross.

Fix: Build sortedClipPcts from pending-composed positions: for each keyframe, use pendingRetimeRef.current.get(kfKey)?.clipPct ?? kf.percentage before sorting. Recompute draggedIndex from that composed array so clamp and fromClipPct agree on which keyframe is where.

Review by Rames D Jusso

// cache entry has not rebuilt yet) instead of the stale rendered value.
const pendingRetimeRef = useRef(new Map<string, { clipPct: number; tweenPct: number }>());
useEffect(() => {
// Clear a pending entry once the authoritative cache reflects a keyframe at

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 pending cleanup effect matches ANY keyframe at destination, not the same one

for (const [key, pending] of pendingRetimeRef.current) {
  if (keyframesData.keyframes.some((k) => Math.abs(k.percentage - pending.clipPct) < 0.2)) {
    pendingRetimeRef.current.delete(key);
  }
}

Predicate is .some(k => |k.percentage - pending.clipPct| < 0.2) — clears whenever ANY keyframe in row happens to sit near pending destination %, not the specific keyframe identified by key. In a multi-keyframe animation, if the retime tracked silently failed (finding #5) but an UNRELATED keyframe on the same lane happens to be at ~that %, pending gets cleared falsely, and subsequent drags on originally-moved diamond fall back to stale cache position rather than pending one. Easy to hit with evenly-spaced keyframes (0/25/50/75/100) when a retime lands near an existing sibling %.

Fix: Include identity (kfKey / animationId + tweenPercentage) in predicate, e.g. keyframesData.keyframes.some(k => timelineKeyframeSelectionKey(elementId, kfTarget(k)) === key && Math.abs(k.percentage - pending.clipPct) < 0.2).

Review by Rames D Jusso

if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage);
else onClickKeyframe?.(kf.percentage);
if (e.shiftKey) onShiftClickKeyframe?.(target);
else onClickKeyframe?.(target);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 newTweenPct clamp uses stale keyframe range for boundary-resize case

const tweenPcts = animKfs
  .map((k) => k.tweenPercentage)
  .filter((v): v is number => typeof v === "number");
const clampTween = (v: number) =>
  tweenPcts.length
    ? Math.max(Math.min(...tweenPcts), Math.min(Math.max(...tweenPcts), v))
    : v;
const newTweenPct = clampTween(clipToTweenPercentage(animKfs, res.toClipPct));

For within-window drag this is fine, but resolveKeyframeRetime can also return kind: 'resize' — which grows tween window and REMAPS every existing keyframe's tween-% (per pctRemap in keyframeRetime.ts). Clamp uses pre-resize min/max of tween-%s from animKfs (currently in cache), so on rightward boundary drag past tween end, newTweenPct is clamped to current max (typically 100) even though mutation is about to remap dragged keyframe onto new resized window where correct value might be lower (e.g. previous last keyframe now sits at ~83% and dragged one sits at 100). onClickKeyframe toggles a selection key against newTweenPct that doesn't match any diamond cache will produce.

Fix: For boundary-resize path, don't compute newTweenPct here at all — await cache refresh and re-derive selection, or replicate resolveKeyframeRetime's pctRemap logic locally so reselected tween-% matches post-resize world.

Review by Rames D Jusso

}: TimelineClipDiamondsProps) {
groupAware = false,
globalEase = "none",
}: TimelineDiamondLaneProps) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 new Map allocated as useRef initializer argument on every render

const pendingRetimeRef = useRef(new Map<string, { clipPct: number; tweenPct: number }>());

useRef(new Map(...)) evaluates new Map(...) on every render even though only the first is kept — React discards subsequent allocations. On a long timeline with many mounted TimelineDiamondLanes, wastes a Map allocation per render per lane. Cosmetic GC nit but fix is one line.

Fix: Use lazy init: const pendingRetimeRef = useRef<Map<string, {clipPct: number; tweenPct: number}> | null>(null); if (!pendingRetimeRef.current) pendingRetimeRef.current = new Map();

Review by Rames D Jusso

@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-b-variable-layout-v2 branch from 514a219 to 97dd041 Compare July 25, 2026 21:17
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-b-keyframe-retiming-v2 branch from 50a9077 to 0779ac9 Compare July 25, 2026 21:17
…view

Escape now ends an in-flight diamond drag the way it already ends clip
and element drags: the armed gesture is marked cancelled, the preview is
dropped, and the pointerup that follows is swallowed instead of falling
through to the click branch.

The preview also flushes once per animation frame instead of once per
pointermove, so a high-rate trackpad no longer re-renders every diamond
in the row several times a frame. Single-diamond retime stays the
documented scope; multi-select drag needs a batched mutation the script
ops do not express yet.
CONTRIBUTING.md asks for a guard clause rather than a non-null assertion outside
an already-checked path. The index check and the lookup are now the same guard.
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-b-variable-layout-v2 branch from 97dd041 to 41b0a68 Compare July 25, 2026 23:51
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-b-keyframe-retiming-v2 branch from 0779ac9 to 32427d9 Compare July 25, 2026 23:51
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