@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,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
+ }
@@ -1,10 +1,10 @@
1
1
  // @vitest-environment happy-dom
2
2
  import React, { act } from "react";
3
- import { createRoot, type Root } from "react-dom/client";
4
3
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
4
  import { MockResizeObserver, reportResize } from "../../hooks/resizeObserverTestUtils";
6
5
  import { thumbnailScheduler } from "../lib/thumbnailScheduler";
7
6
  import { decodeVideoThumbnail } from "../lib/thumbnailVideoDecoder";
7
+ import { createHappyDomRootHarness } from "./testRootHarness";
8
8
  import { VideoThumbnail } from "./VideoThumbnail";
9
9
 
10
10
  vi.mock("../lib/thumbnailVideoDecoder", () => ({ decodeVideoThumbnail: vi.fn() }));
@@ -15,28 +15,26 @@ Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
15
15
  });
16
16
 
17
17
  let host: HTMLDivElement;
18
- let root: Root | null = null;
19
18
 
20
19
  beforeEach(() => {
21
20
  globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
22
- host = document.createElement("div");
23
- document.body.append(host);
21
+ host = document.body.appendChild(document.createElement("div"));
24
22
  });
25
23
 
26
24
  afterEach(() => {
27
- act(() => root?.unmount());
28
- root = null;
29
25
  thumbnailScheduler.invalidateProject("p");
30
26
  vi.clearAllMocks();
31
- document.body.innerHTML = "";
32
27
  });
33
28
 
