Skip to content

Commit 2e239d6

Browse files
committed
perf(studio): clips that scroll into view get their pictures when the scroll stops
Keeping every clip's content mounted through a scroll made each jump of the 50,000-clip viewport gate mount and lay out a screenful of new thumbnails, a 90 to 130 ms long task per run where main has none. A clip's content now mounts only while the timeline is at rest and then stays through later scrolls, so a picture already on screen never blanks and a scroll never mounts new ones.
1 parent 03db58c commit 2e239d6

3 files changed

Lines changed: 68 additions & 2 deletions

File tree

‎packages/studio/src/player/components/Timeline.virtualization.test.tsx‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,50 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
185185
}
186186
});
187187

188+
it("gives a clip that appears mid-scroll its picture once the scroll settles", async () => {
189+
const [{ Timeline }, { usePlayerStore }] = await Promise.all([
190+
import("./Timeline"),
191+
import("../store/playerStore"),
192+
]);
193+
const clip = (id: string, start: number) => ({
194+
id,
195+
label: id,
196+
tag: "div",
197+
start,
198+
duration: 5,
199+
track: 0,
200+
});
201+
usePlayerStore.setState({ duration: 60, timelineReady: true, elements: [clip("clip-0", 0)] });
202+
203+
const { host, root } = await mountTimeline(
204+
React.createElement(Timeline, {
205+
renderClipContent: (element: { id: string }) =>
206+
React.createElement("span", { "data-rich-content": element.id }),
207+
}),
208+
);
209+
try {
210+
await act(async () => {
211+
await new Promise((resolve) => setTimeout(resolve, 110));
212+
});
213+
const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
214+
if (scroller) await dispatchScroll(scroller);
215+
act(() => {
216+
usePlayerStore.setState({ elements: [clip("clip-0", 0), clip("clip-1", 5)] });
217+
});
218+
expect(host.querySelector('[data-el-id="clip-1"]')).not.toBeNull();
219+
expect(host.querySelector('[data-rich-content="clip-1"]')).toBeNull();
220+
expect(host.querySelector('[data-rich-content="clip-0"]')).not.toBeNull();
221+
222+
await act(async () => {
223+
await new Promise((resolve) => setTimeout(resolve, 110));
224+
});
225+
expect(host.querySelector('[data-rich-content="clip-1"]')).not.toBeNull();
226+
} finally {
227+
act(() => root.unmount());
228+
usePlayerStore.getState().reset();
229+
}
230+
});
231+
188232
it("mounts a bounded list range over the full geometry height", async () => {
189233
const [{ Timeline }, { usePlayerStore }, { getTimelineCanvasHeight, TRACK_H }] =
190234
await Promise.all([

‎packages/studio/src/player/components/timelineClipChildren.tsx‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type ReactNode } from "react";
1+
import { useState, type ReactNode } from "react";
22
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
33
import type { TimelineTimeRange } from "../lib/timelineClipIndex";
44
import type { TrackVisualStyle } from "./timelineIcons";
@@ -29,6 +29,16 @@ function ClipLintDot({ element }: { element: TimelineElement }) {
2929
);
3030
}
3131

32+
/**
33+
* Mounts a clip's content only once the timeline is at rest, then keeps it through later scrolls,
34+
* so a scroll never blanks a picture already on screen and never mounts a screenful of new ones.
35+
*/
36+
export function ClipContentOnceShown({ hold, children }: { hold: boolean; children: ReactNode }) {
37+
const [shown, setShown] = useState(!hold);
38+
if (!shown && !hold) setShown(true);
39+
return shown ? children : null;
40+
}
41+
3242
export function renderClipChildren(
3343
element: TimelineElement,
3444
clipStyle: TrackVisualStyle,

‎packages/studio/src/player/components/useTimelineProviderState.tsx‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { useTimelineGeometry } from "./useTimelineGeometry";
2121
import { useAutoExpandKeyframedClips } from "./useAutoExpandKeyframedClips";
2222
import { GUTTER, LABEL_COL_W, TRACKS_LEFT_PAD } from "./timelineLayout";
2323
import { useTimelineScrollViewport } from "./useTimelineScrollViewport";
24+
import { ClipContentOnceShown } from "./timelineClipChildren";
2425
import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks";
2526
import type { TimelineProps } from "./TimelineTypes";
2627
import {
@@ -508,6 +509,17 @@ export function useTimelineProviderState({
508509
onRazorSplit: editContext.onRazorSplit,
509510
onRazorSplitAll: editContext.onRazorSplitAll,
510511
};
512+
const holdNewClipContent = timelineFocus.rowVirtualizationActive && viewport.isScrolling;
513+
const timelineRenderClipContent = useMemo<typeof renderClipContent>(
514+
() =>
515+
renderClipContent &&
516+
((element, style, context) => (
517+
<ClipContentOnceShown hold={holdNewClipContent}>
518+
{renderClipContent(element, style, context)}
519+
</ClipContentOnceShown>
520+
)),
521+
[holdNewClipContent, renderClipContent],
522+
);
511523
const timelineMeta = buildTimelineMeta({
512524
emptyState: {
513525
isDragOver: assetDrop.isDragOver,
@@ -574,7 +586,7 @@ export function useTimelineProviderState({
574586
overlays,
575587
},
576588
actions: {
577-
renderClipContent,
589+
renderClipContent: timelineRenderClipContent,
578590
renderClipOverlay,
579591
setFocusedEaseSegment,
580592
},

0 commit comments

Comments
 (0)