Skip to content

Commit df6b9c0

Browse files
committed
perf(studio): centralize timeline viewport geometry
1 parent 89f3608 commit df6b9c0

29 files changed

Lines changed: 684 additions & 159 deletions

packages/studio/src/components/nle/NLEContext.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export interface NLEContextValue {
4848
compositionLoading: boolean;
4949
setCompositionLoading: (loading: boolean) => void;
5050
timelineDisabled: boolean;
51+
timelineSessionEpoch: number;
5152
hasLoadedOnceRef: React.MutableRefObject<boolean>;
5253
// preview composition size (for preview block drop)
5354
previewCompositionSize: { width: number; height: number } | null;
@@ -103,7 +104,7 @@ export function NLEProvider({
103104
// project would otherwise keep rendering (and re-fetching from) the old project
104105
// after switching.
105106
useEffect(() => {
106-
usePlayerStore.getState().reset();
107+
usePlayerStore.getState().beginTimelineSession(projectId);
107108
useAssetPreviewStore.getState().clearPreviewAsset();
108109
}, [projectId]);
109110

@@ -289,6 +290,7 @@ export function NLEProvider({
289290
setCompositionLoadingRaw(loading);
290291
}, []);
291292
const timelineDisabled = shouldDisableTimelineWhileCompositionLoading(compositionLoading);
293+
const timelineSessionEpoch = usePlayerStore((state) => state.timelineSessionEpoch);
292294

293295
useEffect(() => {
294296
onCompositionLoadingChange?.(compositionLoading);
@@ -319,6 +321,7 @@ export function NLEProvider({
319321
compositionLoading,
320322
setCompositionLoading,
321323
timelineDisabled,
324+
timelineSessionEpoch,
322325
hasLoadedOnceRef,
323326
previewCompositionSize,
324327
setPreviewCompositionSize,

packages/studio/src/components/nle/TimelinePane.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ export function TimelinePane({
127127
persistTimelineH,
128128
containerRef,
129129
timelineDisabled,
130+
timelineSessionEpoch,
130131
} = useNLEContext();
131132

132133
// Move/resize/split come from the timeline edit context, not props — the
@@ -271,6 +272,7 @@ export function TimelinePane({
271272
>
272273
<div className="flex-shrink-0">{timelineToolbar}</div>
273274
<Timeline
275+
sessionEpoch={timelineSessionEpoch}
274276
onSeek={seek}
275277
onDrillDown={handleDrillDown}
276278
renderClipContent={renderClipContent}

packages/studio/src/hooks/useStudioTestHooks.test.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
55
import { usePlayerStore } from "../player/store/playerStore";
66
import {
77
createTimelinePerformanceFixture,
8+
hasTimelinePerformanceFixtureLease,
9+
setTimelinePerformanceFixtureLease,
810
type TimelinePerformanceFixtureProfile,
911
} from "../player/lib/timelinePerformanceFixture";
1012
import { useStudioTestHooks } from "./useStudioTestHooks";
@@ -30,6 +32,7 @@ function Probe(): null {
3032

3133
describe("timeline performance fixture", () => {
3234
afterEach(() => {
35+
setTimelinePerformanceFixtureLease(false);
3336
window.__studioTest = undefined;
3437
usePlayerStore.getState().reset();
3538
});
@@ -112,6 +115,7 @@ describe("timeline performance fixture", () => {
112115
expect(usePlayerStore.getState().lintFindingsByElement.size).toBe(0);
113116
expect(usePlayerStore.getState().elements).toHaveLength(1_000);
114117
expect(usePlayerStore.getState().expandedClipIds.size).toBe(1_000);
118+
expect(hasTimelinePerformanceFixtureLease()).toBe(true);
115119

116120
api.resetTimelinePerformanceFixture();
117121
expect(notifications).toBe(2);
@@ -121,9 +125,11 @@ describe("timeline performance fixture", () => {
121125
timelineReady: false,
122126
elements: [],
123127
});
128+
expect(hasTimelinePerformanceFixtureLease()).toBe(true);
124129
unsubscribe();
125130
act(() => root.unmount());
126131
expect(window.__studioTest).toBeUndefined();
132+
expect(hasTimelinePerformanceFixtureLease()).toBe(false);
127133
});
128134

129135
it("does not mutate state when the fixture request is invalid", () => {

packages/studio/src/hooks/useStudioTestHooks.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from "../player/lib/timelinePerformanceDiagnostics";
88
import {
99
createTimelinePerformanceFixture,
10+
setTimelinePerformanceFixtureLease,
1011
type TimelinePerformanceFixtureSpec,
1112
type TimelinePerformanceFixtureSummary,
1213
} from "../player/lib/timelinePerformanceFixture";
@@ -71,6 +72,7 @@ export function useStudioTestHooks({
7172
},
7273
loadTimelinePerformanceFixture: (spec) => {
7374
const fixture = createTimelinePerformanceFixture(spec);
75+
setTimelinePerformanceFixtureLease(true);
7476
usePlayerStore.setState({
7577
...createTimelineResetState(),
7678
currentTime: 0,
@@ -97,6 +99,7 @@ export function useStudioTestHooks({
9799
};
98100
window.__studioTest = api;
99101
return () => {
102+
setTimelinePerformanceFixtureLease(false);
100103
// delete, not `= undefined`: an own key holding undefined keeps
101104
// `"__studioTest" in window` true, which defeats feature detection.
102105
delete window.__studioTest;

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import {
1717
shouldShowTimelineShortcutHint,
1818
shouldHandleTimelineDeleteKey,
1919
shouldAutoScrollTimeline,
20+
getTimelineVisibleTimeRange,
21+
getTimelineScrollTopForGeometryChange,
2022
} from "./Timeline";
2123
import {
2224
CLIP_Y,
@@ -32,6 +34,7 @@ import {
3234
getTimelineDisplayContentWidth,
3335
getTimelineFitPps,
3436
getTimelineLaneTop,
37+
createTimelineRowGeometry,
3538
} from "./timelineLayout";
3639
import { formatTime } from "../lib/time";
3740
import { usePlayerStore } from "../store/playerStore";
@@ -44,6 +47,25 @@ afterEach(() => {
4447
usePlayerStore.getState().reset();
4548
});
4649

50+
describe("timeline viewport geometry", () => {
51+
it("derives a clamped visible time range from the raw viewport", () => {
52+
expect(
53+
getTimelineVisibleTimeRange({ scrollLeft: 300, clientWidth: 500 }, 100, 200, 20),
54+
).toEqual({ start: 1, end: 6 });
55+
expect(getTimelineVisibleTimeRange({ scrollLeft: 0, clientWidth: 100 }, 100, 200, 20)).toEqual({
56+
start: 0,
57+
end: 0,
58+
});
59+
});
60+
61+
it("keeps the same row anchored when a row above it expands", () => {
62+
const previous = createTimelineRowGeometry([1, 2, 3], [48, 48, 48]);
63+
const next = createTimelineRowGeometry([1, 2, 3], [104, 48, 48]);
64+
const scrollTop = previous.getRowTop(2) - RULER_H + 6;
65+
expect(getTimelineScrollTopForGeometryChange(previous, next, scrollTop)).toBe(scrollTop + 56);
66+
});
67+
});
68+
4769
function getHorizontalGeometry(host: HTMLElement, clipId: string, tickLabel: string) {
4870
const clip = host.querySelector<HTMLElement>(`[data-el-id="${clipId}"]`);
4971
if (!clip) throw new Error(`Missing timeline clip ${clipId}`);

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

Lines changed: 64 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { useRef, useMemo, useCallback, useState, memo } from "react";
1+
import { useRef, useMemo, useCallback, useState, useLayoutEffect, memo } from "react";
22
import { useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
3-
import { isMusicTrack } from "../../utils/timelineInspector";
43
import { remapBeatAnalysisToComposition } from "../../utils/beatEditActions";
54
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
65
import { useExpandedTimelineElements } from "../hooks/useExpandedTimelineElements";
@@ -42,6 +41,8 @@ import {
4241
import { useTimelineSelectionLifecycle } from "./useTimelineSelectionLifecycle";
4342
import { useTimelineShiftModifier } from "./useTimelineShiftModifier";
4443
import { useTimelineTicks } from "./useTimelineTicks";
44+
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
45+
import { getTimelineScrollTopForGeometryChange } from "./timelineViewportGeometry";
4546

4647
// Re-export pure utilities so existing imports from "./Timeline" still resolve.
4748
export {
@@ -57,6 +58,11 @@ export {
5758
} from "./timelineLayout";
5859
export { formatTimelineTickLabel, generateTicks } from "./timelineRulerGeometry";
5960

61+
export {
62+
getTimelineScrollTopForGeometryChange,
63+
getTimelineVisibleTimeRange,
64+
} from "./timelineViewportGeometry";
65+
6066
export const Timeline = memo(function Timeline({
6167
onSeek,
6268
onDrillDown,
@@ -75,6 +81,7 @@ export const Timeline = memo(function Timeline({
7581
onSplitElement: onSplitElementOverride,
7682
onSelectElement,
7783
theme: themeOverrides,
84+
sessionEpoch = 0,
7885
}: TimelineProps = {}) {
7986
const {
8087
onMoveElement,
@@ -106,7 +113,7 @@ export const Timeline = memo(function Timeline({
106113
const rawElements = usePlayerStore((s) => s.elements);
107114
const expandedElements = useExpandedTimelineElements();
108115
const beatAnalysis = usePlayerStore((s) => s.beatAnalysis);
109-
const musicElement = usePlayerStore((s) => s.elements.find(isMusicTrack) ?? null);
116+
const musicElement = usePlayerStore((s) => getTimelineElementIndexes(s.elements).musicElement);
110117
const beatEdits = usePlayerStore((s) => s.beatEdits);
111118
const adjustedBeatAnalysis = useMemo(
112119
() => remapBeatAnalysisToComposition(beatAnalysis, musicElement, beatEdits),
@@ -164,8 +171,20 @@ export const Timeline = memo(function Timeline({
164171

165172
const keyframeCache = usePlayerStore((s) => s.keyframeCache);
166173
useAutoExpandKeyframedClips(gsapAnimations);
167-
const { tracks, trackStyles, trackOrder, trackOrderRef, laneCounts, rowHeights, rowHeightsRef } =
168-
useTimelineTrackLayout(expandedElements, gsapAnimations, selectedElementId, selectedElementIds);
174+
const {
175+
tracks,
176+
trackStyles,
177+
trackOrder,
178+
trackOrderRef,
179+
laneCounts,
180+
rowGeometry,
181+
rowGeometryRef,
182+
} = useTimelineTrackLayout(
183+
expandedElements,
184+
gsapAnimations,
185+
selectedElementId,
186+
selectedElementIds,
187+
);
169188
const expandedElementsRef = useRef(expandedElements);
170189
expandedElementsRef.current = expandedElements;
171190

@@ -232,7 +251,7 @@ export const Timeline = memo(function Timeline({
232251
ppsRef,
233252
durationRef,
234253
trackOrderRef,
235-
rowHeightsRef,
254+
rowGeometryRef,
236255
onMoveElement: pinnedOnMoveElement,
237256
onMoveElements: pinnedOnMoveElements,
238257
onResizeElement: pinnedOnResizeElement,
@@ -251,25 +270,52 @@ export const Timeline = memo(function Timeline({
251270
ppsRef,
252271
durationRef,
253272
trackOrderRef,
254-
rowHeightsRef,
273+
rowGeometryRef,
255274
contentOrigin,
256275
onFileDrop: pinnedOnFileDrop,
257276
onAssetDrop: pinnedOnAssetDrop,
258277
onBlockDrop: pinnedOnBlockDrop,
259278
onCompositionDrop: pinnedOnCompositionDrop,
260279
});
261280

262-
const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowHeights);
281+
const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowGeometry);
263282
const { recordTimelineScroll } = useTimelinePerformanceTelemetry({
264283
totalClipCount: expandedElements.length,
265284
totalRowCount: displayLayout.displayTrackOrder.length,
266285
zoomMode,
267286
});
268-
const { viewportWidth, showShortcutHint, setScrollRef } = useTimelineScrollViewport(scrollRef, [
269-
timelineReady,
270-
expandedElements.length,
271-
displayLayout.totalH,
272-
]);
287+
const { viewport, showShortcutHint, setScrollRef, syncScrollViewport } =
288+
useTimelineScrollViewport(scrollRef, [
289+
timelineReady,
290+
expandedElements.length,
291+
displayLayout.totalH,
292+
]);
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]);
273319
const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes);
274320
const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe);
275321
const { onClickKeyframe, onSelectSegment, onShiftClickKeyframe, onContextMenuKeyframe } =
@@ -292,7 +338,7 @@ export const Timeline = memo(function Timeline({
292338
zoomModeRef,
293339
manualZoomPercentRef,
294340
} = useTimelineGeometry({
295-
viewportWidth,
341+
viewportWidth: viewport.clientWidth,
296342
effectiveDuration,
297343
zoomMode,
298344
manualZoomPercent,
@@ -375,7 +421,7 @@ export const Timeline = memo(function Timeline({
375421
setShowPopover,
376422
elementsRef: expandedElementsRef,
377423
trackOrderRef,
378-
rowHeightsRef,
424+
rowGeometryRef,
379425
onSelectElement,
380426
contentOrigin,
381427
});
@@ -409,6 +455,7 @@ export const Timeline = memo(function Timeline({
409455
<div
410456
ref={setContainerRef}
411457
aria-label="Timeline"
458+
data-timeline-element-count={expandedElements.length}
412459
className={`relative border-t select-none h-full overflow-hidden ${isDragOver ? "ring-1 ring-inset ring-studio-accent/60" : ""} ${activeTool === "razor" ? "cursor-crosshair" : shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
413460
onMouseMove={updateRazorGuide}
414461
onMouseLeave={clearRazorGuide}
@@ -420,11 +467,13 @@ export const Timeline = memo(function Timeline({
420467
>
421468
<div
422469
ref={setScrollRef}
470+
data-timeline-scroll-viewport
423471
tabIndex={-1}
424472
className={`${zoomMode === "fit" ? "overflow-x-hidden" : "overflow-x-auto"} overflow-y-auto h-full outline-none`}
425473
onScroll={(e) => {
426474
lastScrollLeftRef.current = e.currentTarget.scrollLeft; // restored across post-edit reload
427475
recordTimelineScroll(e.currentTarget);
476+
syncScrollViewport(e.currentTarget, true);
428477
}}
429478
onDragOver={handleAssetDragOver}
430479
onDragLeave={() => clearDropPreview()}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import type { TimelineTheme } from "./timelineTheme";
55
import type { TimelineEditOverrides } from "./useResolvedTimelineEditCallbacks";
66

77
export interface TimelineProps extends TimelineDropCallbacks, TimelineEditOverrides {
8+
/** Project-scoped reset boundary; soft source refreshes retain the same epoch. */
9+
sessionEpoch?: number;
810
onSeek?: (time: number) => void;
911
onDrillDown?: (element: TimelineElement) => void;
1012
renderClipContent?: (

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,15 @@ import {
55
TIMELINE_COMPOSITION_MIME,
66
} from "../../utils/timelineCompositionDrop";
77
import { usePlayerStore } from "../store/playerStore";
8-
import { resolveTimelineAssetDrop } from "./timelineLayout";
8+
import { resolveTimelineAssetDrop, type TimelineRowGeometry } from "./timelineLayout";
99
import type { TimelineDropCallbacks } from "./timelineCallbacks";
1010

1111
interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
1212
scrollRef: RefObject<HTMLDivElement | null>;
1313
ppsRef: RefObject<number>;
1414
durationRef: RefObject<number>;
1515
trackOrderRef: RefObject<number[]>;
16-
rowHeightsRef: RefObject<readonly number[]>;
16+
rowGeometryRef: RefObject<TimelineRowGeometry>;
1717
contentOrigin: number;
1818
}
1919

@@ -56,7 +56,7 @@ export function useTimelineAssetDrop({
5656
ppsRef,
5757
durationRef,
5858
trackOrderRef,
59-
rowHeightsRef,
59+
rowGeometryRef,
6060
contentOrigin,
6161
onFileDrop,
6262
onAssetDrop,
@@ -93,7 +93,7 @@ export function useTimelineAssetDrop({
9393
pixelsPerSecond: ppsRef.current,
9494
duration: durationRef.current,
9595
clampStartToDuration: !usePointerStart,
96-
rowHeights: rowHeightsRef.current,
96+
rowHeights: rowGeometryRef.current.rowHeights,
9797
trackOrder: trackOrderRef.current,
9898
},
9999
clientX,
@@ -104,7 +104,7 @@ export function useTimelineAssetDrop({
104104
track: pointer.track,
105105
};
106106
},
107-
[scrollRef, ppsRef, durationRef, trackOrderRef, rowHeightsRef, contentOrigin],
107+
[scrollRef, ppsRef, durationRef, trackOrderRef, rowGeometryRef, contentOrigin],
108108
);
109109

110110
const handleAssetDrop = useCallback(

0 commit comments

Comments
 (0)