Skip to content

Commit 3702ced

Browse files
committed
perf(studio): centralize timeline viewport geometry
1 parent f8cd5e9 commit 3702ced

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";
@@ -41,6 +40,8 @@ import {
4140
import { useTimelineSelectionLifecycle } from "./useTimelineSelectionLifecycle";
4241
import { useTimelineShiftModifier } from "./useTimelineShiftModifier";
4342
import { useTimelineTicks } from "./useTimelineTicks";
43+
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
44+
import { getTimelineScrollTopForGeometryChange } from "./timelineViewportGeometry";
4445

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

60+
export {
61+
getTimelineScrollTopForGeometryChange,
62+
getTimelineVisibleTimeRange,
63+
} from "./timelineViewportGeometry";
64+
5965
export const Timeline = memo(function Timeline({
6066
onSeek,
6167
onDrillDown,
@@ -74,6 +80,7 @@ export const Timeline = memo(function Timeline({
7480
onSplitElement: onSplitElementOverride,
7581
onSelectElement,
7682
theme: themeOverrides,
83+
sessionEpoch = 0,
7784
}: TimelineProps = {}) {
7885
const {
7986
onMoveElement,
@@ -105,7 +112,7 @@ export const Timeline = memo(function Timeline({
105112
const rawElements = usePlayerStore((s) => s.elements);
106113
const expandedElements = useExpandedTimelineElements();
107114
const beatAnalysis = usePlayerStore((s) => s.beatAnalysis);
108-
const musicElement = usePlayerStore((s) => s.elements.find(isMusicTrack) ?? null);
115+
const musicElement = usePlayerStore((s) => getTimelineElementIndexes(s.elements).musicElement);
109116
const beatEdits = usePlayerStore((s) => s.beatEdits);
110117
const adjustedBeatAnalysis = useMemo(
111118
() => remapBeatAnalysisToComposition(beatAnalysis, musicElement, beatEdits),
@@ -163,8 +170,20 @@ export const Timeline = memo(function Timeline({
163170

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

@@ -231,7 +250,7 @@ export const Timeline = memo(function Timeline({
231250
ppsRef,
232251
durationRef,
233252
trackOrderRef,
234-
rowHeightsRef,
253+
rowGeometryRef,
235254
onMoveElement: pinnedOnMoveElement,
236255
onMoveElements: pinnedOnMoveElements,
237256
onResizeElement: pinnedOnResizeElement,
@@ -250,20 +269,47 @@ export const Timeline = memo(function Timeline({
250269
ppsRef,
251270
durationRef,
252271
trackOrderRef,
253-
rowHeightsRef,
272+
rowGeometryRef,
254273
contentOrigin,
255274
onFileDrop: pinnedOnFileDrop,
256275
onAssetDrop: pinnedOnAssetDrop,
257276
onBlockDrop: pinnedOnBlockDrop,
258277
onCompositionDrop: pinnedOnCompositionDrop,
259278
});
260279

261-
const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowHeights);
262-
const { viewportWidth, showShortcutHint, setScrollRef } = useTimelineScrollViewport(scrollRef, [
263-
timelineReady,
264-
expandedElements.length,
265-
displayLayout.totalH,
266-
]);
280+
const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowGeometry);
281+
const { viewport, showShortcutHint, setScrollRef, syncScrollViewport } =
282+
useTimelineScrollViewport(scrollRef, [
283+
timelineReady,
284+
expandedElements.length,
285+
displayLayout.totalH,
286+
]);
287+
const previousLayoutRef = useRef(displayLayout.rowGeometry);
288+
const previousSessionEpochRef = useRef(sessionEpoch);
289+
useLayoutEffect(() => {
290+
const scroll = scrollRef.current;
291+
const previousGeometry = previousLayoutRef.current;
292+
if (previousSessionEpochRef.current !== sessionEpoch) {
293+
previousSessionEpochRef.current = sessionEpoch;
294+
lastScrollLeftRef.current = 0;
295+
if (scroll) {
296+
scroll.scrollLeft = 0;
297+
scroll.scrollTop = 0;
298+
syncScrollViewport(scroll);
299+
}
300+
} else if (scroll && previousGeometry !== displayLayout.rowGeometry) {
301+
const nextScrollTop = getTimelineScrollTopForGeometryChange(
302+
previousGeometry,
303+
displayLayout.rowGeometry,
304+
scroll.scrollTop,
305+
);
306+
if (nextScrollTop !== scroll.scrollTop) {
307+
scroll.scrollTop = nextScrollTop;
308+
syncScrollViewport(scroll);
309+
}
310+
}
311+
previousLayoutRef.current = displayLayout.rowGeometry;
312+
}, [displayLayout.rowGeometry, sessionEpoch, syncScrollViewport]);
267313
const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes);
268314
const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe);
269315
const { onClickKeyframe, onSelectSegment, onShiftClickKeyframe, onContextMenuKeyframe } =
@@ -286,7 +332,7 @@ export const Timeline = memo(function Timeline({
286332
zoomModeRef,
287333
manualZoomPercentRef,
288334
} = useTimelineGeometry({
289-
viewportWidth,
335+
viewportWidth: viewport.clientWidth,
290336
effectiveDuration,
291337
zoomMode,
292338
manualZoomPercent,
@@ -369,7 +415,7 @@ export const Timeline = memo(function Timeline({
369415
setShowPopover,
370416
elementsRef: expandedElementsRef,
371417
trackOrderRef,
372-
rowHeightsRef,
418+
rowGeometryRef,
373419
onSelectElement,
374420
contentOrigin,
375421
});
@@ -403,6 +449,7 @@ export const Timeline = memo(function Timeline({
403449
<div
404450
ref={setContainerRef}
405451
aria-label="Timeline"
452+
data-timeline-element-count={expandedElements.length}
406453
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"}`}
407454
onMouseMove={updateRazorGuide}
408455
onMouseLeave={clearRazorGuide}
@@ -414,10 +461,12 @@ export const Timeline = memo(function Timeline({
414461
>
415462
<div
416463
ref={setScrollRef}
464+
data-timeline-scroll-viewport
417465
tabIndex={-1}
418466
className={`${zoomMode === "fit" ? "overflow-x-hidden" : "overflow-x-auto"} overflow-y-auto h-full outline-none`}
419467
onScroll={(e) => {
420468
lastScrollLeftRef.current = e.currentTarget.scrollLeft; // restored across post-edit reload
469+
syncScrollViewport(e.currentTarget, true);
421470
}}
422471
onDragOver={handleAssetDragOver}
423472
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)