Skip to content

Commit 7cd7cae

Browse files
committed
perf(studio): virtualize timeline marquee selection
1 parent 90ba298 commit 7cd7cae

6 files changed

Lines changed: 531 additions & 114 deletions

File tree

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,7 @@ export const Timeline = memo(function Timeline({
407407
handlePointerDown,
408408
handlePointerMove,
409409
handlePointerUp,
410+
handlePointerCancel,
410411
} = useTimelineRangeSelection({
411412
scrollRef,
412413
ppsRef,
@@ -419,10 +420,11 @@ export const Timeline = memo(function Timeline({
419420
isDragging,
420421
setShowPopover,
421422
elementsRef: expandedElementsRef,
422-
trackOrderRef,
423+
clipIndex,
423424
rowGeometryRef,
424425
onSelectElement,
425426
contentOrigin,
427+
sessionEpoch,
426428
});
427429
setRangeSelectionRef.current = setRangeSelection; // stable ref consumed by useTimelineClipDrag
428430

@@ -492,7 +494,8 @@ export const Timeline = memo(function Timeline({
492494
}}
493495
onPointerMove={handlePointerMove}
494496
onPointerUp={handlePointerUp}
495-
onLostPointerCapture={handlePointerUp}
497+
onPointerCancel={handlePointerCancel}
498+
onLostPointerCapture={handlePointerCancel}
496499
>
497500
<TimelineCanvas
498501
major={major}

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

Lines changed: 73 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,19 @@ import {
55
isTimelineRulerPress,
66
getMarqueeRect,
77
getTimelineClipRect,
8+
getMarqueeClipCandidates,
89
computeMarqueeSelection,
910
} from "./timelineMarquee";
11+
import { createTimelineClipIndex } from "../lib/timelineClipIndex";
12+
import type { TimelineElement } from "../store/playerStore";
1013
import {
1114
GUTTER,
1215
LANE_H,
1316
TRACK_H,
1417
RULER_H,
1518
CLIP_Y,
1619
TRACKS_LEFT_PAD,
20+
createTimelineRowGeometry,
1721
getTimelineRowTop,
1822
} from "./timelineLayout";
1923

@@ -95,9 +99,13 @@ describe("getMarqueeRect", () => {
9599

96100
describe("getTimelineClipRect", () => {
97101
const trackOrder = [0, 2, 5];
102+
const geometry = createTimelineRowGeometry(
103+
trackOrder,
104+
trackOrder.map(() => TRACK_H),
105+
);
98106

99107
it("maps start/duration to x via pps and the track row to y via the shared row→y helper", () => {
100-
const rect = getTimelineClipRect({ start: 2, duration: 3, track: 2 }, trackOrder, 100, GUTTER);
108+
const rect = getTimelineClipRect({ start: 2, duration: 3, track: 2 }, geometry, 100, GUTTER);
101109
expect(rect).toEqual({
102110
left: GUTTER + 200,
103111
top: getTimelineRowTop(1) + CLIP_Y,
@@ -107,61 +115,59 @@ describe("getTimelineClipRect", () => {
107115
});
108116

109117
it("places the first visible track below the ruler + top breathing pad", () => {
110-
const rect = getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, 50, GUTTER);
118+
const rect = getTimelineClipRect({ start: 0, duration: 1, track: 0 }, geometry, 50, GUTTER);
111119
expect(rect?.top).toBe(getTimelineRowTop(0) + CLIP_Y);
112120
expect(rect?.left).toBe(GUTTER);
113121
});
114122

115123
it("uses the row index in trackOrder, not the raw track number", () => {
116-
const rect = getTimelineClipRect({ start: 0, duration: 1, track: 5 }, trackOrder, 50, GUTTER);
124+
const rect = getTimelineClipRect({ start: 0, duration: 1, track: 5 }, geometry, 50, GUTTER);
117125
expect(rect?.top).toBe(getTimelineRowTop(2) + CLIP_Y);
118126
});
119127

120128
it("uses cumulative tops and the resolved height for an expanded row", () => {
121129
const rowHeights = [TRACK_H + 2 * LANE_H, TRACK_H, TRACK_H];
130+
const expandedGeometry = createTimelineRowGeometry(trackOrder, rowHeights);
122131
const rect = getTimelineClipRect(
123132
{ start: 0, duration: 1, track: 0 },
124-
trackOrder,
133+
expandedGeometry,
125134
50,
126135
GUTTER,
127-
rowHeights,
128136
);
129137
expect(rect).toMatchObject({
130138
top: getTimelineRowTop(0, rowHeights) + CLIP_Y,
131-
height: rowHeights[0] - CLIP_Y * 2,
139+
height: TRACK_H - CLIP_Y * 2,
132140
});
133141
expect(
134-
getTimelineClipRect({ start: 0, duration: 1, track: 2 }, trackOrder, 50, GUTTER, rowHeights)
135-
?.top,
142+
getTimelineClipRect({ start: 0, duration: 1, track: 2 }, expandedGeometry, 50, GUTTER)?.top,
136143
).toBe(getTimelineRowTop(1, rowHeights) + CLIP_Y);
137144
});
138145

139146
it("enforces the 4px minimum rendered width", () => {
140-
const rect = getTimelineClipRect(
141-
{ start: 0, duration: 0.01, track: 0 },
142-
trackOrder,
143-
10,
144-
GUTTER,
145-
);
147+
const rect = getTimelineClipRect({ start: 0, duration: 0.01, track: 0 }, geometry, 10, GUTTER);
146148
expect(rect?.width).toBe(4);
147149
});
148150

149151
it("returns null for a track that is not displayed or an invalid pps", () => {
150152
expect(
151-
getTimelineClipRect({ start: 0, duration: 1, track: 9 }, trackOrder, 100, GUTTER),
153+
getTimelineClipRect({ start: 0, duration: 1, track: 9 }, geometry, 100, GUTTER),
152154
).toBeNull();
153155
expect(
154-
getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, 0, GUTTER),
156+
getTimelineClipRect({ start: 0, duration: 1, track: 0 }, geometry, 0, GUTTER),
155157
).toBeNull();
156158
expect(
157-
getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, NaN, GUTTER),
159+
getTimelineClipRect({ start: 0, duration: 1, track: 0 }, geometry, NaN, GUTTER),
158160
).toBeNull();
159161
});
160162
});
161163

162164
describe("computeMarqueeSelection", () => {
163165
// Two visible tracks: row 0 = track 0, row 1 = track 1. pps 100.
164166
const trackOrder = [0, 1];
167+
const rowGeometry = createTimelineRowGeometry(
168+
trackOrder,
169+
trackOrder.map(() => TRACK_H),
170+
);
165171
const pps = 100;
166172
const clips = [
167173
{ id: "a", start: 0, duration: 1, track: 0 }, // x [32,132], row 0
@@ -175,7 +181,7 @@ describe("computeMarqueeSelection", () => {
175181
const marquee = { left: ORIGIN, top: row0Top, width: 50, height: 10 };
176182
const { ids, primaryId } = computeMarqueeSelection({
177183
clips,
178-
trackOrder,
184+
rowGeometry,
179185
pps,
180186
contentOrigin: ORIGIN,
181187
marquee,
@@ -188,7 +194,7 @@ describe("computeMarqueeSelection", () => {
188194
const marquee = { left: ORIGIN, top: row0Top, width: 60, height: row1Top - row0Top + 5 };
189195
const { ids } = computeMarqueeSelection({
190196
clips,
191-
trackOrder,
197+
rowGeometry,
192198
pps,
193199
contentOrigin: ORIGIN,
194200
marquee,
@@ -200,7 +206,7 @@ describe("computeMarqueeSelection", () => {
200206
const marquee = { left: ORIGIN + 140, top: row0Top, width: 50, height: 10 };
201207
const { ids } = computeMarqueeSelection({
202208
clips,
203-
trackOrder,
209+
rowGeometry,
204210
pps,
205211
contentOrigin: ORIGIN,
206212
marquee,
@@ -212,7 +218,7 @@ describe("computeMarqueeSelection", () => {
212218
const marquee = { left: GUTTER + 140, top: row0Top, width: 50, height: 10 };
213219
const { ids, primaryId } = computeMarqueeSelection({
214220
clips,
215-
trackOrder,
221+
rowGeometry,
216222
pps,
217223
contentOrigin: GUTTER,
218224
marquee,
@@ -226,7 +232,7 @@ describe("computeMarqueeSelection", () => {
226232
const marquee = { left: GUTTER, top: row1Top, width: 100, height: 10 };
227233
const { ids, primaryId } = computeMarqueeSelection({
228234
clips,
229-
trackOrder,
235+
rowGeometry,
230236
pps,
231237
contentOrigin: GUTTER,
232238
marquee,
@@ -240,10 +246,11 @@ describe("computeMarqueeSelection", () => {
240246
const wide = { left: ORIGIN, top: row0Top, width: 320, height: 10 };
241247
const narrow = { left: ORIGIN, top: row0Top, width: 80, height: 10 };
242248
expect(
243-
computeMarqueeSelection({ clips, trackOrder, pps, contentOrigin: ORIGIN, marquee: wide }).ids,
249+
computeMarqueeSelection({ clips, rowGeometry, pps, contentOrigin: ORIGIN, marquee: wide })
250+
.ids,
244251
).toEqual(new Set(["a", "b"]));
245252
expect(
246-
computeMarqueeSelection({ clips, trackOrder, pps, contentOrigin: ORIGIN, marquee: narrow })
253+
computeMarqueeSelection({ clips, rowGeometry, pps, contentOrigin: ORIGIN, marquee: narrow })
247254
.ids,
248255
).toEqual(new Set(["a"]));
249256
});
@@ -252,11 +259,52 @@ describe("computeMarqueeSelection", () => {
252259
const marquee = { left: 0, top: 0, width: 10000, height: 10000 };
253260
const { ids } = computeMarqueeSelection({
254261
clips: [{ id: "x", start: 0, duration: 1, track: 7 }],
255-
trackOrder,
262+
rowGeometry,
256263
pps,
257264
contentOrigin: GUTTER,
258265
marquee,
259266
});
260267
expect(ids).toEqual(new Set());
261268
});
262269
});
270+
271+
describe("getMarqueeClipCandidates", () => {
272+
it("queries only the intersecting rows and time span", () => {
273+
const rowGeometry = createTimelineRowGeometry([0, 1, 2], [TRACK_H, TRACK_H, TRACK_H]);
274+
const near: TimelineElement = { id: "near", tag: "div", start: 1, duration: 1, track: 1 };
275+
const wrongTime: TimelineElement = {
276+
id: "wrong-time",
277+
tag: "div",
278+
start: 20,
279+
duration: 1,
280+
track: 1,
281+
};
282+
const wrongRow: TimelineElement = {
283+
id: "wrong-row",
284+
tag: "div",
285+
start: 1,
286+
duration: 1,
287+
track: 2,
288+
};
289+
const clipIndex = createTimelineClipIndex([
290+
[0, []],
291+
[1, [near, wrongTime]],
292+
[2, [wrongRow]],
293+
]);
294+
295+
expect(
296+
getMarqueeClipCandidates({
297+
clipIndex,
298+
rowGeometry,
299+
marquee: {
300+
left: ORIGIN + 100,
301+
top: getTimelineRowTop(1),
302+
width: 100,
303+
height: TRACK_H - 1,
304+
},
305+
pps: 100,
306+
contentOrigin: ORIGIN,
307+
}),
308+
).toEqual([near]);
309+
});
310+
});

packages/studio/src/player/components/timelineMarquee.ts

Lines changed: 44 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
import { RULER_H, CLIP_Y, getTimelineRowHeight, getTimelineRowTop } from "./timelineLayout";
1+
import { RULER_H, CLIP_Y, TRACK_H, type TimelineRowGeometry } from "./timelineLayout";
22
import { rectsOverlap, type Rect } from "../../utils/marqueeGeometry";
3+
import { queryTimelineClipIndex, type TimelineClipIndex } from "../lib/timelineClipIndex";
4+
import type { TimelineElement } from "../store/playerStore";
35

46
/** Pointer must travel at least this far (either axis) before a pointerdown on
57
* the empty timeline body becomes a marquee drag instead of a plain click. */
@@ -62,23 +64,22 @@ export function getMarqueeRect(
6264
/**
6365
* A clip's rendered rect in canvas/content coordinates (the same space the
6466
* marquee rect lives in): x from the shared content origin + start * pps, y from the clip's row
65-
* index within the visible track order (cumulative row top + CLIP_Y).
67+
* index within the canonical row geometry (cumulative row top + CLIP_Y).
6668
* Returns null when the clip's track is not currently displayed.
6769
*/
6870
export function getTimelineClipRect(
6971
clip: Pick<MarqueeClipInput, "start" | "duration" | "track">,
70-
trackOrder: number[],
72+
rowGeometry: TimelineRowGeometry,
7173
pps: number,
7274
contentOrigin: number,
73-
rowHeights: readonly number[] = [],
7475
): Rect | null {
75-
const row = trackOrder.indexOf(clip.track);
76+
const row = rowGeometry.getRowIndex(clip.track);
7677
if (row < 0 || !Number.isFinite(pps) || pps <= 0) return null;
7778
return {
7879
left: contentOrigin + clip.start * pps,
79-
top: getTimelineRowTop(row, rowHeights) + CLIP_Y,
80+
top: rowGeometry.getRowTop(row) + CLIP_Y,
8081
width: Math.max(clip.duration * pps, MIN_CLIP_W),
81-
height: getTimelineRowHeight(row, rowHeights) - CLIP_Y * 2,
82+
height: TRACK_H - CLIP_Y * 2,
8283
};
8384
}
8485

@@ -90,29 +91,56 @@ export interface MarqueeSelectionResult {
9091
primaryId: string | null;
9192
}
9293

94+
/** Narrow a marquee hit test to the intersecting logical rows and time span. */
95+
export function getMarqueeClipCandidates(input: {
96+
clipIndex: TimelineClipIndex;
97+
rowGeometry: TimelineRowGeometry;
98+
marquee: Rect;
99+
pps: number;
100+
contentOrigin: number;
101+
}): readonly TimelineElement[] {
102+
if (!(input.pps > 0) || input.marquee.width <= 0 || input.marquee.height <= 0) return [];
103+
const lastRow = input.rowGeometry.rowKeys.length - 1;
104+
const first = Math.max(0, Math.floor(input.rowGeometry.getRowFromY(input.marquee.top)));
105+
const last = Math.min(
106+
lastRow,
107+
Math.floor(input.rowGeometry.getRowFromY(input.marquee.top + input.marquee.height)),
108+
);
109+
if (first > last) return [];
110+
const paddingSeconds = MIN_CLIP_W / input.pps;
111+
const start = Math.max(
112+
0,
113+
(input.marquee.left - input.contentOrigin) / input.pps - paddingSeconds,
114+
);
115+
const end =
116+
(input.marquee.left + input.marquee.width - input.contentOrigin) / input.pps + paddingSeconds;
117+
if (end <= start) return [];
118+
119+
const candidates: TimelineElement[] = [];
120+
for (let row = first; row <= last; row += 1) {
121+
const rowKey = input.rowGeometry.rowKeys[row];
122+
if (rowKey === undefined) continue;
123+
candidates.push(...queryTimelineClipIndex(input.clipIndex, rowKey, { start, end }));
124+
}
125+
return candidates;
126+
}
127+
93128
/**
94129
* Live marquee selection: every clip whose rendered rect intersects the marquee.
95130
* `baseSelection` (shift/cmd-additive) is unioned in but never affects primaryId.
96131
*/
97132
export function computeMarqueeSelection(input: {
98133
clips: MarqueeClipInput[];
99-
trackOrder: number[];
134+
rowGeometry: TimelineRowGeometry;
100135
pps: number;
101136
contentOrigin: number;
102137
marquee: Rect;
103138
baseSelection?: Iterable<string>;
104-
rowHeights?: readonly number[];
105139
}): MarqueeSelectionResult {
106140
const ids = new Set<string>(input.baseSelection ?? []);
107141
let primaryId: string | null = null;
108142
for (const clip of input.clips) {
109-
const rect = getTimelineClipRect(
110-
clip,
111-
input.trackOrder,
112-
input.pps,
113-
input.contentOrigin,
114-
input.rowHeights,
115-
);
143+
const rect = getTimelineClipRect(clip, input.rowGeometry, input.pps, input.contentOrigin);
116144
if (rect && rectsOverlap(rect, input.marquee)) {
117145
ids.add(clip.id);
118146
primaryId = clip.id;

0 commit comments

Comments
 (0)