refactor(studio): share keyframe clip-% precision across lane writers

Review follow-ups on the expanded-lanes tip:

- TimelinePropertyLanes.groupKeyframes re-derived the clip-relative
  percentage inline and skipped toClipKeyframes' rounding, the one
  precision every keyframe-cache writer has to agree on (selection keys
  embed the number). It now loops the shared helper per animation with
  the group filter and only overrides the lane's own group and ease.
- TimelineClipDiamonds rebuilt and re-sorted each tween's sibling row
  inside the marker loop, so a row of N diamonds allocated N sorted
  copies of itself on every playhead tick. Built once per render instead,
  keyed by animation id.
- Add the missing symmetry test at the real callback boundary: the
  diamond test mocks onMoveKeyframe, so nothing proved the rapid-second
  retime resolves a pending clip-% the keyframe cache has not caught up
  to. The new test asserts the identity-carrying target retimes and that
  the same drag without identity fields cannot.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-28 00:40:47 +02:00
parent e317f1fbe3
commit 3181657147
3 changed files with 97 additions and 23 deletions
@@ -499,6 +499,63 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
view.unmount();
});
// The diamond's rapid-second-retime path reports the PENDING clip-% (where the
// first drag put the keyframe), which the keyframe cache has not caught up to.
// TimelineClipDiamonds' own test mocks onMoveKeyframe, so only this one proves
// the real callback resolves that stale-cache position off the identity fields
// instead of failing the lookup.
it("retimes from a pending position the keyframe cache has not caught up to", async () => {
const authored = authoredInteriorAnimation();
mocks.animations = [authored];
usePlayerStore.setState({
elements: [element],
gsapAnimations: new Map([["index.html#box", [authored]]]),
// Still the pre-drag positions: 75% is not in here.
keyframeCache: new Map([
[
"index.html#box",
{
format: "percentage" as const,
keyframes: [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 50, properties: { x: 210 } },
{ percentage: 100, properties: { x: 420 } },
],
},
],
]),
});
const view = renderCallbacks();
await expect(
view.callbacks.onMoveKeyframe?.(
"index.html#box",
{
percentage: 75,
propertyGroup: "position",
tweenPercentage: 50,
animationId: authored.id,
},
85,
),
).resolves.toBe(true);
expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(
authored.id,
50,
85,
mocks.selection,
);
// Control: the same drag WITHOUT the identity fields falls back to the cache
// lookup, finds nothing at 75%, and cannot retime.
mocks.actions.handleGsapMoveKeyframe.mockClear();
await expect(
view.callbacks.onMoveKeyframe?.("index.html#box", { percentage: 75 }, 85),
).resolves.toBe(false);
expect(mocks.actions.handleGsapMoveKeyframe).not.toHaveBeenCalled();
view.unmount();
});
it("uses the clip timing basis when retiming a duration-less tween", async () => {
const durationless = {
...authoredInteriorAnimation(),