From fed5e5b71df505b2598fe31522eba1710ea62c7c Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 20 Jul 2026 16:51:46 +0200 Subject: [PATCH] feat(studio): add timeline property lanes --- .../components/TimelineClipDiamonds.tsx | 2 +- .../components/TimelinePropertyLanes.test.tsx | 433 ++++++++++++++++++ .../components/TimelinePropertyLanes.tsx | 163 +++++++ .../src/player/components/timelineLayout.ts | 4 + .../useAutoExpandKeyframedClips.test.tsx | 75 +++ .../components/useAutoExpandKeyframedClips.ts | 36 ++ 6 files changed, 712 insertions(+), 1 deletion(-) create mode 100644 packages/studio/src/player/components/TimelinePropertyLanes.test.tsx create mode 100644 packages/studio/src/player/components/TimelinePropertyLanes.tsx create mode 100644 packages/studio/src/player/components/useAutoExpandKeyframedClips.test.tsx create mode 100644 packages/studio/src/player/components/useAutoExpandKeyframedClips.ts diff --git a/packages/studio/src/player/components/TimelineClipDiamonds.tsx b/packages/studio/src/player/components/TimelineClipDiamonds.tsx index a592a4db5..1a066d5d0 100644 --- a/packages/studio/src/player/components/TimelineClipDiamonds.tsx +++ b/packages/studio/src/player/components/TimelineClipDiamonds.tsx @@ -12,7 +12,7 @@ import { timelineKeyframeSelectionKey, type TimelineKeyframeTarget, } from "./timelineKeyframeIdentity"; -interface TimelineDiamondKeyframe { +export interface TimelineDiamondKeyframe { percentage: number; /** Tween-relative percentage (the retime mutation keys on this, not clip %). */ tweenPercentage?: number; diff --git a/packages/studio/src/player/components/TimelinePropertyLanes.test.tsx b/packages/studio/src/player/components/TimelinePropertyLanes.test.tsx new file mode 100644 index 000000000..475dbd1e9 --- /dev/null +++ b/packages/studio/src/player/components/TimelinePropertyLanes.test.tsx @@ -0,0 +1,433 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TimelineClipDiamonds } from "./TimelineClipDiamonds"; +import { + getTimelinePropertyLanes, + TimelinePropertyLanes, + type TimelinePropertyLanesProps, +} from "./TimelinePropertyLanes"; +import { timelineKeyframeSelectionKey } from "./timelineKeyframeIdentity"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +function animation( + id: string, + propertyGroup: PropertyGroupName, + keyframes: Array<{ + percentage: number; + properties: Record; + ease?: string; + }>, +): GsapAnimation { + return { + id, + targetSelector: "#clip-1", + method: "to", + position: 0, + duration: 1, + properties: {}, + propertyGroup, + keyframes: { format: "percentage", keyframes }, + }; +} + +function flatAnimation( + id: string, + propertyGroup: PropertyGroupName, + properties: Record, +): GsapAnimation { + return { + id, + targetSelector: "#clip-1", + method: "to", + position: 0, + duration: 1, + properties, + propertyGroup, + }; +} + +function renderPropertyLanes(overrides: Partial = {}): { + host: HTMLDivElement; + root: Root; +} { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + return { host, root }; +} + +function laneDiamonds(host: HTMLElement, group: string): HTMLButtonElement[] { + return Array.from( + host.querySelectorAll( + `[data-property-group="${group}"] button[data-keyframe-percentage]`, + ), + ); +} + +function expectLanePercentages(host: HTMLElement, group: string, percentages: string[]) { + expect(laneDiamonds(host, group).map((diamond) => diamond.dataset.keyframePercentage)).toEqual( + percentages, + ); +} + +function laneEaseButtons(host: HTMLElement, group: string): HTMLButtonElement[] { + return Array.from( + host.querySelectorAll( + `[data-property-group="${group}"] button[data-keyframe-ease-button]`, + ), + ); +} + +function laneEaseSegments(host: HTMLElement, group: string): HTMLElement[] { + return Array.from( + host.querySelectorAll( + `[data-property-group="${group}"] [data-keyframe-ease-segment]`, + ), + ); +} + +// The mid-segment ease button is revealed on hover (Figma parity), so tests must +// hover the segment strip before its button exists. React derives onMouseEnter +// from a bubbling mouseover, so dispatching that is what arms the hover. +function revealEaseButton(segment: HTMLElement): HTMLButtonElement | null { + act(() => { + segment.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + }); + return segment.querySelector("button[data-keyframe-ease-button]"); +} + +const POSITION_SEGMENT_ANIMATION = animation("position-tween", "position", [ + { percentage: 0, properties: { x: 0 } }, + { percentage: 50, properties: { x: 50 } }, +]); + +describe("TimelinePropertyLanes", () => { + it("returns a position lane with synthesized endpoints for a flat tween", () => { + const lanes = getTimelinePropertyLanes( + [flatAnimation("position-tween", "position", { x: 420 })], + 0, + 1, + ); + + expect(lanes).toHaveLength(1); + expect(lanes[0]?.group).toBe("position"); + expect(lanes[0]?.keyframes).toEqual([ + { + percentage: 0, + tweenPercentage: 0, + properties: { x: 0 }, + propertyGroup: "position", + animationId: "position-tween", + }, + { + percentage: 100, + tweenPercentage: 100, + properties: { x: 420 }, + propertyGroup: "position", + animationId: "position-tween", + }, + ]); + }); + + it("returns both flat and authored keyframe property groups", () => { + const lanes = getTimelinePropertyLanes( + [ + flatAnimation("position-tween", "position", { x: 420 }), + animation("visual-tween", "visual", [ + { percentage: 0, properties: { opacity: 0 } }, + { percentage: 100, properties: { opacity: 1 } }, + ]), + ], + 0, + 1, + ); + + expect(lanes.map((lane) => lane.group)).toEqual(["position", "visual"]); + expect(lanes.map((lane) => lane.keyframes.map((keyframe) => keyframe.percentage))).toEqual([ + [0, 100], + [0, 100], + ]); + }); + + it("renders each source property group at its independent keyframe positions", () => { + const animations = [ + animation("position-tween", "position", [ + { percentage: 0, properties: { x: 0, y: 0 } }, + { percentage: 50, properties: { x: 100, y: 20 } }, + { percentage: 100, properties: { x: 200, y: 40 } }, + ]), + animation("visual-tween", "visual", [{ percentage: 25, properties: { opacity: 0.5 } }]), + ]; + + const { host, root } = renderPropertyLanes({ animations }); + const position = laneDiamonds(host, "position"); + const visual = laneDiamonds(host, "visual"); + + expect(position).toHaveLength(3); + expect(visual).toHaveLength(1); + // Diamonds are centered on their true keyframe time (0% at -half); the + // reserved left gutter (content origin inset, tested at the Timeline level) + // keeps the overflowing left half visible rather than clamping it inward. + expect(position.map((diamond) => diamond.style.left)).toEqual(["-11px", "89px", "189px"]); + expect(visual[0]?.style.left).toBe("39px"); + expect( + host.querySelectorAll('[data-property-group="position"] [data-keyframe-connector]'), + ).toHaveLength(2); + act(() => root.unmount()); + }); + + it("keeps both groups' diamonds when their source keyframes share 0% and 100%", () => { + const animations = [ + animation("position-tween", "position", [ + { percentage: 0, properties: { x: 0 } }, + { percentage: 100, properties: { x: 100 } }, + ]), + animation("visual-tween", "visual", [ + { percentage: 0, properties: { opacity: 0 } }, + { percentage: 100, properties: { opacity: 1 } }, + ]), + ]; + + const { host, root } = renderPropertyLanes({ animations }); + + expectLanePercentages(host, "position", ["0", "100"]); + expectLanePercentages(host, "visual", ["0", "100"]); + act(() => root.unmount()); + }); + + it("renders an authored hold keyframe whose value equals its predecessor", () => { + const animations = [ + animation("position-tween", "position", [ + { percentage: 0, properties: { x: 10 } }, + { percentage: 50, properties: { x: 10 } }, + { percentage: 100, properties: { x: 20 } }, + ]), + ]; + + const { host, root } = renderPropertyLanes({ animations }); + + expectLanePercentages(host, "position", ["0", "50", "100"]); + act(() => root.unmount()); + }); + + it("keeps Position@50% selection distinct from Opacity@50%", () => { + const onClickKeyframe = vi.fn(); + const animations = [ + animation("position-tween", "position", [{ percentage: 50, properties: { x: 50 } }]), + animation("visual-tween", "visual", [{ percentage: 50, properties: { opacity: 0.5 } }]), + ]; + const { host, root } = renderPropertyLanes({ animations, onClickKeyframe }); + const position = laneDiamonds(host, "position")[0]!; + + act(() => { + position.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, button: 0 })); + }); + + const target = onClickKeyframe.mock.calls[0]?.[0]; + expect(target).toEqual({ + animationId: "position-tween", + percentage: 50, + propertyGroup: "position", + tweenPercentage: 50, + }); + + act(() => { + root.render( + , + ); + }); + + const positionFill = laneDiamonds(host, "position")[0]?.querySelector("path:last-child"); + const visualFill = laneDiamonds(host, "visual")[0]?.querySelector("path:last-child"); + expect(positionFill?.getAttribute("fill")).toBe("#4ba3d2"); + expect(visualFill?.getAttribute("fill")).toBe("#a3a3a3"); + act(() => root.unmount()); + }); + + it("reveals one midpoint ease button per segment on hover, regardless of selection", () => { + const animations = [ + animation("position-tween", "position", [ + { percentage: 0, properties: { x: 0 } }, + { percentage: 50, properties: { x: 50 } }, + { percentage: 100, properties: { x: 100 } }, + ]), + ]; + const { host, root } = renderPropertyLanes({ animations, onSelectSegment: vi.fn() }); + + const segments = laneEaseSegments(host, "position"); + expect(segments).toHaveLength(2); + expect(segments.map((segment) => segment.style.left)).toEqual(["0px", "100px"]); + expect(laneDiamonds(host, "position")).toHaveLength(3); + // Resting state: no button until a segment is hovered. + expect(laneEaseButtons(host, "position")).toHaveLength(0); + + // Hovering reveals exactly one button — the hovered segment's. + expect(revealEaseButton(segments[0]!)).not.toBeNull(); + expect(laneEaseButtons(host, "position")).toHaveLength(1); + + // The ease button is available on hover even when the element is NOT selected + // (a lane shows for the track's active/primary clip, not only the selected one). + act(() => { + root.render( + , + ); + }); + const unselectedSegments = laneEaseSegments(host, "position"); + expect(unselectedSegments).toHaveLength(2); + expect(revealEaseButton(unselectedSegments[0]!)).not.toBeNull(); + act(() => root.unmount()); + }); + + it("reveals each segment's button with its destination keyframe ease curve", () => { + const animations = [ + animation("position-tween", "position", [ + { percentage: 0, properties: { x: 0 } }, + { percentage: 33, properties: { x: 33 }, ease: "none" }, + { percentage: 66, properties: { x: 66 }, ease: "power2.out" }, + { + percentage: 100, + properties: { x: 100 }, + ease: "custom(M0,0 C0.1,0.2 0.3,0.9 1,1)", + }, + ]), + ]; + const { host, root } = renderPropertyLanes({ animations, onSelectSegment: vi.fn() }); + + const segments = laneEaseSegments(host, "position"); + expect(segments).toHaveLength(3); + const paths = segments.map((segment) => + revealEaseButton(segment)?.querySelector("path")?.getAttribute("d"), + ); + expect(paths).toHaveLength(3); + expect(new Set(paths).size).toBe(3); + act(() => root.unmount()); + }); + + it("selects the destination keyframe when a hovered segment's ease button is clicked", () => { + const onSelectSegment = vi.fn(); + const { host, root } = renderPropertyLanes({ + animations: [POSITION_SEGMENT_ANIMATION], + onSelectSegment, + }); + + const button = revealEaseButton(laneEaseSegments(host, "position")[0]!); + act(() => button?.click()); + + expect(onSelectSegment).toHaveBeenCalledWith({ + animationId: "position-tween", + percentage: 50, + propertyGroup: "position", + tweenPercentage: 50, + }); + act(() => root.unmount()); + }); + + it("routes a colliding Position segment to the Position animation", () => { + const onSelectSegment = vi.fn(); + const animations = [ + POSITION_SEGMENT_ANIMATION, + animation("visual-tween", "visual", [ + { percentage: 0, properties: { opacity: 0 } }, + { percentage: 50, properties: { opacity: 0.5 } }, + ]), + ]; + const { host, root } = renderPropertyLanes({ animations, onSelectSegment }); + + const button = revealEaseButton(laneEaseSegments(host, "position")[0]!); + act(() => button?.click()); + + expect(onSelectSegment.mock.calls[0]?.[0]).toMatchObject({ + animationId: "position-tween", + propertyGroup: "position", + }); + act(() => root.unmount()); + }); + + it("keeps the collapsed TimelineClipDiamonds positions and callback contract unchanged", () => { + const onClickKeyframe = vi.fn(); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + const diamonds = Array.from(host.querySelectorAll("button")); + + // Unified keyframe-diamond size (LANE_H·ratio ≈ 22px, half 11) on collapsed + // clips too, so 0% sits at -11px regardless of clip-bar height. + expect(diamonds.map((diamond) => diamond.style.left)).toEqual(["-11px", "89px"]); + expect(diamonds[1]?.querySelector("path:last-child")?.getAttribute("fill")).toBe("#4ba3d2"); + act(() => { + diamonds[1]?.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, button: 0 })); + }); + expect(onClickKeyframe).toHaveBeenCalledWith(50); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/player/components/TimelinePropertyLanes.tsx b/packages/studio/src/player/components/TimelinePropertyLanes.tsx new file mode 100644 index 000000000..bd518e772 --- /dev/null +++ b/packages/studio/src/player/components/TimelinePropertyLanes.tsx @@ -0,0 +1,163 @@ +import type { MouseEvent as ReactMouseEvent, RefObject } from "react"; +import { + classifyPropertyGroup, + type GsapAnimation, + type PropertyGroupName, +} from "@hyperframes/core/gsap-parser"; +import { toAbsoluteTime } from "../../hooks/gsapShared"; +import { synthesizeFlatTweenKeyframes } from "../../hooks/gsapTweenSynth"; +import { TimelineDiamondLane, type TimelineDiamondKeyframe } from "./TimelineClipDiamonds"; +import { LANE_H, getTimelineLaneTop } from "./timelineLayout"; +import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity"; + +export interface TimelinePropertyLanesProps { + animations: readonly GsapAnimation[]; + clipStart: number; + clipDuration: number; + clipLeftPx: number; + clipWidthPx: number; + accentColor: string; + isSelected: boolean; + currentPercentage: number; + elementId: string; + selectedKeyframes: ReadonlySet; + onSelectSegment?: (target: TimelineKeyframeTarget) => void; + onClickKeyframe?: (target: TimelineKeyframeTarget) => void; + onShiftClickKeyframe?: (target: TimelineKeyframeTarget) => void; + onContextMenuKeyframe?: (e: ReactMouseEvent, target: TimelineKeyframeTarget) => void; + onMoveKeyframe?: (target: TimelineKeyframeTarget, toClipPercentage: number) => Promise; + suppressClickRef?: RefObject; +} + +function hasGroupProperty( + properties: Record, + group: PropertyGroupName, +): boolean { + return Object.keys(properties).some((property) => classifyPropertyGroup(property) === group); +} + +/** The tween's editable keyframes: its real keyframes, or the start→end pair + * synthesized for a flat tween. Empty for a tween that animates nothing. */ +function animationKeyframes(animation: GsapAnimation) { + return animation.keyframes?.keyframes ?? synthesizeFlatTweenKeyframes(animation)?.keyframes ?? []; +} + +/** A tween contributes a property lane when it has a group and at least one + * editable keyframe (real or synthesized). */ +export function animationContributesLane(animation: GsapAnimation): boolean { + return !!animation.propertyGroup && animationKeyframes(animation).length > 0; +} + +function sourceGroups(animations: readonly GsapAnimation[]) { + const groups = new Map(); + for (const animation of animations) { + if (!animation.propertyGroup || !animationContributesLane(animation)) continue; + const groupAnimations = groups.get(animation.propertyGroup) ?? []; + groupAnimations.push(animation); + groups.set(animation.propertyGroup, groupAnimations); + } + return groups; +} + +function groupKeyframes( + animations: readonly GsapAnimation[], + group: PropertyGroupName, + clipStart: number, + clipDuration: number, +): TimelineDiamondKeyframe[] { + const keyframes: TimelineDiamondKeyframe[] = []; + for (const animation of animations) { + const tweenStart = + animation.resolvedStart ?? (typeof animation.position === "number" ? animation.position : 0); + const tweenDuration = animation.duration ?? clipDuration; + for (const keyframe of animationKeyframes(animation)) { + if (!hasGroupProperty(keyframe.properties, group)) continue; + const absoluteTime = toAbsoluteTime(tweenStart, tweenDuration, keyframe.percentage); + keyframes.push({ + ...keyframe, + percentage: ((absoluteTime - clipStart) / clipDuration) * 100, + tweenPercentage: keyframe.percentage, + propertyGroup: group, + animationId: animation.id, + }); + } + } + return keyframes; +} + +export function getTimelinePropertyLanes( + animations: readonly GsapAnimation[], + clipStart: number, + clipDuration: number, +) { + if (clipDuration <= 0) return []; + return Array.from(sourceGroups(animations), ([group, groupAnimations]) => ({ + group, + animations: groupAnimations, + keyframes: groupKeyframes(groupAnimations, group, clipStart, clipDuration), + })).filter((lane) => lane.keyframes.length > 0); +} + +export function TimelinePropertyLanes({ + animations, + clipStart, + clipDuration, + clipLeftPx, + clipWidthPx, + accentColor, + isSelected, + currentPercentage, + elementId, + selectedKeyframes, + onSelectSegment, + onClickKeyframe, + onShiftClickKeyframe, + onContextMenuKeyframe, + onMoveKeyframe, + suppressClickRef, +}: TimelinePropertyLanesProps) { + if (clipWidthPx < 20 || clipDuration <= 0) return null; + const lanes = getTimelinePropertyLanes(animations, clipStart, clipDuration); + + if (lanes.length === 0) return null; + return ( + <> + {lanes.map(({ group, animations: groupAnimations, keyframes }, laneIndex) => ( +
+ +
+ ))} + + ); +} diff --git a/packages/studio/src/player/components/timelineLayout.ts b/packages/studio/src/player/components/timelineLayout.ts index 2677bf3a4..74eda4efe 100644 --- a/packages/studio/src/player/components/timelineLayout.ts +++ b/packages/studio/src/player/components/timelineLayout.ts @@ -9,6 +9,10 @@ export const RULER_H = 24; export const CLIP_Y = 3; export const CLIP_HANDLE_W = 18; +export function getTimelineLaneTop(laneIndex: number): number { + return TRACK_H + Math.max(0, Math.trunc(laneIndex)) * LANE_H; +} + /** * Collapsed-row characterization value for the new-track INSERT band. Runtime * hit-testing uses getTimelineInsertBoundaryBand with the concrete row height. diff --git a/packages/studio/src/player/components/useAutoExpandKeyframedClips.test.tsx b/packages/studio/src/player/components/useAutoExpandKeyframedClips.test.tsx new file mode 100644 index 000000000..fdf7ff5f0 --- /dev/null +++ b/packages/studio/src/player/components/useAutoExpandKeyframedClips.test.tsx @@ -0,0 +1,75 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { usePlayerStore } from "../store/playerStore"; +import { useAutoExpandKeyframedClips } from "./useAutoExpandKeyframedClips"; + +const studioShell = vi.hoisted(() => ({ projectId: "project-a" })); +vi.mock("../../contexts/StudioContext", () => ({ + useStudioShellContextOptional: () => studioShell, +})); + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; + studioShell.projectId = "project-a"; + usePlayerStore.getState().reset(); +}); + +const animations = new Map([ + [ + "clip-1", + [ + { + id: "position-tween", + targetSelector: "#clip-1", + method: "to", + position: 0, + duration: 1, + properties: { x: 100 }, + propertyGroup: "position", + }, + ], + ], +]); + +function AutoExpandHarness({ value }: { value: Map }) { + useAutoExpandKeyframedClips(value); + return null; +} + +describe("useAutoExpandKeyframedClips", () => { + it("preserves manual collapse within a project and expands again in a different project", () => { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const render = (projectId: string, value = new Map(animations)) => { + studioShell.projectId = projectId; + act(() => root.render()); + }; + + const projectAAnimations = new Map(animations); + render("project-a", projectAAnimations); + expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set(["clip-1"])); + + act(() => usePlayerStore.getState().toggleClipExpanded("clip-1")); + expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set()); + + const refreshedProjectAAnimations = new Map(animations); + render("project-a", refreshedProjectAAnimations); + expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set()); + + render("project-b", refreshedProjectAAnimations); + expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set()); + + render("project-b", new Map()); + render("project-b"); + expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set(["clip-1"])); + + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/player/components/useAutoExpandKeyframedClips.ts b/packages/studio/src/player/components/useAutoExpandKeyframedClips.ts new file mode 100644 index 000000000..1ff3e1d91 --- /dev/null +++ b/packages/studio/src/player/components/useAutoExpandKeyframedClips.ts @@ -0,0 +1,36 @@ +import { useEffect, useRef } from "react"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import { usePlayerStore } from "../store/playerStore"; +import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability"; +import { useStudioShellContextOptional } from "../../contexts/StudioContext"; +import { animationContributesLane } from "./TimelinePropertyLanes"; + +/** + * Keyframed clips start expanded (AE/Figma default). Auto-expands each clip the + * first time it contributes a lane — real keyframes OR a synthesizable flat tween + * — tracked per-clip so a later user collapse sticks and never bounces back open + * (and clips added later still auto-expand). + */ +export function useAutoExpandKeyframedClips(gsapAnimations: Map): void { + const expandClips = usePlayerStore((s) => s.expandClips); + const projectId = useStudioShellContextOptional()?.projectId ?? null; + const seen = useRef({ projectId, source: gsapAnimations, clips: new Set() }); + useEffect(() => { + if (!STUDIO_KEYFRAMES_ENABLED) return; + if (seen.current.projectId !== projectId) { + const sourceChanged = seen.current.source !== gsapAnimations; + seen.current = { projectId, source: gsapAnimations, clips: new Set() }; + if (!sourceChanged) return; + } else { + seen.current.source = gsapAnimations; + } + const fresh: string[] = []; + for (const [key, animations] of gsapAnimations) { + if (seen.current.clips.has(key)) continue; + if (animations.some(animationContributesLane)) fresh.push(key); + } + if (fresh.length === 0) return; + for (const key of fresh) seen.current.clips.add(key); + expandClips(fresh); + }, [gsapAnimations, expandClips, projectId]); +}