@hyperframes/studio 0.8.125 → 0.8.127

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 (61) hide show
  1. package/dist/assets/{hyperframes-player-CmIh-wq9.js → hyperframes-player-uqzDaxVL.js} +1 -1
  2. package/dist/assets/{index-4LRR6o2F.js → index-CLBNYlDY.js} +1 -1
  3. package/dist/assets/{index-CrYoEZ5p.js → index-COhbL1xq.js} +177 -177
  4. package/dist/assets/{index-yCK8osBg.js → index-DXSYlqmV.js} +1 -1
  5. package/dist/index.html +1 -1
  6. package/dist/index.js +5368 -4986
  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 +167 -33
  13. package/src/components/editor/MotionPathOverlay.test.tsx +45 -0
  14. package/src/components/editor/MotionPathOverlay.tsx +49 -86
  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/motionPathCommit.nodeDrop.test.ts +212 -0
  19. package/src/components/editor/motionPathCommit.ts +58 -0
  20. package/src/components/editor/motionPathGeometry.test.ts +61 -1
  21. package/src/components/editor/motionPathGeometry.ts +50 -10
  22. package/src/components/editor/motionPathHome.ts +75 -0
  23. package/src/components/editor/motionPathLayerNode.test.ts +43 -35
  24. package/src/components/editor/motionPathLayerNode.ts +20 -16
  25. package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +20 -0
  26. package/src/components/editor/studioMotion.ts +7 -19
  27. package/src/components/editor/useMotionPathData.test.tsx +236 -0
  28. package/src/components/editor/useMotionPathData.ts +23 -61
  29. package/src/components/sidebar/CompositionsTab.thumbnails.test.tsx +1 -0
  30. package/src/hooks/gsapDragCommit.ts +33 -1
  31. package/src/hooks/gsapEditOutcome.ts +27 -2
  32. package/src/hooks/gsapParsedTween.test-helpers.ts +28 -1
  33. package/src/hooks/gsapParsedTween.ts +39 -2
  34. package/src/hooks/gsapRuntimeBridge.ts +7 -1
  35. package/src/hooks/gsapRuntimeKeyframes.realGsap.test.ts +48 -0
  36. package/src/hooks/gsapRuntimeKeyframes.ts +11 -4
  37. package/src/hooks/gsapShared.test.ts +1 -0
  38. package/src/hooks/gsapShared.ts +8 -3
  39. package/src/hooks/gsapShared.writeTarget.test.ts +10 -0
  40. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +107 -5
  41. package/src/hooks/gsapValueAtPlayhead.test.ts +129 -27
  42. package/src/hooks/gsapValueAtPlayhead.ts +46 -6
  43. package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +72 -1
  44. package/src/hooks/gsapWholePropertyOffsetCommit.ts +6 -1
  45. package/src/hooks/intersectionObserverTestUtils.ts +16 -0
  46. package/src/hooks/resizeObserverTestUtils.ts +9 -2
  47. package/src/hooks/timelineEditingHelpers.ts +2 -9
  48. package/src/hooks/useCommitPreflightCapabilities.ts +2 -2
  49. package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +25 -0
  50. package/src/hooks/useThumbnailStripSize.test.tsx +236 -1
  51. package/src/hooks/useThumbnailStripSize.ts +206 -17
  52. package/src/noWindowBoundDomChecks.test.ts +31 -0
  53. package/src/player/components/CompositionThumbnail.tsx +10 -6
  54. package/src/player/components/ImageThumbnail.tsx +11 -5
  55. package/src/player/components/ThumbnailTiles.test.tsx +30 -0
  56. package/src/player/components/ThumbnailTiles.tsx +41 -0
  57. package/src/player/components/VideoThumbnail.test.tsx +116 -9
  58. package/src/player/components/VideoThumbnail.tsx +11 -5
  59. package/src/player/lib/timelineGroupInfo.realm.test.ts +20 -0
  60. package/src/player/lib/timelineGroupInfo.ts +2 -1
  61. package/src/webmcp/webmcpTestUtils.ts +2 -2
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
3
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
- import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
4
+ import { commitWholePathOffset, type GsapDragCommitCallbacks } from "./gsapDragCommit";
5
5
  import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
6
6
 
7
7
  // Regression (#1808): with auto-keyframe recording off, a manual edit on an
@@ -203,3 +203,74 @@ describe("commitWholePropertyOffset", () => {
203
203
  expect(mutations[0]).toMatchObject({ easeEach: "none" });
204
204
  });
205
205
  });
