Skip to content

Commit 9b5b8b2

Browse files
committed
perf(studio): virtualize timeline clip windows
1 parent 06c0f1d commit 9b5b8b2

19 files changed

Lines changed: 883 additions & 242 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: 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)