Skip to content

Commit b15627d

Browse files
committed
perf(studio): centralize timeline viewport geometry
1 parent 2b89498 commit b15627d

29 files changed

Lines changed: 679 additions & 161 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: 5 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,9 +115,11 @@ 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
unsubscribe();
116120
act(() => root.unmount());
117121
expect(window.__studioTest).toBeUndefined();
122+
expect(hasTimelinePerformanceFixtureLease()).toBe(false);
118123
});
119124

120125
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";
@@ -70,6 +71,7 @@ export function useStudioTestHooks({
7071
},
7172
loadTimelinePerformanceFixture: (spec) => {
7273
const fixture = createTimelinePerformanceFixture(spec);
74+
setTimelinePerformanceFixtureLease(true);
7375
usePlayerStore.setState({
7476
...createTimelineResetState(),
7577
currentTime: 0,
@@ -93,6 +95,7 @@ export function useStudioTestHooks({
9395
};
9496
window.__studioTest = api;
9597
return () => {
98+
setTimelinePerformanceFixtureLease(false);
9699
// delete, not `= undefined`: an own key holding undefined keeps
97100
// `"__studioTest" in window` true, which defeats feature detection.
98101
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,20 +270,47 @@ 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);
263-
const { viewportWidth, showShortcutHint, setScrollRef } = useTimelineScrollViewport(scrollRef, [
264-
timelineReady,
265-
expandedElements.length,
266-
displayLayout.totalH,
267-
]);
281+
const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowGeometry);
282+
const { viewport, showShortcutHint, setScrollRef, syncScrollViewport } =
283+
useTimelineScrollViewport(scrollRef, [
284+
timelineReady,
285+
expandedElements.length,
286+
displayLayout.totalH,
287+
]);
288+
const previousLayoutRef = useRef(displayLayout.rowGeometry);
289+
const previousSessionEpochRef = useRef(sessionEpoch);
290+
useLayoutEffect(() => {
291+
const scroll = scrollRef.current;
292+
const previousGeometry = previousLayoutRef.current;
293+
if (previousSessionEpochRef.current !== sessionEpoch) {
294+
previousSessionEpochRef.current = sessionEpoch;
295+
lastScrollLeftRef.current = 0;
296+
if (scroll) {
297+
scroll.scrollLeft = 0;
298+
scroll.scrollTop = 0;
299+
syncScrollViewport(scroll);
300+
}
301+
} else if (scroll && previousGeometry !== displayLayout.rowGeometry) {
302+
const nextScrollTop = getTimelineScrollTopForGeometryChange(
303+
previousGeometry,
304+
displayLayout.rowGeometry,
305+
scroll.scrollTop,
306+
);
307+
if (nextScrollTop !== scroll.scrollTop) {
308+
scroll.scrollTop = nextScrollTop;
309+
syncScrollViewport(scroll);
310+
}
311+
}
312+
previousLayoutRef.current = displayLayout.rowGeometry;
313+
}, [displayLayout.rowGeometry, sessionEpoch, syncScrollViewport]);
268314
const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes);
269315
const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe);
270316
const { onClickKeyframe, onSelectSegment, onShiftClickKeyframe, onContextMenuKeyframe } =
@@ -287,7 +333,7 @@ export const Timeline = memo(function Timeline({
287333
zoomModeRef,
288334
manualZoomPercentRef,
289335
} = useTimelineGeometry({
290-
viewportWidth,
336+
viewportWidth: viewport.clientWidth,
291337
effectiveDuration,
292338
zoomMode,
293339
manualZoomPercent,
@@ -370,7 +416,7 @@ export const Timeline = memo(function Timeline({
370416
setShowPopover,
371417
elementsRef: expandedElementsRef,
372418
trackOrderRef,
373-
rowHeightsRef,
419+
rowGeometryRef,
374420
onSelectElement,
375421
contentOrigin,
376422
});
@@ -404,6 +450,7 @@ export const Timeline = memo(function Timeline({
404450
<div
405451
ref={setContainerRef}
406452
aria-label="Timeline"
453+
data-timeline-element-count={expandedElements.length}
407454
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"}`}
408455
onMouseMove={updateRazorGuide}
409456
onMouseLeave={clearRazorGuide}
@@ -415,10 +462,12 @@ export const Timeline = memo(function Timeline({
415462
>
416463
<div
417464
ref={setScrollRef}
465+
data-timeline-scroll-viewport
418466
tabIndex={-1}
419467
className={`${zoomMode === "fit" ? "overflow-x-hidden" : "overflow-x-auto"} overflow-y-auto h-full outline-none`}
420468
onScroll={(e) => {
421469
lastScrollLeftRef.current = e.currentTarget.scrollLeft; // restored across post-edit reload
470+
syncScrollViewport(e.currentTarget, true);
422471
}}
423472
onDragOver={handleAssetDragOver}
424473
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)