206
+
207
+ it.each([
208
+ [{ rotation: "+=40" }, "array-step-relative"],
209
+ [{ rotation: 40, onComplete: "__raw:done" }, "array-step-callback"],
210
+ [{ rotation: "random(0, 90)" }, "array-step-random"],
211
+ ])("refuses to rewrite a step list holding %o and writes nothing", async (last, detail) => {
212
+ const steps = [{ rotation: 10 }, last].map((properties, i) => ({
213
+ percentage: (i + 1) * 50,
214
+ properties,
215
+ }));
216
+ const anim = {
217
+ id: "#box-steps",
218
+ targetSelector: "#box",
219
+ method: "to",
220
+ resolvedStart: 0,
221
+ duration: 2,
222
+ keyframes: { format: "object-array", keyframes: steps },
223
+ } as unknown as GsapAnimation;
224
+ const { mutations, callbacks } = recordingCallbacks();
225
+ const rotate = commitWholePropertyOffset(
226
+ selection(),
227
+ anim,
228
+ { rotation: 50 },
229
+ 100,
230
+ null,
231
+ callbacks,
232
+ "Rotate",
233
+ );
234
+ await expect(rotate).rejects.toMatchObject({ reason: "keyframes-uneditable", detail });
235
+ expect(mutations).toEqual([]);
236
+ });
237
+
238
+ it("refuses to shift a layer's step list whose step has its own delay, writing nothing", async () => {
239
+ // No drag baseline on the element: the move starts from a zero offset.
240
+ const el = {
241
+ style: { getPropertyValue: () => "", setProperty: () => {} },
242
+ getAttribute: () => null,
243
+ removeAttribute: () => {},
244
+ getBoundingClientRect: () => ({ top: 0, left: 0 }),
245
+ };
246
+ const anim = {
247
+ id: "#box-steps",
248
+ targetSelector: "#box",
249
+ method: "to",
250
+ resolvedStart: 0,
251
+ duration: 2,
252
+ keyframes: {
253
+ format: "object-array",
254
+ keyframes: [
255
+ { percentage: 50, properties: { x: 100, delay: 0.5 } },
256
+ { percentage: 100, properties: { x: 200 } },
257
+ ],
258
+ },
259
+ } as unknown as GsapAnimation;
260
+ const { mutations, callbacks } = recordingCallbacks();
261
+ const sel = { ...selection(), element: el } as DomEditSelection;
262
+ const move = commitWholePathOffset(
263
+ sel,
264
+ anim,
265
+ { x: 40, y: 0 },
266
+ { x: 0, y: 0 },
267
+ null,
268
+ "#box",
269
+ callbacks,
270
+ );
271
+ await expect(move).rejects.toMatchObject({
272
+ reason: "keyframes-uneditable",
273
+ detail: "array-step-delay",
274
+ });
275
+ expect(mutations).toEqual([]);
276
+ });
@@ -8,7 +8,11 @@ import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeComp
8
8
  import { roundTo3 } from "../utils/rounding";
9
9
  import { keyframeEases, PROPERTY_DEFAULTS } from "./gsapShared";
10
10
  import { synthesizeFlatTweenKeyframes } from "./gsapTweenSynth";
11
- import { materializeIfDynamic, type GsapDragCommitCallbacks } from "./gsapDragCommit";
11
+ import {
12
+ materializeIfDynamic,
13
+ refuseStepListRewrite,
14
+ type GsapDragCommitCallbacks,
15
+ } from "./gsapDragCommit";
12
16
 
