@hyperframes/studio 0.8.125 → 0.8.126

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/assets/{hyperframes-player-CmIh-wq9.js → hyperframes-player-BbbN-wWN.js} +1 -1
  2. package/dist/assets/{index-CrYoEZ5p.js → index-B8kU2xXl.js} +170 -170
  3. package/dist/assets/{index-yCK8osBg.js → index-Cll7uTNy.js} +1 -1
  4. package/dist/assets/{index-4LRR6o2F.js → index-nvLF29ZH.js} +1 -1
  5. package/dist/index.html +1 -1
  6. package/dist/index.js +4251 -3984
  7. package/dist/index.js.map +1 -1
  8. package/package.json +9 -8
  9. package/src/components/editor/DomEditSelectionChrome.test.tsx +5 -0
  10. package/src/components/editor/DomEditSelectionChrome.tsx +1 -0
  11. package/src/components/editor/MotionPathNode.tsx +56 -0
  12. package/src/components/editor/MotionPathOverlay.layerPress.test.tsx +140 -48
  13. package/src/components/editor/MotionPathOverlay.test.tsx +45 -0
  14. package/src/components/editor/MotionPathOverlay.tsx +30 -61
  15. package/src/components/editor/canvasContextMenuZOrder.ts +1 -10
  16. package/src/components/editor/manualEditsSeekReapply.ts +3 -6
  17. package/src/components/editor/manualOffsetDrag.ts +2 -3
  18. package/src/components/editor/motionPathGeometry.test.ts +48 -1
  19. package/src/components/editor/motionPathGeometry.ts +46 -8
  20. package/src/components/editor/motionPathHome.ts +75 -0
  21. package/src/components/editor/motionPathLayerNode.test.ts +43 -35
  22. package/src/components/editor/motionPathLayerNode.ts +20 -16
  23. package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +20 -0
  24. package/src/components/editor/studioMotion.ts +7 -19
  25. package/src/components/editor/useMotionPathData.test.tsx +236 -0
  26. package/src/components/editor/useMotionPathData.ts +23 -61
  27. package/src/components/sidebar/CompositionsTab.thumbnails.test.tsx +1 -0
  28. package/src/hooks/gsapParsedTween.ts +16 -0
  29. package/src/hooks/gsapRuntimeKeyframes.realGsap.test.ts +48 -0
  30. package/src/hooks/gsapRuntimeKeyframes.ts +10 -3
  31. package/src/hooks/gsapShared.ts +2 -1
  32. package/src/hooks/gsapShared.writeTarget.test.ts +10 -0
  33. package/src/hooks/intersectionObserverTestUtils.ts +16 -0
  34. package/src/hooks/resizeObserverTestUtils.ts +9 -2
  35. package/src/hooks/timelineEditingHelpers.ts +2 -9
  36. package/src/hooks/useThumbnailStripSize.test.tsx +236 -1
  37. package/src/hooks/useThumbnailStripSize.ts +206 -17
  38. package/src/noWindowBoundDomChecks.test.ts +31 -0
  39. package/src/player/components/CompositionThumbnail.tsx +10 -6
  40. package/src/player/components/ImageThumbnail.tsx +11 -5
  41. package/src/player/components/ThumbnailTiles.test.tsx +30 -0
  42. package/src/player/components/ThumbnailTiles.tsx +41 -0
  43. package/src/player/components/VideoThumbnail.test.tsx +116 -9
  44. package/src/player/components/VideoThumbnail.tsx +11 -5
  45. package/src/player/lib/timelineGroupInfo.realm.test.ts +20 -0
  46. package/src/player/lib/timelineGroupInfo.ts +2 -1
  47. package/src/webmcp/webmcpTestUtils.ts +2 -2
@@ -1,7 +1,9 @@
1
1
  // @vitest-environment happy-dom
2
2
  import { act } from "react";
3
3
  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";
5
7
  import { MockResizeObserver, reportResize } from "./resizeObserverTestUtils";
6
8
  import { useThumbnailStripSize } from "./useThumbnailStripSize";
7
9
 
