@hyperframes/studio 0.8.125 → 0.8.126

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/assets/{hyperframes-player-CmIh-wq9.js → hyperframes-player-BbbN-wWN.js} +1 -1
  2. package/dist/assets/{index-CrYoEZ5p.js → index-B8kU2xXl.js} +170 -170
  3. package/dist/assets/{index-yCK8osBg.js → index-Cll7uTNy.js} +1 -1
  4. package/dist/assets/{index-4LRR6o2F.js → index-nvLF29ZH.js} +1 -1
  5. package/dist/index.html +1 -1
  6. package/dist/index.js +4251 -3984
  7. package/dist/index.js.map +1 -1
  8. package/package.json +9 -8
  9. package/src/components/editor/DomEditSelectionChrome.test.tsx +5 -0
  10. package/src/components/editor/DomEditSelectionChrome.tsx +1 -0
  11. package/src/components/editor/MotionPathNode.tsx +56 -0
  12. package/src/components/editor/MotionPathOverlay.layerPress.test.tsx +140 -48
  13. package/src/components/editor/MotionPathOverlay.test.tsx +45 -0
  14. package/src/components/editor/MotionPathOverlay.tsx +30 -61
  15. package/src/components/editor/canvasContextMenuZOrder.ts +1 -10
  16. package/src/components/editor/manualEditsSeekReapply.ts +3 -6
  17. package/src/components/editor/manualOffsetDrag.ts +2 -3
  18. package/src/components/editor/motionPathGeometry.test.ts +48 -1
  19. package/src/components/editor/motionPathGeometry.ts +46 -8
  20. package/src/components/editor/motionPathHome.ts +75 -0
  21. package/src/components/editor/motionPathLayerNode.test.ts +43 -35
  22. package/src/components/editor/motionPathLayerNode.ts +20 -16
  23. package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +20 -0
  24. package/src/components/editor/studioMotion.ts +7 -19
  25. package/src/components/editor/useMotionPathData.test.tsx +236 -0
  26. package/src/components/editor/useMotionPathData.ts +23 -61
  27. package/src/components/sidebar/CompositionsTab.thumbnails.test.tsx +1 -0
  28. package/src/hooks/gsapParsedTween.ts +16 -0
  29. package/src/hooks/gsapRuntimeKeyframes.realGsap.test.ts +48 -0
  30. package/src/hooks/gsapRuntimeKeyframes.ts +10 -3
  31. package/src/hooks/gsapShared.ts +2 -1
  32. package/src/hooks/gsapShared.writeTarget.test.ts +10 -0
  33. package/src/hooks/intersectionObserverTestUtils.ts +16 -0
  34. package/src/hooks/resizeObserverTestUtils.ts +9 -2
  35. package/src/hooks/timelineEditingHelpers.ts +2 -9
  36. package/src/hooks/useThumbnailStripSize.test.tsx +236 -1
  37. package/src/hooks/useThumbnailStripSize.ts +206 -17
  38. package/src/noWindowBoundDomChecks.test.ts +31 -0
  39. package/src/player/components/CompositionThumbnail.tsx +10 -6
  40. package/src/player/components/ImageThumbnail.tsx +11 -5
  41. package/src/player/components/ThumbnailTiles.test.tsx +30 -0
  42. package/src/player/components/ThumbnailTiles.tsx +41 -0
  43. package/src/player/components/VideoThumbnail.test.tsx +116 -9
  44. package/src/player/components/VideoThumbnail.tsx +11 -5
  45. package/src/player/lib/timelineGroupInfo.realm.test.ts +20 -0
  46. package/src/player/lib/timelineGroupInfo.ts +2 -1
  47. package/src/webmcp/webmcpTestUtils.ts +2 -2
@@ -1,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),
@@ -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
- const HTMLElementCtor = doc.defaultView?.HTMLElement;
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;