Skip to content

Commit df935c5

Browse files
committed
perf(studio): virtualize timeline clip windows
1 parent 029971e commit df935c5

18 files changed

Lines changed: 882 additions & 241 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";
@@ -41,9 +40,11 @@ import { useTimelineSelectionLifecycle } from "./useTimelineSelectionLifecycle";
4140
import { useTimelineShiftModifier } from "./useTimelineShiftModifier";
4241
import { useTimelineTicks } from "./useTimelineTicks";
4342
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
43+
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
4444
import { useTimelineRowVirtualization } from "./useTimelineRowVirtualization";
45+
import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow";
46+
import { useTimelineActiveClips } from "./useTimelineActiveClips";
4547

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

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

168167
const effectiveDuration = useMemo(
@@ -314,28 +313,50 @@ export const Timeline = memo(function Timeline({
314313
toggleSelectedKeyframe,
315314
});
316315

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

341362
const laneGapStrips = useTimelineGapHighlights({
@@ -370,11 +391,6 @@ export const Timeline = memo(function Timeline({
370391
onSeek,
371392
contentOrigin,
372393
});
373-
useTimelineActiveClips({
374-
scrollRef,
375-
currentTime,
376-
clipStateVersion,
377-
});
378394
const { razorGuideX, updateRazorGuide, clearRazorGuide, splitAllAtPointer } =
379395
useTimelineRazorInteraction({
380396
active: activeTool === "razor",
@@ -416,8 +432,12 @@ export const Timeline = memo(function Timeline({
416432
setRangeSelection(null),
417433
);
418434

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

422442
const getPreviewElement = useCallback(
423443
(element: TimelineElement): TimelineElement => getTimelinePreviewElement(element, resizingClip),
@@ -493,6 +513,9 @@ export const Timeline = memo(function Timeline({
493513
rowGeometry={displayLayout.rowGeometry}
494514
virtualRows={virtualRows}
495515
rowsVirtualized={rowVirtualizationActive}
516+
clipIndex={clipIndex}
517+
renderTimeRange={renderTimeRange}
518+
pinnedClipIdentities={pinnedClipIdentities}
496519
trackOrder={trackOrder}
497520
tracks={tracks}
498521
trackStyles={trackStyles}
@@ -506,7 +529,7 @@ export const Timeline = memo(function Timeline({
506529
blockedClipRef={blockedClipRef}
507530
suppressClickRef={suppressClickRef}
508531
scrollRef={scrollRef}
509-
renderClipContent={renderClipContent}
532+
renderClipContent={viewport.isScrolling ? undefined : renderClipContent}
510533
renderClipOverlay={renderClipOverlay}
511534
playheadRef={playheadRef}
512535
onDrillDown={onDrillDown}

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

Lines changed: 153 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,25 @@ afterAll(() => {
5757
document.body.innerHTML = "";
5858
});
5959

60-
describe("Timeline row virtualization", () => {
60+
/**
61+
* The virtualized list only mounts rows/clips after its ResizeObserver and the
62+
* follow-up layout effect have both flushed, which is more than one React tick.
63+
* A fixed number of flushes is a coin flip once the rest of the suite is
64+
* competing for workers, so wait for the DOM the assertions actually need.
65+
*/
66+
async function settleUntil(predicate: () => boolean, tries = 60): Promise<void> {
67+
for (let attempt = 0; attempt < tries; attempt++) {
68+
if (predicate()) return;
69+
await act(async () => {
70+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
71+
});
72+
}
73+
}
74+
75+
// These tests mount 500-10,000 timeline elements and settle the virtualizer,
76+
// which lands right on the 5s default once the rest of the suite is competing
77+
// for workers. The generous ceiling is a flake guard, not an expected runtime.
78+
describe("Timeline row virtualization", { timeout: 30_000 }, () => {
6179
it("keeps a zero-size first render bounded while the feature flag is enabled", async () => {
6280
clientWidth = 0;
6381
clientHeight = 0;
@@ -89,7 +107,60 @@ describe("Timeline row virtualization", () => {
89107

90108
act(() => root.unmount());
91109
usePlayerStore.getState().reset();
92-
}, 10_000);
110+
});
111+
112+
it("defers rich clip content while scrolling without replacing the clip shell", async () => {
113+
const [{ Timeline }, { usePlayerStore }] = await Promise.all([
114+
import("./Timeline"),
115+
import("../store/playerStore"),
116+
]);
117+
usePlayerStore.setState({
118+
duration: 60,
119+
timelineReady: true,
120+
selectedElementId: "clip-0",
121+
elements: [{ id: "clip-0", label: "Clip 0", tag: "div", start: 0, duration: 10, track: 0 }],
122+
});
123+
124+
const host = document.createElement("div");
125+
document.body.append(host);
126+
const root = createRoot(host);
127+
try {
128+
await act(async () =>
129+
root.render(
130+
React.createElement(Timeline, {
131+
renderClipContent: () => React.createElement("span", { "data-rich-content": true }),
132+
}),
133+
),
134+
);
135+
await act(async () => {});
136+
await act(async () => {
137+
await new Promise((resolve) => setTimeout(resolve, 110));
138+
});
139+
140+
const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
141+
const clip = host.querySelector<HTMLElement>('[data-el-id="clip-0"]');
142+
expect(scroller).not.toBeNull();
143+
expect(clip).not.toBeNull();
144+
expect(clip?.title).toBe("Clip 0 • 0.0s – 10.0s");
145+
expect(host.querySelector("[data-rich-content]")).not.toBeNull();
146+
147+
await act(async () => {
148+
scroller?.dispatchEvent(new Event("scroll"));
149+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
150+
});
151+
expect(host.querySelector('[data-el-id="clip-0"]')).toBe(clip);
152+
expect(host.querySelector("[data-rich-content]")).toBeNull();
153+
154+
await act(async () => {
155+
await new Promise((resolve) => setTimeout(resolve, 110));
156+
});
157+
expect(host.querySelector('[data-el-id="clip-0"]')).toBe(clip);
158+
expect(host.querySelector("[data-rich-content]")).not.toBeNull();
159+
} finally {
160+
act(() => root.unmount());
161+
usePlayerStore.getState().reset();
162+
}
163+
});
93164

94165
it("mounts a bounded list range over the full geometry height", async () => {
95166
const [{ Timeline }, { usePlayerStore }, { getTimelineCanvasHeight, TRACK_H }] =
@@ -101,6 +172,8 @@ describe("Timeline row virtualization", () => {
101172
usePlayerStore.setState({
102173
duration: 60,
103174
timelineReady: true,
175+
// Repeated fixture shape intentionally contrasts row and clip windowing scales.
176+
// fallow-ignore-next-line code-duplication
104177
elements: Array.from({ length: 1_000 }, (_, track) => ({
105178
id: `clip-${track}`,
106179
tag: "div",
@@ -116,6 +189,11 @@ describe("Timeline row virtualization", () => {
116189
await act(async () => root.render(React.createElement(Timeline, { sessionEpoch: 3 })));
117190
await act(async () => {});
118191

192+
await settleUntil(
193+
() =>
194+
(host.querySelector('[role="list"]')?.querySelectorAll('[role="listitem"]').length ?? 0) >
195+
0,
196+
);
119197
const list = host.querySelector<HTMLElement>('[role="list"]');
120198
const rows = list?.querySelectorAll('[role="listitem"]') ?? [];
121199
expect(rows.length).toBeGreaterThan(0);
@@ -143,5 +221,77 @@ describe("Timeline row virtualization", () => {
143221

144222
act(() => root.unmount());
145223
usePlayerStore.getState().reset();
146-
}, 10_000);
224+
});
225+
226+
it("windows clips and ruler cells while retaining an off-window selected clip", async () => {
227+
const [{ Timeline }, { usePlayerStore }, { TIMELINE_VIEWPORT_BUDGETS }] = await Promise.all([
228+
import("./Timeline"),
229+
import("../store/playerStore"),
230+
import("../lib/timelineViewportBudgets"),
231+
]);
232+
usePlayerStore.setState({
233+
duration: 1_000,
234+
timelineReady: true,
235+
zoomMode: "manual",
236+
manualZoomPercent: 2_000,
237+
selectedElementId: "clip-490",
238+
selectedElementIds: new Set(["clip-490"]),
239+
// Repeated fixture shape intentionally contrasts row and clip windowing scales.
240+
// fallow-ignore-next-line code-duplication
241+
elements: Array.from({ length: 500 }, (_, index) => ({
242+
id: `clip-${index}`,
243+
tag: "div",
244+
start: index * 2,
245+
duration: 1,
246+
track: 0,
247+
})),
248+
});
249+
250+
const host = document.createElement("div");
251+
document.body.append(host);
252+
const root = createRoot(host);
253+
await act(async () => root.render(React.createElement(Timeline, { sessionEpoch: 4 })));
254+
await act(async () => {});
255+
256+
await settleUntil(() => host.querySelectorAll("[data-clip]").length > 1);
257+
const initialClips = [...host.querySelectorAll<HTMLElement>("[data-clip]")];
258+
const initialGridCells = host.querySelectorAll("[data-timeline-grid-cell]");
259+
expect(initialClips.length).toBeGreaterThan(1);
260+
expect(initialClips.length).toBeLessThanOrEqual(
261+
TIMELINE_VIEWPORT_BUDGETS.maxMountedClipRootsPerRow + 1,
262+
);
263+
expect(initialGridCells.length).toBeLessThan(100);
264+
expect(host.querySelector('[data-el-id="clip-490"]')).not.toBeNull();
265+
const initialWindowIds = initialClips.map((clip) => clip.dataset.elId);
266+
267+
const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
268+
expect(scroller).not.toBeNull();
269+
if (scroller) {
270+
scroller.scrollLeft = 8_000;
271+
await act(async () => {
272+
scroller.dispatchEvent(new Event("scroll"));
273+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
274+
});
275+
}
276+
277+
const scrolledClips = [...host.querySelectorAll<HTMLElement>("[data-clip]")];
278+
expect(scrolledClips.map((clip) => clip.dataset.elId)).not.toEqual(initialWindowIds);
279+
expect(scrolledClips.length).toBeLessThanOrEqual(
280+
TIMELINE_VIEWPORT_BUDGETS.maxMountedClipRootsPerRow + 1,
281+
);
282+
expect(host.querySelector('[data-el-id="clip-490"]')).not.toBeNull();
283+
expect(host.querySelectorAll("[data-timeline-grid-cell]").length).toBeLessThan(100);
284+
285+
await act(async () => usePlayerStore.getState().requestClipReveal("clip-300"));
286+
await act(async () => {
287+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
288+
});
289+
await act(async () => {});
290+
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
291+
expect(document.activeElement?.getAttribute("data-el-id")).toBe("clip-300");
292+
expect(host.querySelector('[data-el-id="clip-490"]')).not.toBeNull();
293+
294+
act(() => root.unmount());
295+
usePlayerStore.getState().reset();
296+
});
147297
});

0 commit comments

Comments
 (0)