Skip to content

Commit 1bdb5ed

Browse files
perf(studio): long clips mount only the picture tiles in view (#4993)
* perf(studio): long clips mount only the picture tiles in view A clip's picture strip mounted one tile per frame width, so a 10-minute clip at full zoom mounted 18,000 tiles. The strip-size hook now also reports the strip's on-screen span (refreshed on scroll, resize and clip moves), and the video, image and composition strips mount only the tiles inside it. * refactor(studio): read the strip span outside the state updater * fix(studio): a dragged long clip keeps its picture across the screen The strip refreshed its span only on scroll, resize or a style change on its own clip, but a drag moves the ghost through a wrapper, so a long clip's ghost went blank past about 600 px of drag. Each strip now marks its unmounted ends and re-measures when either nears the screen, whatever moved it, and commits before paint. The tile row is one shared ThumbnailTiles for the video, image and composition strips. * fix(studio): warn of a tile gap inside the timeline scroller too * fix(studio): clip strips re-measure together, one layout per frame With row virtualization off the timeline mounts every clip, and each strip had its own scroll listener and frame that read layout and then committed. A scroll jump changed every strip's span, so a 1,000-clip timeline laid out about once per clip per frame (the viewport gate's 1,000-row arm went to ~3 s frames). All strips now share one scroll listener, observers and frame: every position is read first, then every update commits in one batch. * perf(studio): a scroll re-renders only the clip strips crossing the screen edge A strip's span is clamped to its own measured width, and each strip keeps its current size outside React so an unchanged size never calls setState. A clip wholly on or wholly off screen then does no work on a scroll; only strips straddling the edge re-render. * perf(studio): a scroll re-measures only clip strips near the screen The shared frame read every mounted strip's position, so a timeline of 1,000 unvirtualized clips forced about 1,000 style-and-layout passes per scroll frame. An IntersectionObserver now tracks which strips are within 256 px of the screen; a scroll re-measures only those, and a strip is measured as it comes near. A clip wholly off screen mounts no tile. * test(studio): share the strip test setup through the root harness * test(studio): a strip is measured as it comes near the screen * fix(studio): a scroll jump measures the clips it brings on screen in the same frame Each frame moves every strip's last measured box by its scroller's offset change and reads only the boxes that land within 256 px of the screen. A jump therefore measures exactly the strips it brings into view, with no blank frame, at the cost of a short scroll; strips far away get their span from the moved box without a layout read. * perf(studio): a strip off either side of the screen keeps one empty span * perf(studio): a clip strip far above or below the screen mounts no tiles * perf(studio): a scroll frame leaves off-screen clip strips untouched * perf(studio): short clips keep every tile and do no work on a scroll A strip up to 4,096 px wide keeps all its tiles, as before this change, and a scroll frame skips it; only long clips mount the tiles in view. Short clips gain nothing from virtualizing and paid a re-render each time they crossed the screen's edge. * fix(studio): a scroll is never applied twice to a clip strip's position Each strip remembers the scroll offset its box was read at, so a strip read between a scroll and that scroll's frame is not shifted again. The empty-span guards are removed: a near strip always starts inside itself and a far one already gets nothing. * fix(studio): a clip on screen keeps its pictures after a move and a scroll A strip showing tiles is re-read on every scroll frame instead of trusting its last box shifted by the scroll, which a move without a scroll (rows removed above it, a dock resize) leaves stale. Tests now pin the vertical shift, the 256 px band, the 4,096 px short-clip limit and the tile padding.
1 parent f70dce7 commit 1bdb5ed

11 files changed

Lines changed: 687 additions & 45 deletions

‎packages/studio/src/components/sidebar/CompositionsTab.thumbnails.test.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ class ViewportObserver {
3939
const entry = { isIntersecting: true } as IntersectionObserverEntry;
4040
this.callback([entry], this as unknown as IntersectionObserver);
4141
}
42+
unobserve() {}
4243
disconnect() {}
4344
}
4445

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Test-only IntersectionObserver stand-in: everything observed is reported near the screen, asynchronously.
2+
export function NearScreenIntersectionObserver(callback: IntersectionObserverCallback) {
3+
const observer = {
4+
observe(target: Element) {
5+
queueMicrotask(() =>
6+
callback(
7+
[{ isIntersecting: true, target } as IntersectionObserverEntry],
8+
observer as unknown as IntersectionObserver,
9+
),
10+
);
11+
},
12+
unobserve() {},
13+
disconnect() {},
14+
};
15+
return observer;
16+
}
Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
11
// Test-only ResizeObserver stand-in: a test delivers a size by hand with `reportResize`.
22
let resizeCallback: ResizeObserverCallback | null = null;
3+
let observed: Element | null = null;
34

45
export function MockResizeObserver(callback: ResizeObserverCallback) {
56
resizeCallback = callback;
6-
return { observe() {}, disconnect() {}, unobserve() {} };
7+
return {
8+
observe(target: Element) {
9+
observed = target;
10+
},
11+
disconnect() {},
12+
unobserve() {},
13+
};
714
}
815

916
export function reportResize(width: number, height: number): void {
1017
resizeCallback!(
11-
[{ contentRect: { width, height } } as ResizeObserverEntry],
18+
[{ contentRect: { width, height }, target: observed } as ResizeObserverEntry],
1219
{} as ResizeObserver,
1320
);
1421
}

‎packages/studio/src/hooks/useThumbnailStripSize.test.tsx‎

Lines changed: 236 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
// @vitest-environment happy-dom
22
import { act } from "react";
33
import { createRoot } from "react-dom/client";
4-
import { expect, it } from "vitest";
4+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5+
import { createHappyDomRootHarness } from "../player/components/testRootHarness";
6+
import { NearScreenIntersectionObserver } from "./intersectionObserverTestUtils";
57
import { MockResizeObserver, reportResize } from "./resizeObserverTestUtils";
68
import { useThumbnailStripSize } from "./useThumbnailStripSize";
79

@@ -45,3 +47,236 @@ it("does not re-render the strip when the observer reports the size it already h
4547
globalThis.ResizeObserver = originalResizeObserver;
4648
}
4749
});
50+
51+
describe("on a scroll", () => {
52+
const originalResizeObserver = globalThis.ResizeObserver;
53+
const originalIntersectionObserver = globalThis.IntersectionObserver;
54+
const frames: FrameRequestCallback[] = [];
55+
const harness = createHappyDomRootHarness();
56+
let host: HTMLDivElement;
57+
let root: ReturnType<typeof createRoot>;
58+
let left = 0;
59+
let top = 0;
60+
let scrolled = 0;
61+
let scrolledDown = 0;
62+
let stripWidth = 1_000_000;
63+
let renders = 0;
64+
65+
function Strip() {
66+
renders += 1;
67+
const [size, ref] = useThumbnailStripSize();
68+
return (
69+
<div>
70+
<div ref={ref} data-in-view={`${size.inViewStart}-${size.inViewEnd}`} />
71+
</div>
72+
);
73+
}
74+
75+
const mountStrips = (count: number) =>
76+
act(async () =>
77+
root.render(Array.from({ length: count }, (_, index) => <Strip key={index} />)),
78+
);
79+
80+
const spans = () =>
81+
[...host.querySelectorAll("[data-in-view]")].map((strip) => strip.getAttribute("data-in-view"));
82+
83+
/** The timeline scrolls so the strips' left edge lands at `nextLeft`, then one frame runs. */
84+
const scrollTo = (nextLeft: number) => {
85+
scrolled += left - nextLeft;
86+
left = nextLeft;
87+
act(() => host.dispatchEvent(new Event("scroll")));
88+
expect(window.requestAnimationFrame).toHaveBeenCalledTimes(1);
89+
act(() => frames.splice(0).forEach((frame) => frame(0)));
90+
};
91+
92+
const neverReportedNear = () => {
93+
globalThis.IntersectionObserver = class {
94+
observe() {}
95+
unobserve() {}
96+
disconnect() {}
97+
} as unknown as typeof IntersectionObserver;
98+
};
99+
100+
beforeEach(() => {
101+
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
102+
globalThis.IntersectionObserver =
103+
NearScreenIntersectionObserver as unknown as typeof IntersectionObserver;
104+
vi.spyOn(window, "requestAnimationFrame").mockImplementation((frame) => frames.push(frame));
105+
vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(
106+
() => ({ left, top, width: stripWidth, height: 40 }) as DOMRect,
107+
);
108+
vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockImplementation(() => stripWidth);
109+
left = 0;
110+
top = 0;
111+
scrolled = 0;
112+
scrolledDown = 0;
113+
stripWidth = 1_000_000;
114+
renders = 0;
115+
host = document.body.appendChild(document.createElement("div"));
116+
host.setAttribute("data-timeline-scroll-viewport", "");
117+
Object.defineProperty(host, "scrollLeft", { configurable: true, get: () => scrolled });
118+
Object.defineProperty(host, "scrollTop", { configurable: true, get: () => scrolledDown });
119+
root = harness.mount(host);
120+
});
121+
122+
afterEach(() => {
123+
frames.length = 0;
124+
vi.restoreAllMocks();
125+
globalThis.ResizeObserver = originalResizeObserver;
126+
globalThis.IntersectionObserver = originalIntersectionObserver;
127+
});
128+
129+
it("re-measures every near strip in one shared frame, so a full timeline lays out once", async () => {
130+
await mountStrips(50);
131+
132+
scrollTo(-10_000);
133+
134+
expect(spans()).toEqual(Array(50).fill("9216-11776"));
135+
});
136+
137+
it("keeps a visible strip measured when it is read after a scroll but before that scroll's frame", async () => {
138+
stripWidth = 5_000;
139+
left = -1_000;
140+
await mountStrips(1);
141+
142+
scrolled += 3_000;
143+
left = -4_000;
144+
act(() => reportResize(stripWidth, 40));
145+
act(() => host.dispatchEvent(new Event("scroll")));
146+
act(() => frames.splice(0).forEach((frame) => frame(0)));
147+
148+
expect(spans()).toEqual(["3072-5000"]);
149+
});
150+
151+
it("keeps a strip on screen measured after a move without a scroll, then a scroll", async () => {
152+
await mountStrips(1);
153+
expect(spans()).toEqual(["0-1536"]);
154+
155+
left = -3_000;
156+
scrollTo(-1_000);
157+
expect(spans()).toEqual(["0-2560"]);
158+
});
159+
160+
it("measures a strip a vertical scroll brings on screen in that same frame", async () => {
161+
neverReportedNear();
162+
top = 5_000;
163+
await mountStrips(1);
164+
expect(spans()).toEqual(["0-0"]);
165+
166+
scrolledDown += 4_900;
167+
top = 100;
168+
act(() => host.dispatchEvent(new Event("scroll")));
169+
act(() => frames.splice(0).forEach((frame) => frame(0)));
170+
171+
expect(spans()).toEqual(["0-1536"]);
172+
});
173+
174+
it("keeps the tiles of a strip that ends just off screen", async () => {
175+
await mountStrips(1);
176+
177+
scrollTo(-(stripWidth + 100));
178+
179+
expect(spans()).toEqual(["999424-1000000"]);
180+
});
181+
182+
it("measures the strips a jump brings on screen in that same frame", async () => {
183+
neverReportedNear();
184+
left = 50_000;
185+
await mountStrips(50);
186+
expect(spans()).toEqual(Array(50).fill("0-0"));
187+
188+
scrollTo(0);
189+
190+
expect(spans()).toEqual(Array(50).fill("0-1536"));
191+
});
192+
193+
it("does not re-render a strip wholly on screen when it moves", async () => {
194+
stripWidth = 300;
195+
left = 100;
196+
await mountStrips(1);
197+
const settled = renders;
198+
199+
scrollTo(700);
200+
201+
expect(renders).toBe(settled);
202+
});
203+
204+
it("does not re-render strips that a jump carries from one side of the screen to the other", async () => {
205+
left = 50_000;
206+
await mountStrips(50);
207+
const settled = renders;
208+
209+
scrollTo(-2_000_000);
210+
211+
expect(renders).toBe(settled);
212+
});
213+
214+
it("keeps every tile of a short clip, even far from the screen", async () => {
215+
stripWidth = 4_096;
216+
top = 5_000;
217+
await mountStrips(1);
218+
219+
expect(spans()).toEqual(["0-4096"]);
220+
});
221+
222+
it("never re-measures a short clip on a scroll", async () => {
223+
stripWidth = 300;
224+
left = 100;
225+
await mountStrips(1);
226+
const reads = vi.mocked(Element.prototype.getBoundingClientRect);
227+
reads.mockClear();
228+
229+
scrollTo(50);
230+
231+
expect(reads).not.toHaveBeenCalled();
232+
});
233+
234+
it("mounts nothing for a strip far above or below the screen", async () => {
235+
top = 5_000;
236+
await mountStrips(1);
237+
238+
expect(spans()).toEqual(["0-0"]);
239+
});
240+
241+
it("measures a strip as it comes near the screen, without waiting for a frame", async () => {
242+
const reports: IntersectionObserverCallback[] = [];
243+
globalThis.IntersectionObserver = class {
244+
constructor(callback: IntersectionObserverCallback) {
245+
reports.push(callback);
246+
}
247+
observe() {}
248+
unobserve() {}
249+
disconnect() {}
250+
} as unknown as typeof IntersectionObserver;
251+
await mountStrips(1);
252+
const strip = host.querySelector("[data-in-view]")!;
253+
254+
left = -10_000;
255+
const [presence] = reports;
256+
act(() =>
257+
presence!(
258+
[
259+
{
260+
isIntersecting: true,
261+
target: strip.parentElement!,
262+
} as unknown as IntersectionObserverEntry,
263+
],
264+
{} as IntersectionObserver,
265+
),
266+
);
267+
268+
expect(frames).toHaveLength(0);
269+
expect(strip.getAttribute("data-in-view")).toBe("9216-11776");
270+
});
271+
272+
it("reads no strip far from the screen, however many clips the timeline mounts", async () => {
273+
left = 50_000;
274+
await mountStrips(50);
275+
const reads = vi.mocked(Element.prototype.getBoundingClientRect);
276+
reads.mockClear();
277+
278+
scrollTo(49_900);
279+
280+
expect(reads).not.toHaveBeenCalled();
281+
});
282+
});

0 commit comments

Comments
 (0)