@hyperframes/studio 0.8.144 → 0.8.145

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 (40) hide show
  1. package/dist/assets/{hyperframes-player-Bw_jIaSX.js → hyperframes-player-DZ5JrMDg.js} +1 -1
  2. package/dist/assets/{index-DyH9dT7b.js → index-BOs6C0aZ.js} +1 -1
  3. package/dist/assets/{index-Cj7W_c7p.js → index-BqFRhNZa.js} +1 -1
  4. package/dist/assets/{index-BVM9NuWl.js → index-D8W0a0Z9.js} +131 -131
  5. package/dist/{chunk-3GHTHPZD.js → chunk-6JY3MTIX.js} +101 -2
  6. package/dist/chunk-6JY3MTIX.js.map +1 -0
  7. package/dist/{chunk-G3EDKRZH.js → chunk-NZPJUZKT.js} +99 -3
  8. package/dist/chunk-NZPJUZKT.js.map +1 -0
  9. package/dist/{chunk-PEAKCOUW.js → chunk-TP6D3U37.js} +3 -100
  10. package/dist/chunk-TP6D3U37.js.map +1 -0
  11. package/dist/{domEditingLayers-PYST4G45.js → domEditingLayers-PTP2ICGM.js} +3 -3
  12. package/dist/index.d.ts +8 -5
  13. package/dist/index.html +1 -1
  14. package/dist/index.js +410 -429
  15. package/dist/index.js.map +1 -1
  16. package/dist/player.js +2 -2
  17. package/package.json +8 -8
  18. package/src/components/StudioLeftPanels.tsx +2 -2
  19. package/src/components/editor/LayersPanel.test.ts +4 -4
  20. package/src/components/editor/LayersPanel.tsx +2 -2
  21. package/src/components/editor/TopologyLens.test.tsx +5 -4
  22. package/src/components/renders/GpuEncodingToggle.tsx +28 -0
  23. package/src/components/renders/RenderQueue.test.tsx +20 -2
  24. package/src/components/renders/RenderQueue.tsx +13 -1
  25. package/src/components/renders/RenderQueuePanel.tsx +2 -1
  26. package/src/components/renders/renderSettings.ts +5 -2
  27. package/src/components/renders/useRenderQueue.ts +3 -0
  28. package/src/components/renders/useRenderQueueComposition.test.tsx +12 -0
  29. package/src/hooks/useConsoleErrorCapture.test.tsx +21 -2
  30. package/src/hooks/useConsoleErrorCapture.ts +3 -2
  31. package/src/index.ts +1 -0
  32. package/src/player/components/Player.test.ts +44 -0
  33. package/src/player/components/Player.tsx +11 -0
  34. package/src/player/lib/playbackTypes.ts +1 -0
  35. package/src/player/sceneSwap.test.ts +27 -1
  36. package/src/player/sceneSwap.ts +16 -2
  37. package/dist/chunk-3GHTHPZD.js.map +0 -1
  38. package/dist/chunk-G3EDKRZH.js.map +0 -1
  39. package/dist/chunk-PEAKCOUW.js.map +0 -1
  40. /package/dist/{domEditingLayers-PYST4G45.js.map → domEditingLayers-PTP2ICGM.js.map} +0 -0
package/dist/player.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  Player
3
- } from "./chunk-G3EDKRZH.js";
3
+ } from "./chunk-NZPJUZKT.js";
4
4
  import "./chunk-Y2OHHNM2.js";
5
- import "./chunk-3GHTHPZD.js";
5
+ import "./chunk-6JY3MTIX.js";
6
6
  import "./chunk-PZ5AY32C.js";
7
7
  export {
8
8
  Player
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.144",
3
+ "version": "0.8.145",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,12 +60,12 @@
60
60
  "dockview-react": "8.3.1",
61
61
  "gsap": "^3.13.0",
62
62
  "mediabunny": "^1.45.3",
63
- "@hyperframes/core": "0.8.144",
64
- "@hyperframes/parsers": "0.8.144",
65
- "@hyperframes/sdk": "0.8.144",
66
- "@hyperframes/player": "0.8.144",
67
- "@hyperframes/shader-transitions": "0.8.144",
68
- "@hyperframes/studio-server": "0.8.144"
63
+ "@hyperframes/core": "0.8.145",
64
+ "@hyperframes/sdk": "0.8.145",
65
+ "@hyperframes/player": "0.8.145",
66
+ "@hyperframes/shader-transitions": "0.8.145",
67
+ "@hyperframes/parsers": "0.8.145",
68
+ "@hyperframes/studio-server": "0.8.145"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@tailwindcss/vite": "^4.3.3",
@@ -82,7 +82,7 @@
82
82
  "vite": "^6.4.2",
83
83
  "vitest": "^4.1.11",
84
84
  "zustand": "^5.0.0",
85
- "@hyperframes/producer": "0.8.144"
85
+ "@hyperframes/producer": "0.8.145"
86
86
  },
