@hyperframes/studio 0.6.0-alpha.2 → 0.6.0-alpha.5

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.
@@ -55,20 +55,24 @@ interface NLELayoutProps {
55
55
  onInspectTimelineElement?: (element: TimelineElement) => void;
56
56
  inspectedTimelineElementId?: string | null;
57
57
  timelineLayerChildCounts?: ReadonlyMap<string, number>;
58
- thumbnailedTimelineElementIds?: ReadonlySet<string>;
59
- onToggleTimelineElementThumbnail?: (element: TimelineElement) => void;
60
58
  /** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */
61
59
  onCompIdToSrcChange?: (map: Map<string, string>) => void;
62
60
  /** Whether the timeline panel is visible (default: true) */
63
61
  timelineVisible?: boolean;
64
62
  /** Callback to toggle timeline visibility */
65
63
  onToggleTimeline?: () => void;
64
+ /** Notifies parent when composition loading state changes */
65
+ onCompositionLoadingChange?: (loading: boolean) => void;
66
66
  }
67
67
 
68
68
  const MIN_TIMELINE_H = 100;
69
69
  const DEFAULT_TIMELINE_H = 220;
70
70
  const MIN_PREVIEW_H = 120;
71
71
 
72
+ export function shouldDisableTimelineWhileCompositionLoading(compositionLoading: boolean): boolean {
73
+ return compositionLoading;
74
+ }
75
+
72
76
  export const NLELayout = memo(function NLELayout({
73
77
  projectId,
74
78
  portrait,
@@ -90,11 +94,10 @@ export const NLELayout = memo(function NLELayout({
90
94
  onInspectTimelineElement,
91
95
  inspectedTimelineElementId,
92
96
  timelineLayerChildCounts,
93
- thumbnailedTimelineElementIds,
94
- onToggleTimelineElementThumbnail,
95
97
  onCompIdToSrcChange,
96
98
  timelineVisible,
97
99
  onToggleTimeline,
100
+ onCompositionLoadingChange: onCompositionLoadingChangeParent,
98
101
  }: NLELayoutProps) {
99
102
  const {
100
103
  iframeRef,
@@ -214,6 +217,12 @@ export const NLELayout = memo(function NLELayout({
214
217
 
215
218
  // Resizable timeline height
216
219
  const [timelineH, setTimelineH] = useState(DEFAULT_TIMELINE_H);
220
+ const [compositionLoading, setCompositionLoading] = useState(true);
221
+ const timelineDisabled = shouldDisableTimelineWhileCompositionLoading(compositionLoading);
222
+
223
+ useEffect(() => {
224
+ onCompositionLoadingChangeParent?.(compositionLoading);
225
+ }, [compositionLoading, onCompositionLoadingChangeParent]);
217
226
  const isTimelineVisible = timelineVisible ?? true;
218
227
  const isDragging = useRef(false);
219
228
  const containerRef = useRef<HTMLDivElement>(null);
@@ -327,23 +336,31 @@ export const NLELayout = memo(function NLELayout({
327
336
  }, [activeCompositionPath, projectId, updateCompositionStack]);
328
337
 
329
338
  // Resize divider handlers
330
- const handleDividerPointerDown = useCallback((e: React.PointerEvent) => {
331
- e.preventDefault();
332
- isDragging.current = true;
333
- (e.target as HTMLElement).setPointerCapture(e.pointerId);
334
- }, []);
339
+ const handleDividerPointerDown = useCallback(
340
+ (e: React.PointerEvent) => {
341
+ if (timelineDisabled) return;
342
+ e.preventDefault();
343
+ isDragging.current = true;
344
+ (e.target as HTMLElement).setPointerCapture(e.pointerId);
345
+ },
346
+ [timelineDisabled],
347
+ );
335
348
 
336
- const handleDividerPointerMove = useCallback((e: React.PointerEvent) => {
337
- if (!isDragging.current || !containerRef.current) return;
338
- const rect = containerRef.current.getBoundingClientRect();
339
- const mouseY = e.clientY - rect.top;
340
- const containerH = rect.height;
341
- const newTimelineH = Math.max(
342
- MIN_TIMELINE_H,
343
- Math.min(containerH - MIN_PREVIEW_H, containerH - mouseY),
344
- );
345
- setTimelineH(newTimelineH);
346
- }, []);
349
+ const handleDividerPointerMove = useCallback(
350
+ (e: React.PointerEvent) => {
351
+ if (timelineDisabled) return;
352
+ if (!isDragging.current || !containerRef.current) return;
353
+ const rect = containerRef.current.getBoundingClientRect();
354
+ const mouseY = e.clientY - rect.top;
355
+ const containerH = rect.height;
356
+ const newTimelineH = Math.max(
357
+ MIN_TIMELINE_H,
358
+ Math.min(containerH - MIN_PREVIEW_H, containerH - mouseY),
359
+ );
360
+ setTimelineH(newTimelineH);
361
+ },
362
+ [timelineDisabled],
363
+ );
347
364
 
348
365
  const handleDividerPointerUp = useCallback(() => {
349
366
  isDragging.current = false;
@@ -374,6 +391,7 @@ export const NLELayout = memo(function NLELayout({
374
391
  projectId={projectId}
375
392
  iframeRef={iframeRef}
376
393
  onIframeLoad={onIframeLoad}
394
+ onCompositionLoadingChange={setCompositionLoading}
377
395
  portrait={portrait}
378
396
  directUrl={directUrl}
379
397
  refreshKey={refreshKey}
@@ -388,7 +406,7 @@ export const NLELayout = memo(function NLELayout({
388
406
  onNavigate={handleNavigateComposition}
389
407
  />
390
408
  )}
391
- <PlayerControls onTogglePlay={togglePlay} onSeek={seek} />
409
+ <PlayerControls onTogglePlay={togglePlay} onSeek={seek} disabled={timelineDisabled} />
392
410
  </div>
393
411
  </div>
394
412
 
@@ -406,13 +424,18 @@ export const NLELayout = memo(function NLELayout({
406
424
  </div>
407
425
 
408
426
  {/* Timeline section — fixed height, resizable */}
409
- <div className="flex flex-col flex-shrink-0" style={{ height: timelineH }}>
427
+ <div
428
+ className="relative flex flex-col flex-shrink-0"
429
+ style={{ height: timelineH }}
430
+ aria-disabled={timelineDisabled || undefined}
431
+ >
410
432
  {/* Timeline tracks */}
411
433
  <div
412
434
  // flex-col: toolbar takes natural height, Timeline fills remainder.
413
435
  className="flex flex-col flex-1 min-h-0 overflow-hidden bg-neutral-950"
414
436
  onDoubleClick={(e) => {
415
437
  if ((e.target as HTMLElement).closest("[data-clip]")) return;
438
+ if (timelineDisabled) return;
416
439
  if (compositionStack.length > 1) {
417
440
  updateCompositionStack((prev) => prev.slice(0, -1));
418
441
  }
@@ -433,11 +456,20 @@ export const NLELayout = memo(function NLELayout({
433
456
  onInspectElement={onInspectTimelineElement}
434
457
  inspectedElementId={inspectedTimelineElementId}
435
458
  layerChildCounts={timelineLayerChildCounts}
436
- thumbnailedElementIds={thumbnailedTimelineElementIds}
437
- onToggleElementThumbnail={onToggleTimelineElementThumbnail}
459
+ disabled={timelineDisabled}
438
460
  />
439
461
  </div>
440
462
  {timelineFooter && <div className="flex-shrink-0">{timelineFooter}</div>}
463
+ {timelineDisabled && (
464
+ <div
465
+ className="absolute inset-0 z-30 cursor-not-allowed bg-black/18"
466
+ data-testid="timeline-loading-disabled-overlay"
467
+ aria-hidden="true"
468
+ onPointerDown={(event) => event.preventDefault()}
469
+ onDragOver={(event) => event.preventDefault()}
470
+ onDrop={(event) => event.preventDefault()}
471
+ />
472
+ )}
441
473
  </div>
442
474
  </>
443
475
  ) : onToggleTimeline ? (
@@ -5,6 +5,7 @@ interface NLEPreviewProps {
5
5
  projectId: string;
6
6
  iframeRef: Ref<HTMLIFrameElement>;
7
7
  onIframeLoad: () => void;
8
+ onCompositionLoadingChange?: (loading: boolean) => void;
8
9
  portrait?: boolean;
9
10
  directUrl?: string;
10
11
  refreshKey?: number;
@@ -36,6 +37,7 @@ export const NLEPreview = memo(function NLEPreview({
36
37
  projectId,
37
38
  iframeRef,
38
39
  onIframeLoad,
40
+ onCompositionLoadingChange,
39
41
  portrait,
40
42
  directUrl,
41
43
  refreshKey,
@@ -88,6 +90,7 @@ export const NLEPreview = memo(function NLEPreview({
88
90
  projectId={directUrl ? undefined : projectId}
89
91
  directUrl={directUrl}
90
92
  onLoad={retiringKey ? handleNewPlayerLoad : onIframeLoad}
93
+ onCompositionLoadingChange={onCompositionLoadingChange}
91
94
  portrait={portrait}
92
95
  style={retiringKey ? { position: "absolute", inset: 0, zIndex: 1 } : undefined}
93
96
  />
@@ -1,10 +1,12 @@
1
1
  import { memo, useState, useRef, useEffect } from "react";
2
2
  import { RenderQueueItem } from "./RenderQueueItem";
3
- import type { RenderJob } from "./useRenderQueue";
3
+ import type { RenderJob, ResolutionPreset } from "./useRenderQueue";
4
4
 
5
5
  type StartRenderHandler = (
6
6
  format: "mp4" | "webm" | "mov",
7
7
  quality: "draft" | "standard" | "high",
8
+ resolution: ResolutionPreset | "auto",
9
+ fps: 24 | 30 | 60,
8
10
  ) => void | Promise<void>;
9
11
 
10
12
  interface RenderQueueProps {
@@ -16,6 +18,36 @@ interface RenderQueueProps {
16
18
  isRendering: boolean;
17
19
  }
18
20
 
21
+ // Indexing the table by `ResolutionPreset | "auto"` makes adding a new preset
22
+ // to `core.types` (e.g. an 8K row) a TypeScript error here instead of a
23
+ // silently missing dropdown entry. Order is fixed by the array below.
24
+ const RESOLUTION_LABELS: Record<ResolutionPreset | "auto", { label: string; title: string }> = {
25
+ auto: { label: "Auto", title: "Render at the composition's authored resolution" },
26
+ landscape: { label: "1080p ↔", title: "1920×1080 landscape" },
27
+ portrait: { label: "1080p ↕", title: "1080×1920 portrait" },
28
+ "landscape-4k": {
29
+ label: "4K ↔",
30
+ title: "3840×2160 — supersamples a 1080p composition via Chrome DPR. Slower, larger files.",
31
+ },
32
+ "portrait-4k": {
33
+ label: "4K ↕",
34
+ title: "2160×3840 — supersamples a 1080p portrait composition via Chrome DPR.",
35
+ },
36
+ };
37
+
38
+ const RESOLUTION_OPTION_ORDER: (ResolutionPreset | "auto")[] = [
39
+ "auto",
40
+ "landscape",
41
+ "portrait",
42
+ "landscape-4k",
43
+ "portrait-4k",
44
+ ];
45
+
46
+ const RESOLUTION_OPTIONS = RESOLUTION_OPTION_ORDER.map((value) => ({
47
+ value,
48
+ ...RESOLUTION_LABELS[value],
49
+ }));
50
+
19
51
  const FORMAT_INFO: Record<"mp4" | "webm" | "mov", { label: string; desc: string }> = {
20
52
  mp4: { label: "MP4", desc: "Best for general use. Smallest file, universal playback." },
21
53
  mov: {
@@ -101,6 +133,8 @@ function FormatExportButton({
101
133
  }) {
102
134
  const [format, setFormat] = useState<"mp4" | "webm" | "mov">("mp4");
103
135
  const [quality, setQuality] = useState<"draft" | "standard" | "high">("standard");
136
+ const [resolution, setResolution] = useState<ResolutionPreset | "auto">("auto");
137
+ const [fps, setFps] = useState<24 | 30 | 60>(30);
104
138
 
105
139
  // MOV (ProRes) is a fixed-quality codec — quality selector has no effect.
106
140
  const showQuality = format !== "mov";
@@ -108,13 +142,30 @@ function FormatExportButton({
108
142
  return (
109
143
  <div className="flex items-center gap-1">
110
144
  <FormatInfoTooltip format={format} />
145
+ {/* Resolution must remain the leftmost <select> in this row — it
146
+ carries `rounded-l` for the joined-button look. If you ever hide it
147
+ (feature-flag, etc.), move `rounded-l` to whichever element ends up
148
+ leftmost. */}
149
+ <select
150
+ value={resolution}
151
+ onChange={(e) => setResolution(e.target.value as ResolutionPreset | "auto")}
152
+ disabled={isRendering}
153
+ title={RESOLUTION_OPTIONS.find((r) => r.value === resolution)?.title}
154
+ className="h-5 px-1 text-[10px] rounded-l bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
155
+ >
156
+ {RESOLUTION_OPTIONS.map((r) => (
157
+ <option key={r.value} value={r.value} title={r.title}>
158
+ {r.label}
159
+ </option>
160
+ ))}
161
+ </select>
111
162
  {showQuality && (
112
163
  <select
113
164
  value={quality}
114
165
  onChange={(e) => setQuality(e.target.value as "draft" | "standard" | "high")}
115
166
  disabled={isRendering}
116
167
  title={QUALITY_OPTIONS.find((q) => q.value === quality)?.title}
117
- className="h-5 px-1 text-[10px] rounded-l bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
168
+ className="h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
118
169
  >
119
170
  {QUALITY_OPTIONS.map((q) => (
120
171
  <option key={q.value} value={q.value} title={q.title}>
@@ -123,11 +174,22 @@ function FormatExportButton({
123
174
  ))}
124
175
  </select>
125
176
  )}
177
+ <select
178
+ value={fps}
179
+ onChange={(e) => setFps(Number(e.target.value) as 24 | 30 | 60)}
180
+ disabled={isRendering}
181
+ title="Frames per second"
182
+ className="h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
183
+ >
184
+ <option value={24}>24fps</option>
185
+ <option value={30}>30fps</option>
186
+ <option value={60}>60fps</option>
187
+ </select>
126
188
  <select
127
189
  value={format}
128
190
  onChange={(e) => setFormat(e.target.value as "mp4" | "webm" | "mov")}
129
191
  disabled={isRendering}
130
- className={`h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50 ${showQuality ? "" : "rounded-l"}`}
192
+ className="h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
131
193
  >
132
194
  <option value="mp4">MP4</option>
133
195
  <option value="mov">MOV</option>
@@ -135,7 +197,7 @@ function FormatExportButton({
135
197
  </select>
136
198
  <button
137
199
  onClick={() => {
138
- void onStartRender(format, quality);
200
+ void onStartRender(format, quality, resolution, fps);
139
201
  }}
140
202
  disabled={isRendering}
141
203
  className="flex items-center gap-1 px-2 py-0.5 text-[10px] font-semibold rounded-r bg-studio-accent text-[#09090B] hover:brightness-110 transition-colors disabled:opacity-50"
@@ -11,6 +11,20 @@ export interface RenderJob {
11
11
  durationMs?: number;
12
12
  }
13
13
 
14
+ // Mirrors `CanvasResolution` from @hyperframes/core. Kept local because
15
+ // studio's tsconfig doesn't include node types, and the core barrel
16
+ // transitively pulls in modules with `node:fs` imports. Drift risk is
17
+ // low (4 string literals tied to a stable enum).
18
+ export type ResolutionPreset = "landscape" | "portrait" | "landscape-4k" | "portrait-4k";
19
+
20
+ export interface StartRenderOptions {
21
+ fps?: number;
22
+ quality?: "draft" | "standard" | "high";
23
+ format?: "mp4" | "webm" | "mov";
24
+ /** `"auto"` (default) renders at the composition's authored dimensions. */
25
+ resolution?: ResolutionPreset | "auto";
26
+ }
27
+
14
28
  export function useRenderQueue(projectId: string | null) {
15
29
  const [jobs, setJobs] = useState<RenderJob[]>([]);
16
30
  const eventSourceRef = useRef<EventSource | null>(null);
@@ -59,20 +73,30 @@ export function useRenderQueue(projectId: string | null) {
59
73
 
60
74
  // Start a render and track progress via SSE
61
75
  const startRender = useCallback(
62
- async (
63
- fps = 30,
64
- quality: "draft" | "standard" | "high" = "standard",
65
- format: "mp4" | "webm" | "mov" = "mp4",
66
- ) => {
76
+ async (opts: StartRenderOptions = {}) => {
67
77
  if (!projectId) return;
68
78
 
79
+ const fps = opts.fps ?? 30;
80
+ const quality = opts.quality ?? "standard";
81
+ const format = opts.format ?? "mp4";
82
+ const resolution = opts.resolution;
83
+
69
84
  const startTime = Date.now();
85
+ // "auto" / undefined means "render at the composition's authored size".
86
+ // Omit the field entirely — sending "auto" would trip the route's
87
+ // enum validation set.
88
+ const body: { fps: number; quality: string; format: string; resolution?: string } = {
89
+ fps,
90
+ quality,
91
+ format,
92
+ };
93
+ if (resolution && resolution !== "auto") body.resolution = resolution;
70
94
  let res: Response;
71
95
  try {
72
96
  res = await fetch(`/api/projects/${projectId}/render`, {
73
97
  method: "POST",
74
98
  headers: { "Content-Type": "application/json" },
75
- body: JSON.stringify({ fps, quality, format }),
99
+ body: JSON.stringify(body),
76
100
  });
77
101
  } catch {
78
102
  const failedJob: RenderJob = {
@@ -0,0 +1,58 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { hasUnloadedAssets, shouldShowCompositionLoadingOverlay } from "./Player";
5
+
6
+ describe("composition loading overlay", () => {
7
+ it("shows while the composition is loading", () => {
8
+ expect(shouldShowCompositionLoadingOverlay(true)).toBe(true);
9
+ });
10
+
11
+ it("hides after the composition is ready", () => {
12
+ expect(shouldShowCompositionLoadingOverlay(false)).toBe(false);
13
+ });
14
+
15
+ 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();
20
+ Object.defineProperty(audio, "readyState", {
21
+ value: 0,
22
+ configurable: true,
23
+ });
24
+ Object.defineProperty(audio, "networkState", {
25
+ value: 2,
26
+ configurable: true,
27
+ });
28
+ iframe.contentDocument?.body.appendChild(audio!);
29
+
30
+ expect(hasUnloadedAssets(iframe, false)).toBe(true);
31
+
32
+ iframe.remove();
33
+ });
34
+
35
+ 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();
40
+ Object.defineProperty(audio, "error", {
41
+ value: { code: 4, message: "format error" },
42
+ configurable: true,
43
+ });
44
+ Object.defineProperty(audio, "readyState", {
45
+ value: 0,
46
+ configurable: true,
47
+ });
48
+ Object.defineProperty(audio, "networkState", {
49
+ value: 3,
50
+ configurable: true,
51
+ });
52
+ iframe.contentDocument?.body.appendChild(audio!);
53
+
54
+ expect(hasUnloadedAssets(iframe, false)).toBe(false);
55
+
56
+ iframe.remove();
57
+ });
58
+ });
@@ -10,6 +10,7 @@ interface PlayerProps {
10
10
  projectId?: string;
11
11
  directUrl?: string;
12
12
  onLoad: () => void;
13
+ onCompositionLoadingChange?: (loading: boolean) => void;
13
14
  portrait?: boolean;
14
15
  style?: React.CSSProperties;
15
16
  }
@@ -18,6 +19,9 @@ interface HyperframesPlayerElement extends HTMLElement {
18
19
  iframeElement: HTMLIFrameElement;
19
20
  }
20
21
 
22
+ const MEDIA_HAVE_FUTURE_DATA = 3;
23
+ const MEDIA_NETWORK_NO_SOURCE = 3;
24
+
21
25
  function isRecord(value: unknown): value is Record<string, unknown> {
22
26
  return typeof value === "object" && value !== null;
23
27
  }
@@ -31,6 +35,10 @@ function getShaderTransitionLoading(event: Event): boolean | null {
31
35
  return state.loading === true && state.ready !== true;
32
36
  }
33
37
 
38
+ export function shouldShowCompositionLoadingOverlay(compositionLoading: boolean): boolean {
39
+ return compositionLoading;
40
+ }
41
+
34
42
  function enableInteractiveIframe(player: HyperframesPlayerElement): void {
35
43
  const root = player.shadowRoot;
36
44
  if (!root) return;
@@ -42,6 +50,11 @@ function enableInteractiveIframe(player: HyperframesPlayerElement): void {
42
50
  iframe?.style.setProperty("pointer-events", "auto");
43
51
  }
44
52
 
53
+ function isPreviewMediaElement(el: Element): el is HTMLMediaElement {
54
+ const tagName = el.tagName.toLowerCase();
55
+ return tagName === "video" || tagName === "audio";
56
+ }
57
+
45
58
  // Assets are considered ready when every `<video>`/`<audio>` has enough data
46
59
  // to play through without buffering, and every registered Lottie animation has
47
60
  // finished loading.
@@ -50,14 +63,19 @@ function enableInteractiveIframe(player: HyperframesPlayerElement): void {
50
63
  // races so a brief access failure (e.g. an iframe that just swapped src)
51
64
  // doesn't flicker the overlay state — we keep showing whatever was most
52
65
  // recently true.
53
- function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean): boolean {
66
+ export function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean): boolean {
54
67
  try {
55
68
  const win = iframe.contentWindow as unknown as (Window & { __hfLottie?: unknown[] }) | null;
56
69
  const doc = iframe.contentDocument;
57
70
  if (!win || !doc) return lastResult;
58
71
 
59
72
  for (const el of doc.querySelectorAll("video, audio")) {
60
- if (el instanceof HTMLMediaElement && el.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) {
73
+ if (
74
+ isPreviewMediaElement(el) &&
75
+ !el.error &&
76
+ el.networkState !== MEDIA_NETWORK_NO_SOURCE &&
77
+ el.readyState < MEDIA_HAVE_FUTURE_DATA
78
+ ) {
61
79
  return true;
62
80
  }
63
81
  }
@@ -84,7 +102,7 @@ function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean): bool
84
102
  * timeline probing, and DOM inspection.
85
103
  */
86
104
  export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
87
- ({ projectId, directUrl, onLoad, portrait, style }, ref) => {
105
+ ({ projectId, directUrl, onLoad, onCompositionLoadingChange, portrait, style }, ref) => {
88
106
  const containerRef = useRef<HTMLDivElement>(null);
89
107
  const loadCountRef = useRef(0);
90
108
  const assetPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -93,6 +111,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
93
111
  const [assetOverlayVisible, setAssetOverlayVisible] = useState(false);
94
112
  const [assetOverlayFading, setAssetOverlayFading] = useState(false);
95
113
  const [shaderTransitionLoading, setShaderTransitionLoading] = useState(false);
114
+ const [compositionLoading, setCompositionLoading] = useState(true);
96
115
 
97
116
  useMountEffect(() => {
98
117
  const container = containerRef.current;
@@ -138,10 +157,20 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
138
157
  };
139
158
  player.addEventListener("shadertransitionstate", handleShaderTransitionState);
140
159
 
160
+ const handleReady = () => {
161
+ setCompositionLoading(false);
162
+ };
163
+ const handleError = () => {
164
+ setCompositionLoading(false);
165
+ };
166
+ player.addEventListener("ready", handleReady);
167
+ player.addEventListener("error", handleError);
168
+
141
169
  // Forward the iframe's native load event to the studio's onIframeLoad.
142
170
  const handleLoad = () => {
143
171
  loadCountRef.current++;
144
172
  setShaderTransitionLoading(false);
173
+ setCompositionLoading(true);
145
174
  // Reveal animation on reload (hot-reload, composition switch)
146
175
  if (loadCountRef.current > 1) {
147
176
  container.classList.remove("preview-revealing");
@@ -192,6 +221,8 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
192
221
  iframe.removeEventListener("load", handleLoad);
193
222
  player.removeEventListener("click", preventToggle, { capture: true });
194
223
  player.removeEventListener("shadertransitionstate", handleShaderTransitionState);
224
+ player.removeEventListener("ready", handleReady);
225
+ player.removeEventListener("error", handleError);
195
226
  if (assetPollRef.current) clearInterval(assetPollRef.current);
196
227
  assetPollRef.current = null;
197
228
  container.removeChild(player);
@@ -237,7 +268,13 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
237
268
  };
238
269
  }, [assetsLoading]);
239
270
 
240
- const showAssetOverlay = assetOverlayVisible && !shaderTransitionLoading;
271
+ const showCompositionOverlay = shouldShowCompositionLoadingOverlay(compositionLoading);
272
+ const showAssetOverlay =
273
+ assetOverlayVisible && !shaderTransitionLoading && !showCompositionOverlay;
274
+
275
+ useEffect(() => {
276
+ onCompositionLoadingChange?.(showCompositionOverlay);
277
+ }, [onCompositionLoadingChange, showCompositionOverlay]);
241
278
 
242
279
  return (
243
280
  <div
@@ -245,6 +282,23 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
245
282
  style={style}
246
283
  >
247
284
  <div ref={containerRef} className="w-full h-full" />
285
+ {showCompositionOverlay && (
286
+ <div
287
+ className="absolute inset-0 bg-black flex items-center justify-center z-30 select-none"
288
+ data-hyperframes-ignore=""
289
+ data-testid="composition-loading-overlay"
290
+ draggable={false}
291
+ onDragStart={(event) => event.preventDefault()}
292
+ onMouseDown={(event) => event.preventDefault()}
293
+ onPointerDown={(event) => event.preventDefault()}
294
+ >
295
+ <HyperframesLoader
296
+ title="Loading composition"
297
+ detail="Preparing the Studio preview."
298
+ size={56}
299
+ />
300
+ </div>
301
+ )}
248
302
  {showAssetOverlay && (
249
303
  <div
250
304
  className="absolute inset-0 bg-black flex items-center justify-center z-20 select-none"