@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.77",
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/core": "0.7.77",
50
- "@hyperframes/parsers": "0.7.77",
51
- "@hyperframes/player": "0.7.77",
52
- "@hyperframes/sdk": "0.7.77",
53
- "@hyperframes/studio-server": "0.7.77"
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.77"
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
- requestIframePlaybackSync(hovered);
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
- <iframe
220
- ref={iframeRef}
221
- src={previewUrl}
222
- sandbox="allow-scripts allow-same-origin"
223
- loading="lazy"
224
- className="absolute border-none pointer-events-none"
225
- style={{
226
- transformOrigin: "0 0",
227
- width: stageSize.width,
228
- height: stageSize.height,
229
- left: thumbnailOffsetX,
230
- top: thumbnailOffsetY,
231
- transform: `scale(${previewScale})`,
232
- }}
233
- onLoad={(e) => {
234
- try {
235
- const iframe = e.currentTarget;
236
- const root = iframe.contentDocument?.querySelector("[data-composition-id]");
237
- const width = Number(root?.getAttribute("data-width")) || DEFAULT_PREVIEW_STAGE.width;
238
- const height =
239
- Number(root?.getAttribute("data-height")) || DEFAULT_PREVIEW_STAGE.height;
240
- setStageSize({ width, height });
241
- requestIframePlaybackSync(hovered);
242
- } catch {
243
- setStageSize(DEFAULT_PREVIEW_STAGE);
244
- }
245
- }}
246
- title={`${name} preview`}
247
- tabIndex={-1}
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 { describe, expect, it } from "vitest";
4
- import { hasUnloadedAssets, shouldShowCompositionLoadingOverlay } from "./Player";
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 = document.createElement("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 = document.createElement("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
- player.setAttribute("shader-capture-scale", "1");
165
- player.setAttribute("shader-loading", "player");
166
- player.setAttribute("src", src);
167
- player.setAttribute("width", String(portrait ? 1080 : 1920));
168
- player.setAttribute("height", String(portrait ? 1920 : 1080));
169
- player.style.width = "100%";
170
- player.style.height = "100%";
171
- player.style.display = "block";
172
- player.style.background = "transparent";
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
  },