@hyperframes/studio 0.7.77 → 0.7.78
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-CEggaaxR.js → hyperframes-player-uiAAPKvw.js} +1 -1
- package/dist/assets/{index-Bp4jAYZG.js → index-BSZrK0bx.js} +172 -172
- package/dist/assets/{index-B1Tjjdse.js → index-CDSTMtUs.js} +1 -1
- package/dist/assets/index-DpkO2Hvw.css +1 -0
- package/dist/assets/{index-B1INQNmj.js → index-r1tKKlU7.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/index.js +129 -60
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/sidebar/CompositionsTab.drag.test.tsx +41 -0
- package/src/components/sidebar/CompositionsTab.tsx +66 -32
- package/src/player/components/Player.test.ts +143 -12
- package/src/player/components/Player.tsx +84 -43
- package/dist/assets/index-CBAfprvx.css +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.78",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -46,11 +46,11 @@
|
|
|
46
46
|
"gsap": "^3.13.0",
|
|
47
47
|
"marked": "^14.1.4",
|
|
48
48
|
"mediabunny": "^1.45.3",
|
|
49
|
-
"@hyperframes/
|
|
50
|
-
"@hyperframes/
|
|
51
|
-
"@hyperframes/
|
|
52
|
-
"@hyperframes/
|
|
53
|
-
"@hyperframes/
|
|
49
|
+
"@hyperframes/parsers": "0.7.78",
|
|
50
|
+
"@hyperframes/core": "0.7.78",
|
|
51
|
+
"@hyperframes/sdk": "0.7.78",
|
|
52
|
+
"@hyperframes/studio-server": "0.7.78",
|
|
53
|
+
"@hyperframes/player": "0.7.78"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@types/react": "19",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"vite": "^6.4.2",
|
|
66
66
|
"vitest": "^3.2.4",
|
|
67
67
|
"zustand": "^5.0.0",
|
|
68
|
-
"@hyperframes/producer": "0.7.
|
|
68
|
+
"@hyperframes/producer": "0.7.78"
|
|
69
69
|
},
|
|
70
70
|
"peerDependencies": {
|
|
71
71
|
"react": "19",
|
|
@@ -40,6 +40,47 @@ function mount(onSelect = vi.fn(), onAddToTimeline = vi.fn()) {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
describe("composition card drag", () => {
|
|
43
|
+
it("uses a cached image instead of eagerly mounting a live preview iframe", () => {
|
|
44
|
+
const { host } = mount();
|
|
45
|
+
const thumbnail = host.querySelector<HTMLImageElement>('img[src*="/thumbnail/"]');
|
|
46
|
+
expect(thumbnail).not.toBeNull();
|
|
47
|
+
expect(new URL(thumbnail?.src ?? "").searchParams.get("t")).toBe("3.00");
|
|
48
|
+
expect(host.querySelector("iframe")).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("shows a fallback when the cached thumbnail fails", () => {
|
|
52
|
+
const { host } = mount();
|
|
53
|
+
const thumbnail = host.querySelector<HTMLImageElement>('img[src*="/thumbnail/"]');
|
|
54
|
+
if (!thumbnail) throw new Error("composition thumbnail did not render");
|
|
55
|
+
|
|
56
|
+
act(() => thumbnail.dispatchEvent(new Event("error")));
|
|
57
|
+
|
|
58
|
+
expect(host.textContent).toContain("Preview unavailable");
|
|
59
|
+
expect(host.querySelector('img[src*="/thumbnail/"]')).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("mounts one live preview only after sustained hover and removes it on leave", () => {
|
|
63
|
+
vi.useFakeTimers();
|
|
64
|
+
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
65
|
+
try {
|
|
66
|
+
const { host, card } = mount();
|
|
67
|
+
act(() => {
|
|
68
|
+
card.dispatchEvent(new Event("pointerover", { bubbles: true }));
|
|
69
|
+
vi.advanceTimersByTime(300);
|
|
70
|
+
});
|
|
71
|
+
expect(host.querySelectorAll("iframe")).toHaveLength(1);
|
|
72
|
+
|
|
73
|
+
act(() => {
|
|
74
|
+
card.dispatchEvent(new Event("pointerout", { bubbles: true }));
|
|
75
|
+
});
|
|
76
|
+
expect(host.querySelector("iframe")).toBeNull();
|
|
77
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
78
|
+
} finally {
|
|
79
|
+
consoleError.mockRestore();
|
|
80
|
+
vi.useRealTimers();
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
43
84
|
it("keeps ordinary click navigation", () => {
|
|
44
85
|
const { card, onSelect } = mount();
|
|
45
86
|
act(() => card.click());
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { setPreviewMediaMuted } from "../../player/lib/timelineIframeHelpers";
|
|
3
|
+
import { buildCompositionThumbnailUrl } from "../../player/components/CompositionThumbnail";
|
|
3
4
|
import { TIMELINE_COMPOSITION_MIME } from "../../utils/timelineCompositionDrop";
|
|
4
5
|
|
|
5
6
|
interface CompositionsTabProps {
|
|
@@ -130,6 +131,8 @@ function CompCard({
|
|
|
130
131
|
}) {
|
|
131
132
|
const [hovered, setHovered] = useState(false);
|
|
132
133
|
const [stageSize, setStageSize] = useState(DEFAULT_PREVIEW_STAGE);
|
|
134
|
+
const [livePreviewLoaded, setLivePreviewLoaded] = useState(false);
|
|
135
|
+
const [thumbnailFailed, setThumbnailFailed] = useState(false);
|
|
133
136
|
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
134
137
|
const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
135
138
|
const syncTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
@@ -158,10 +161,21 @@ function CompCard({
|
|
|
158
161
|
clearTimeout(hoverTimer.current);
|
|
159
162
|
hoverTimer.current = null;
|
|
160
163
|
}
|
|
164
|
+
if (syncTimer.current) {
|
|
165
|
+
clearTimeout(syncTimer.current);
|
|
166
|
+
syncTimer.current = null;
|
|
167
|
+
}
|
|
161
168
|
setHovered(false);
|
|
169
|
+
setLivePreviewLoaded(false);
|
|
162
170
|
};
|
|
163
171
|
const name = comp.replace(/^compositions\//, "").replace(/\.html$/, "");
|
|
164
172
|
const previewUrl = `/api/projects/${projectId}/preview/comp/${comp}`;
|
|
173
|
+
const thumbnailUrl = buildCompositionThumbnailUrl({
|
|
174
|
+
previewUrl,
|
|
175
|
+
seekTime: THUMBNAIL_SEEK_TIME_SECONDS,
|
|
176
|
+
duration: 0,
|
|
177
|
+
origin: window.location.origin,
|
|
178
|
+
});
|
|
165
179
|
const previewScale = resolveCompositionPreviewScale({
|
|
166
180
|
cardWidth: CARD_W,
|
|
167
181
|
cardHeight: CARD_H,
|
|
@@ -172,7 +186,7 @@ function CompCard({
|
|
|
172
186
|
const thumbnailOffsetY = (CARD_H - stageSize.height * previewScale) / 2;
|
|
173
187
|
|
|
174
188
|
useEffect(() => {
|
|
175
|
-
|
|
189
|
+
if (hovered) requestIframePlaybackSync(true);
|
|
176
190
|
}, [hovered, requestIframePlaybackSync]);
|
|
177
191
|
|
|
178
192
|
useEffect(() => {
|
|
@@ -216,36 +230,56 @@ function CompCard({
|
|
|
216
230
|
}`}
|
|
217
231
|
>
|
|
218
232
|
<div className="w-20 h-[45px] rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
233
|
+
{thumbnailFailed ? (
|
|
234
|
+
<div className="absolute inset-0 flex items-center justify-center px-1 text-center text-[8px] leading-tight text-neutral-600">
|
|
235
|
+
Preview unavailable
|
|
236
|
+
</div>
|
|
237
|
+
) : (
|
|
238
|
+
<img
|
|
239
|
+
src={thumbnailUrl}
|
|
240
|
+
alt=""
|
|
241
|
+
draggable={false}
|
|
242
|
+
loading="lazy"
|
|
243
|
+
decoding="async"
|
|
244
|
+
onError={() => setThumbnailFailed(true)}
|
|
245
|
+
className={`absolute inset-0 h-full w-full object-contain transition-opacity ${
|
|
246
|
+
livePreviewLoaded ? "opacity-0" : "opacity-100"
|
|
247
|
+
}`}
|
|
248
|
+
/>
|
|
249
|
+
)}
|
|
250
|
+
{hovered && (
|
|
251
|
+
<iframe
|
|
252
|
+
ref={iframeRef}
|
|
253
|
+
src={previewUrl}
|
|
254
|
+
sandbox="allow-scripts allow-same-origin"
|
|
255
|
+
className="absolute border-none pointer-events-none"
|
|
256
|
+
style={{
|
|
257
|
+
transformOrigin: "0 0",
|
|
258
|
+
width: stageSize.width,
|
|
259
|
+
height: stageSize.height,
|
|
260
|
+
left: thumbnailOffsetX,
|
|
261
|
+
top: thumbnailOffsetY,
|
|
262
|
+
transform: `scale(${previewScale})`,
|
|
263
|
+
}}
|
|
264
|
+
onLoad={(e) => {
|
|
265
|
+
try {
|
|
266
|
+
const iframe = e.currentTarget;
|
|
267
|
+
const root = iframe.contentDocument?.querySelector("[data-composition-id]");
|
|
268
|
+
const width =
|
|
269
|
+
Number(root?.getAttribute("data-width")) || DEFAULT_PREVIEW_STAGE.width;
|
|
270
|
+
const height =
|
|
271
|
+
Number(root?.getAttribute("data-height")) || DEFAULT_PREVIEW_STAGE.height;
|
|
272
|
+
setStageSize({ width, height });
|
|
273
|
+
setLivePreviewLoaded(true);
|
|
274
|
+
requestIframePlaybackSync(true);
|
|
275
|
+
} catch {
|
|
276
|
+
setStageSize(DEFAULT_PREVIEW_STAGE);
|
|
277
|
+
}
|
|
278
|
+
}}
|
|
279
|
+
title={`${name} preview`}
|
|
280
|
+
tabIndex={-1}
|
|
281
|
+
/>
|
|
282
|
+
)}
|
|
249
283
|
</div>
|
|
250
284
|
<div
|
|
251
285
|
className="min-w-0 flex-1"
|
|
@@ -331,7 +365,7 @@ export const CompositionsTab = memo(function CompositionsTab({
|
|
|
331
365
|
<div className="flex-1 overflow-y-auto">
|
|
332
366
|
{compositions.map((comp) => (
|
|
333
367
|
<CompCard
|
|
334
|
-
key={comp}
|
|
368
|
+
key={`${projectId}:${comp}`}
|
|
335
369
|
projectId={projectId}
|
|
336
370
|
comp={comp}
|
|
337
371
|
isActive={activeComposition === comp}
|
|
@@ -1,7 +1,146 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { act, createElement } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import {
|
|
7
|
+
hasUnloadedAssets,
|
|
8
|
+
Player,
|
|
9
|
+
readPreviewErrorMessage,
|
|
10
|
+
shouldShowCompositionLoadingOverlay,
|
|
11
|
+
} from "./Player";
|
|
12
|
+
|
|
13
|
+
vi.mock("@hyperframes/player", () => ({}));
|
|
14
|
+
|
|
15
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
16
|
+
|
|
17
|
+
let root: Root | null = null;
|
|
18
|
+
let lifecycleLog: string[] = [];
|
|
19
|
+
|
|
20
|
+
class TestHyperframesPlayer extends HTMLElement {
|
|
21
|
+
readonly iframeElement = document.createElement("iframe");
|
|
22
|
+
|
|
23
|
+
constructor() {
|
|
24
|
+
super();
|
|
25
|
+
|
|
26
|
+
const addIframeListener = this.iframeElement.addEventListener.bind(this.iframeElement);
|
|
27
|
+
this.iframeElement.addEventListener = ((type, listener, options) => {
|
|
28
|
+
lifecycleLog.push(`iframe:${type}`);
|
|
29
|
+
addIframeListener(type, listener, options);
|
|
30
|
+
}) as typeof this.iframeElement.addEventListener;
|
|
31
|
+
|
|
32
|
+
const addPlayerListener = this.addEventListener.bind(this);
|
|
33
|
+
this.addEventListener = ((type, listener, options) => {
|
|
34
|
+
lifecycleLog.push(`player:${type}`);
|
|
35
|
+
addPlayerListener(type, listener, options);
|
|
36
|
+
}) as typeof this.addEventListener;
|
|
37
|
+
|
|
38
|
+
const setPlayerAttribute = this.setAttribute.bind(this);
|
|
39
|
+
this.setAttribute = (name, value) => {
|
|
40
|
+
if (name === "src") lifecycleLog.push("src");
|
|
41
|
+
setPlayerAttribute(name, value);
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!customElements.get("hyperframes-player")) {
|
|
47
|
+
customElements.define("hyperframes-player", TestHyperframesPlayer);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
if (root) act(() => root?.unmount());
|
|
52
|
+
root = null;
|
|
53
|
+
lifecycleLog = [];
|
|
54
|
+
document.body.innerHTML = "";
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
async function mountPlayer() {
|
|
58
|
+
const host = document.createElement("div");
|
|
59
|
+
document.body.append(host);
|
|
60
|
+
root = createRoot(host);
|
|
61
|
+
await act(async () => {
|
|
62
|
+
root?.render(
|
|
63
|
+
createElement(Player, {
|
|
64
|
+
directUrl: "/api/projects/demo/preview",
|
|
65
|
+
onLoad: vi.fn(),
|
|
66
|
+
suppressLoadingOverlay: true,
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
await Promise.resolve();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const player = host.querySelector<TestHyperframesPlayer>("hyperframes-player");
|
|
73
|
+
if (!player) throw new Error("player did not mount");
|
|
74
|
+
return { host, player };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function createAudioIframe() {
|
|
78
|
+
const iframe = document.createElement("iframe");
|
|
79
|
+
document.body.appendChild(iframe);
|
|
80
|
+
const audio = iframe.contentDocument?.createElement("audio");
|
|
81
|
+
expect(audio).toBeDefined();
|
|
82
|
+
iframe.contentDocument?.body.appendChild(audio!);
|
|
83
|
+
return { audio: audio!, iframe };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe("preview errors", () => {
|
|
87
|
+
it("reads the player probe error for the visible retry state", () => {
|
|
88
|
+
expect(
|
|
89
|
+
readPreviewErrorMessage(
|
|
90
|
+
new CustomEvent("error", {
|
|
91
|
+
detail: { message: "Composition timeline not found after 8s" },
|
|
92
|
+
}),
|
|
93
|
+
),
|
|
94
|
+
).toBe("Composition timeline not found after 8s");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("falls back when the player emits an unstructured error", () => {
|
|
98
|
+
expect(readPreviewErrorMessage(new Event("error"))).toBe(
|
|
99
|
+
"The composition preview did not become ready.",
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("attaches lifecycle listeners before navigating the player", async () => {
|
|
104
|
+
await mountPlayer();
|
|
105
|
+
const srcIndex = lifecycleLog.indexOf("src");
|
|
106
|
+
|
|
107
|
+
expect(srcIndex).toBeGreaterThan(-1);
|
|
108
|
+
for (const listener of [
|
|
109
|
+
"iframe:load",
|
|
110
|
+
"player:click",
|
|
111
|
+
"player:shadertransitionstate",
|
|
112
|
+
"player:ready",
|
|
113
|
+
"player:error",
|
|
114
|
+
]) {
|
|
115
|
+
expect(lifecycleLog.indexOf(listener)).toBeGreaterThan(-1);
|
|
116
|
+
expect(lifecycleLog.indexOf(listener)).toBeLessThan(srcIndex);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("retries a failed preview with a fresh player URL", async () => {
|
|
121
|
+
const { host, player } = await mountPlayer();
|
|
122
|
+
|
|
123
|
+
act(() => {
|
|
124
|
+
player.dispatchEvent(
|
|
125
|
+
new CustomEvent("error", {
|
|
126
|
+
detail: { message: "Composition timeline not found after 8s" },
|
|
127
|
+
}),
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(host.querySelector('[data-testid="composition-preview-error"]')).not.toBeNull();
|
|
132
|
+
const retry = Array.from(host.querySelectorAll("button")).find(
|
|
133
|
+
(button) => button.textContent === "Retry preview",
|
|
134
|
+
);
|
|
135
|
+
if (!retry) throw new Error("retry action did not render");
|
|
136
|
+
|
|
137
|
+
act(() => retry.click());
|
|
138
|
+
|
|
139
|
+
const retryUrl = new URL(player.getAttribute("src") ?? "", window.location.origin);
|
|
140
|
+
expect(retryUrl.searchParams.get("_hfStudioRetry")).toBe("1");
|
|
141
|
+
expect(host.querySelector('[data-testid="composition-preview-error"]')).toBeNull();
|
|
142
|
+
});
|
|
143
|
+
});
|
|
5
144
|
|
|
6
145
|
describe("composition loading overlay", () => {
|
|
7
146
|
it("shows while the composition is loading", () => {
|
|
@@ -13,10 +152,7 @@ describe("composition loading overlay", () => {
|
|
|
13
152
|
});
|
|
14
153
|
|
|
15
154
|
it("keeps the asset overlay up while media is still buffering", () => {
|
|
16
|
-
const iframe =
|
|
17
|
-
document.body.appendChild(iframe);
|
|
18
|
-
const audio = iframe.contentDocument?.createElement("audio");
|
|
19
|
-
expect(audio).toBeDefined();
|
|
155
|
+
const { audio, iframe } = createAudioIframe();
|
|
20
156
|
Object.defineProperty(audio, "readyState", {
|
|
21
157
|
value: 0,
|
|
22
158
|
configurable: true,
|
|
@@ -25,7 +161,6 @@ describe("composition loading overlay", () => {
|
|
|
25
161
|
value: 2,
|
|
26
162
|
configurable: true,
|
|
27
163
|
});
|
|
28
|
-
iframe.contentDocument?.body.appendChild(audio!);
|
|
29
164
|
|
|
30
165
|
expect(hasUnloadedAssets(iframe, false)).toBe(true);
|
|
31
166
|
|
|
@@ -33,10 +168,7 @@ describe("composition loading overlay", () => {
|
|
|
33
168
|
});
|
|
34
169
|
|
|
35
170
|
it("does not keep the asset overlay stuck on failed media sources", () => {
|
|
36
|
-
const iframe =
|
|
37
|
-
document.body.appendChild(iframe);
|
|
38
|
-
const audio = iframe.contentDocument?.createElement("audio");
|
|
39
|
-
expect(audio).toBeDefined();
|
|
171
|
+
const { audio, iframe } = createAudioIframe();
|
|
40
172
|
Object.defineProperty(audio, "error", {
|
|
41
173
|
value: { code: 4, message: "format error" },
|
|
42
174
|
configurable: true,
|
|
@@ -49,7 +181,6 @@ describe("composition loading overlay", () => {
|
|
|
49
181
|
value: 3,
|
|
50
182
|
configurable: true,
|
|
51
183
|
});
|
|
52
|
-
iframe.contentDocument?.body.appendChild(audio!);
|
|
53
184
|
|
|
54
185
|
expect(hasUnloadedAssets(iframe, false)).toBe(false);
|
|
55
186
|
|
|
@@ -38,11 +38,19 @@ function getShaderTransitionLoading(event: Event): boolean | null {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
const COMPOSITION_LOADING_OVERLAY_DELAY_MS = 400;
|
|
41
|
+
const DEFAULT_PREVIEW_ERROR = "The composition preview did not become ready.";
|
|
41
42
|
|
|
42
43
|
export function shouldShowCompositionLoadingOverlay(compositionLoading: boolean): boolean {
|
|
43
44
|
return compositionLoading;
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
export function readPreviewErrorMessage(event: Event): string {
|
|
48
|
+
if (!(event instanceof CustomEvent) || !isRecord(event.detail)) return DEFAULT_PREVIEW_ERROR;
|
|
49
|
+
return typeof event.detail.message === "string" && event.detail.message.trim()
|
|
50
|
+
? event.detail.message
|
|
51
|
+
: DEFAULT_PREVIEW_ERROR;
|
|
52
|
+
}
|
|
53
|
+
|
|
46
54
|
function enableInteractiveIframe(player: HyperframesPlayerElement): void {
|
|
47
55
|
const root = player.shadowRoot;
|
|
48
56
|
if (!root) return;
|
|
@@ -122,12 +130,15 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
122
130
|
const loadCountRef = useRef(0);
|
|
123
131
|
const assetPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
124
132
|
const assetFadeRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
133
|
+
const retryPreviewRef = useRef<(() => void) | null>(null);
|
|
134
|
+
const retryCountRef = useRef(0);
|
|
125
135
|
const [assetsLoading, setAssetsLoading] = useState(false);
|
|
126
136
|
const [assetOverlayVisible, setAssetOverlayVisible] = useState(false);
|
|
127
137
|
const [assetOverlayFading, setAssetOverlayFading] = useState(false);
|
|
128
138
|
const [shaderTransitionLoading, setShaderTransitionLoading] = useState(false);
|
|
129
139
|
const [compositionLoading, setCompositionLoading] = useState(true);
|
|
130
140
|
const [compositionOverlayDeferred, setCompositionOverlayDeferred] = useState(true);
|
|
141
|
+
const [previewError, setPreviewError] = useState<string | null>(null);
|
|
131
142
|
|
|
132
143
|
// eslint-disable-next-line no-restricted-syntax
|
|
133
144
|
useEffect(() => {
|
|
@@ -161,62 +172,32 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
161
172
|
);
|
|
162
173
|
applyPreviewVariablesToUrl(srcUrl);
|
|
163
174
|
const src = srcUrl.pathname + srcUrl.search;
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
container.appendChild(player);
|
|
174
|
-
|
|
175
|
-
// Inject pasteboard shadow: let the shadow around the canvas bleed
|
|
176
|
-
// into the surrounding pasteboard area (overflow: visible on the container)
|
|
177
|
-
// and add a subtle outline + drop-shadow so the canvas boundary reads
|
|
178
|
-
// against the gray pasteboard, consistent with professional editors.
|
|
179
|
-
if (player.shadowRoot) {
|
|
180
|
-
const pasteboardStyle = document.createElement("style");
|
|
181
|
-
pasteboardStyle.textContent =
|
|
182
|
-
".hfp-container{overflow:visible}" +
|
|
183
|
-
".hfp-iframe{box-shadow:0 0 0 1px rgba(255,255,255,0.08),0 4px 32px rgba(0,0,0,.7)}";
|
|
184
|
-
player.shadowRoot.appendChild(pasteboardStyle);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
enableInteractiveIframe(player);
|
|
188
|
-
|
|
189
|
-
// Bridge the inner iframe to the forwarded ref for useTimelinePlayer.
|
|
175
|
+
const retryPreview = () => {
|
|
176
|
+
retryCountRef.current += 1;
|
|
177
|
+
const retryUrl = new URL(src, window.location.origin);
|
|
178
|
+
retryUrl.searchParams.set("_hfStudioRetry", String(retryCountRef.current));
|
|
179
|
+
setPreviewError(null);
|
|
180
|
+
setCompositionLoading(true);
|
|
181
|
+
player.setAttribute("src", retryUrl.pathname + retryUrl.search);
|
|
182
|
+
};
|
|
183
|
+
retryPreviewRef.current = retryPreview;
|
|
190
184
|
const iframe = player.iframeElement;
|
|
191
|
-
if (typeof ref === "function") {
|
|
192
|
-
ref(iframe);
|
|
193
|
-
} else if (ref) {
|
|
194
|
-
(ref as React.MutableRefObject<HTMLIFrameElement | null>).current = iframe;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// Prevent the web component's built-in click-to-toggle behavior.
|
|
198
|
-
// The studio manages playback exclusively via useTimelinePlayer.
|
|
199
185
|
const preventToggle = (e: Event) => e.stopImmediatePropagation();
|
|
200
|
-
player.addEventListener("click", preventToggle, { capture: true });
|
|
201
|
-
|
|
202
186
|
const handleShaderTransitionState = (event: Event) => {
|
|
203
187
|
const loading = getShaderTransitionLoading(event);
|
|
204
188
|
if (loading !== null) setShaderTransitionLoading(loading);
|
|
205
189
|
};
|
|
206
|
-
player.addEventListener("shadertransitionstate", handleShaderTransitionState);
|
|
207
|
-
|
|
208
190
|
const handleReady = () => {
|
|
191
|
+
setPreviewError(null);
|
|
209
192
|
setCompositionLoading(false);
|
|
210
193
|
};
|
|
211
|
-
const handleError = () => {
|
|
194
|
+
const handleError = (event: Event) => {
|
|
195
|
+
setPreviewError(readPreviewErrorMessage(event));
|
|
212
196
|
setCompositionLoading(false);
|
|
213
197
|
};
|
|
214
|
-
player.addEventListener("ready", handleReady);
|
|
215
|
-
player.addEventListener("error", handleError);
|
|
216
|
-
|
|
217
|
-
// Forward the iframe's native load event to the studio's onIframeLoad.
|
|
218
198
|
const handleLoad = () => {
|
|
219
199
|
loadCountRef.current++;
|
|
200
|
+
setPreviewError(null);
|
|
220
201
|
setShaderTransitionLoading(false);
|
|
221
202
|
setCompositionLoading(true);
|
|
222
203
|
// Reveal animation on reload (hot-reload, composition switch)
|
|
@@ -264,7 +245,47 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
264
245
|
setAssetsLoading(false);
|
|
265
246
|
}
|
|
266
247
|
};
|
|
248
|
+
|
|
249
|
+
// Attach lifecycle listeners before assigning src or connecting the
|
|
250
|
+
// custom element. A warm local iframe can otherwise finish before
|
|
251
|
+
// Studio observes its load and never initialize the timeline.
|
|
267
252
|
iframe.addEventListener("load", handleLoad);
|
|
253
|
+
player.addEventListener("click", preventToggle, { capture: true });
|
|
254
|
+
player.addEventListener("shadertransitionstate", handleShaderTransitionState);
|
|
255
|
+
player.addEventListener("ready", handleReady);
|
|
256
|
+
player.addEventListener("error", handleError);
|
|
257
|
+
|
|
258
|
+
// Bridge the inner iframe to the forwarded ref for useTimelinePlayer.
|
|
259
|
+
if (typeof ref === "function") {
|
|
260
|
+
ref(iframe);
|
|
261
|
+
} else if (ref) {
|
|
262
|
+
(ref as React.MutableRefObject<HTMLIFrameElement | null>).current = iframe;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
player.setAttribute("shader-capture-scale", "1");
|
|
266
|
+
player.setAttribute("shader-loading", "player");
|
|
267
|
+
player.setAttribute("width", String(portrait ? 1080 : 1920));
|
|
268
|
+
player.setAttribute("height", String(portrait ? 1920 : 1080));
|
|
269
|
+
player.style.width = "100%";
|
|
270
|
+
player.style.height = "100%";
|
|
271
|
+
player.style.display = "block";
|
|
272
|
+
player.style.background = "transparent";
|
|
273
|
+
player.setAttribute("src", src);
|
|
274
|
+
container.appendChild(player);
|
|
275
|
+
|
|
276
|
+
// Inject pasteboard shadow: let the shadow around the canvas bleed
|
|
277
|
+
// into the surrounding pasteboard area (overflow: visible on the container)
|
|
278
|
+
// and add a subtle outline + drop-shadow so the canvas boundary reads
|
|
279
|
+
// against the gray pasteboard, consistent with professional editors.
|
|
280
|
+
if (player.shadowRoot) {
|
|
281
|
+
const pasteboardStyle = document.createElement("style");
|
|
282
|
+
pasteboardStyle.textContent =
|
|
283
|
+
".hfp-container{overflow:visible}" +
|
|
284
|
+
".hfp-iframe{box-shadow:0 0 0 1px rgba(255,255,255,0.08),0 4px 32px rgba(0,0,0,.7)}";
|
|
285
|
+
player.shadowRoot.appendChild(pasteboardStyle);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
enableInteractiveIframe(player);
|
|
268
289
|
|
|
269
290
|
cleanup = () => {
|
|
270
291
|
iframe.removeEventListener("load", handleLoad);
|
|
@@ -275,6 +296,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
275
296
|
if (assetPollRef.current) clearInterval(assetPollRef.current);
|
|
276
297
|
assetPollRef.current = null;
|
|
277
298
|
container.removeChild(player);
|
|
299
|
+
if (retryPreviewRef.current === retryPreview) retryPreviewRef.current = null;
|
|
278
300
|
// Clear the forwarded ref only if it still points to THIS iframe.
|
|
279
301
|
// During crossfade refreshes the retiring Player unmounts after the
|
|
280
302
|
// new Player has already assigned its iframe to the same ref — blindly
|
|
@@ -381,6 +403,25 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
381
403
|
/>
|
|
382
404
|
</div>
|
|
383
405
|
)}
|
|
406
|
+
{previewError && (
|
|
407
|
+
<div
|
|
408
|
+
className="absolute inset-0 z-40 flex items-center justify-center bg-black/90 px-6 text-center"
|
|
409
|
+
data-hyperframes-ignore=""
|
|
410
|
+
data-testid="composition-preview-error"
|
|
411
|
+
>
|
|
412
|
+
<div className="max-w-sm">
|
|
413
|
+
<p className="text-sm font-semibold text-white">Preview failed to load</p>
|
|
414
|
+
<p className="mt-1 text-xs text-neutral-400">{previewError}</p>
|
|
415
|
+
<button
|
|
416
|
+
type="button"
|
|
417
|
+
className="mt-4 rounded-md bg-white px-3 py-1.5 text-xs font-semibold text-black transition-colors hover:bg-neutral-200"
|
|
418
|
+
onClick={() => retryPreviewRef.current?.()}
|
|
419
|
+
>
|
|
420
|
+
Retry preview
|
|
421
|
+
</button>
|
|
422
|
+
</div>
|
|
423
|
+
</div>
|
|
424
|
+
)}
|
|
384
425
|
</div>
|
|
385
426
|
);
|
|
386
427
|
},
|