Skip to content

Commit bde178d

Browse files
committed
perf(studio): follow playhead across virtualized rows
1 parent 028174c commit bde178d

19 files changed

Lines changed: 912 additions & 73 deletions

bun.lock

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/studio/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
"@hyperframes/sdk": "workspace:*",
7272
"@hyperframes/studio-server": "workspace:*",
7373
"@phosphor-icons/react": "^2.1.10",
74+
"@tanstack/react-virtual": "^3.14.6",
7475
"bpm-detective": "^2.0.5",
7576
"dompurify": "^3.2.4",
7677
"gsap": "^3.13.0",

packages/studio/src/player/components/Timeline.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
getTimelineCanvasHeight,
1313
resolveTimelineAssetDrop,
1414
getTimelinePlayheadLeft,
15+
getTimelinePlaybackFollowScrollLeft,
1516
getTimelineScrollLeftForZoomAnchor,
1617
getTimelineScrollLeftForZoomTransition,
1718
shouldShowTimelineShortcutHint,
@@ -274,6 +275,33 @@ describe("Timeline provider boundary", () => {
274275
act(() => root.unmount());
275276
});
276277

278+
it("renders the complete track list while row virtualization is gated off", () => {
279+
const host = createSizedTimelineHost(640);
280+
usePlayerStore.setState({
281+
duration: 4,
282+
timelineReady: true,
283+
elements: Array.from({ length: 12 }, (_, track) => ({
284+
id: `clip-${track}`,
285+
tag: "div",
286+
start: 0,
287+
duration: 2,
288+
track,
289+
})),
290+
});
291+
const root = createRoot(host);
292+
act(() => root.render(React.createElement(Timeline)));
293+
294+
const list = host.querySelector<HTMLElement>('[role="list"]');
295+
const rows = list?.querySelectorAll('[role="listitem"]') ?? [];
296+
expect(rows).toHaveLength(12);
297+
expect(rows[0]?.getAttribute("aria-posinset")).toBe("1");
298+
expect(rows[0]?.getAttribute("aria-setsize")).toBe("12");
299+
expect(rows[11]?.getAttribute("aria-posinset")).toBe("12");
300+
301+
act(() => root.unmount());
302+
});
303+
304+
// fallow-ignore-next-line code-duplication
277305
it("renders the gutter without legacy icons or hue dots", () => {
278306
const { host, root } = renderBasicTimeline();
279307

@@ -976,6 +1004,56 @@ describe("getTimelinePlayheadLeft", () => {
9761004
});
9771005
});
9781006

