@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.
- package/dist/assets/{hyperframes-player-CmIh-wq9.js → hyperframes-player-BbbN-wWN.js} +1 -1
- package/dist/assets/{index-CrYoEZ5p.js → index-B8kU2xXl.js} +170 -170
- package/dist/assets/{index-yCK8osBg.js → index-Cll7uTNy.js} +1 -1
- package/dist/assets/{index-4LRR6o2F.js → index-nvLF29ZH.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +4251 -3984
- 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 +140 -48
- package/src/components/editor/MotionPathOverlay.test.tsx +45 -0
- package/src/components/editor/MotionPathOverlay.tsx +30 -61
- 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/motionPathGeometry.test.ts +48 -1
- package/src/components/editor/motionPathGeometry.ts +46 -8
- 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/gsapParsedTween.ts +16 -0
- package/src/hooks/gsapRuntimeKeyframes.realGsap.test.ts +48 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +10 -3
- package/src/hooks/gsapShared.ts +2 -1
- package/src/hooks/gsapShared.writeTarget.test.ts +10 -0
- package/src/hooks/intersectionObserverTestUtils.ts +16 -0
- package/src/hooks/resizeObserverTestUtils.ts +9 -2
- package/src/hooks/timelineEditingHelpers.ts +2 -9
- 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,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),
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* existing `hooks/domSelectionTestHarness.ts` convention.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { isHtmlElement } from "@hyperframes/core/runtime/dom-realm";
|
|
8
9
|
import { expect } from "vitest";
|
|
9
10
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
10
11
|
import type { ToolFailure, ToolResult } from "./toolResult";
|
|
@@ -31,8 +32,7 @@ export function previewDoc(html: string): Document {
|
|
|
31
32
|
export function previewElement(html: string, id: string): HTMLElement {
|
|
32
33
|
const doc = previewDoc(html);
|
|
33
34
|
const element = doc.getElementById(id);
|
|
34
|
-
|
|
35
|
-
if (!HTMLElementCtor || !(element instanceof HTMLElementCtor)) {
|
|
35
|
+
if (!isHtmlElement(element)) {
|
|
36
36
|
throw new Error(`expected preview element #${id}`);
|
|
37
37
|
}
|
|
38
38
|
return element;
|