Skip to content

Commit eab22e6

Browse files
committed
perf(studio): centralize timeline viewport geometry
1 parent 29b22a9 commit eab22e6

29 files changed

Lines changed: 708 additions & 188 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
});
@@ -101,9 +104,11 @@ describe("timeline performance fixture", () => {
101104
});
102105
expect(usePlayerStore.getState().elements).toHaveLength(1_000);
103106
expect(usePlayerStore.getState().expandedClipIds.size).toBe(1_000);
107+
expect(hasTimelinePerformanceFixtureLease()).toBe(true);
104108
unsubscribe();
105109
act(() => root.unmount());
106110
expect(window.__studioTest).toBeUndefined();
111+
expect(hasTimelinePerformanceFixtureLease()).toBe(false);
107112
});
108113

109114
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
currentTime: 0,
7577
duration: fixture.summary.duration,
@@ -91,6 +93,7 @@ export function useStudioTestHooks({
9193
};
9294
window.__studioTest = api;
9395
return () => {
96+
setTimelinePerformanceFixtureLease(false);
9497
// delete, not `= undefined`: an own key holding undefined keeps
9598
// `"__studioTest" in window` true, which defeats feature detection.
9699
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 {
@@ -58,6 +59,11 @@ export {
5859
getDefaultDroppedTrack,
5960
} from "./timelineLayout";
6061

62+
export {
63+
getTimelineScrollTopForGeometryChange,
64+
getTimelineVisibleTimeRange,
65+
} from "./timelineViewportGeometry";
66+
6167
export const Timeline = memo(function Timeline({
6268
onSeek,
6369
onDrillDown,
@@ -76,6 +82,7 @@ export const Timeline = memo(function Timeline({
7682
onSplitElement: onSplitElementOverride,
7783
onSelectElement,
7884
theme: themeOverrides,
85+
sessionEpoch = 0,
7986
}: TimelineProps = {}) {
8087
const {
8188
onMoveElement,
@@ -107,7 +114,7 @@ export const Timeline = memo(function Timeline({
107114
const rawElements = usePlayerStore((s) => s.elements);
108115
const expandedElements = useExpandedTimelineElements();
109116
const beatAnalysis = usePlayerStore((s) => s.beatAnalysis);
110-
const musicElement = usePlayerStore((s) => s.elements.find(isMusicTrack) ?? null);
117+
const musicElement = usePlayerStore((s) => getTimelineElementIndexes(s.elements).musicElement);
111118
const beatEdits = usePlayerStore((s) => s.beatEdits);
112119
const adjustedBeatAnalysis = useMemo(
113120
() => remapBeatAnalysisToComposition(beatAnalysis, musicElement, beatEdits),
@@ -165,8 +172,20 @@ export const Timeline = memo(function Timeline({
165172

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

@@ -233,7 +252,7 @@ export const Timeline = memo(function Timeline({
233252
ppsRef,
234253
durationRef,
235254
trackOrderRef,
236-
rowHeightsRef,
255+
rowGeometryRef,
237256
onMoveElement: pinnedOnMoveElement,
238257
onMoveElements: pinnedOnMoveElements,
239258
onResizeElement: pinnedOnResizeElement,
@@ -252,20 +271,47 @@ export const Timeline = memo(function Timeline({
252271
ppsRef,
253272
durationRef,
254273
trackOrderRef,
255-
rowHeightsRef,
274+
rowGeometryRef,
256275
contentOrigin,
257276
onFileDrop: pinnedOnFileDrop,
258277
onAssetDrop: pinnedOnAssetDrop,
259278
onBlockDrop: pinnedOnBlockDrop,
260279
onCompositionDrop: pinnedOnCompositionDrop,
261280
});
262281

263-
const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowHeights);
264-
const { viewportWidth, showShortcutHint, setScrollRef } = useTimelineScrollViewport(scrollRef, [
265-
timelineReady,
266-
expandedElements.length,
267-
displayLayout.totalH,
268-
]);
282+
const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowGeometry);
283+
const { viewport, showShortcutHint, setScrollRef, syncScrollViewport } =
284+
useTimelineScrollViewport(scrollRef, [
285+
timelineReady,
286+
expandedElements.length,
287+
displayLayout.totalH,
288+
]);
289+
const previousLayoutRef = useRef(displayLayout.rowGeometry);
290+
const previousSessionEpochRef = useRef(sessionEpoch);
291+
useLayoutEffect(() => {
292+
const scroll = scrollRef.current;
293+
const previousGeometry = previousLayoutRef.current;
294+
if (previousSessionEpochRef.current !== sessionEpoch) {
295+
previousSessionEpochRef.current = sessionEpoch;
296+
lastScrollLeftRef.current = 0;
297+
if (scroll) {
298+
scroll.scrollLeft = 0;
299+
scroll.scrollTop = 0;
300+
syncScrollViewport(scroll);
301+
}
302+
} else if (scroll && previousGeometry !== displayLayout.rowGeometry) {
303+
const nextScrollTop = getTimelineScrollTopForGeometryChange(
304+
previousGeometry,
305+
displayLayout.rowGeometry,
306+
scroll.scrollTop,
307+
);
308+
if (nextScrollTop !== scroll.scrollTop) {
309+
scroll.scrollTop = nextScrollTop;
310+
syncScrollViewport(scroll);
311+
}
312+
}
313+
previousLayoutRef.current = displayLayout.rowGeometry;
314+
}, [displayLayout.rowGeometry, sessionEpoch, syncScrollViewport]);
269315
const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes);
270316
const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe);
271317
const { onClickKeyframe, onSelectSegment, onShiftClickKeyframe, onContextMenuKeyframe } =
@@ -288,7 +334,7 @@ export const Timeline = memo(function Timeline({
288334
zoomModeRef,
289335
manualZoomPercentRef,
290336
} = useTimelineGeometry({
291-
viewportWidth,
337+
viewportWidth: viewport.clientWidth,
292338
effectiveDuration,
293339
zoomMode,
294340
manualZoomPercent,
@@ -371,7 +417,7 @@ export const Timeline = memo(function Timeline({
371417
setShowPopover,
372418
elementsRef: expandedElementsRef,
373419
trackOrderRef,
374-
rowHeightsRef,
420+
rowGeometryRef,
375421
onSelectElement,
376422
contentOrigin,
377423
});
@@ -405,6 +451,7 @@ export const Timeline = memo(function Timeline({
405451
<div
406452
ref={setContainerRef}
407453
aria-label="Timeline"
454+
data-timeline-element-count={expandedElements.length}
408455
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"}`}
409456
onMouseMove={updateRazorGuide}
410457
onMouseLeave={clearRazorGuide}
@@ -416,10 +463,12 @@ export const Timeline = memo(function Timeline({
416463
>
417464
<div
418465
ref={setScrollRef}
466+
data-timeline-scroll-viewport
419467
tabIndex={-1}
420468
className={`${zoomMode === "fit" ? "overflow-x-hidden" : "overflow-x-auto"} overflow-y-auto h-full outline-none`}
421469
onScroll={(e) => {
422470
lastScrollLeftRef.current = e.currentTarget.scrollLeft; // restored across post-edit reload
471+
syncScrollViewport(e.currentTarget, true);
423472
}}
424473
onDragOver={handleAssetDragOver}
425474
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)