13
17
  /**
14
18
  * Generic sibling of commitWholePathOffset for property groups other than
@@ -32,6 +36,7 @@ export async function commitWholePropertyOffset(
32
36
  // fallow-ignore-next-line code-duplication
33
37
  let effectiveAnim = anim;
34
38
  if (anim.keyframes) {
39
+ refuseStepListRewrite(anim);
35
40
  const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
36
41
  if (newId) effectiveAnim = { ...anim, id: newId };
37
42
  }
@@ -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
+ }
@@ -1,14 +1,21 @@
1
1
  // Test-only ResizeObserver stand-in: a test delivers a size by hand with `reportResize`.
2
2
  let resizeCallback: ResizeObserverCallback | null = null;
3
+ let observed: Element | null = null;
3
4
 
4
5
  export function MockResizeObserver(callback: ResizeObserverCallback) {
5
6
  resizeCallback = callback;
6
- return { observe() {}, disconnect() {}, unobserve() {} };
7
+ return {
8
+ observe(target: Element) {
9
+ observed = target;
10
+ },
11
+ disconnect() {},
12
+ unobserve() {},
13
+ };
7
14
  }
8
15
 
9
16
  export function reportResize(width: number, height: number): void {
10
17
  resizeCallback!(
11
- [{ contentRect: { width, height } } as ResizeObserverEntry],
18
+ [{ contentRect: { width, height }, target: observed } as ResizeObserverEntry],
12
19
  {} as ResizeObserver,
13
20
  );
14
21
  }
@@ -1,3 +1,4 @@
1
+ import { isHtmlElement } from "@hyperframes/core/runtime/dom-realm";
1
2
  import { type TimelineElement, usePlayerStore } from "../player/store/playerStore";
2
3
  import { toAuthoredStart } from "../player/store/timelineElement";
3
4
  import {
@@ -35,14 +36,6 @@ import {
35
36
  } from "../components/editor/domEditingElement";
36
37
  export { deleteSelectedKeyframes } from "./deleteSelectedKeyframes";
37
38
  export { readFileContent };
38
- function isHTMLElement(element: Element | null): element is HTMLElement {
39
- if (!element) return false;
40
- // Use the element's OWN realm's HTMLElement: timeline clips live in the preview
41
- // iframe, and cross-realm `element instanceof HTMLElement` (main window) is
42
- // always false — which silently dropped every timeline z-index commit.
43
- const Ctor = element.ownerDocument?.defaultView?.HTMLElement ?? globalThis.HTMLElement;
44
- return element instanceof Ctor;
45
- }
46
39
  /**
47
40
  * Resolve a timeline vertical move to a z-index stacking reorder and commit it
48
41
  * through the shared layers-panel reorder path. Reads live sibling z-index from
@@ -96,7 +89,7 @@ export function applyTimelineStackingReorder(input: {
96
89
  input.activeCompPath,
97
90
  )
98
91
  : null;
99
- if (!isHTMLElement(element)) return Promise.resolve();
92
+ if (!isHtmlElement(element)) return Promise.resolve();
100
93
  if (getElementZIndex(element) === change.zIndex) continue;
101
94
  commitEntries.push({
102
95
  element,
@@ -3,7 +3,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
3
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
4
  import { dragEditOutcome, preflightGsapRotationIntercept } from "./gsapRuntimeBridge";
5
5
  import { preflightGsapResizeIntercept } from "./gsapResizePreflight";
6
- import { GSAP_EDIT_BLOCK_COPY, type GsapEditOutcome } from "./gsapEditOutcome";
6
+ import { gsapEditBlockMessage, type GsapEditOutcome } from "./gsapEditOutcome";
7
7
  import { fetchParsedAnimations, parseCacheKey } from "./keyframeCacheAstLoad";
8
8
  import { getAnimationsForElement } from "./gsapElementMatch";
9
9
  import { gsapSourceFileForSelection } from "./useGsapAnimationFetchFallback";
@@ -44,7 +44,7 @@ const MANUAL_FLAGS = [
44
44
  function refusal(preflight: CommitPreflight | null, check: keyof CommitPreflight): string | null {
45
45
  const outcome = preflight?.[check];
46
46
  if (!outcome) return "";
47
- return outcome.status === "blocked" ? GSAP_EDIT_BLOCK_COPY[outcome.reason] : null;
47
+ return outcome.status === "blocked" ? gsapEditBlockMessage(outcome.reason, outcome.detail) : null;
48
48
  }
49
49
 
50
50
  /** Closes each manual flag whose commit Studio would refuse, and says why. */
@@ -127,6 +127,31 @@ describe("a group drag plans every member before its first write", () => {
127
127
  act(() => h.root.unmount());
128
128
  });
129
129
 
130
+ it("with auto-keyframe off, refuses the whole group when one member's step list holds a step delay", async () => {
131
+ usePlayerStore.setState({ autoKeyframeEnabled: false });
132
+ const steps = {
133
+ format: "object-array",
134
+ keyframes: [
135
+ { percentage: 50, properties: { x: 100, y: 0, delay: 0.5 } },
136
+ { percentage: 100, properties: { x: 200, y: 0 } },
137
+ ],
138
+ };
139
+ const h = mountGroup([
140
+ positionTween("a"),
141
+ positionTween("b", { keyframes: steps } as Partial<GsapAnimation>),
142
+ positionTween("c"),
143
+ ]);
144
+
145
+ await expect(h.groupCommit(h.updates)).rejects.toMatchObject({
146
+ reason: "keyframes-uneditable",
147
+ detail: "array-step-delay",
148
+ });
149
+
150
+ expect(h.written()).toEqual([]);
151
+ expect(trackStudioEditBlocked).toHaveBeenCalledWith(expect.objectContaining({ targetId: "b" }));
152
+ act(() => h.root.unmount());
153
+ });
154
+
130
155
  it("refuses members animated in two files, since the batch writes one file", async () => {
131
156
  const h = mountGroup(["a", "b", "c"].map((id) => positionTween(id)));
132
157
  const updates = h.updates.map((u, i) => ({
@@ -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
+ });