Skip to content

Commit 3572dc8

Browse files
committed
perf(studio): follow playhead across virtualized rows
1 parent df6b9c0 commit 3572dc8

19 files changed

Lines changed: 970 additions & 74 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,13 +42,14 @@ 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 {
4949
shouldAutoScrollTimeline,
5050
getTimelineScrollLeftForZoomTransition,
5151
getTimelineScrollLeftForZoomAnchor,
52+
getTimelinePlaybackFollowScrollLeft,
5253
getTimelinePlayheadLeft,
5354
getTimelineCanvasHeight,
5455
shouldShowTimelineShortcutHint,
@@ -124,6 +125,7 @@ export const Timeline = memo(function Timeline({
124125
const timelineReady = usePlayerStore((s) => s.timelineReady);
125126
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
126127
const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
128+
const clipRevealRequest = usePlayerStore((s) => s.clipRevealRequest);
127129
const gsapAnimations = usePlayerStore((s) => s.gsapAnimations);
128130
// Label mode = comp has keyframed clips (not just when expanded): keeps the layer
129131
// disclosure + property column visible and reserves a GUTTER before 0s (Figma).
@@ -290,32 +292,21 @@ export const Timeline = memo(function Timeline({
290292
expandedElements.length,
291293
displayLayout.totalH,
292294
]);
293-
const previousLayoutRef = useRef(displayLayout.rowGeometry);
294-
const previousSessionEpochRef = useRef(sessionEpoch);
295-
useLayoutEffect(() => {
296-
const scroll = scrollRef.current;
297-
const previousGeometry = previousLayoutRef.current;
298-
if (previousSessionEpochRef.current !== sessionEpoch) {
299-
previousSessionEpochRef.current = sessionEpoch;
300-
lastScrollLeftRef.current = 0;
301-
if (scroll) {
302-
scroll.scrollLeft = 0;
303-
scroll.scrollTop = 0;
304-
syncScrollViewport(scroll);
305-
}
306-
} else if (scroll && previousGeometry !== displayLayout.rowGeometry) {
307-
const nextScrollTop = getTimelineScrollTopForGeometryChange(
308-
previousGeometry,
309-
displayLayout.rowGeometry,
310-
scroll.scrollTop,
311-
);
312-
if (nextScrollTop !== scroll.scrollTop) {
313-
scroll.scrollTop = nextScrollTop;
314-
syncScrollViewport(scroll);
315-
}
316-
}
317-
previousLayoutRef.current = displayLayout.rowGeometry;
318-
}, [displayLayout.rowGeometry, sessionEpoch, syncScrollViewport]);
295+
const { enabled: rowVirtualizationActive, virtualRows } = useTimelineRowVirtualization({
296+
scrollRef,
297+
viewport,
298+
rowGeometry: displayLayout.rowGeometry,
299+
sessionEpoch,
300+
elements: expandedElements,
301+
selectedElementId,
302+
revealElementId: clipRevealRequest?.elementId ?? null,
303+
draggedRowKey: draggedClip?.started ? draggedClip.previewTrack : undefined,
304+
resizingRowKey: resizingClip?.element.track,
305+
clipContextMenuRowKey: clipContextMenu?.element.track,
306+
keyframeContextMenuRowKey: kfContextMenu?.element.track,
307+
lastScrollLeftRef,
308+
syncScrollViewport,
309+
});
319310
const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes);
320311
const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe);
321312
const { onClickKeyframe, onSelectSegment, onShiftClickKeyframe, onContextMenuKeyframe } =
@@ -468,6 +459,7 @@ export const Timeline = memo(function Timeline({
468459
<div
469460
ref={setScrollRef}
470461
data-timeline-scroll-viewport
462+
data-timeline-auto-scroll-left-inset={labelMode ? LABEL_COL_W : 0}
471463
tabIndex={-1}
472464
className={`${zoomMode === "fit" ? "overflow-x-hidden" : "overflow-x-auto"} overflow-y-auto h-full outline-none`}
473465
onScroll={(e) => {
@@ -505,6 +497,9 @@ export const Timeline = memo(function Timeline({
505497
theme={theme}
506498
displayTrackOrder={displayLayout.displayTrackOrder}
507499
rowHeights={displayLayout.displayRowHeights}
500+
rowGeometry={displayLayout.rowGeometry}
501+
virtualRows={virtualRows}
502+
rowsVirtualized={rowVirtualizationActive}
508503
trackOrder={trackOrder}
509504
tracks={tracks}
510505
trackStyles={trackStyles}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// @vitest-environment happy-dom
2+
3+
import React, { act } from "react";
4+
import { createRoot } from "react-dom/client";
5+
import { afterAll, beforeAll, beforeEach, 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+
let clientWidth = 900;
30+
let clientHeight = 240;
31+
32+
beforeAll(() => {
33+
vi.stubEnv("VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED", "1");
34+
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
35+
Object.defineProperty(HTMLElement.prototype, "clientWidth", {
36+
configurable: true,
37+
get: () => clientWidth,
38+
});
39+
Object.defineProperty(HTMLElement.prototype, "clientHeight", {
40+
configurable: true,
41+
get: () => clientHeight,
42+
});
43+
});
44+
45+
beforeEach(() => {
46+
clientWidth = 900;
47+
clientHeight = 240;
48+
});
49+
50+
afterAll(() => {
51+
vi.unstubAllEnvs();
52+
globalThis.ResizeObserver = originalResizeObserver;
53+
if (originalClientWidth)
54+
Object.defineProperty(HTMLElement.prototype, "clientWidth", originalClientWidth);
55+
if (originalClientHeight)
56+
Object.defineProperty(HTMLElement.prototype, "clientHeight", originalClientHeight);
57+
document.body.innerHTML = "";
58+
});
59+
60+
describe("Timeline row virtualization", () => {
61+
it("keeps a zero-size first render bounded while the feature flag is enabled", async () => {
62+
clientWidth = 0;
63+
clientHeight = 0;
64+
const [{ Timeline }, { usePlayerStore }] = await Promise.all([
65+
import("./Timeline"),
66+
import("../store/playerStore"),
67+
]);
68+
usePlayerStore.setState({
69+
duration: 60,
70+
timelineReady: true,
71+
elements: Array.from({ length: 10_000 }, (_, track) => ({
72+
id: `clip-${track}`,
73+
tag: "div",
74+
start: 0,
75+
duration: 1,
76+
track,
77+
})),
78+
});
79+
80+
const host = document.createElement("div");
81+
document.body.append(host);
82+
const root = createRoot(host);
83+
await act(async () => root.render(React.createElement(Timeline, { sessionEpoch: 2 })));
84+
await act(async () => {});
85+
86+
const rows = host.querySelectorAll('[role="listitem"]');
87+
expect(rows.length).toBeGreaterThan(0);
88+
expect(rows.length).toBeLessThanOrEqual(16);
89+
90+
act(() => root.unmount());
91+
usePlayerStore.getState().reset();
92+
}, 10_000);
93+
94+
it("mounts a bounded list range over the full geometry height", async () => {
95+
const [{ Timeline }, { usePlayerStore }, { getTimelineCanvasHeight, TRACK_H }] =
96+
await Promise.all([
97+
import("./Timeline"),
98+
import("../store/playerStore"),
99+
import("./timelineLayout"),
100+
]);
101+
usePlayerStore.setState({
102+
duration: 60,
103+
timelineReady: true,
104+
elements: Array.from({ length: 1_000 }, (_, track) => ({
105+
id: `clip-${track}`,
106+
tag: "div",
107+
start: 0,
108+
duration: 1,
109+
track,
110+
})),
111+
});
112+
113+
const host = document.createElement("div");
114+
document.body.append(host);
115+
const root = createRoot(host);
116+
await act(async () => root.render(React.createElement(Timeline, { sessionEpoch: 3 })));
117+
await act(async () => {});
118+
119+
const list = host.querySelector<HTMLElement>('[role="list"]');
120+
const rows = list?.querySelectorAll('[role="listitem"]') ?? [];
121+
expect(rows.length).toBeGreaterThan(0);
122+
expect(rows.length).toBeLessThanOrEqual(16);
123+
expect(rows[0]?.getAttribute("aria-posinset")).toBe("1");
124+
expect(rows[0]?.getAttribute("aria-setsize")).toBe("1000");
125+
expect(list?.parentElement?.style.height).toBe(
126+
`${getTimelineCanvasHeight(Array.from({ length: 1_000 }, () => TRACK_H))}px`,
127+
);
128+
129+
const firstRow = rows[0] as HTMLElement;
130+
const focusedControl = firstRow.querySelector<HTMLButtonElement>("button");
131+
expect(focusedControl).not.toBeNull();
132+
act(() => focusedControl?.focus());
133+
const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
134+
expect(scroller).not.toBeNull();
135+
if (scroller) {
136+
scroller.scrollTop = 500 * 48;
137+
await act(async () => {
138+
scroller.dispatchEvent(new Event("scroll"));
139+
});
140+
}
141+
expect(list?.querySelector('[data-timeline-row-key="0"]')).not.toBeNull();
142+
expect(document.activeElement).toBe(focusedControl);
143+
144+
act(() => root.unmount());
145+
usePlayerStore.getState().reset();
146+
}, 10_000);
147+
});

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

0 commit comments

Comments
 (0)