Skip to content

Commit 76db7a2

Browse files
committed
perf(studio): virtualize timeline clip windows
1 parent 4f820c4 commit 76db7a2

16 files changed

Lines changed: 795 additions & 234 deletions

packages/studio/src/player/components/Timeline.tsx

Lines changed: 57 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { useExpandedTimelineElements } from "../hooks/useExpandedTimelineElement
66
import { defaultTimelineTheme } from "./timelineTheme";
77
import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
88
import { useTimelinePlayhead } from "./useTimelinePlayhead";
9-
import { useTimelineActiveClips } from "./useTimelineActiveClips";
109
import { useTimelineZoom } from "./useTimelineZoom";
1110
import { useTimelineAssetDrop } from "./timelineDragDrop";
1211
import { TimelineEmptyState } from "./TimelineEmptyState";
@@ -42,9 +41,11 @@ import { useTimelineSelectionLifecycle } from "./useTimelineSelectionLifecycle";
4241
import { useTimelineShiftModifier } from "./useTimelineShiftModifier";
4342
import { useTimelineTicks } from "./useTimelineTicks";
4443
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
44+
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
4545
import { useTimelineRowVirtualization } from "./useTimelineRowVirtualization";
46+
import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow";
47+
import { useTimelineActiveClips } from "./useTimelineActiveClips";
4648