87
87
  "peerDependencies": {
88
88
  "react": "19",
@@ -75,8 +75,8 @@ export const StudioLeftPanels = memo(function StudioLeftPanels({
75
75
  return;
76
76
  }
77
77
  await waitForPendingDomEditSaves();
78
- const { format, quality, fps } = getPersistedRenderSettings();
79
- await renderQueue.startRender({ composition: comp, format, quality, fps });
78
+ const { format, quality, fps, gpu } = getPersistedRenderSettings();
79
+ await renderQueue.startRender({ composition: comp, format, quality, fps, gpu });
80
80
  },
81
81
  [renderQueue, waitForPendingDomEditSaves],
82
82
  );
@@ -9,7 +9,7 @@ import type { DomEditLayerItem } from "./domEditingTypes";
9
9
  import { createRafThrottle, LayersPanel, sortLayersByZIndex } from "./LayersPanel";
10
10
  import { isLayerDraggable } from "./useLayerDrag";
11
11
  import { liveTime } from "../../player";
12
- import { sceneSwapFor } from "../../player/sceneSwap";
12
+ import { announcePreviewDocumentLoaded, sceneSwapFor } from "../../player/sceneSwap";
13
13
 
14
14
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
15
15
 
@@ -284,12 +284,12 @@ describe("LayersPanel preview promotion", () => {
284
284
  mocks.collect.mockClear();
285
285
 
286
286
  act(() => {
287
- a.dispatchEvent(new Event("load"));
287
+ announcePreviewDocumentLoaded(a);
288
288
  });
289
289
  expect(mocks.collect).not.toHaveBeenCalled();
290
290
 
291
291
  act(() => {
292
- b.dispatchEvent(new Event("load"));
292
+ announcePreviewDocumentLoaded(b);
293
293
  });
294
294
  expect(collectedRootIds()).toEqual(["b"]);
295
295
  });
@@ -325,7 +325,7 @@ describe("LayersPanel preview promotion", () => {
325
325
  mocks.previewIframeRef.current = iframe;
326
326
  usePreviewIframeStore.setState({ iframe });
327
327
  act(() => root.render(createElement(LayersPanel)));
328
- act(() => void iframe.dispatchEvent(new Event("load")));
328
+ act(() => void announcePreviewDocumentLoaded(iframe));
329
329
 
330
330
  const rows = [...host.querySelectorAll("[data-layer-index]")];
331
331
  expect(rows).toHaveLength(2);
@@ -89,8 +89,8 @@ interface CollapsedState {
89
89
  [key: string]: boolean;
90
90
  }
91
91
 
92
- /** Studio state the panel reads; passed by hosts outside Studio's providers. Keep previewIframeRef stable,
93
- * and bump refreshKey when the preview document is rebuilt without an iframe load. */
92
+ /** Studio state the panel reads; passed by hosts outside Studio's providers. Keep previewIframeRef stable;
93
+ * a host with its own player calls announcePreviewDocumentLoaded on each new page, or bumps refreshKey. */
94
94
  export type LayersPanelHost = Pick<
95
95
  StudioShellValue,
96
96
  "previewIframeRef" | "activeCompPath" | "showToast"
@@ -5,6 +5,7 @@ import { createRoot, type Root } from "react-dom/client";
5
5
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
6
  import { studioEditLifecycle, type StudioWriteResult } from "../../webmcp/writeCoordinator";
7
7
  import { usePreviewIframeStore } from "../../player/store/previewIframeStore";
8
+ import { announcePreviewDocumentLoaded } from "../../player/sceneSwap";
8
9
  import { TopologyLens } from "./TopologyLens";
9
10
 
10
11
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -259,7 +260,7 @@ describe("TopologyLens", () => {
259
260
  mount();
260
261
  begin();
261
262
 
262
- act(() => iframe?.dispatchEvent(new Event("load")));
263
+ act(() => announcePreviewDocumentLoaded(iframe!));
263
264
  expect(host?.querySelector('[data-topology-lens="hidden"]')).not.toBeNull();
264
265
 
265
266
  begin();
@@ -286,7 +287,7 @@ describe("TopologyLens", () => {
286
287
  root = null;
287
288
 
288
289
  expect(vi.getTimerCount()).toBe(1);
289
- expect(removeListener).toHaveBeenCalledWith("load", expect.any(Function));
290
+ expect(removeListener).toHaveBeenCalledWith("hf-preview-document-loaded", expect.any(Function));
290
291
  expect(studioEditLifecycle.getSnapshot()).toMatchObject({ phase: "dispatching" });
291
292
  act(() => vi.advanceTimersByTime(0));
292
293
  expect(vi.getTimerCount()).toBe(0);
@@ -312,10 +313,10 @@ describe("TopologyLens", () => {
312
313
  ref.current = b;
313
314
  usePreviewIframeStore.getState().setIframe(b);
314
315
  });
315
- act(() => a.dispatchEvent(new Event("load")));
316
+ act(() => announcePreviewDocumentLoaded(a));
316
317
  expect(host.querySelector('[data-topology-lens="hidden"]')).toBeNull();
317
318
 
318
- act(() => b.dispatchEvent(new Event("load")));
319
+ act(() => announcePreviewDocumentLoaded(b));
319
320
  expect(host.querySelector('[data-topology-lens="hidden"]')).not.toBeNull();
320
321
  usePreviewIframeStore.setState({ iframe: null });
321
322
  });
@@ -0,0 +1,28 @@
1
+ export function GpuEncodingToggle({
2
+ format,
3
+ checked,
4
+ disabled,
5
+ onToggle,
6
+ }: {
7
+ format: "mp4" | "webm" | "mov";
8
+ checked: boolean;
9
+ disabled: boolean;
10
+ onToggle: () => void;
11
+ }) {
12
+ // GPU encoders exist for H.264/H.265 only, so only MP4 can use one.
13
+ if (format !== "mp4") return null;
14
+ return (
15
+ <label
16
+ className={`flex items-center gap-2 text-step-10 text-text-4 ${disabled ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}`}
17
+ >
18
+ <input
19
+ type="checkbox"
20
+ checked={checked}
21
+ disabled={disabled}
22
+ onChange={onToggle}
23
+ className="accent-studio-accent"
24
+ />
25
+ <span>Use GPU encoding</span>
26
+ </label>
27
+ );
28
+ }
@@ -142,7 +142,7 @@ describe("RenderQueue controls", () => {
142
142
  exportButtonIn(host).dispatchEvent(new MouseEvent("click", { bubbles: true }));
143
143
  });
144
144
 
145
- expect(onStartRender).toHaveBeenCalledWith("mp4", "standard", "landscape-4k", 30);
145
+ expect(onStartRender).toHaveBeenCalledWith("mp4", "standard", "landscape-4k", 30, false);
146
146
  });
147
147
 
148
148
  it("refuses a resolution the composition cannot reach, and says why", async () => {
@@ -172,7 +172,23 @@ describe("RenderQueue controls", () => {
172
172
  exportButtonIn(host).dispatchEvent(new MouseEvent("click", { bubbles: true }));
173
173
  });
174
174
 
175
- expect(onStartRender).toHaveBeenCalledWith("mp4", "standard", "auto", 30);
175
+ expect(onStartRender).toHaveBeenCalledWith("mp4", "standard", "auto", 30, false);
176
+ });
177
+
178
+ it("exports with GPU encoding once the box is ticked, and remembers it", () => {
179
+ const onStartRender: Mock<StartRenderHandler> = vi.fn();
180
+ const host = mountRenderQueue(onStartRender);
181
+ const gpu = [...host.querySelectorAll("label")].find((label) =>
182
+ label.textContent?.includes("Use GPU encoding"),
183
+ );
184
+
185
+ act(() => gpu?.querySelector("input")?.click());
186
+ act(() => {
187
+ exportButtonIn(host).dispatchEvent(new MouseEvent("click", { bubbles: true }));
188
+ });
189
+
190
+ expect(onStartRender).toHaveBeenCalledWith("mp4", "standard", "auto", 30, true);
191
+ expect(getPersistedRenderSettings().gpu).toBe(true);
176
192
  });
177
193
 
178
194
  it("persists a changed format as the literal union value", async () => {
@@ -185,7 +201,9 @@ describe("RenderQueue controls", () => {
185
201
  format: "mov",
186
202
  quality: "standard",
187
203
  fps: 30,
204
+ gpu: false,
188
205
  });
206
+ expect(host.textContent).not.toContain("Use GPU encoding");
189
207
  });
190
208
  });
191
209
 
@@ -11,6 +11,7 @@ import { Tooltip } from "../ui/Tooltip";
11
11
  import { resolveFloatingPanelPosition, type FloatingPosition } from "../editor/floatingPanel";
12
12
  import type { RenderJob, ResolutionPreset } from "./useRenderQueue";
13
13
  import { getPersistedRenderSettings, persistRenderSettings } from "./renderSettings";
14
+ import { GpuEncodingToggle } from "./GpuEncodingToggle";
14
15
  import { trackStudioEvent } from "../../utils/studioTelemetry";
15
16
 
16
17
  export interface CompositionDimensions {
@@ -23,6 +24,7 @@ export type StartRenderHandler = (
23
24
  quality: "draft" | "standard" | "high",
24
25
  resolution: ResolutionPreset | "auto",
25
26
  fps: 24 | 30 | 60,
27
+ gpu?: boolean,
26
28
  ) => void | Promise<void>;
27
29
 
28
30
  export interface RenderQueueProps {
@@ -303,6 +305,7 @@ function FormatExportButton({
303
305
  const [quality, setQuality] = useState<"draft" | "standard" | "high">(persisted.quality);
304
306
  const [resolution, setResolution] = useState<RenderScale>("auto");
305
307
  const [fps, setFps] = useState<24 | 30 | 60>(persisted.fps);
308
+ const [gpu, setGpu] = useState(persisted.gpu);
306
309
 
307
310
  // Only a definite "not installed" blocks Export. A null status means the
308
311
  // probe gave no answer, and refusing to export on no answer would break
@@ -385,6 +388,15 @@ function FormatExportButton({
385
388
  </div>
386
389
  )}
387
390
  </div>
391
+ <GpuEncodingToggle
392
+ format={format}
393
+ checked={gpu}
394
+ disabled={isRendering}
395
+ onToggle={() => {
396
+ setGpu(!gpu);
397
+ persistRenderSettings(format, quality, fps, !gpu);
398
+ }}
399
+ />
388
400
  <Button
389
401
  variant="primary"
390
402
  size="md"
@@ -398,7 +410,7 @@ function FormatExportButton({
398
410
  if (isRendering || missingFfmpeg) return;
399
411
  const outputResolution = resolveResolution(resolution, compositionDimensions);
400
412
  trackStudioEvent("render_start", { format, quality, resolution: outputResolution, fps });
401
- void onStartRender(format, quality, outputResolution, fps);
413
+ void onStartRender(format, quality, outputResolution, fps, gpu);
402
414
  }}
403
415
  // Width only. A type size or a weight here would win the merge against
404
416
  // the size recipe and leave this Export a step away from the header's
@@ -29,7 +29,7 @@ export const RenderQueuePanel = memo(function RenderQueuePanel() {
29
29
  ffmpeg={renderQueue.ffmpeg}
30
30
  ffmpegChecking={renderQueue.ffmpegChecking}
31
31
  onRecheckFfmpeg={renderQueue.recheckFfmpeg}
32
- onStartRender={async (format, quality, resolution, fps) => {
32
+ onStartRender={async (format, quality, resolution, fps, gpu) => {
33
33
  await waitForPendingDomEditSaves();
34
34
  // No `composition`: startRender targets the active one by default.
35
35
  await renderQueue.startRender({
@@ -37,6 +37,7 @@ export const RenderQueuePanel = memo(function RenderQueuePanel() {
37
37
  quality,
38
38
  format,
39
39
  resolution,
40
+ gpu,
40
41
  // Render what the user is previewing: active variable overrides
41
42
  // from the Variables panel ride along (undefined = defaults).
42
43
  variables: usePreviewVariablesStore.getState().values ?? undefined,
@@ -4,6 +4,7 @@ export interface PersistedRenderSettings {
4
4
  format: "mp4" | "webm" | "mov";
5
5
  quality: "draft" | "standard" | "high";
6
6
  fps: 24 | 30 | 60;
7
+ gpu: boolean;
7
8
  }
8
9
 
9
10
  export function getPersistedRenderSettings(): PersistedRenderSettings {
@@ -17,21 +18,23 @@ export function getPersistedRenderSettings(): PersistedRenderSettings {
17
18
  ? parsed.quality
18
19
  : "standard",
19
20
  fps: [24, 30, 60].includes(parsed.fps) ? parsed.fps : 30,
21
+ gpu: parsed.gpu === true,
20
22
  };
21
23
  }
22
24
  } catch {
23
25
  /* ignore */
24
26
  }
25
- return { format: "mp4", quality: "standard", fps: 30 };
27
+ return { format: "mp4", quality: "standard", fps: 30, gpu: false };
26
28
  }
27
29
 
28
30
  export function persistRenderSettings(
29
31
  format: PersistedRenderSettings["format"],
30
32
  quality: PersistedRenderSettings["quality"],
31
33
  fps: PersistedRenderSettings["fps"],
34
+ gpu: boolean = getPersistedRenderSettings().gpu,
32
35
  ): void {
33
36
  try {
34
- localStorage.setItem(RENDER_SETTINGS_KEY, JSON.stringify({ format, quality, fps }));
37
+ localStorage.setItem(RENDER_SETTINGS_KEY, JSON.stringify({ format, quality, fps, gpu }));
35
38
  } catch {
36
39
  /* ignore */
37
40
  }
@@ -32,6 +32,7 @@ export interface StartRenderOptions {
32
32
  format?: "mp4" | "webm" | "mov";
33
33
  /** `"auto"` (default) renders at the composition's authored dimensions. */
34
34
  resolution?: ResolutionPreset | "auto";
35
+ gpu?: boolean;
35
36
  /**
36
37
  * Render a specific composition file. Omit it to render the composition the
37
38
  * user currently has open — only the sidebar's per-composition Render button
@@ -234,6 +235,7 @@ export function useRenderQueue(
234
235
  resolution?: string;
235
236
  composition?: string;
236
237
  variables?: Record<string, unknown>;
238
+ gpu?: boolean;
237
239
  telemetryDistinctId?: string;
238
240
  telemetryOptOut?: boolean;
239
241
  } = {
@@ -258,6 +260,7 @@ export function useRenderQueue(
258
260
  }
259
261
  if (resolution && resolution !== "auto") body.resolution = resolution;
260
262
  if (composition) body.composition = composition;
263
+ if (opts.gpu) body.gpu = true;
261
264
  if (opts.variables && Object.keys(opts.variables).length > 0) {
262
265
  body.variables = opts.variables;
263
266
  }
@@ -53,3 +53,15 @@ describe("render target composition", () => {
53
53
  expect(body["composition"]).toBeUndefined();
54
54
  });
55
55
  });
56
+
57
+ describe("GPU encoding", () => {
58
+ it("asks the server for GPU encoding when the export asks for it", async () => {
59
+ const body = await renderBody(null, { format: "mp4", gpu: true });
60
+ expect(body["gpu"]).toBe(true);
61
+ });
62
+
63
+ it("leaves GPU encoding off by default", async () => {
64
+ const body = await renderBody(null, { format: "mp4" });
65
+ expect(body["gpu"]).toBeUndefined();
66
+ });
67
+ });
@@ -4,6 +4,7 @@ import React, { act } from "react";
4
4
  import { createRoot, type Root } from "react-dom/client";
5
5
  import { afterEach, expect, it } from "vitest";
6
6
  import { STUDIO_PREVIEW_ERRORS } from "@hyperframes/core/studio-preview-mark";
7
+ import { announcePreviewDocumentLoaded } from "../player/sceneSwap";
7
8
  import { useConsoleErrorCapture } from "./useConsoleErrorCapture";
8
9
 
9
10
  Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
@@ -36,7 +37,7 @@ it("shows the errors a preview raised before its load, once each", async () => {
36
37
  root = createRoot(document.createElement("div"));
37
38
  await act(async () => root?.render(React.createElement(Harness, { iframe })));
38
39
  Object.assign(previewWindow, { [STUDIO_PREVIEW_ERRORS]: [raisedBeforeLoad] });
39
- await act(async () => iframe.dispatchEvent(new Event("load")));
40
+ await act(async () => announcePreviewDocumentLoaded(iframe));
40
41
 
41
42
  expect(shown()).toEqual([raisedBeforeLoad]);
42
43
  });
@@ -79,9 +80,27 @@ it("clears a document's errors when its load replaces it, and when the preview g
79
80
  previewWindow.dispatchEvent(new ErrorEvent("error", { message: "Uncaught Error: old" })),
80
81
  );
81
82
  Object.assign(previewWindow, { [STUDIO_PREVIEW_ERRORS]: ["Uncaught Error: new"] });
82
- await act(async () => iframe.dispatchEvent(new Event("load")));
83
+ await act(async () => announcePreviewDocumentLoaded(iframe));
83
84
  expect(shown()).toEqual(["Uncaught Error: new"]);
84
85
 
85
86
  await act(async () => root?.render(React.createElement(NullableHarness, { frame: null })));
86
87
  expect(capture.consoleErrors).toBeNull();
88
+ await act(async () => announcePreviewDocumentLoaded(iframe));
89
+ expect(capture.consoleErrors).toBeNull();
90
+ });
91
+
92
+ it("keeps a page's errors through its own late load, and clears them for the next announced page", async () => {
93
+ const { iframe, previewWindow } = previewFrame();
94
+ root = createRoot(document.createElement("div"));
95
+ await act(async () => root?.render(React.createElement(Harness, { iframe })));
96
+ await act(async () =>
97
+ previewWindow.dispatchEvent(new ErrorEvent("error", { message: "Uncaught Error: kept" })),
98
+ );
99
+
100
+ await act(async () => iframe.dispatchEvent(new Event("load")));
101
+ expect(shown()).toEqual(["Uncaught Error: kept"]);
102
+
103
+ Object.assign(previewWindow, { [STUDIO_PREVIEW_ERRORS]: ["Uncaught Error: new page"] });
104
+ await act(async () => announcePreviewDocumentLoaded(iframe));
105
+ expect(shown()).toEqual(["Uncaught Error: new page"]);
87
106
  });
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import { STUDIO_PREVIEW_ERRORS } from "@hyperframes/core/studio-preview-mark";
3
+ import { onPreviewDocumentLoaded } from "../player/sceneSwap";
3
4
  import type { LintFinding } from "../components/LintModal";
4
5
 
5
6
  /**
@@ -73,9 +74,9 @@ export function useConsoleErrorCapture(previewIframe: HTMLIFrameElement | null)
73
74
  };
74
75
 
75
76
  attachErrorCapture();
76
- previewIframe.addEventListener("load", attachErrorCapture);
77
+ const stopLoaded = onPreviewDocumentLoaded(previewIframe, attachErrorCapture);
77
78
  return () => {
78
- previewIframe.removeEventListener("load", attachErrorCapture);
79
+ stopLoaded();
79
80
  detachErrorCapture();
80
81
  };
81
82
  }, [previewIframe, resetErrors]);
package/src/index.ts CHANGED
@@ -202,6 +202,7 @@ export type { UseClipboardOptions } from "./hooks/useClipboard";
202
202
  export { useDomEditSession } from "./hooks/useDomEditSession";
203
203
  export type { UseDomEditSessionParams } from "./hooks/useDomEditSession";
204
204
  export { useLivePreviewIframe } from "./hooks/useLivePreviewIframe";
205
+ export { announcePreviewDocumentLoaded } from "./player/sceneSwap";
205
206
  export type { PreviewPromotion } from "./player/sceneSwap";
206
207
  export { usePreviewPersistence } from "./hooks/usePreviewPersistence";
207
208
  export type { UsePreviewPersistenceParams } from "./hooks/usePreviewPersistence";
@@ -10,6 +10,7 @@ import {
10
10
  shouldShowCompositionLoadingOverlay,
11
11
  } from "./Player";
12
12
  import { usePlayerStore } from "../store/playerStore";
13
+ import { onPreviewDocumentLoaded } from "../sceneSwap";
13
14
 
14
15
  vi.mock("@hyperframes/player", () => ({}));
15
16
 
@@ -196,6 +197,49 @@ describe("callbacks after the player is already mounted", () => {
196
197
  expect(first).not.toHaveBeenCalled();
197
198
  });
198
199
 
200
+ it("loads the preview on the runtime's ready while a stalled font holds the window's load", async () => {
201
+ const onLoad = vi.fn();
202
+ const { player } = await mountPlayer({ onLoad });
203
+ const iframe = (player as TestHyperframesPlayer).iframeElement;
204
+ let page = {};
205
+ Object.defineProperty(iframe, "contentDocument", { get: () => page });
206
+ Object.defineProperty(iframe, "contentWindow", { get: () => ({ __playerReady: true }) });
207
+ const announced = vi.fn();
208
+ onPreviewDocumentLoaded(iframe, announced);
209
+
210
+ act(() => void player.dispatchEvent(new Event("ready")));
211
+ expect(onLoad).toHaveBeenCalledTimes(1);
212
+
213
+ act(() => void iframe.dispatchEvent(new Event("load")));
214
+ act(() => void player.dispatchEvent(new Event("ready")));
215
+ expect(onLoad).toHaveBeenCalledTimes(1);
216
+
217
+ page = {};
218
+ act(() => void player.dispatchEvent(new Event("ready")));
219
+ expect(onLoad).toHaveBeenCalledTimes(2);
220
+ expect(announced).toHaveBeenCalledTimes(2);
221
+ });
222
+
223
+ it("leaves a ready that arrives before the page's runtime boots to the page's own load", async () => {
224
+ const onLoad = vi.fn();
225
+ const { player } = await mountPlayer({ onLoad });
226
+ const iframe = (player as TestHyperframesPlayer).iframeElement;
227
+ const page = {};
228
+ let runtime: { __playerReady?: boolean } = {};
229
+ Object.defineProperty(iframe, "contentDocument", { get: () => page });
230
+ Object.defineProperty(iframe, "contentWindow", { get: () => runtime });
231
+
232
+ act(() => void player.dispatchEvent(new Event("ready")));
233
+ expect(onLoad).not.toHaveBeenCalled();
234
+
235
+ act(() => void iframe.dispatchEvent(new Event("load")));
236
+ expect(onLoad).toHaveBeenCalledTimes(1);
237
+
238
+ runtime = { __playerReady: true };
239
+ act(() => void player.dispatchEvent(new Event("ready")));
240
+ expect(onLoad).toHaveBeenCalledTimes(1);
241
+ });
242
+
199
243
  it("reports the preview error cause", async () => {
200
244
  const onPreviewError = vi.fn();
201
245
  const { player } = await mountPlayer({ onPreviewError });
@@ -5,6 +5,8 @@ import { useMountEffect } from "../../hooks/useMountEffect";
5
5
  import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore";
6
6
  import { HyperframesLoader } from "../../components/ui";
7
7
  import { usePlayerStore } from "../store/playerStore";
8
+ import { announcePreviewDocumentLoaded } from "../sceneSwap";
9
+ import type { IframeWindow } from "../lib/playbackTypes";
8
10
  // Importing "@hyperframes/player" registers a class extending HTMLElement at
9
11
  // module load, which throws under SSR, hence the dynamic import behind a
10
12
  // `typeof window` guard. Kicking it here rather than in the mount effect puts
@@ -128,6 +130,9 @@ export function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean
128
130
  }
129
131
  }
130
132
 
133
+ const runtimeBootedIn = (iframe: HTMLIFrameElement) =>
134
+ (iframe.contentWindow as IframeWindow | null)?.__playerReady === true;
135
+
131
136
  /**
132
137
  * Renders a composition preview using the <hyperframes-player> web component.
133
138
  *
@@ -228,9 +233,11 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
228
233
  const loading = getShaderTransitionLoading(event);
229
234
  if (loading !== null) setShaderTransitionLoading(loading);
230
235
  };
236
+ let loadedDocument: Document | null = null;
231
237
  const handleReady = () => {
232
238
  setPreviewError(null);
233
239
  setCompositionLoading(false);
240
+ if (iframe.contentDocument && runtimeBootedIn(iframe)) handleLoad();
234
241
  };
235
242
  const handlePainted = () => {
236
243
  setPainted(true);
@@ -243,6 +250,9 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
243
250
  setCompositionLoading(false);
244
251
  };
245
252
  const handleLoad = () => {
253
+ const doc = iframe.contentDocument;
254
+ if (doc && doc === loadedDocument) return;
255
+ loadedDocument = doc;
246
256
  loadCountRef.current++;
247
257
  setLoaded(true);
248
258
  setPreviewError(null);
@@ -258,6 +268,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
258
268
  container.addEventListener("animationend", onEnd, { once: true });
259
269
  }
260
270
  onLoadRef.current();
271
+ announcePreviewDocumentLoaded(iframe);
261
272
 
262
273
  // Show a loading overlay until every `<video>`/`<audio>` and Lottie
263
274
  // asset is ready. Without this users can click play before audio has
@@ -59,6 +59,7 @@ export interface ClipManifest {
59
59
 
60
60
  export type IframeWindow = Window & {
61
61
  __player?: RuntimePlaybackAdapter;
62
+ __playerReady?: boolean;
62
63
  __timeline?: TimelineLike;
63
64
  __timelines?: Record<string, TimelineLike>;
64
65
  __clipManifest?: ClipManifest;
@@ -1,6 +1,11 @@
1
1
  // @vitest-environment happy-dom
2
2
  import { afterEach, describe, expect, it, vi } from "vitest";
3
- import { SCENE_SWAP_MS, sceneSwapFor } from "./sceneSwap";
3
+ import {
4
+ SCENE_SWAP_MS,
5
+ announcePreviewDocumentLoaded,
6
+ onPreviewContentReplaced,
7
+ sceneSwapFor,
8
+ } from "./sceneSwap";
4
9
 
5
10
  afterEach(() => {
6
11
  vi.useRealTimers();
@@ -93,3 +98,24 @@ describe("sceneSwapFor", () => {
93
98
  expect(swap).toHaveBeenCalledWith("<html></html>", expect.anything());
94
99
  });
95
100
  });
101
+
102
+ describe("onPreviewContentReplaced", () => {
103
+ it("reports Player's document-loaded announcement and a scene swap, never a raw load", () => {
104
+ const iframe = document.createElement("iframe");
105
+ const replaced = vi.fn();
106
+ const stop = onPreviewContentReplaced(iframe, replaced);
107
+
108
+ iframe.dispatchEvent(new Event("load"));
109
+ expect(replaced).not.toHaveBeenCalled();
110
+
111
+ announcePreviewDocumentLoaded(iframe);
112
+ expect(replaced).toHaveBeenCalledTimes(1);
113
+
114
+ iframe.dispatchEvent(new Event("hf-scenes-swapped"));
115
+ expect(replaced).toHaveBeenCalledTimes(2);
116
+
117
+ stop();
118
+ announcePreviewDocumentLoaded(iframe);
119
+ expect(replaced).toHaveBeenCalledTimes(2);
120
+ });
121
+ });
@@ -6,14 +6,28 @@ type SwapWindow = Window & {
6
6
  const SCENES_SWAPPED = "hf-scenes-swapped";
7
7
  export const SCENE_SWAP_MS = 5000;
8
8
 
9
+ const PREVIEW_DOCUMENT_LOADED = "hf-preview-document-loaded";
10
+
11
+ export function announcePreviewDocumentLoaded(iframe: HTMLIFrameElement): void {
12
+ iframe.dispatchEvent(new Event(PREVIEW_DOCUMENT_LOADED));
13
+ }
14
+
15
+ export function onPreviewDocumentLoaded(
16
+ iframe: HTMLIFrameElement,
17
+ onLoaded: () => void,
18
+ ): () => void {
19
+ iframe.addEventListener(PREVIEW_DOCUMENT_LOADED, onLoaded);
20
+ return () => iframe.removeEventListener(PREVIEW_DOCUMENT_LOADED, onLoaded);
21
+ }
22
+
9
23
  export function onPreviewContentReplaced(
10
24
  iframe: HTMLIFrameElement,
11
25
  onReplaced: () => void,
12
26
  ): () => void {
13
- iframe.addEventListener("load", onReplaced);
27
+ const stopLoaded = onPreviewDocumentLoaded(iframe, onReplaced);
14
28
  iframe.addEventListener(SCENES_SWAPPED, onReplaced);
15
29
  return () => {
16
- iframe.removeEventListener("load", onReplaced);
30
+ stopLoaded();
17
31
  iframe.removeEventListener(SCENES_SWAPPED, onReplaced);
18
32
  };
19
33
  }