1007+
describe("getTimelinePlaybackFollowScrollLeft", () => {
1008+
it("holds the viewport still while the playhead remains inside the comfort area", () => {
1009+
expect(
1010+
getTimelinePlaybackFollowScrollLeft({
1011+
playheadX: 700,
1012+
currentScrollLeft: 100,
1013+
viewportWidth: 1000,
1014+
contentOrigin: 264,
1015+
maxScrollLeft: 2000,
1016+
}),
1017+
).toBe(100);
1018+
});
1019+
1020+
it("follows forward playback at the right-side comfort line", () => {
1021+
expect(
1022+
getTimelinePlaybackFollowScrollLeft({
1023+
playheadX: 1200,
1024+
currentScrollLeft: 100,
1025+
viewportWidth: 1000,
1026+
contentOrigin: 264,
1027+
maxScrollLeft: 2000,
1028+
}),
1029+
).toBe(384);
1030+
});
1031+
1032+
it("returns to the matching earlier viewport after a playback loop", () => {
1033+
expect(
1034+
getTimelinePlaybackFollowScrollLeft({
1035+
playheadX: 264,
1036+
currentScrollLeft: 900,
1037+
viewportWidth: 1000,
1038+
contentOrigin: 264,
1039+
maxScrollLeft: 2000,
1040+
}),
1041+
).toBe(0);
1042+
});
1043+
1044+
it("clamps at the end of the scrollable timeline", () => {
1045+
expect(
1046+
getTimelinePlaybackFollowScrollLeft({
1047+
playheadX: 5000,
1048+
currentScrollLeft: 100,
1049+
viewportWidth: 1000,
1050+
contentOrigin: 264,
1051+
maxScrollLeft: 1500,
1052+
}),
1053+
).toBe(1500);
1054+
});
1055+
});
1056+
9791057
describe("getTimelineCanvasHeight", () => {
9801058
it("includes bottom scroll buffer below the last track", () => {
9811059
expect(getTimelineCanvasHeight([TRACK_H, TRACK_H, TRACK_H])).toBeGreaterThan(

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

Lines changed: 23 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useRef, useMemo, useCallback, useState, useLayoutEffect, memo } from "react";
1+
import { useRef, useMemo, useCallback, useState, memo } from "react";
22
import { useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
33
import { remapBeatAnalysisToComposition } from "../../utils/beatEditActions";
44
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
@@ -42,7 +42,7 @@ import { useTimelineSelectionLifecycle } from "./useTimelineSelectionLifecycle";
4242
import { useTimelineShiftModifier } from "./useTimelineShiftModifier";
4343
import { useTimelineTicks } from "./useTimelineTicks";
4444
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
45-
import { getTimelineScrollTopForGeometryChange } from "./timelineViewportGeometry";
45+
import { useTimelineRowVirtualization } from "./useTimelineRowVirtualization";
4646

4747
// Re-export pure utilities so existing imports from "./Timeline" still resolve.
4848
export {
@@ -51,6 +51,7 @@ export {
5151
shouldAutoScrollTimeline,
5252
getTimelineScrollLeftForZoomTransition,
5353
getTimelineScrollLeftForZoomAnchor,
54+
getTimelinePlaybackFollowScrollLeft,
5455
getTimelinePlayheadLeft,
5556
getTimelineCanvasHeight,
5657
shouldShowTimelineShortcutHint,
@@ -125,6 +126,7 @@ export const Timeline = memo(function Timeline({
125126
const timelineReady = usePlayerStore((s) => s.timelineReady);
126127
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
127128
const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
129+
const clipRevealRequest = usePlayerStore((s) => s.clipRevealRequest);
128130
const gsapAnimations = usePlayerStore((s) => s.gsapAnimations);
129131
// Label mode = comp has keyframed clips (not just when expanded): keeps the layer
130132
// disclosure + property column visible and reserves a GUTTER before 0s (Figma).
@@ -286,32 +288,21 @@ export const Timeline = memo(function Timeline({
286288
expandedElements.length,
287289
displayLayout.totalH,
288290
]);
289-
const previousLayoutRef = useRef(displayLayout.rowGeometry);
290-
const previousSessionEpochRef = useRef(sessionEpoch);
291-
useLayoutEffect(() => {
292-
const scroll = scrollRef.current;
293-
const previousGeometry = previousLayoutRef.current;
294-
if (previousSessionEpochRef.current !== sessionEpoch) {
295-
previousSessionEpochRef.current = sessionEpoch;
296-
lastScrollLeftRef.current = 0;
297-
if (scroll) {
298-
scroll.scrollLeft = 0;
299-
scroll.scrollTop = 0;
300-
syncScrollViewport(scroll);
301-
}
302-
} else if (scroll && previousGeometry !== displayLayout.rowGeometry) {
303-
const nextScrollTop = getTimelineScrollTopForGeometryChange(
304-
previousGeometry,
305-
displayLayout.rowGeometry,
306-
scroll.scrollTop,
307-
);
308-
if (nextScrollTop !== scroll.scrollTop) {
309-
scroll.scrollTop = nextScrollTop;
310-
syncScrollViewport(scroll);
311-
}
312-
}
313-
previousLayoutRef.current = displayLayout.rowGeometry;
314-
}, [displayLayout.rowGeometry, sessionEpoch, syncScrollViewport]);
291+
const { enabled: rowVirtualizationActive, virtualRows } = useTimelineRowVirtualization({
292+
scrollRef,
293+
viewport,
294+
rowGeometry: displayLayout.rowGeometry,
295+
sessionEpoch,
296+
elements: expandedElements,
297+
selectedElementId,
298+
revealElementId: clipRevealRequest?.elementId ?? null,
299+
draggedRowKey: draggedClip?.started ? draggedClip.previewTrack : undefined,
300+
resizingRowKey: resizingClip?.element.track,
301+
clipContextMenuRowKey: clipContextMenu?.element.track,
302+
keyframeContextMenuRowKey: kfContextMenu?.element.track,
303+
lastScrollLeftRef,
304+
syncScrollViewport,
305+
});
315306
const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes);
316307
const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe);
317308
const { onClickKeyframe, onSelectSegment, onShiftClickKeyframe, onContextMenuKeyframe } =
@@ -464,6 +455,7 @@ export const Timeline = memo(function Timeline({
464455
<div
465456
ref={setScrollRef}
466457
data-timeline-scroll-viewport
458+
data-timeline-auto-scroll-left-inset={labelMode ? LABEL_COL_W : 0}
467459
tabIndex={-1}
468460
className={`${zoomMode === "fit" ? "overflow-x-hidden" : "overflow-x-auto"} overflow-y-auto h-full outline-none`}
469461
onScroll={(e) => {
@@ -500,6 +492,9 @@ export const Timeline = memo(function Timeline({
500492
theme={theme}
501493
displayTrackOrder={displayLayout.displayTrackOrder}
502494
rowHeights={displayLayout.displayRowHeights}
495+
rowGeometry={displayLayout.rowGeometry}
496+
virtualRows={virtualRows}
497+
rowsVirtualized={rowVirtualizationActive}
503498
trackOrder={trackOrder}
504499
tracks={tracks}
505500
trackStyles={trackStyles}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// @vitest-environment happy-dom
2+
3+
import React, { act } from "react";
4+
import { createRoot } from "react-dom/client";
5+
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
6+
7+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
8+
9+
class MockResizeObserver {
10+
constructor(private readonly callback: ResizeObserverCallback) {}
11+
observe(target: Element) {
12+
this.callback(
13+
[
14+
{
15+
target,
16+
borderBoxSize: [{ inlineSize: target.clientWidth, blockSize: target.clientHeight }],
17+
} as unknown as ResizeObserverEntry,
18+
],
19+
this as unknown as ResizeObserver,
20+
);
21+
}
22+
unobserve() {}
23+
disconnect() {}
24+
}
25+
26+
const originalResizeObserver = globalThis.ResizeObserver;
27+
const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientWidth");
28+
const originalClientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight");
29+
30+
beforeAll(() => {
31+
vi.stubEnv("VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED", "1");
32+
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
33+
Object.defineProperty(HTMLElement.prototype, "clientWidth", {
34+
configurable: true,
35+
get: () => 900,
36+
});
37+
Object.defineProperty(HTMLElement.prototype, "clientHeight", {
38+
configurable: true,
39+
get: () => 240,
40+
});
41+
});
42+
43+
afterAll(() => {
44+
vi.unstubAllEnvs();
45+
globalThis.ResizeObserver = originalResizeObserver;
46+
if (originalClientWidth)
47+
Object.defineProperty(HTMLElement.prototype, "clientWidth", originalClientWidth);
48+
if (originalClientHeight)
49+
Object.defineProperty(HTMLElement.prototype, "clientHeight", originalClientHeight);
50+
document.body.innerHTML = "";
51+
});
52+
53+
describe("Timeline row virtualization", () => {
54+
it("mounts a bounded list range over the full geometry height", async () => {
55+
const [{ Timeline }, { usePlayerStore }, { getTimelineCanvasHeight, TRACK_H }] =
56+
await Promise.all([
57+
import("./Timeline"),
58+
import("../store/playerStore"),
59+
import("./timelineLayout"),
60+
]);
61+
usePlayerStore.setState({
62+
duration: 60,
63+
timelineReady: true,
64+
elements: Array.from({ length: 1_000 }, (_, track) => ({
65+
id: `clip-${track}`,
66+
tag: "div",
67+
start: 0,
68+
duration: 1,
69+
track,
70+
})),
71+
});
72+
73+
const host = document.createElement("div");
74+
document.body.append(host);
75+
const root = createRoot(host);
76+
await act(async () => root.render(React.createElement(Timeline, { sessionEpoch: 3 })));
77+
await act(async () => {});
78+
79+
const list = host.querySelector<HTMLElement>('[role="list"]');
80+
const rows = list?.querySelectorAll('[role="listitem"]') ?? [];
81+
expect(rows.length).toBeGreaterThan(0);
82+
expect(rows.length).toBeLessThanOrEqual(16);
83+
expect(rows[0]?.getAttribute("aria-posinset")).toBe("1");
84+
expect(rows[0]?.getAttribute("aria-setsize")).toBe("1000");
85+
expect(list?.parentElement?.style.height).toBe(
86+
`${getTimelineCanvasHeight(Array.from({ length: 1_000 }, () => TRACK_H))}px`,
87+
);
88+
89+
const firstRow = rows[0] as HTMLElement;
90+
const focusedControl = firstRow.querySelector<HTMLButtonElement>("button");
91+
expect(focusedControl).not.toBeNull();
92+
act(() => focusedControl?.focus());
93+
const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
94+
expect(scroller).not.toBeNull();
95+
if (scroller) {
96+
scroller.scrollTop = 500 * 48;
97+
await act(async () => {
98+
scroller.dispatchEvent(new Event("scroll"));
99+
});
100+
}
101+
expect(list?.querySelector('[data-timeline-row-key="0"]')).not.toBeNull();
102+
expect(document.activeElement).toBe(focusedControl);
103+
104+
act(() => root.unmount());
105+
usePlayerStore.getState().reset();
106+
}, 10_000);
107+
});

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
130130

131131
{/* Breathing room between the sticky ruler and the first track lane — the
132132
top half of the CapCut-style padding (see TRACKS_TOP_PAD). */}
133-
<div aria-hidden="true" style={{ height: TRACKS_TOP_PAD }} />
133+
<div aria-hidden="true" style={{ height: props.rowsVirtualized ? 0 : TRACKS_TOP_PAD }} />
134134

135135
<TimelineLanes
136136
{...props}
@@ -147,7 +147,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
147147
{/* Breathing room below the last track lane (~1.5 track heights) — a real
148148
scrollable surface, so a clip can be dragged into the void to create a
149149
new bottom track comfortably (see TRACKS_BOTTOM_PAD / getTimelineCanvasHeight). */}
150-
<div aria-hidden="true" style={{ height: TRACKS_BOTTOM_PAD }} />
150+
<div aria-hidden="true" style={{ height: props.rowsVirtualized ? 0 : TRACKS_BOTTOM_PAD }} />
151151

152152
{/* Gap strips — loud dashed fill for the gap(s) a hovered "Close gap(s)"
153153
menu row would collapse; a quiet tint for every gap on the selected

packages/studio/src/player/components/TimelineLanes.test.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
77
import { TimelineLanes } from "./TimelineLanes";
88
import { getTrackStyle } from "./timelineIcons";
99
import { defaultTimelineTheme } from "./timelineTheme";
10-
import { TRACK_H } from "./timelineLayout";
10+
import { TRACK_H, getTimelineRowGeometry } from "./timelineLayout";
1111
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
1212
import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
1313
import type { TimelineEditCallbacks } from "./timelineCallbacks";
@@ -78,6 +78,7 @@ function renderLanes(options: RenderLanesOptions = {}): {
7878
const laneCounts = new Map(
7979
elements.map((el) => [el.id, (gsapAnimations.get(el.id) ?? []).length]),
8080
);
81+
const rowHeights = displayTrackOrder.map(() => TRACK_H);
8182
act(() => {
8283
usePlayerStore.setState({ expandedClipIds: new Set(next.expandedClipIds ?? []) });
8384
root.render(
@@ -88,7 +89,10 @@ function renderLanes(options: RenderLanesOptions = {}): {
8889
trackContentWidth={800}
8990
theme={defaultTimelineTheme}
9091
displayTrackOrder={displayTrackOrder}
91-
rowHeights={displayTrackOrder.map(() => TRACK_H)}
92+
rowHeights={rowHeights}
93+
rowGeometry={getTimelineRowGeometry(rowHeights)}
94+
virtualRows={displayTrackOrder.map((_, index) => ({ index, rowKey: index }))}
95+
rowsVirtualized={false}
9296
trackOrder={displayTrackOrder}
9397
tracks={tracks}
9498
trackStyles={new Map()}
@@ -171,8 +175,10 @@ describe("TimelineLanes track numbering", () => {
171175
onContextMenuLane,
172176
});
173177

174-
// Row children: [sticky header column, time-mapped track content].
175-
const rows = Array.from(view.host.children);
178+
// Row children: [sticky header column, time-mapped track content]. The rows
179+
// sit inside the lanes list, which is what carries the virtualization
180+
// positioning context.
181+
const rows = Array.from(view.host.querySelectorAll('[role="listitem"]'));
176182
const secondTrackContent = rows[1]?.children.item(1);
177183
act(() => {
178184
secondTrackContent?.dispatchEvent(

0 commit comments

Comments
 (0)