47-
// Re-export pure utilities so existing imports from "./Timeline" still resolve.
4849
export {
4950
shouldAutoScrollTimeline,
5051
getTimelineScrollLeftForZoomTransition,
@@ -126,9 +127,8 @@ export const Timeline = memo(function Timeline({
126127
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
127128
const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
128129
const clipRevealRequest = usePlayerStore((s) => s.clipRevealRequest);
130+
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
129131
const gsapAnimations = usePlayerStore((s) => s.gsapAnimations);
130-
// Label mode = comp has keyframed clips (not just when expanded): keeps the layer
131-
// disclosure + property column visible and reserves a GUTTER before 0s (Figma).
132132
const hasKeyframedClips = useMemo(
133133
() => hasKeyframedTimelineClips(gsapAnimations),
134134
[gsapAnimations],
@@ -163,7 +163,6 @@ export const Timeline = memo(function Timeline({
163163
containerRef.current = el;
164164
}, []);
165165

166-
// Last horizontal scroll offset, restored across the post-edit iframe reload (pinned zoom).
167166
const lastScrollLeftRef = useRef(0);
168167

169168
const effectiveDuration = useMemo(
@@ -315,28 +314,50 @@ export const Timeline = memo(function Timeline({
315314
toggleSelectedKeyframe,
316315
});
317316

318-
const {
319-
pps,
320-
fitPps,
321-
displayContentWidth,
322-
displayDuration,
323-
clipStateVersion,
324-
zoomModeRef,
325-
manualZoomPercentRef,
326-
} = useTimelineGeometry({
327-
viewportWidth: viewport.clientWidth,
328-
effectiveDuration,
329-
zoomMode,
330-
manualZoomPercent,
331-
ppsRef,
332-
fitPpsRef,
333-
draggedClip,
334-
resizingClip,
335-
expandedElements,
336-
isDragging,
337-
scrollRef,
338-
lastScrollLeftRef,
317+
const { pps, fitPps, displayContentWidth, displayDuration, zoomModeRef, manualZoomPercentRef } =
318+
useTimelineGeometry({
319+
viewportWidth: viewport.clientWidth,
320+
effectiveDuration,
321+
zoomMode,
322+
manualZoomPercent,
323+
ppsRef,
324+
fitPpsRef,
325+
draggedClip,
326+
resizingClip,
327+
expandedElements,
328+
isDragging,
329+
scrollRef,
330+
lastScrollLeftRef,
331+
contentOrigin,
332+
});
333+
const { clipIndex, renderTimeRange, pinnedClipIdentities } = useTimelineClipRenderWindow({
334+
tracks,
335+
viewport,
336+
pixelsPerSecond: pps,
339337
contentOrigin,
338+
duration: displayDuration,
339+
selectedElementId: selectedElementId ?? undefined,
340+
draggedElementId: draggedClip ? getTimelineElementIdentity(draggedClip.element) : undefined,
341+
resizingElementId: resizingClip ? getTimelineElementIdentity(resizingClip.element) : undefined,
342+
revealElementId: clipRevealRequest?.elementId,
343+
focusedEaseElementId: focusedEaseSegment?.elementId,
344+
clipContextMenuElementId: clipContextMenu
345+
? getTimelineElementIdentity(clipContextMenu.element)
346+
: undefined,
347+
keyframeContextMenuElementId: kfContextMenu
348+
? getTimelineElementIdentity(kfContextMenu.element)
349+
: undefined,
350+
scrollRef,
351+
elements: expandedElements,
352+
rowGeometry: displayLayout.rowGeometry,
353+
allowHorizontalReveal: zoomMode === "manual",
354+
sessionEpoch,
355+
});
356+
useTimelineActiveClips({
357+
scrollRef,
358+
currentTime,
359+
clipStateVersion: renderTimeRange,
360+
elementStateVersion: expandedElements,
340361
});
341362

342363
const laneGapStrips = useTimelineGapHighlights({
@@ -371,11 +392,6 @@ export const Timeline = memo(function Timeline({
371392
onSeek,
372393
contentOrigin,
373394
});
374-
useTimelineActiveClips({
375-
scrollRef,
376-
currentTime,
377-
clipStateVersion,
378-
});
379395
const { razorGuideX, updateRazorGuide, clearRazorGuide, splitAllAtPointer } =
380396
useTimelineRazorInteraction({
381397
active: activeTool === "razor",
@@ -417,8 +433,12 @@ export const Timeline = memo(function Timeline({
417433
setRangeSelection(null),
418434
);
419435

420-
const { major, minor } = useTimelineTicks(displayDuration, pps, timeDisplayMode);
421-
const majorTickInterval = major.length >= 2 ? major[1] - major[0] : effectiveDuration;
436+
const { major, minor, majorTickInterval } = useTimelineTicks(
437+
displayDuration,
438+
pps,
439+
timeDisplayMode,
440+
rowVirtualizationActive ? renderTimeRange : undefined,
441+
);
422442

423443
const getPreviewElement = useCallback(
424444
(element: TimelineElement): TimelineElement => getTimelinePreviewElement(element, resizingClip),
@@ -494,6 +514,9 @@ export const Timeline = memo(function Timeline({
494514
rowGeometry={displayLayout.rowGeometry}
495515
virtualRows={virtualRows}
496516
rowsVirtualized={rowVirtualizationActive}
517+
clipIndex={clipIndex}
518+
renderTimeRange={renderTimeRange}
519+
pinnedClipIdentities={pinnedClipIdentities}
497520
trackOrder={trackOrder}
498521
tracks={tracks}
499522
trackStyles={trackStyles}
@@ -507,7 +530,7 @@ export const Timeline = memo(function Timeline({
507530
blockedClipRef={blockedClipRef}
508531
suppressClickRef={suppressClickRef}
509532
scrollRef={scrollRef}
510-
renderClipContent={renderClipContent}
533+
renderClipContent={viewport.isScrolling ? undefined : renderClipContent}
511534
renderClipOverlay={renderClipOverlay}
512535
playheadRef={playheadRef}
513536
onDrillDown={onDrillDown}

packages/studio/src/player/components/Timeline.virtualization.test.tsx

Lines changed: 152 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,78 @@ afterAll(() => {
5050
document.body.innerHTML = "";
5151
});
5252

53-
describe("Timeline row virtualization", () => {
53+
/**
54+
* The virtualized list only mounts rows/clips after its ResizeObserver and the
55+
* follow-up layout effect have both flushed, which is more than one React tick.
56+
* A fixed number of flushes is a coin flip once the rest of the suite is
57+
* competing for workers, so wait for the DOM the assertions actually need.
58+
*/
59+
async function settleUntil(predicate: () => boolean, tries = 60): Promise<void> {
60+
for (let attempt = 0; attempt < tries; attempt++) {
61+
if (predicate()) return;
62+
await act(async () => {
63+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
64+
});
65+
}
66+
}
67+
68+
// Every test here mounts 500-1000 timeline elements and settles the virtualizer,
69+
// which lands right on the 5s default once the rest of the suite is competing
70+
// for workers. The generous ceiling is a flake guard, not an expected runtime.
71+
describe("Timeline row virtualization", { timeout: 30_000 }, () => {
72+
it("defers rich clip content while scrolling without replacing the clip shell", async () => {
73+
const [{ Timeline }, { usePlayerStore }] = await Promise.all([
74+
import("./Timeline"),
75+
import("../store/playerStore"),
76+
]);
77+
usePlayerStore.setState({
78+
duration: 60,
79+
timelineReady: true,
80+
selectedElementId: "clip-0",
81+
elements: [{ id: "clip-0", label: "Clip 0", tag: "div", start: 0, duration: 10, track: 0 }],
82+
});
83+
84+
const host = document.createElement("div");
85+
document.body.append(host);
86+
const root = createRoot(host);
87+
try {
88+
await act(async () =>
89+
root.render(
90+
React.createElement(Timeline, {
91+
renderClipContent: () => React.createElement("span", { "data-rich-content": true }),
92+
}),
93+
),
94+
);
95+
await act(async () => {});
96+
await act(async () => {
97+
await new Promise((resolve) => setTimeout(resolve, 110));
98+
});
99+
100+
const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
101+
const clip = host.querySelector<HTMLElement>('[data-el-id="clip-0"]');
102+
expect(scroller).not.toBeNull();
103+
expect(clip).not.toBeNull();
104+
expect(clip?.title).toBe("Clip 0 • 0.0s – 10.0s");
105+
expect(host.querySelector("[data-rich-content]")).not.toBeNull();
106+
107+
await act(async () => {
108+
scroller?.dispatchEvent(new Event("scroll"));
109+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
110+
});
111+
expect(host.querySelector('[data-el-id="clip-0"]')).toBe(clip);
112+
expect(host.querySelector("[data-rich-content]")).toBeNull();
113+
114+
await act(async () => {
115+
await new Promise((resolve) => setTimeout(resolve, 110));
116+
});
117+
expect(host.querySelector('[data-el-id="clip-0"]')).toBe(clip);
118+
expect(host.querySelector("[data-rich-content]")).not.toBeNull();
119+
} finally {
120+
act(() => root.unmount());
121+
usePlayerStore.getState().reset();
122+
}
123+
});
124+
54125
it("mounts a bounded list range over the full geometry height", async () => {
55126
const [{ Timeline }, { usePlayerStore }, { getTimelineCanvasHeight, TRACK_H }] =
56127
await Promise.all([
@@ -61,6 +132,8 @@ describe("Timeline row virtualization", () => {
61132
usePlayerStore.setState({
62133
duration: 60,
63134
timelineReady: true,
135+
// Repeated fixture shape intentionally contrasts row and clip windowing scales.
136+
// fallow-ignore-next-line code-duplication
64137
elements: Array.from({ length: 1_000 }, (_, track) => ({
65138
id: `clip-${track}`,
66139
tag: "div",
@@ -76,6 +149,11 @@ describe("Timeline row virtualization", () => {
76149
await act(async () => root.render(React.createElement(Timeline, { sessionEpoch: 3 })));
77150
await act(async () => {});
78151

152+
await settleUntil(
153+
() =>
154+
(host.querySelector('[role="list"]')?.querySelectorAll('[role="listitem"]').length ?? 0) >
155+
0,
156+
);
79157
const list = host.querySelector<HTMLElement>('[role="list"]');
80158
const rows = list?.querySelectorAll('[role="listitem"]') ?? [];
81159
expect(rows.length).toBeGreaterThan(0);
@@ -103,5 +181,77 @@ describe("Timeline row virtualization", () => {
103181

104182
act(() => root.unmount());
105183
usePlayerStore.getState().reset();
106-
}, 10_000);
184+
});
185+
186+
it("windows clips and ruler cells while retaining an off-window selected clip", async () => {
187+
const [{ Timeline }, { usePlayerStore }, { TIMELINE_VIEWPORT_BUDGETS }] = await Promise.all([
188+
import("./Timeline"),
189+
import("../store/playerStore"),
190+
import("../lib/timelineViewportBudgets"),
191+
]);
192+
usePlayerStore.setState({
193+
duration: 1_000,
194+
timelineReady: true,
195+
zoomMode: "manual",
196+
manualZoomPercent: 2_000,
197+
selectedElementId: "clip-490",
198+
selectedElementIds: new Set(["clip-490"]),
199+
// Repeated fixture shape intentionally contrasts row and clip windowing scales.
200+
// fallow-ignore-next-line code-duplication
201+
elements: Array.from({ length: 500 }, (_, index) => ({
202+
id: `clip-${index}`,
203+
tag: "div",
204+
start: index * 2,
205+
duration: 1,
206+
track: 0,
207+
})),
208+
});
209+
210+
const host = document.createElement("div");
211+
document.body.append(host);
212+
const root = createRoot(host);
213+
await act(async () => root.render(React.createElement(Timeline, { sessionEpoch: 4 })));
214+
await act(async () => {});
215+
216+
await settleUntil(() => host.querySelectorAll("[data-clip]").length > 1);
217+
const initialClips = [...host.querySelectorAll<HTMLElement>("[data-clip]")];
218+
const initialGridCells = host.querySelectorAll("[data-timeline-grid-cell]");
219+
expect(initialClips.length).toBeGreaterThan(1);
220+
expect(initialClips.length).toBeLessThanOrEqual(
221+
TIMELINE_VIEWPORT_BUDGETS.maxMountedClipRootsPerRow + 1,
222+
);
223+
expect(initialGridCells.length).toBeLessThan(100);
224+
expect(host.querySelector('[data-el-id="clip-490"]')).not.toBeNull();
225+
const initialWindowIds = initialClips.map((clip) => clip.dataset.elId);
226+
227+
const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
228+
expect(scroller).not.toBeNull();
229+
if (scroller) {
230+
scroller.scrollLeft = 8_000;
231+
await act(async () => {
232+
scroller.dispatchEvent(new Event("scroll"));
233+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
234+
});
235+
}
236+
237+
const scrolledClips = [...host.querySelectorAll<HTMLElement>("[data-clip]")];
238+
expect(scrolledClips.map((clip) => clip.dataset.elId)).not.toEqual(initialWindowIds);
239+
expect(scrolledClips.length).toBeLessThanOrEqual(
240+
TIMELINE_VIEWPORT_BUDGETS.maxMountedClipRootsPerRow + 1,
241+
);
242+
expect(host.querySelector('[data-el-id="clip-490"]')).not.toBeNull();
243+
expect(host.querySelectorAll("[data-timeline-grid-cell]").length).toBeLessThan(100);
244+
245+
await act(async () => usePlayerStore.getState().requestClipReveal("clip-300"));
246+
await act(async () => {
247+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
248+
});
249+
await act(async () => {});
250+
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
251+
expect(document.activeElement?.getAttribute("data-el-id")).toBe("clip-300");
252+
expect(host.querySelector('[data-el-id="clip-490"]')).not.toBeNull();
253+
254+
act(() => root.unmount());
255+
usePlayerStore.getState().reset();
256+
});
107257
});

0 commit comments

Comments
 (0)