29
+ // Registered after the reset above, so each test's strip unmounts before the cache is cleared.
30
+ const harness = createHappyDomRootHarness();
31
+
34
32
  async function render(width = 0, height = 40) {
35
33
  Object.defineProperty(host, "clientWidth", { configurable: true, value: width });
36
34
  Object.defineProperty(host, "clientHeight", { configurable: true, value: height });
37
- root = createRoot(host);
35
+ const root = harness.mount(host);
38
36
  await act(async () => {
39
- root!.render(
37
+ root.render(
40
38
  <VideoThumbnail
41
39
  videoSrc="/api/projects/p/preview/assets/clip.mp4"
42
40
  label=""
@@ -95,6 +93,115 @@ describe("VideoThumbnail", () => {
95
93
  expect(tiles).toEqual(["blob:0", "blob:2", "blob:4", "blob:5", "blob:7"]);
96
94
  });
97
95
 
96
+ describe("on a 10-minute clip at full zoom", () => {
97
+ const frames: FrameRequestCallback[] = [];
98
+ const originalIntersectionObserver = globalThis.IntersectionObserver;
99
+ let left = -432_000;
100
+ let scrolled = 0;
101
+ let reportGapNearScreen: () => void = () => {};
102
+
103
+ beforeEach(() => {
104
+ vi.mocked(decodeVideoThumbnail).mockResolvedValue({
105
+ value: { kind: "filmstrip", urls: ["blob:a", "blob:b"], aspect: 16 / 9 },
106
+ weight: 256,
107
+ });
108
+ vi.spyOn(window, "requestAnimationFrame").mockImplementation((frame) => frames.push(frame));
109
+ globalThis.IntersectionObserver = class {
110
+ observed = new Set<Element>();
111
+ constructor(private readonly callback: IntersectionObserverCallback) {
112
+ reportGapNearScreen = () =>
113
+ callback(
114
+ [...this.observed].map(
115
+ (target) => ({ isIntersecting: true, target }) as IntersectionObserverEntry,
116
+ ),
117
+ this as unknown as IntersectionObserver,
118
+ );
119
+ }
120
+ observe(target: Element) {
121
+ this.observed.add(target);
122
+ queueMicrotask(() =>
123
+ this.callback(
124
+ [{ isIntersecting: true, target } as IntersectionObserverEntry],
125
+ this as unknown as IntersectionObserver,
126
+ ),
127
+ );
128
+ }
129
+ unobserve(target: Element) {
130
+ this.observed.delete(target);
131
+ }
132
+ disconnect() {
133
+ this.observed.clear();
134
+ }
135
+ } as unknown as typeof IntersectionObserver;
136
+ left = -432_000;
137
+ scrolled = 0;
138
+ host.getBoundingClientRect = () =>
139
+ ({ left, top: 0, width: 600 * 1440, height: 40 }) as DOMRect;
140
+ const scroller = document.body.appendChild(document.createElement("div"));
141
+ scroller.setAttribute("data-timeline-scroll-viewport", "");
142
+ Object.defineProperty(scroller, "scrollLeft", { configurable: true, get: () => scrolled });
143
+ scroller.append(host);
144
+ });
145
+
146
+ const scrollTo = (nextLeft: number) => {
147
+ scrolled += left - nextLeft;
148
+ left = nextLeft;
149
+ host.dispatchEvent(new Event("scroll"));
150
+ };
151
+
152
+ afterEach(() => {
153
+ frames.length = 0;
154
+ vi.restoreAllMocks();
155
+ globalThis.IntersectionObserver = originalIntersectionObserver;
156
+ });
157
+
158
+ const settle = () =>
159
+ act(async () => {
160
+ await Promise.resolve();
161
+ for (const frame of frames.splice(0)) frame(0);
162
+ });
163
+
164
+ const expectTilesCoverTheWindow = () => {
165
+ const tiles = host.querySelectorAll("img").length;
166
+ const skipped = parseFloat(
167
+ (host.querySelector("img")!.closest(".flex") as HTMLElement).style.paddingLeft,
168
+ );
169
+ expect(tiles).toBeLessThan(60);
170
+ expect(skipped).toBeLessThanOrEqual(-left);
171
+ expect(skipped + tiles * 71).toBeGreaterThanOrEqual(-left + window.innerWidth);
172
+ };
173
+
174
+ it("mounts only the tiles in view, and follows each scroll", async () => {
175
+ await render(600 * 1440, 40);
176
+ expectTilesCoverTheWindow();
177
+
178
+ for (const scrolledTo of [100_000, 300_000]) {
179
+ scrollTo(-scrolledTo);
180
+ await settle();
181
+ expectTilesCoverTheWindow();
182
+ }
183
+ });
184
+
185
+ it("mounts no tiles once the clip is wholly off screen", async () => {
186
+ await render(600 * 1440, 40);
187
+
188
+ scrollTo(-(600 * 1440 + 5_000));
189
+ await settle();
190
+
191
+ expect(host.querySelectorAll("img")).toHaveLength(0);
192
+ });
193
+
194
+ it("follows the strip when something else moves it, as a drag moves its ghost", async () => {
195
+ await render(600 * 1440, 40);
196
+
197
+ left = -430_500;
198
+ reportGapNearScreen();
199
+ await settle();
200
+
201
+ expectTilesCoverTheWindow();
202
+ });
203
+ });
204
+
98
205
  it("issues a single decode job for a narrow clip", async () => {
99
206
  vi.mocked(decodeVideoThumbnail).mockResolvedValue({
100
207
  value: { kind: "image", url: "blob:poster", aspect: 16 / 9 },
@@ -7,6 +7,7 @@ import {
7
7
  type ThumbnailSnapshot,
8
8
  } from "../lib/thumbnailScheduler";
9
9
  import { decodeVideoThumbnail } from "../lib/thumbnailVideoDecoder";
10
+ import { ThumbnailTiles } from "./ThumbnailTiles";
10
11
  import { computeThumbnailStrip, quantizeThumbnailFrameCount } from "./thumbnailUtils";
11
12
 
12
13
  interface VideoThumbnailProps {
@@ -85,7 +86,7 @@ export const VideoThumbnail = memo(function VideoThumbnail({
85
86
  sessionEpoch = 0,
86
87
  priority = "visible",
87
88
  }: VideoThumbnailProps) {
88
- const [container, setContainerRef] = useThumbnailStripSize();
89
+ const [container, setContainerRef, watchGap] = useThumbnailStripSize();
89
90
  const requestFrameCount = quantizeThumbnailFrameCount(
90
91
  computeThumbnailStrip(container.width, 16 / 9, container.height).frameCount,
91
92
  );
@@ -122,8 +123,13 @@ export const VideoThumbnail = memo(function VideoThumbnail({
122
123
  return (
123
124
  <div ref={setContainerRef} className="absolute inset-0 overflow-hidden">
124
125
  {urls.length > 0 && (
125
- <div className="absolute inset-0 flex">
126
- {Array.from({ length: frameCount }, (_, index) => {
126
+ <ThumbnailTiles
127
+ strip={container}
128
+ frameW={frameW}
129
+ frameCount={frameCount}
130
+ watchGap={watchGap}
131
+ >
132
+ {(index) => {
127
133
  const src = urls[Math.round((index * (urls.length - 1)) / Math.max(1, frameCount - 1))];
128
134
  return (
129
135
  <div
@@ -139,8 +145,8 @@ export const VideoThumbnail = memo(function VideoThumbnail({
139
145
  />
140
146
  </div>
141
147
  );
142
- })}
143
- </div>
148
+ }}
149
+ </ThumbnailTiles>
144
150
  )}
145
151
  {snapshot.status === "loading" && urls.length === 0 && (
146
152
  <div
@@ -0,0 +1,20 @@
1
+ // @vitest-environment jsdom
2
+ import { expect, it } from "vitest";
3
+ import { groupInfoFor } from "./timelineGroupInfo";
4
+
5
+ it("sees a group the preview adds, whichever window built its node", () => {
6
+ const frame = document.body.appendChild(document.createElement("iframe"));
7
+ const doc = frame.contentDocument!;
8
+ const member = doc.createElement("audio");
9
+ member.id = "a1";
10
+ member.setAttribute("data-audio-group", "g1");
11
+ doc.body.append(member);
12
+ expect(groupInfoFor(doc, "g1").label).toBe("g1");
13
+ const group = doc.createElement("hf-audio-group");
14
+ group.id = "g1";
15
+ group.setAttribute("data-label", "Drums");
16
+ doc.body.append(group);
17
+
18
+ expect(groupInfoFor(doc, "g1").label).toBe("Drums");
19
+ frame.remove();
20
+ });
@@ -6,6 +6,7 @@
6
6
  * cache, its revision counter, the observer that bumps it, and the one reader.
7
7
  */
8
8
 
9
+ import { isElementNode } from "@hyperframes/core/runtime/dom-realm";
9
10
  import {
10
11
  HF_AUDIO_GROUP_ATTR,
11
12
  HF_AUDIO_GROUP_TAG,
@@ -52,7 +53,7 @@ function countGroupChanges(doc: Document, records: readonly MutationRecord[]): v
52
53
  record.type !== "childList" ||
53
54
  [...record.addedNodes, ...record.removedNodes].some(
54
55
  (node) =>
55
- node instanceof Element &&
56
+ isElementNode(node) &&
56
57
  (node.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG ||
57
58
  node.hasAttribute(HF_AUDIO_GROUP_ATTR) ||
58
59
  node.querySelector?.(`${HF_AUDIO_GROUP_TAG},[${HF_AUDIO_GROUP_ATTR}]`) != null),