@@ -45,3 +47,236 @@ it("does not re-render the strip when the observer reports the size it already h
45
47
  globalThis.ResizeObserver = originalResizeObserver;
46
48
  }
47
49
  });
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
+ });
@@ -1,31 +1,220 @@
1
- import { useCallback, useRef, useState } from "react";
2
- import { useMountEffect } from "./useMountEffect";
1
+ import { useCallback, useState } from "react";
2
+ import { flushSync } from "react-dom";
3
3
 
4
- interface StripSize {
4
+ export interface StripSize {
5
5
  width: number;
6
6
  height: number;
7
+ inViewStart: number;
8
+ inViewEnd: number;
7
9
  }
8
10
 
9
- const resize = (width: number, height: number) => (prev: StripSize) =>
10
- prev.width === width && prev.height === height ? prev : { width, height };
11
+ const IN_VIEW_CHUNK_PX = 512;
11
12
 
12
- /** Size of the thumbnail's parent, kept current by a ResizeObserver; 0x0 until measured. */
13
+ const chunk = (px: number, round: (n: number) => number) =>
14
+ Math.max(0, round(px / IN_VIEW_CHUNK_PX) * IN_VIEW_CHUNK_PX);
15
+
16
+ const spanAt = (left: number) => ({
17
+ inViewStart: chunk(-left - IN_VIEW_CHUNK_PX, Math.floor),
18
+ inViewEnd: chunk(window.innerWidth - left + IN_VIEW_CHUNK_PX, Math.ceil),
19
+ });
20
+
21
+ // scrollMargin widens the band inside the timeline's own scroller too; TypeScript's DOM types lack it.
22
+ const GAP_WARNING: IntersectionObserverInit & { scrollMargin: string } = {
23
+ rootMargin: `0px ${IN_VIEW_CHUNK_PX / 2}px`,
24
+ scrollMargin: `0px ${IN_VIEW_CHUNK_PX / 2}px`,
25
+ };
26
+
27
+ // A short strip keeps every tile, as before, so only long clips do work on a scroll.
28
+ const SHORT_STRIP_MAX_PX = 8 * IN_VIEW_CHUNK_PX;
29
+ const isShort = (width: number) => width <= SHORT_STRIP_MAX_PX;
30
+
31
+ const EMPTY_STRIP: StripSize = { width: 0, height: 0, inViewStart: 0, inViewEnd: 0 };
32
+
33
+ // Clamped to a measured strip, so a strip changes only when it crosses the screen's edge.
34
+ const merge = (prev: StripSize, patch: Partial<StripSize>): StripSize => {
35
+ const next = { ...prev, ...patch };
36
+ const width = next.width > 0 ? Math.ceil(next.width) : Infinity;
37
+ next.inViewStart = isShort(width) ? 0 : Math.min(next.inViewStart, width);
38
+ next.inViewEnd = isShort(width) ? width : Math.min(next.inViewEnd, width);
39
+ return (Object.keys(next) as (keyof StripSize)[]).every((key) => next[key] === prev[key])
40
+ ? prev
41
+ : next;
42
+ };
43
+
44
+ type Apply = (patch: Partial<StripSize>) => void;
45
+
46
+ interface Strip {
47
+ apply: Apply;
48
+ scroller: Element | null;
49
+ box: { left: number; top: number; width: number; height: number };
50
+ readAt: { x: number; y: number };
51
+ showing: boolean;
52
+ }
53
+
54
+ const NEAR_PX = IN_VIEW_CHUNK_PX / 2;
55
+
56
+ // Each frame reads the strips showing tiles, which a move without a scroll may have carried, and the
57
+ // strips whose last box, moved by the scroll since, lands near the screen; reads precede one commit.
58
+ const strips = new Map<Element, Strip>();
59
+ let users = 0;
60
+ let frame = 0;
61
+ let shared: {
62
+ resize: ResizeObserver;
63
+ presence: IntersectionObserver | null;
64
+ gaps: IntersectionObserver | null;
65
+ } | null = null;
66
+
67
+ const offsetOf = (scroller: Element | null) =>
68
+ scroller ? { x: scroller.scrollLeft, y: scroller.scrollTop } : { x: scrollX, y: scrollY };
69
+
70
+ const isNear = ({
71
+ left,
72
+ top,
73
+ width,
74
+ height,
75
+ }: {
76
+ left: number;
77
+ top: number;
78
+ width: number;
79
+ height: number;
80
+ }) =>
81
+ left < innerWidth + NEAR_PX &&
82
+ left + width > -NEAR_PX &&
83
+ top < innerHeight + NEAR_PX &&
84
+ top + height > -NEAR_PX;
85
+
86
+ const NOTHING_IN_VIEW = { inViewStart: 0, inViewEnd: 0 };
87
+
88
+ const spanOf = (box: Strip["box"]) => (isNear(box) ? spanAt(box.left) : NOTHING_IN_VIEW);
89
+
90
+ const read = (target: Element, strip: Strip) => {
91
+ const { left, top, width, height } = target.getBoundingClientRect();
92
+ strip.box = { left, top, width, height };
93
+ strip.readAt = offsetOf(strip.scroller);
94
+ return spanOf(strip.box);
95
+ };
96
+
97
+ const commit = (updates: (readonly [Apply, Partial<StripSize>])[]) =>
98
+ flushSync(() => updates.forEach(([apply, patch]) => apply(patch)));
99
+
100
+ const refresh = () => {
101
+ frame = 0;
102
+ const offsetsNow = new Map<Element | null, { x: number; y: number }>();
103
+ const updates: (readonly [Apply, Partial<StripSize>])[] = [];
104
+ for (const [target, strip] of strips) {
105
+ if (isShort(strip.box.width)) continue;
106
+ let now = offsetsNow.get(strip.scroller);
107
+ if (!now) offsetsNow.set(strip.scroller, (now = offsetOf(strip.scroller)));
108
+ const { box, readAt } = strip;
109
+ const moved = {
110
+ left: box.left - (now.x - readAt.x),
111
+ top: box.top - (now.y - readAt.y),
112
+ width: box.width,
113
+ height: box.height,
114
+ };
115
+ if (strip.showing || isNear(moved)) updates.push([strip.apply, read(target, strip)]);
116
+ }
117
+ commit(updates);
118
+ };
119
+
120
+ const scheduleRefresh = () => {
121
+ if (!frame) frame = requestAnimationFrame(refresh);
122
+ };
123
+
124
+ const measure = (entries: { target: Element; size?: { width: number; height: number } }[]) =>
125
+ commit(
126
+ entries.flatMap(({ target, size }) => {
127
+ const strip = strips.get(target);
128
+ return strip ? [[strip.apply, { ...size, ...read(target, strip) }] as const] : [];
129
+ }),
130
+ );
131
+
132
+ const onPresence = (entries: IntersectionObserverEntry[]) =>
133
+ measure(entries.filter((entry) => entry.isIntersecting));
134
+
135
+ const onResize = (entries: ResizeObserverEntry[]) =>
136
+ measure(
137
+ entries.map(({ target, contentRect: { width, height } }) => ({
138
+ target,
139
+ size: { width, height },
140
+ })),
141
+ );
142
+
143
+ const observeIntersections = (callback: IntersectionObserverCallback) =>
144
+ typeof IntersectionObserver === "undefined"
145
+ ? null
146
+ : new IntersectionObserver(callback, GAP_WARNING);
147
+
148
+ function acquire() {
149
+ if (users++ === 0) {
150
+ shared = {
151
+ resize: new ResizeObserver(onResize),
152
+ presence: observeIntersections(onPresence),
153
+ gaps: observeIntersections(
154
+ (entries) => entries.some((entry) => entry.isIntersecting) && scheduleRefresh(),
155
+ ),
156
+ };
157
+ window.addEventListener("scroll", scheduleRefresh, { capture: true, passive: true });
158
+ }
159
+ return shared!;
160
+ }
161
+
162
+ function release() {
163
+ if (--users > 0) return;
164
+ shared?.resize.disconnect();
165
+ shared?.presence?.disconnect();
166
+ shared?.gaps?.disconnect();
167
+ shared = null;
168
+ window.removeEventListener("scroll", scheduleRefresh, { capture: true });
169
+ cancelAnimationFrame(frame);
170
+ frame = 0;
171
+ }
172
+
173
+ const watchGap = (gap: HTMLDivElement | null) => {
174
+ if (!gap) return;
175
+ const { gaps } = acquire();
176
+ gaps?.observe(gap);
177
+ return () => {
178
+ gaps?.unobserve(gap);
179
+ release();
180
+ };
181
+ };
182
+
183
+ /** Size of the thumbnail's parent and its span in the window, kept current on resize, scroll and moves. */
13
184
  export function useThumbnailStripSize() {
14
- const [size, setSize] = useState<StripSize>({ width: 0, height: 0 });
15
- const observerRef = useRef<ResizeObserver | null>(null);
185
+ const [size, setSize] = useState(EMPTY_STRIP);
16
186
 
17
187
  const ref = useCallback((element: HTMLDivElement | null) => {
18
- observerRef.current?.disconnect();
19
188
  if (!element) return;
20
189
  const target = element.parentElement ?? element;
21
- setSize(resize(target.clientWidth, target.clientHeight));
22
- observerRef.current = new ResizeObserver(([entry]) =>
23
- setSize(resize(entry.contentRect.width, entry.contentRect.height)),
24
- );
25
- observerRef.current.observe(target);
190
+ const { resize, presence } = acquire();
191
+ let current = EMPTY_STRIP;
192
+ const apply: Apply = (patch) => {
193
+ const next = merge(current, patch);
194
+ if (next === current) return;
195
+ current = next;
196
+ strip.showing = next.inViewEnd > 0;
197
+ setSize(next);
198
+ };
199
+ const scroller = target.closest("[data-timeline-scroll-viewport]");
200
+ const strip: Strip = {
201
+ apply,
202
+ scroller,
203
+ box: { left: 0, top: 0, width: 0, height: 0 },
204
+ readAt: { x: 0, y: 0 },
205
+ showing: false,
206
+ };
207
+ strips.set(target, strip);
208
+ apply({ width: target.clientWidth, height: target.clientHeight, ...read(target, strip) });
209
+ resize.observe(target);
210
+ presence?.observe(target);
211
+ return () => {
212
+ resize.unobserve(target);
213
+ presence?.unobserve(target);
214
+ strips.delete(target);
215
+ release();
216
+ };
26
217
  }, []);
27
218
 
28
- useMountEffect(() => () => observerRef.current?.disconnect());
29
-
30
- return [size, ref] as const;
219
+ return [size, ref, watchGap] as const;
31
220
  }
@@ -0,0 +1,31 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { dirname, join, relative } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { expect, it } from "vitest";
5
+
6
+ const SRC = dirname(fileURLToPath(import.meta.url));
7
+ const DOM_CTOR = String.raw`(?:HTML\w*Element|SVG\w*Element|Element|Node|Text|ShadowRoot|DocumentFragment)\b`;
8
+ // A DOM constructor read off any object, a window most of all, asks which window built a node.
9
+ const WINDOW_BOUND = [
10
+ new RegExp(String.raw`[\w$)\]!?]\??\.\s*${DOM_CTOR}`),
11
+ new RegExp(String.raw`^\s*\??\.\s*${DOM_CTOR}`),
12
+ new RegExp(String.raw`\[\s*["'](?:HTML|SVG)\w*Element["']`),
13
+ ];
14
+
15
+ function sources(dir: string): string[] {
16
+ return readdirSync(dir, { withFileTypes: true, recursive: true })
17
+ .filter((e) => e.isFile() && /\.tsx?$/.test(e.name) && !/\.test(-helpers)?\.tsx?$/.test(e.name))
18
+ .map((e) => join(e.parentPath, e.name));
19
+ }
20
+
21
+ it("no studio source checks a DOM node against a window's own constructor", () => {
22
+ const hits = sources(SRC).flatMap((file) =>
23
+ readFileSync(file, "utf8")
24
+ .split("\n")
25
+ .map((line, i) => ({ line, at: `${relative(SRC, file)}:${i + 1}` }))
26
+ .filter(({ line }) => !/^\s*(\/\/|\*|\/\*)/.test(line))
27
+ .filter(({ line }) => WINDOW_BOUND.some((re) => re.test(line)))
28
+ .map(({ at, line }) => `${at} ${line.trim()}`),
29
+ );
30
+ expect(hits, "use @hyperframes/core/runtime/dom-realm instead").toEqual([]);
31
+ });
@@ -7,6 +7,7 @@ import {
7
7
  type ThumbnailRequest,
8
8
  } from "../lib/thumbnailScheduler";
9
9
  import { TIMELINE_VIEWPORT_BUDGETS } from "../lib/timelineViewportBudgets";
10
+ import { ThumbnailTiles } from "./ThumbnailTiles";
10
11
  import { computeThumbnailStrip, probeImageAspect } from "./thumbnailUtils";
11
12
  import { studioApiFetch } from "../../utils/studioApiFetch";
12
13
 
@@ -142,7 +143,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
142
143
  contentRevision = 0,
143
144
  priority = "visible",
144
145
  }: CompositionThumbnailProps) {
145
- const [container, setContainerRef] = useThumbnailStripSize();
146
+ const [container, setContainerRef, watchGap] = useThumbnailStripSize();
146
147
  const url = buildCompositionThumbnailUrl({
147
148
  previewUrl,
148
149
  seekTime,
@@ -169,15 +170,18 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
169
170
  return (
170
171
  <div ref={setContainerRef} className="absolute inset-0 overflow-hidden">
171
172
  {value && (
172
- <div
173
- className="absolute inset-0 flex"
173
+ <ThumbnailTiles
174
+ strip={container}
175
+ frameW={frameW}
176
+ frameCount={frameCount}
177
+ watchGap={watchGap}
174
178
  style={{
175
179
  animation: "hf-thumb-fade 200ms ease-out",
176
180
  mixBlendMode:
177
181
  "var(--timeline-composition-thumbnail-blend)" as CSSProperties["mixBlendMode"],
178
182
  }}
179
183
  >
180
- {Array.from({ length: frameCount }, (_, index) => (
184
+ {(index) => (
181
185
  <div
182
186
  key={index}
183
187
  className="relative h-full shrink-0 overflow-hidden"
@@ -191,8 +195,8 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
191
195
  style={{ opacity: "var(--timeline-composition-thumbnail-opacity)" }}
192
196
  />
193
197
  </div>
194
- ))}
195
- </div>
198
+ )}
199
+ </ThumbnailTiles>
196
200
  )}
197
201
  {snapshot.status === "loading" && (
198
202
  <div className="absolute inset-0 animate-pulse bg-text-0/[0.035]" />
@@ -3,6 +3,7 @@ import { useThumbnailLease } from "../../hooks/useThumbnailLease";
3
3
  import { useThumbnailStripSize } from "../../hooks/useThumbnailStripSize";
4
4
  import { createThumbnailKey, type ThumbnailPriority } from "../lib/thumbnailScheduler";
5
5
  import { decodeImageThumbnail } from "../lib/thumbnailImageDecoder";
6
+ import { ThumbnailTiles } from "./ThumbnailTiles";
6
7
  import { computeThumbnailStrip } from "./thumbnailUtils";
7
8
 
8
9
  export interface ImageThumbnailProps {
@@ -25,7 +26,7 @@ export const ImageThumbnail = memo(function ImageThumbnail({
25
26
  priority = "visible",
26
27
  rich = false,
27
28
  }: ImageThumbnailProps) {
28
- const [container, setContainerRef] = useThumbnailStripSize();
29
+ const [container, setContainerRef, watchGap] = useThumbnailStripSize();
29
30
  const request = useMemo(
30
31
  () => ({
31
32
  key: createThumbnailKey({ kind: "image", source: imageSrc, rich: Number(rich) }),
@@ -46,8 +47,13 @@ export const ImageThumbnail = memo(function ImageThumbnail({
46
47
  return (
47
48
  <div ref={setContainerRef} className="absolute inset-0 overflow-hidden">
48
49
  {value?.kind === "image" && (
49
- <div className="absolute inset-0 flex">
50
- {Array.from({ length: frameCount }, (_, index) => (
50
+ <ThumbnailTiles
51
+ strip={container}
52
+ frameW={frameW}
53
+ frameCount={frameCount}
54
+ watchGap={watchGap}
55
+ >
56
+ {(index) => (
51
57
  <div
52
58
  key={index}
53
59
  className="relative h-full shrink-0 overflow-hidden bg-neutral-900"
@@ -61,8 +67,8 @@ export const ImageThumbnail = memo(function ImageThumbnail({
61
67
  className="absolute inset-0 h-full w-full object-cover"
62
68
  />
63
69
  </div>
64
- ))}
65
- </div>
70
+ )}
71
+ </ThumbnailTiles>
66
72
  )}
67
73
  {snapshot.status === "loading" && (
68
74
  <div
@@ -0,0 +1,30 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { expect, it } from "vitest";
4
+ import { createHappyDomRootHarness } from "./testRootHarness";
5
+ import { ThumbnailTiles } from "./ThumbnailTiles";
6
+
7
+ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
8
+
9
+ const harness = createHappyDomRootHarness();
10
+
11
+ it("places the first mounted tile where the full strip would have it", () => {
12
+ const host = document.body.appendChild(document.createElement("div"));
13
+ act(() =>
14
+ harness.mount(host).render(
15
+ <ThumbnailTiles
16
+ strip={{ width: 10_000, height: 40, inViewStart: 1_024, inViewEnd: 2_048 }}
17
+ frameW={100}
18
+ frameCount={100}
19
+ watchGap={() => {}}
20
+ >
21
+ {(index) => <i key={index} data-index={index} />}
22
+ </ThumbnailTiles>,
23
+ ),
24
+ );
25
+
26
+ const row = host.firstElementChild as HTMLElement;
27
+ const first = Number(row.querySelector("i")!.dataset.index);
28
+ expect(first).toBe(10);
29
+ expect(row.style.paddingLeft).toBe(`${first * 100}px`);
30
+ });
@@ -0,0 +1,41 @@
1
+ import type { CSSProperties, ReactNode } from "react";
2
+ import type { StripSize } from "../../hooks/useThumbnailStripSize";
3
+
4
+ interface ThumbnailTilesProps {
5
+ strip: StripSize;
6
+ frameW: number;
7
+ frameCount: number;
8
+ watchGap: (gap: HTMLDivElement | null) => void | (() => void);
9
+ style?: CSSProperties;
10
+ children: (index: number) => ReactNode;
11
+ }
12
+
13
+ /** A clip's tile row, mounting only the tiles in the strip's on-screen span, each at its own place. */
14
+ export function ThumbnailTiles({
15
+ strip,
16
+ frameW,
17
+ frameCount,
18
+ watchGap,
19
+ style,
20
+ children,
21
+ }: ThumbnailTilesProps) {
22
+ const end = Math.min(frameCount, Math.ceil(strip.inViewEnd / frameW));
23
+ const first = Math.min(end, Math.floor(strip.inViewStart / frameW));
24
+ return (
25
+ <div className="absolute inset-0 flex" style={{ ...style, paddingLeft: first * frameW }}>
26
+ <div
27
+ ref={watchGap}
28
+ data-thumbnail-gap="start"
29
+ className="pointer-events-none absolute inset-y-0 left-0"
30
+ style={{ width: first * frameW }}
31
+ />
32
+ {Array.from({ length: end - first }, (_, offset) => children(first + offset))}
33
+ <div
34
+ ref={watchGap}
35
+ data-thumbnail-gap="end"
36
+ className="pointer-events-none absolute inset-y-0 right-0"
37
+ style={{ left: end * frameW }}
38
+ />
39
+ </div>
40
+ );
41
+ }