fix(studio): lane every tween and attribute tweens to their real target

Two halves of one inversion in the expanded timeline lanes: the tweens
that should show were filtered out, and a tween that should not be there
was the only survivor.

Lane classification read the parser's whole-tween verdict, which is
undefined for anything spanning more than one property group. `{x,
opacity}` is the canonical HyperFrames entrance tween, so five of the
seven tweens in the swiss-grid graphics example had no caret, no
reserved row and no diamonds. Classify per property instead, through one
helper both the rendered lanes and the reserved row heights count
through so they cannot drift again.

Attribution matched an unanchored leading id, so `#stat3 .block` was
filed under `#stat3`. The child's diamonds landed on its ancestor and
collided with the ancestor's own tween at the shared percentage, which
the same-percentage merge then resolved by dropping the ease. Route
attribution through resolveSelectorElementIds, which anchors a
whole-selector id and otherwise resolves through the live preview DOM,
and anchor its no-DOM fallback so a descendant selector resolves to
nothing rather than to its ancestor. The merge rule is unchanged.

Also brings the last property-lane call site onto the shared clip timing
basis: an expanded sub-composition child's start is host-absolute while
its tweens are local to its own file.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-28 19:02:25 +02:00
parent acad7b268e
commit 59a818e80a
13 changed files with 437 additions and 93 deletions
@@ -6,6 +6,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { afterEach, describe, expect, it } from "vitest";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import { LANE_H, TRACK_H } from "./timelineLayout";
import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
import { useTimelineTrackLayout } from "./useTimelineTrackLayout";
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
@@ -50,4 +51,38 @@ describe("useTimelineTrackLayout", () => {
expect(layout?.rowHeights).toEqual([TRACK_H + LANE_H]);
act(() => root.unmount());
});
// The row height reserved here and the lanes actually rendered are two
// readings of the same question. They used to be two inline copies of the
// group-set rule, and a mixed-group tween made them disagree: zero reserved
// rows under two rendered lanes.
it("reserves exactly as many rows as the lanes a mixed-group tween renders", () => {
const elements: TimelineElement[] = [
{ id: "clip-1", tag: "div", start: 0, duration: 1, track: 0 },
];
const mixed: GsapAnimation = {
id: "entrance",
targetSelector: "#clip-1",
method: "to",
position: 0,
duration: 1,
properties: { x: 420, opacity: 1 },
};
const animations = new Map<string, GsapAnimation[]>([["clip-1", [mixed]]]);
usePlayerStore.setState({ expandedClipIds: new Set(["clip-1"]) });
let layout: ReturnType<typeof useTimelineTrackLayout> | undefined;
function Probe() {
layout = useTimelineTrackLayout(elements, animations, null, new Set());
return null;
}
const root = createRoot(document.createElement("div"));
act(() => root.render(React.createElement(Probe)));
expect(getTimelinePropertyLanes([mixed], 0, 1)).toHaveLength(2);
expect(layout?.laneCounts.get("clip-1")).toBe(2);
expect(layout?.rowHeights).toEqual([TRACK_H + 2 * LANE_H]);
act(() => root.unmount());
});
});