@hyperframes/studio 0.6.0-alpha.2 → 0.6.0-alpha.4
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-CEnWY28J.js +417 -0
- package/dist/assets/index-BfnyZllX.js +106 -0
- package/dist/assets/index-pZvEUcY0.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +217 -98
- package/src/components/editor/PropertyPanel.test.ts +49 -0
- package/src/components/editor/PropertyPanel.tsx +258 -1276
- package/src/components/editor/domEditing.test.ts +248 -0
- package/src/components/editor/domEditing.ts +126 -2
- package/src/components/editor/manualEditingAvailability.test.ts +3 -1
- package/src/components/editor/manualEditingAvailability.ts +4 -2
- package/src/components/editor/manualEdits.ts +15 -3
- package/src/components/nle/NLELayout.test.ts +12 -0
- package/src/components/nle/NLELayout.tsx +49 -24
- package/src/components/nle/NLEPreview.tsx +3 -0
- package/src/components/renders/RenderQueue.tsx +66 -4
- package/src/components/renders/useRenderQueue.ts +30 -6
- package/src/player/components/Player.test.ts +58 -0
- package/src/player/components/Player.tsx +58 -4
- package/src/player/components/PlayerControls.tsx +20 -7
- package/src/player/components/Timeline.tsx +38 -1
- package/dist/assets/hyperframes-player-Cd8vYWxP.js +0 -198
- package/dist/assets/index-UWFaHilT.css +0 -1
- package/dist/assets/index-cPJbxeAk.js +0 -107
|
@@ -55,8 +55,6 @@ 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) */
|
|
@@ -69,6 +67,10 @@ const MIN_TIMELINE_H = 100;
|
|
|
69
67
|
const DEFAULT_TIMELINE_H = 220;
|
|
70
68
|
const MIN_PREVIEW_H = 120;
|
|
71
69
|
|
|
70
|
+
export function shouldDisableTimelineWhileCompositionLoading(compositionLoading: boolean): boolean {
|
|
71
|
+
return compositionLoading;
|
|
72
|
+
}
|
|
73
|
+
|
|
72
74
|
export const NLELayout = memo(function NLELayout({
|
|
73
75
|
projectId,
|
|
74
76
|
portrait,
|
|
@@ -90,8 +92,6 @@ export const NLELayout = memo(function NLELayout({
|
|
|
90
92
|
onInspectTimelineElement,
|
|
91
93
|
inspectedTimelineElementId,
|
|
92
94
|
timelineLayerChildCounts,
|
|
93
|
-
thumbnailedTimelineElementIds,
|
|
94
|
-
onToggleTimelineElementThumbnail,
|
|
95
95
|
onCompIdToSrcChange,
|
|
96
96
|
timelineVisible,
|
|
97
97
|
onToggleTimeline,
|
|
@@ -214,6 +214,8 @@ export const NLELayout = memo(function NLELayout({
|
|
|
214
214
|
|
|
215
215
|
// Resizable timeline height
|
|
216
216
|
const [timelineH, setTimelineH] = useState(DEFAULT_TIMELINE_H);
|
|
217
|
+
const [compositionLoading, setCompositionLoading] = useState(true);
|
|
218
|
+
const timelineDisabled = shouldDisableTimelineWhileCompositionLoading(compositionLoading);
|
|
217
219
|
const isTimelineVisible = timelineVisible ?? true;
|
|
218
220
|
const isDragging = useRef(false);
|
|
219
221
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
@@ -327,23 +329,31 @@ export const NLELayout = memo(function NLELayout({
|
|
|
327
329
|
}, [activeCompositionPath, projectId, updateCompositionStack]);
|
|
328
330
|
|
|
329
331
|
// Resize divider handlers
|
|
330
|
-
const handleDividerPointerDown = useCallback(
|
|
331
|
-
e.
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
332
|
+
const handleDividerPointerDown = useCallback(
|
|
333
|
+
(e: React.PointerEvent) => {
|
|
334
|
+
if (timelineDisabled) return;
|
|
335
|
+
e.preventDefault();
|
|
336
|
+
isDragging.current = true;
|
|
337
|
+
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
|
338
|
+
},
|
|
339
|
+
[timelineDisabled],
|
|
340
|
+
);
|
|
335
341
|
|
|
336
|
-
const handleDividerPointerMove = useCallback(
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
Math.
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
342
|
+
const handleDividerPointerMove = useCallback(
|
|
343
|
+
(e: React.PointerEvent) => {
|
|
344
|
+
if (timelineDisabled) return;
|
|
345
|
+
if (!isDragging.current || !containerRef.current) return;
|
|
346
|
+
const rect = containerRef.current.getBoundingClientRect();
|
|
347
|
+
const mouseY = e.clientY - rect.top;
|
|
348
|
+
const containerH = rect.height;
|
|
349
|
+
const newTimelineH = Math.max(
|
|
350
|
+
MIN_TIMELINE_H,
|
|
351
|
+
Math.min(containerH - MIN_PREVIEW_H, containerH - mouseY),
|
|
352
|
+
);
|
|
353
|
+
setTimelineH(newTimelineH);
|
|
354
|
+
},
|
|
355
|
+
[timelineDisabled],
|
|
356
|
+
);
|
|
347
357
|
|
|
348
358
|
const handleDividerPointerUp = useCallback(() => {
|
|
349
359
|
isDragging.current = false;
|
|
@@ -374,6 +384,7 @@ export const NLELayout = memo(function NLELayout({
|
|
|
374
384
|
projectId={projectId}
|
|
375
385
|
iframeRef={iframeRef}
|
|
376
386
|
onIframeLoad={onIframeLoad}
|
|
387
|
+
onCompositionLoadingChange={setCompositionLoading}
|
|
377
388
|
portrait={portrait}
|
|
378
389
|
directUrl={directUrl}
|
|
379
390
|
refreshKey={refreshKey}
|
|
@@ -388,7 +399,7 @@ export const NLELayout = memo(function NLELayout({
|
|
|
388
399
|
onNavigate={handleNavigateComposition}
|
|
389
400
|
/>
|
|
390
401
|
)}
|
|
391
|
-
<PlayerControls onTogglePlay={togglePlay} onSeek={seek} />
|
|
402
|
+
<PlayerControls onTogglePlay={togglePlay} onSeek={seek} disabled={timelineDisabled} />
|
|
392
403
|
</div>
|
|
393
404
|
</div>
|
|
394
405
|
|
|
@@ -406,13 +417,18 @@ export const NLELayout = memo(function NLELayout({
|
|
|
406
417
|
</div>
|
|
407
418
|
|
|
408
419
|
{/* Timeline section — fixed height, resizable */}
|
|
409
|
-
<div
|
|
420
|
+
<div
|
|
421
|
+
className="relative flex flex-col flex-shrink-0"
|
|
422
|
+
style={{ height: timelineH }}
|
|
423
|
+
aria-disabled={timelineDisabled || undefined}
|
|
424
|
+
>
|
|
410
425
|
{/* Timeline tracks */}
|
|
411
426
|
<div
|
|
412
427
|
// flex-col: toolbar takes natural height, Timeline fills remainder.
|
|
413
428
|
className="flex flex-col flex-1 min-h-0 overflow-hidden bg-neutral-950"
|
|
414
429
|
onDoubleClick={(e) => {
|
|
415
430
|
if ((e.target as HTMLElement).closest("[data-clip]")) return;
|
|
431
|
+
if (timelineDisabled) return;
|
|
416
432
|
if (compositionStack.length > 1) {
|
|
417
433
|
updateCompositionStack((prev) => prev.slice(0, -1));
|
|
418
434
|
}
|
|
@@ -433,11 +449,20 @@ export const NLELayout = memo(function NLELayout({
|
|
|
433
449
|
onInspectElement={onInspectTimelineElement}
|
|
434
450
|
inspectedElementId={inspectedTimelineElementId}
|
|
435
451
|
layerChildCounts={timelineLayerChildCounts}
|
|
436
|
-
|
|
437
|
-
onToggleElementThumbnail={onToggleTimelineElementThumbnail}
|
|
452
|
+
disabled={timelineDisabled}
|
|
438
453
|
/>
|
|
439
454
|
</div>
|
|
440
455
|
{timelineFooter && <div className="flex-shrink-0">{timelineFooter}</div>}
|
|
456
|
+
{timelineDisabled && (
|
|
457
|
+
<div
|
|
458
|
+
className="absolute inset-0 z-30 cursor-not-allowed bg-black/18"
|
|
459
|
+
data-testid="timeline-loading-disabled-overlay"
|
|
460
|
+
aria-hidden="true"
|
|
461
|
+
onPointerDown={(event) => event.preventDefault()}
|
|
462
|
+
onDragOver={(event) => event.preventDefault()}
|
|
463
|
+
onDrop={(event) => event.preventDefault()}
|
|
464
|
+
/>
|
|
465
|
+
)}
|
|
441
466
|
</div>
|
|
442
467
|
</>
|
|
443
468
|
) : 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]
|
|
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=
|
|
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(
|
|
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 (
|
|
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
|
|
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"
|