@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.
- package/dist/assets/{hyperframes-player-Bw_jIaSX.js → hyperframes-player-DZ5JrMDg.js} +1 -1
- package/dist/assets/{index-DyH9dT7b.js → index-BOs6C0aZ.js} +1 -1
- package/dist/assets/{index-Cj7W_c7p.js → index-BqFRhNZa.js} +1 -1
- package/dist/assets/{index-BVM9NuWl.js → index-D8W0a0Z9.js} +131 -131
- package/dist/{chunk-3GHTHPZD.js → chunk-6JY3MTIX.js} +101 -2
- package/dist/chunk-6JY3MTIX.js.map +1 -0
- package/dist/{chunk-G3EDKRZH.js → chunk-NZPJUZKT.js} +99 -3
- package/dist/chunk-NZPJUZKT.js.map +1 -0
- package/dist/{chunk-PEAKCOUW.js → chunk-TP6D3U37.js} +3 -100
- package/dist/chunk-TP6D3U37.js.map +1 -0
- package/dist/{domEditingLayers-PYST4G45.js → domEditingLayers-PTP2ICGM.js} +3 -3
- package/dist/index.d.ts +8 -5
- package/dist/index.html +1 -1
- package/dist/index.js +410 -429
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/package.json +8 -8
- package/src/components/StudioLeftPanels.tsx +2 -2
- package/src/components/editor/LayersPanel.test.ts +4 -4
- package/src/components/editor/LayersPanel.tsx +2 -2
- package/src/components/editor/TopologyLens.test.tsx +5 -4
- package/src/components/renders/GpuEncodingToggle.tsx +28 -0
- package/src/components/renders/RenderQueue.test.tsx +20 -2
- package/src/components/renders/RenderQueue.tsx +13 -1
- package/src/components/renders/RenderQueuePanel.tsx +2 -1
- package/src/components/renders/renderSettings.ts +5 -2
- package/src/components/renders/useRenderQueue.ts +3 -0
- package/src/components/renders/useRenderQueueComposition.test.tsx +12 -0
- package/src/hooks/useConsoleErrorCapture.test.tsx +21 -2
- package/src/hooks/useConsoleErrorCapture.ts +3 -2
- package/src/index.ts +1 -0
- package/src/player/components/Player.test.ts +44 -0
- package/src/player/components/Player.tsx +11 -0
- package/src/player/lib/playbackTypes.ts +1 -0
- package/src/player/sceneSwap.test.ts +27 -1
- package/src/player/sceneSwap.ts +16 -2
- package/dist/chunk-3GHTHPZD.js.map +0 -1
- package/dist/chunk-G3EDKRZH.js.map +0 -1
- package/dist/chunk-PEAKCOUW.js.map +0 -1
- /package/dist/{domEditingLayers-PYST4G45.js.map → domEditingLayers-PTP2ICGM.js.map} +0 -0
package/dist/player.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
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.
|
|
64
|
-
"@hyperframes/
|
|
65
|
-
"@hyperframes/
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/
|
|
68
|
-
"@hyperframes/studio-server": "0.8.
|
|
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.
|
|
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
|
|
287
|
+
announcePreviewDocumentLoaded(a);
|
|
288
288
|
});
|
|
289
289
|
expect(mocks.collect).not.toHaveBeenCalled();
|
|
290
290
|
|
|
291
291
|
act(() => {
|
|
292
|
-
b
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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("
|
|
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
|
|
316
|
+
act(() => announcePreviewDocumentLoaded(a));
|
|
316
317
|
expect(host.querySelector('[data-topology-lens="hidden"]')).toBeNull();
|
|
317
318
|
|
|
318
|
-
act(() => b
|
|
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
|
|
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
|
|
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
|
|
77
|
+
const stopLoaded = onPreviewDocumentLoaded(previewIframe, attachErrorCapture);
|
|
77
78
|
return () => {
|
|
78
|
-
|
|
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
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
-
import {
|
|
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
|
+
});
|
package/src/player/sceneSwap.ts
CHANGED
|
@@ -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
|
|
27
|
+
const stopLoaded = onPreviewDocumentLoaded(iframe, onReplaced);
|
|
14
28
|
iframe.addEventListener(SCENES_SWAPPED, onReplaced);
|
|
15
29
|
return () => {
|
|
16
|
-
|
|
30
|
+
stopLoaded();
|
|
17
31
|
iframe.removeEventListener(SCENES_SWAPPED, onReplaced);
|
|
18
32
|
};
|
|
19
33
|
}
|