@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.
- package/dist/assets/{hyperframes-player-CmIh-wq9.js → hyperframes-player-uqzDaxVL.js} +1 -1
- package/dist/assets/{index-4LRR6o2F.js → index-CLBNYlDY.js} +1 -1
- package/dist/assets/{index-CrYoEZ5p.js → index-COhbL1xq.js} +177 -177
- package/dist/assets/{index-yCK8osBg.js → index-DXSYlqmV.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +5368 -4986
- package/dist/index.js.map +1 -1
- package/package.json +9 -8
- package/src/components/editor/DomEditSelectionChrome.test.tsx +5 -0
- package/src/components/editor/DomEditSelectionChrome.tsx +1 -0
- package/src/components/editor/MotionPathNode.tsx +56 -0
- package/src/components/editor/MotionPathOverlay.layerPress.test.tsx +167 -33
- package/src/components/editor/MotionPathOverlay.test.tsx +45 -0
- package/src/components/editor/MotionPathOverlay.tsx +49 -86
- package/src/components/editor/canvasContextMenuZOrder.ts +1 -10
- package/src/components/editor/manualEditsSeekReapply.ts +3 -6
- package/src/components/editor/manualOffsetDrag.ts +2 -3
- package/src/components/editor/motionPathCommit.nodeDrop.test.ts +212 -0
- package/src/components/editor/motionPathCommit.ts +58 -0
- package/src/components/editor/motionPathGeometry.test.ts +61 -1
- package/src/components/editor/motionPathGeometry.ts +50 -10
- package/src/components/editor/motionPathHome.ts +75 -0
- package/src/components/editor/motionPathLayerNode.test.ts +43 -35
- package/src/components/editor/motionPathLayerNode.ts +20 -16
- package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +20 -0
- package/src/components/editor/studioMotion.ts +7 -19
- package/src/components/editor/useMotionPathData.test.tsx +236 -0
- package/src/components/editor/useMotionPathData.ts +23 -61
- package/src/components/sidebar/CompositionsTab.thumbnails.test.tsx +1 -0
- package/src/hooks/gsapDragCommit.ts +33 -1
- package/src/hooks/gsapEditOutcome.ts +27 -2
- package/src/hooks/gsapParsedTween.test-helpers.ts +28 -1
- package/src/hooks/gsapParsedTween.ts +39 -2
- package/src/hooks/gsapRuntimeBridge.ts +7 -1
- package/src/hooks/gsapRuntimeKeyframes.realGsap.test.ts +48 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +11 -4
- package/src/hooks/gsapShared.test.ts +1 -0
- package/src/hooks/gsapShared.ts +8 -3
- package/src/hooks/gsapShared.writeTarget.test.ts +10 -0
- package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +107 -5
- package/src/hooks/gsapValueAtPlayhead.test.ts +129 -27
- package/src/hooks/gsapValueAtPlayhead.ts +46 -6
- package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +72 -1
- package/src/hooks/gsapWholePropertyOffsetCommit.ts +6 -1
- package/src/hooks/intersectionObserverTestUtils.ts +16 -0
- package/src/hooks/resizeObserverTestUtils.ts +9 -2
- package/src/hooks/timelineEditingHelpers.ts +2 -9
- package/src/hooks/useCommitPreflightCapabilities.ts +2 -2
- package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +25 -0
- package/src/hooks/useThumbnailStripSize.test.tsx +236 -1
- package/src/hooks/useThumbnailStripSize.ts +206 -17
- package/src/noWindowBoundDomChecks.test.ts +31 -0
- package/src/player/components/CompositionThumbnail.tsx +10 -6
- package/src/player/components/ImageThumbnail.tsx +11 -5
- package/src/player/components/ThumbnailTiles.test.tsx +30 -0
- package/src/player/components/ThumbnailTiles.tsx +41 -0
- package/src/player/components/VideoThumbnail.test.tsx +116 -9
- package/src/player/components/VideoThumbnail.tsx +11 -5
- package/src/player/lib/timelineGroupInfo.realm.test.ts +20 -0
- package/src/player/lib/timelineGroupInfo.ts +2 -1
- package/src/webmcp/webmcpTestUtils.ts +2 -2
|
@@ -1,31 +1,220 @@
|
|
|
1
|
-
import { useCallback,
|
|
2
|
-
import {
|
|
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
|
|
10
|
-
prev.width === width && prev.height === height ? prev : { width, height };
|
|
11
|
+
const IN_VIEW_CHUNK_PX = 512;
|
|
11
12
|
|
|
12
|
-
|
|
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
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
173
|
-
|
|
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
|
-
{
|
|
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
|
-
</
|
|
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
|
-
<
|
|
50
|
-
{
|
|
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
|
-
</
|
|
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 =
|
|
35
|
+
const root = harness.mount(host);
|
|
38
36
|
await act(async () => {
|
|
39
|
-
root
|
|
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
|
-
<
|
|
126
|
-
{
|
|
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
|
-
</
|
|
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
|
|
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),
|