diff --git a/packages/studio/src/player/components/TimelineClipDiamonds.test.tsx b/packages/studio/src/player/components/TimelineClipDiamonds.test.tsx
index e745ff912e..c5d53175e7 100644
--- a/packages/studio/src/player/components/TimelineClipDiamonds.test.tsx
+++ b/packages/studio/src/player/components/TimelineClipDiamonds.test.tsx
@@ -220,6 +220,82 @@ describe("TimelineClipDiamonds", () => {
act(() => root.unmount());
});
+ it("composes a rapid second retime from the pending position", () => {
+ const onMoveKeyframe = vi.fn();
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+ act(() => {
+ root.render(
+ ,
+ );
+ });
+ const diamond = host.querySelector('button[title="50%"]');
+ expect(diamond).not.toBeNull();
+
+ act(() => {
+ diamond!.dispatchEvent(
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }),
+ );
+ diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 150 }));
+
+ // The cache still exposes 50%, but this second +10% drag starts at the
+ // pending 75% destination and must therefore land at 85%, not 60%.
+ diamond!.dispatchEvent(
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 150 }),
+ );
+ diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 170 }));
+ });
+
+ expect(onMoveKeyframe).toHaveBeenNthCalledWith(
+ 2,
+ {
+ percentage: 50,
+ tweenPercentage: 50,
+ propertyGroup: "position",
+ animationId: "anim-1",
+ },
+ 85,
+ );
+ act(() => root.unmount());
+ });
+
// Regression: onClickKeyframe's state updates can re-render the diamond
// button out from under the gesture before the browser auto-synthesizes the
// "click" event that follows a button's pointerdown+pointerup. That orphaned
diff --git a/packages/studio/src/player/components/TimelineClipDiamonds.tsx b/packages/studio/src/player/components/TimelineClipDiamonds.tsx
index 9ab08d6797..7c810a1039 100644
--- a/packages/studio/src/player/components/TimelineClipDiamonds.tsx
+++ b/packages/studio/src/player/components/TimelineClipDiamonds.tsx
@@ -1,4 +1,4 @@
-import { Fragment, memo, useRef, useState } from "react";
+import { Fragment, memo, useEffect, useRef, useState } from "react";
import { BEAT_BAND_H } from "./BeatStrip";
import {
KEYFRAME_DRAG_THRESHOLD_PX,
@@ -130,6 +130,13 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
}: TimelineDiamondLaneProps) {
// Hooks must run before the early return below.
const dragRef = useRef(null);
+ const pendingClipPctsRef = useRef(new Map());
+ useEffect(() => {
+ const cachedClipPcts = new Set(keyframesData.keyframes.map((keyframe) => keyframe.percentage));
+ for (const [kfKey, clipPct] of pendingClipPctsRef.current) {
+ if (cachedClipPcts.has(clipPct)) pendingClipPctsRef.current.delete(kfKey);
+ }
+ }, [keyframesData.keyframes]);
// Visual-only preview of the dragged diamond's clip-% — no runtime/GSAP hold
// (that optimistic hold was the #1763 flake). The atomic move-keyframe commit
// on drop re-keys the diamond from source.
@@ -291,7 +298,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
dragRef.current = {
kfKey,
startX: e.clientX,
- fromClipPct: kf.percentage,
+ fromClipPct: pendingClipPctsRef.current.get(kfKey) ?? kf.percentage,
moved: false,
};
}
@@ -348,6 +355,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
if (e.shiftKey) onShiftClickKeyframe?.(target);
else onClickKeyframe?.(target);
} else if (res.kind === "move" && res.toClipPct != null) {
+ pendingClipPctsRef.current.set(kfKey, res.toClipPct);
onMoveKeyframe?.(target, res.toClipPct);
// 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