Skip to content

Commit 79f7704

Browse files
committed
perf(studio): virtualize timeline clip windows
1 parent 143d281 commit 79f7704

16 files changed

Lines changed: 775 additions & 228 deletions

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

Lines changed: 52 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";
@@ -43,8 +42,9 @@ import { useTimelineShiftModifier } from "./useTimelineShiftModifier";
4342
import { useTimelineTicks } from "./useTimelineTicks";
4443
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
4544
import { useTimelineRowVirtualization } from "./useTimelineRowVirtualization";
45+
import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow";
46+
import { useTimelineActiveClips } from "./useTimelineActiveClips";
4647

47-
// Re-export pure utilities so existing imports from "./Timeline" still resolve.
4848
export {
4949
generateTicks,
5050
formatTimelineTickLabel,
@@ -127,9 +127,8 @@ export const Timeline = memo(function Timeline({
127127
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
128128
const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
129129
const clipRevealRequest = usePlayerStore((s) => s.clipRevealRequest);
130+
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
130131
const gsapAnimations = usePlayerStore((s) => s.gsapAnimations);
131-
// Label mode = comp has keyframed clips (not just when expanded): keeps the layer
132-
// disclosure + property column visible and reserves a GUTTER before 0s (Figma).
133132
const hasKeyframedClips = useMemo(
134133
() => hasKeyframedTimelineClips(gsapAnimations),
135134
[gsapAnimations],
@@ -164,7 +163,6 @@ export const Timeline = memo(function Timeline({
164163
containerRef.current = el;
165164
}, []);
166165

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

170168
const effectiveDuration = useMemo(
@@ -316,28 +314,46 @@ export const Timeline = memo(function Timeline({
316314
toggleSelectedKeyframe,
317315
});
318316

319-
const {
320-
pps,
321-
fitPps,
322-
displayContentWidth,
323-
displayDuration,
324-
clipStateVersion,
325-
zoomModeRef,
326-
manualZoomPercentRef,
327-
} = useTimelineGeometry({
328-
viewportWidth: viewport.clientWidth,
329-
effectiveDuration,
330-
zoomMode,
331-
manualZoomPercent,
332-
ppsRef,
333-
fitPpsRef,
334-
draggedClip,
335-
resizingClip,
336-
expandedElements,
337-
isDragging,
338-
scrollRef,
339-
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,
340337
contentOrigin,
338+
duration: displayDuration,
339+
selectedElementId: selectedElementId ?? undefined,
340+
draggedElementId: draggedClip?.element.key ?? draggedClip?.element.id,
341+
resizingElementId: resizingClip?.element.key ?? resizingClip?.element.id,
342+
revealElementId: clipRevealRequest?.elementId,
343+
focusedEaseElementId: focusedEaseSegment?.elementId,
344+
clipContextMenuElementId: clipContextMenu?.element.key ?? clipContextMenu?.element.id,
345+
keyframeContextMenuElementId: kfContextMenu?.element.key ?? kfContextMenu?.element.id,
346+
scrollRef,
347+
elements: expandedElements,
348+
rowGeometry: displayLayout.rowGeometry,
349+
allowHorizontalReveal: zoomMode === "manual",
350+
sessionEpoch,
351+
});
352+
useTimelineActiveClips({
353+
scrollRef,
354+
currentTime,
355+
clipStateVersion: renderTimeRange,
356+
elementStateVersion: expandedElements,
341357
});
342358

343359
const laneGapStrips = useTimelineGapHighlights({
@@ -372,11 +388,6 @@ export const Timeline = memo(function Timeline({
372388
onSeek,
373389
contentOrigin,
374390
});
375-
useTimelineActiveClips({
376-
scrollRef,
377-
currentTime,
378-
clipStateVersion,
379-
});
380391
const { razorGuideX, updateRazorGuide, clearRazorGuide, splitAllAtPointer } =
381392
useTimelineRazorInteraction({
382393
active: activeTool === "razor",
@@ -418,8 +429,12 @@ export const Timeline = memo(function Timeline({
418429
setRangeSelection(null),
419430
);
420431

421-
const { major, minor } = useTimelineTicks(displayDuration, pps, timeDisplayMode);
422-
const majorTickInterval = major.length >= 2 ? major[1] - major[0] : effectiveDuration;
432+
const { major, minor, majorTickInterval } = useTimelineTicks(
433+
displayDuration,
434+
pps,
435+
timeDisplayMode,
436+
rowVirtualizationActive ? renderTimeRange : undefined,
437+
);
423438

424439
const getPreviewElement = useCallback(
425440
(element: TimelineElement): TimelineElement => getTimelinePreviewElement(element, resizingClip),
@@ -495,6 +510,9 @@ export const Timeline = memo(function Timeline({
495510
rowGeometry={displayLayout.rowGeometry}
496511
virtualRows={virtualRows}
497512
rowsVirtualized={rowVirtualizationActive}
513+
clipIndex={clipIndex}
514+
renderTimeRange={renderTimeRange}
515+
pinnedClipIdentities={pinnedClipIdentities}
498516
trackOrder={trackOrder}
499517
tracks={tracks}
500518
trackStyles={trackStyles}
@@ -508,7 +526,7 @@ export const Timeline = memo(function Timeline({
508526
blockedClipRef={blockedClipRef}
509527
suppressClickRef={suppressClickRef}
510528
scrollRef={scrollRef}
511-
renderClipContent={renderClipContent}
529+
renderClipContent={viewport.isScrolling ? undefined : renderClipContent}
512530
renderClipOverlay={renderClipOverlay}
513531
playheadRef={playheadRef}
514532
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
});

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

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ import { TimelineClip } from "./TimelineClip";
2323
import { TimelineLanes } from "./TimelineLanes";
2424
import type { TimelineLaneBaseProps } from "./timelineLaneProps";
2525
import { renderClipChildren } from "./timelineClipChildren";
26-
import { useTimelineRevealClip } from "./useTimelineRevealClip";
2726
import type { TimelineLaneGapStrips } from "./useTimelineGapHighlights";
27+
import { isTimelineClipActive } from "./useTimelineActiveClips";
2828

2929
interface TimelineCanvasProps extends TimelineLaneBaseProps {
3030
major: number[];
@@ -62,8 +62,6 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
6262
onRazorSplitAll,
6363
} = useTimelineEditContextOptional();
6464
const beatDragging = usePlayerStore((s) => s.beatDragging);
65-
// Scroll a clip into view when the sidebar (asset card) requests a reveal.
66-
useTimelineRevealClip(scrollRef);
6765
const draggedElement = draggedClip?.element ?? null;
6866
const activeDraggedElement =
6967
draggedClip?.started === true && draggedElement
@@ -126,6 +124,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
126124
theme={props.theme}
127125
beatAnalysis={props.beatAnalysis}
128126
contentOrigin={props.contentOrigin}
127+
renderTimeRange={props.rowsVirtualized ? props.renderTimeRange : undefined}
129128
/>
130129

131130
{/* Breathing room between the sticky ruler and the first track lane — the
@@ -157,7 +156,13 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
157156
const rowIndex = displayTrackOrder.indexOf(strip.track);
158157
if (rowIndex < 0) return null;
159158
const loud = strip.kind === "hover";
160-
return strip.intervals.map((gap) => (
159+
const visibleIntervals = props.rowsVirtualized
160+
? strip.intervals.filter(
161+
(gap) =>
162+
gap.start < props.renderTimeRange.end && gap.end > props.renderTimeRange.start,
163+
)
164+
: strip.intervals;
165+
return visibleIntervals.map((gap) => (
161166
<div
162167
key={`gap-${strip.kind}-${strip.track}-${gap.start}`}
163168
className="pointer-events-none absolute"
@@ -249,6 +254,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
249254
}
250255
isHovered={false}
251256
isDragging={true}
257+
isActive={isTimelineClipActive(activeDraggedElement, props.currentTime)}
252258
hasCustomContent={!!props.renderClipContent}
253259
capabilities={getTimelineEditCapabilities(activeDraggedElement)}
254260
theme={props.theme}

0 commit comments

Comments
 (0)