mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
fix(studio): improve preview loading reliability
This commit is contained in:
@@ -32,7 +32,7 @@ export interface NLEContextValue {
|
||||
togglePlay: () => void;
|
||||
seek: (time: number, options?: { keepPlaying?: boolean }) => boolean;
|
||||
refreshPlayer: () => void;
|
||||
onIframeLoad: () => void;
|
||||
onIframeLoad: (reportError?: (message: string) => void) => void;
|
||||
// composition stack (from useCompositionStack)
|
||||
compositionStack: CompositionLevel[];
|
||||
updateCompositionStack: React.Dispatch<React.SetStateAction<CompositionLevel[]>>;
|
||||
@@ -122,14 +122,17 @@ export function NLEProvider({
|
||||
refreshPlayer();
|
||||
}, [refreshKey, refreshPlayer]);
|
||||
|
||||
const onIframeLoad = useCallback(() => {
|
||||
baseOnIframeLoad();
|
||||
// Pre-load + register MotionPathPlugin once so adding a motion path in the
|
||||
// studio doesn't take the async plugin-load flash path on the first soft
|
||||
// reload (the comp may not ship the plugin until it actually uses one).
|
||||
ensureMotionPathPluginLoaded(iframeRef.current);
|
||||
onIframeRef?.(iframeRef.current);
|
||||
}, [baseOnIframeLoad, iframeRef, onIframeRef]);
|
||||
const onIframeLoad = useCallback(
|
||||
(reportError?: (message: string) => void) => {
|
||||
baseOnIframeLoad(reportError);
|
||||
// Pre-load + register MotionPathPlugin once so adding a motion path in the
|
||||
// studio doesn't take the async plugin-load flash path on the first soft
|
||||
// reload (the comp may not ship the plugin until it actually uses one).
|
||||
ensureMotionPathPluginLoaded(iframeRef.current);
|
||||
onIframeRef?.(iframeRef.current);
|
||||
},
|
||||
[baseOnIframeLoad, iframeRef, onIframeRef],
|
||||
);
|
||||
|
||||
const {
|
||||
compositionStack,
|
||||
|
||||
@@ -15,7 +15,7 @@ import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/s
|
||||
interface NLEPreviewProps {
|
||||
projectId: string;
|
||||
iframeRef: RefObject<HTMLIFrameElement | null>;
|
||||
onIframeLoad: () => void;
|
||||
onIframeLoad: (reportError?: (message: string) => void) => void;
|
||||
onCompositionLoadingChange?: (loading: boolean) => void;
|
||||
portrait?: boolean;
|
||||
directUrl?: string;
|
||||
@@ -491,9 +491,9 @@ export const NLEPreview = memo(function NLEPreview({
|
||||
ref={setPreviewIframeRef}
|
||||
projectId={directUrl ? undefined : projectId}
|
||||
directUrl={directUrl}
|
||||
onLoad={() => {
|
||||
onLoad={(reportError) => {
|
||||
updateCompositionSizeFromPreview();
|
||||
onIframeLoad();
|
||||
onIframeLoad(reportError);
|
||||
applyInitialZoom();
|
||||
}}
|
||||
onCompositionLoadingChange={onCompositionLoadingChange}
|
||||
|
||||
@@ -40,6 +40,33 @@ function mount(onSelect = vi.fn(), onAddToTimeline = vi.fn()) {
|
||||
}
|
||||
|
||||
describe("composition card drag", () => {
|
||||
it("uses a cached image instead of eagerly mounting a live preview iframe", () => {
|
||||
const { host } = mount();
|
||||
expect(host.querySelector('img[src*="/thumbnail/"]')).not.toBeNull();
|
||||
expect(host.querySelector("iframe")).toBeNull();
|
||||
});
|
||||
|
||||
it("mounts one live preview only after sustained hover and removes it on leave", () => {
|
||||
vi.useFakeTimers();
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
try {
|
||||
const { host, card } = mount();
|
||||
act(() => {
|
||||
card.dispatchEvent(new Event("pointerover", { bubbles: true }));
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
expect(host.querySelectorAll("iframe")).toHaveLength(1);
|
||||
|
||||
act(() => {
|
||||
card.dispatchEvent(new Event("pointerout", { bubbles: true }));
|
||||
});
|
||||
expect(host.querySelector("iframe")).toBeNull();
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps ordinary click navigation", () => {
|
||||
const { card, onSelect } = mount();
|
||||
act(() => card.click());
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { setPreviewMediaMuted } from "../../player/lib/timelineIframeHelpers";
|
||||
import { buildCompositionThumbnailUrl } from "../../player/components/CompositionThumbnail";
|
||||
import { TIMELINE_COMPOSITION_MIME } from "../../utils/timelineCompositionDrop";
|
||||
|
||||
interface CompositionsTabProps {
|
||||
@@ -130,6 +131,7 @@ function CompCard({
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [stageSize, setStageSize] = useState(DEFAULT_PREVIEW_STAGE);
|
||||
const [livePreviewLoaded, setLivePreviewLoaded] = useState(false);
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const syncTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -159,9 +161,16 @@ function CompCard({
|
||||
hoverTimer.current = null;
|
||||
}
|
||||
setHovered(false);
|
||||
setLivePreviewLoaded(false);
|
||||
};
|
||||
const name = comp.replace(/^compositions\//, "").replace(/\.html$/, "");
|
||||
const previewUrl = `/api/projects/${projectId}/preview/comp/${comp}`;
|
||||
const thumbnailUrl = buildCompositionThumbnailUrl({
|
||||
previewUrl,
|
||||
seekTime: 0,
|
||||
duration: THUMBNAIL_SEEK_TIME_SECONDS * 2,
|
||||
origin: window.location.origin,
|
||||
});
|
||||
const previewScale = resolveCompositionPreviewScale({
|
||||
cardWidth: CARD_W,
|
||||
cardHeight: CARD_H,
|
||||
@@ -172,7 +181,7 @@ function CompCard({
|
||||
const thumbnailOffsetY = (CARD_H - stageSize.height * previewScale) / 2;
|
||||
|
||||
useEffect(() => {
|
||||
requestIframePlaybackSync(hovered);
|
||||
if (hovered) requestIframePlaybackSync(true);
|
||||
}, [hovered, requestIframePlaybackSync]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -216,36 +225,49 @@ function CompCard({
|
||||
}`}
|
||||
>
|
||||
<div className="w-20 h-[45px] rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={previewUrl}
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
className="absolute border-none pointer-events-none"
|
||||
style={{
|
||||
transformOrigin: "0 0",
|
||||
width: stageSize.width,
|
||||
height: stageSize.height,
|
||||
left: thumbnailOffsetX,
|
||||
top: thumbnailOffsetY,
|
||||
transform: `scale(${previewScale})`,
|
||||
}}
|
||||
onLoad={(e) => {
|
||||
try {
|
||||
const iframe = e.currentTarget;
|
||||
const root = iframe.contentDocument?.querySelector("[data-composition-id]");
|
||||
const width = Number(root?.getAttribute("data-width")) || DEFAULT_PREVIEW_STAGE.width;
|
||||
const height =
|
||||
Number(root?.getAttribute("data-height")) || DEFAULT_PREVIEW_STAGE.height;
|
||||
setStageSize({ width, height });
|
||||
requestIframePlaybackSync(hovered);
|
||||
} catch {
|
||||
setStageSize(DEFAULT_PREVIEW_STAGE);
|
||||
}
|
||||
}}
|
||||
title={`${name} preview`}
|
||||
tabIndex={-1}
|
||||
decoding="async"
|
||||
className={`absolute inset-0 h-full w-full object-contain transition-opacity ${
|
||||
livePreviewLoaded ? "opacity-0" : "opacity-100"
|
||||
}`}
|
||||
/>
|
||||
{hovered && (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={previewUrl}
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
className="absolute border-none pointer-events-none"
|
||||
style={{
|
||||
transformOrigin: "0 0",
|
||||
width: stageSize.width,
|
||||
height: stageSize.height,
|
||||
left: thumbnailOffsetX,
|
||||
top: thumbnailOffsetY,
|
||||
transform: `scale(${previewScale})`,
|
||||
}}
|
||||
onLoad={(e) => {
|
||||
try {
|
||||
const iframe = e.currentTarget;
|
||||
const root = iframe.contentDocument?.querySelector("[data-composition-id]");
|
||||
const width =
|
||||
Number(root?.getAttribute("data-width")) || DEFAULT_PREVIEW_STAGE.width;
|
||||
const height =
|
||||
Number(root?.getAttribute("data-height")) || DEFAULT_PREVIEW_STAGE.height;
|
||||
setStageSize({ width, height });
|
||||
setLivePreviewLoaded(true);
|
||||
requestIframePlaybackSync(true);
|
||||
} catch {
|
||||
setStageSize(DEFAULT_PREVIEW_STAGE);
|
||||
}
|
||||
}}
|
||||
title={`${name} preview`}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="min-w-0 flex-1"
|
||||
|
||||
@@ -1,7 +1,29 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasUnloadedAssets, shouldShowCompositionLoadingOverlay } from "./Player";
|
||||
import {
|
||||
hasUnloadedAssets,
|
||||
readPreviewErrorMessage,
|
||||
shouldShowCompositionLoadingOverlay,
|
||||
} from "./Player";
|
||||
|
||||
describe("preview errors", () => {
|
||||
it("reads the player probe error for the visible retry state", () => {
|
||||
expect(
|
||||
readPreviewErrorMessage(
|
||||
new CustomEvent("error", {
|
||||
detail: { message: "Composition timeline not found after 8s" },
|
||||
}),
|
||||
),
|
||||
).toBe("Composition timeline not found after 8s");
|
||||
});
|
||||
|
||||
it("falls back when the player emits an unstructured error", () => {
|
||||
expect(readPreviewErrorMessage(new Event("error"))).toBe(
|
||||
"The composition preview did not become ready.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("composition loading overlay", () => {
|
||||
it("shows while the composition is loading", () => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { HyperframesLoader } from "../../components/ui";
|
||||
interface PlayerProps {
|
||||
projectId?: string;
|
||||
directUrl?: string;
|
||||
onLoad: () => void;
|
||||
onLoad: (reportError: (message: string) => void) => void;
|
||||
onCompositionLoadingChange?: (loading: boolean) => void;
|
||||
portrait?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
@@ -38,11 +38,19 @@ function getShaderTransitionLoading(event: Event): boolean | null {
|
||||
}
|
||||
|
||||
const COMPOSITION_LOADING_OVERLAY_DELAY_MS = 400;
|
||||
const DEFAULT_PREVIEW_ERROR = "The composition preview did not become ready.";
|
||||
|
||||
export function shouldShowCompositionLoadingOverlay(compositionLoading: boolean): boolean {
|
||||
return compositionLoading;
|
||||
}
|
||||
|
||||
export function readPreviewErrorMessage(event: Event): string {
|
||||
if (!(event instanceof CustomEvent) || !isRecord(event.detail)) return DEFAULT_PREVIEW_ERROR;
|
||||
return typeof event.detail.message === "string" && event.detail.message.trim()
|
||||
? event.detail.message
|
||||
: DEFAULT_PREVIEW_ERROR;
|
||||
}
|
||||
|
||||
function enableInteractiveIframe(player: HyperframesPlayerElement): void {
|
||||
const root = player.shadowRoot;
|
||||
if (!root) return;
|
||||
@@ -122,12 +130,15 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
||||
const loadCountRef = useRef(0);
|
||||
const assetPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const assetFadeRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const retryPreviewRef = useRef<(() => void) | null>(null);
|
||||
const retryCountRef = useRef(0);
|
||||
const [assetsLoading, setAssetsLoading] = useState(false);
|
||||
const [assetOverlayVisible, setAssetOverlayVisible] = useState(false);
|
||||
const [assetOverlayFading, setAssetOverlayFading] = useState(false);
|
||||
const [shaderTransitionLoading, setShaderTransitionLoading] = useState(false);
|
||||
const [compositionLoading, setCompositionLoading] = useState(true);
|
||||
const [compositionOverlayDeferred, setCompositionOverlayDeferred] = useState(true);
|
||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
@@ -161,62 +172,32 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
||||
);
|
||||
applyPreviewVariablesToUrl(srcUrl);
|
||||
const src = srcUrl.pathname + srcUrl.search;
|
||||
player.setAttribute("shader-capture-scale", "1");
|
||||
player.setAttribute("shader-loading", "player");
|
||||
player.setAttribute("src", src);
|
||||
player.setAttribute("width", String(portrait ? 1080 : 1920));
|
||||
player.setAttribute("height", String(portrait ? 1920 : 1080));
|
||||
player.style.width = "100%";
|
||||
player.style.height = "100%";
|
||||
player.style.display = "block";
|
||||
player.style.background = "transparent";
|
||||
container.appendChild(player);
|
||||
|
||||
// Inject pasteboard shadow: let the shadow around the canvas bleed
|
||||
// into the surrounding pasteboard area (overflow: visible on the container)
|
||||
// and add a subtle outline + drop-shadow so the canvas boundary reads
|
||||
// against the gray pasteboard, consistent with professional editors.
|
||||
if (player.shadowRoot) {
|
||||
const pasteboardStyle = document.createElement("style");
|
||||
pasteboardStyle.textContent =
|
||||
".hfp-container{overflow:visible}" +
|
||||
".hfp-iframe{box-shadow:0 0 0 1px rgba(255,255,255,0.08),0 4px 32px rgba(0,0,0,.7)}";
|
||||
player.shadowRoot.appendChild(pasteboardStyle);
|
||||
}
|
||||
|
||||
enableInteractiveIframe(player);
|
||||
|
||||
// Bridge the inner iframe to the forwarded ref for useTimelinePlayer.
|
||||
const retryPreview = () => {
|
||||
retryCountRef.current += 1;
|
||||
const retryUrl = new URL(src, window.location.origin);
|
||||
retryUrl.searchParams.set("_hfStudioRetry", String(retryCountRef.current));
|
||||
setPreviewError(null);
|
||||
setCompositionLoading(true);
|
||||
player.setAttribute("src", retryUrl.pathname + retryUrl.search);
|
||||
};
|
||||
retryPreviewRef.current = retryPreview;
|
||||
const iframe = player.iframeElement;
|
||||
if (typeof ref === "function") {
|
||||
ref(iframe);
|
||||
} else if (ref) {
|
||||
(ref as React.MutableRefObject<HTMLIFrameElement | null>).current = iframe;
|
||||
}
|
||||
|
||||
// Prevent the web component's built-in click-to-toggle behavior.
|
||||
// The studio manages playback exclusively via useTimelinePlayer.
|
||||
const preventToggle = (e: Event) => e.stopImmediatePropagation();
|
||||
player.addEventListener("click", preventToggle, { capture: true });
|
||||
|
||||
const handleShaderTransitionState = (event: Event) => {
|
||||
const loading = getShaderTransitionLoading(event);
|
||||
if (loading !== null) setShaderTransitionLoading(loading);
|
||||
};
|
||||
player.addEventListener("shadertransitionstate", handleShaderTransitionState);
|
||||
|
||||
const handleReady = () => {
|
||||
setPreviewError(null);
|
||||
setCompositionLoading(false);
|
||||
};
|
||||
const handleError = () => {
|
||||
const handleError = (event: Event) => {
|
||||
setPreviewError(readPreviewErrorMessage(event));
|
||||
setCompositionLoading(false);
|
||||
};
|
||||
player.addEventListener("ready", handleReady);
|
||||
player.addEventListener("error", handleError);
|
||||
|
||||
// Forward the iframe's native load event to the studio's onIframeLoad.
|
||||
const handleLoad = () => {
|
||||
loadCountRef.current++;
|
||||
setPreviewError(null);
|
||||
setShaderTransitionLoading(false);
|
||||
setCompositionLoading(true);
|
||||
// Reveal animation on reload (hot-reload, composition switch)
|
||||
@@ -227,7 +208,10 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
||||
const onEnd = () => container.classList.remove("preview-revealing");
|
||||
container.addEventListener("animationend", onEnd, { once: true });
|
||||
}
|
||||
onLoad();
|
||||
onLoad((message) => {
|
||||
setPreviewError(message);
|
||||
setCompositionLoading(false);
|
||||
});
|
||||
|
||||
// Show a loading overlay until every `<video>`/`<audio>` and Lottie
|
||||
// asset is ready. Without this users can click play before audio has
|
||||
@@ -264,7 +248,47 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
||||
setAssetsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Attach lifecycle listeners before assigning src or connecting the
|
||||
// custom element. A warm local iframe can otherwise finish before
|
||||
// Studio observes its load and never initialize the timeline.
|
||||
iframe.addEventListener("load", handleLoad);
|
||||
player.addEventListener("click", preventToggle, { capture: true });
|
||||
player.addEventListener("shadertransitionstate", handleShaderTransitionState);
|
||||
player.addEventListener("ready", handleReady);
|
||||
player.addEventListener("error", handleError);
|
||||
|
||||
// Bridge the inner iframe to the forwarded ref for useTimelinePlayer.
|
||||
if (typeof ref === "function") {
|
||||
ref(iframe);
|
||||
} else if (ref) {
|
||||
(ref as React.MutableRefObject<HTMLIFrameElement | null>).current = iframe;
|
||||
}
|
||||
|
||||
player.setAttribute("shader-capture-scale", "1");
|
||||
player.setAttribute("shader-loading", "player");
|
||||
player.setAttribute("width", String(portrait ? 1080 : 1920));
|
||||
player.setAttribute("height", String(portrait ? 1920 : 1080));
|
||||
player.style.width = "100%";
|
||||
player.style.height = "100%";
|
||||
player.style.display = "block";
|
||||
player.style.background = "transparent";
|
||||
player.setAttribute("src", src);
|
||||
container.appendChild(player);
|
||||
|
||||
// Inject pasteboard shadow: let the shadow around the canvas bleed
|
||||
// into the surrounding pasteboard area (overflow: visible on the container)
|
||||
// and add a subtle outline + drop-shadow so the canvas boundary reads
|
||||
// against the gray pasteboard, consistent with professional editors.
|
||||
if (player.shadowRoot) {
|
||||
const pasteboardStyle = document.createElement("style");
|
||||
pasteboardStyle.textContent =
|
||||
".hfp-container{overflow:visible}" +
|
||||
".hfp-iframe{box-shadow:0 0 0 1px rgba(255,255,255,0.08),0 4px 32px rgba(0,0,0,.7)}";
|
||||
player.shadowRoot.appendChild(pasteboardStyle);
|
||||
}
|
||||
|
||||
enableInteractiveIframe(player);
|
||||
|
||||
cleanup = () => {
|
||||
iframe.removeEventListener("load", handleLoad);
|
||||
@@ -275,6 +299,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
||||
if (assetPollRef.current) clearInterval(assetPollRef.current);
|
||||
assetPollRef.current = null;
|
||||
container.removeChild(player);
|
||||
if (retryPreviewRef.current === retryPreview) retryPreviewRef.current = null;
|
||||
// Clear the forwarded ref only if it still points to THIS iframe.
|
||||
// During crossfade refreshes the retiring Player unmounts after the
|
||||
// new Player has already assigned its iframe to the same ref — blindly
|
||||
@@ -381,6 +406,25 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{previewError && (
|
||||
<div
|
||||
className="absolute inset-0 z-40 flex items-center justify-center bg-black/90 px-6 text-center"
|
||||
data-hyperframes-ignore=""
|
||||
data-testid="composition-preview-error"
|
||||
>
|
||||
<div className="max-w-sm">
|
||||
<p className="text-sm font-semibold text-white">Preview failed to load</p>
|
||||
<p className="mt-1 text-xs text-neutral-400">{previewError}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-4 rounded-md bg-white px-3 py-1.5 text-xs font-semibold text-black transition-colors hover:bg-neutral-200"
|
||||
onClick={() => retryPreviewRef.current?.()}
|
||||
>
|
||||
Retry preview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -129,6 +129,39 @@ function expectStorePlaybackState(
|
||||
}
|
||||
|
||||
describe("useTimelinePlayer seek hydration", () => {
|
||||
it("reports when Studio cannot initialize a timeline after iframe load", () => {
|
||||
vi.useFakeTimers();
|
||||
const { api, root } = renderTimelinePlayerHarness();
|
||||
const iframe = document.createElement("iframe");
|
||||
const iframeWindow = {
|
||||
postMessage: vi.fn(),
|
||||
scrollTo: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
};
|
||||
Object.defineProperty(iframe, "contentWindow", {
|
||||
value: iframeWindow,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(iframe, "contentDocument", {
|
||||
value: document.implementation.createHTMLDocument("preview"),
|
||||
configurable: true,
|
||||
});
|
||||
const reportError = vi.fn();
|
||||
|
||||
act(() => {
|
||||
api.iframeRef.current = iframe;
|
||||
api.onIframeLoad(reportError);
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
expect(reportError).toHaveBeenCalledWith(
|
||||
"Studio could not initialize the composition timeline.",
|
||||
);
|
||||
unmountWithAct(root);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("keeps an external seek request until the iframe adapter is ready", () => {
|
||||
const observedTimes: number[] = [];
|
||||
const unsubscribe = liveTime.subscribe((time) => {
|
||||
|
||||
@@ -399,51 +399,60 @@ export function useTimelineSyncCallbacks({
|
||||
pendingSeekRef,
|
||||
]);
|
||||
|
||||
const onIframeLoad = useCallback(() => {
|
||||
applyPreviewAudioState();
|
||||
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
|
||||
const onIframeLoad = useCallback(
|
||||
(reportError?: (message: string) => void) => {
|
||||
applyPreviewAudioState();
|
||||
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
|
||||
|
||||
// Fast path: adapter already available (in-place reloads, cached compositions)
|
||||
if (initializeAdapter()) return;
|
||||
// Fast path: adapter already available (in-place reloads, cached compositions)
|
||||
if (initializeAdapter()) return;
|
||||
|
||||
// The runtime posts "state" or "timeline" messages once ready.
|
||||
// Listen for those instead of polling.
|
||||
const iframe = iframeRef.current;
|
||||
let settled = false;
|
||||
// The runtime posts "state" or "timeline" messages once ready.
|
||||
// Listen for those instead of polling.
|
||||
const iframe = iframeRef.current;
|
||||
let settled = false;
|
||||
|
||||
const trySettle = () => {
|
||||
if (settled) return;
|
||||
if (initializeAdapter()) {
|
||||
settled = true;
|
||||
const trySettle = () => {
|
||||
if (settled) return;
|
||||
if (initializeAdapter()) {
|
||||
settled = true;
|
||||
window.removeEventListener("message", onMessage);
|
||||
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
const onMessage = (e: MessageEvent) => {
|
||||
if (e.source && iframe && e.source !== iframe.contentWindow) return;
|
||||
const data = e.data;
|
||||
if (
|
||||
data?.source === "hf-preview" &&
|
||||
(data?.type === "state" || data?.type === "timeline")
|
||||
) {
|
||||
// The main message handler owns protocol-error diagnostics. This readiness-only
|
||||
// listener mirrors its acceptance gate without dispatching a duplicate event:
|
||||
// an unsupported runtime must not make the iframe appear successfully settled.
|
||||
if (inspectStudioRuntimeMessage(data).status === "unsupported") return;
|
||||
trySettle();
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", onMessage);
|
||||
|
||||
// Safety net: if no message arrives within 5s, try one last time then give up.
|
||||
probeIntervalRef.current = setTimeout(() => {
|
||||
if (!settled) {
|
||||
trySettle();
|
||||
if (!settled) {
|
||||
reportError?.("Studio could not initialize the composition timeline.");
|
||||
}
|
||||
}
|
||||
window.removeEventListener("message", onMessage);
|
||||
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
const onMessage = (e: MessageEvent) => {
|
||||
if (e.source && iframe && e.source !== iframe.contentWindow) return;
|
||||
const data = e.data;
|
||||
if (data?.source === "hf-preview" && (data?.type === "state" || data?.type === "timeline")) {
|
||||
// The main message handler owns protocol-error diagnostics. This readiness-only
|
||||
// listener mirrors its acceptance gate without dispatching a duplicate event:
|
||||
// an unsupported runtime must not make the iframe appear successfully settled.
|
||||
if (inspectStudioRuntimeMessage(data).status === "unsupported") return;
|
||||
trySettle();
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", onMessage);
|
||||
|
||||
// Safety net: if no message arrives within 5s, try one last time then give up.
|
||||
probeIntervalRef.current = setTimeout(() => {
|
||||
if (!settled) {
|
||||
trySettle();
|
||||
}
|
||||
window.removeEventListener("message", onMessage);
|
||||
// Never leave the preview stuck invisible if the runtime never settled
|
||||
// (initializeAdapter reveals on success; this covers the give-up case).
|
||||
revealIframe(iframeRef.current);
|
||||
}, 5000) as unknown as ReturnType<typeof setInterval>;
|
||||
}, [initializeAdapter, iframeRef, probeIntervalRef, applyPreviewAudioState]);
|
||||
// Never leave the preview stuck invisible if the runtime never settled
|
||||
// (initializeAdapter reveals on success; this covers the give-up case).
|
||||
revealIframe(iframeRef.current);
|
||||
}, 5000) as unknown as ReturnType<typeof setInterval>;
|
||||
},
|
||||
[initializeAdapter, iframeRef, probeIntervalRef, applyPreviewAudioState],
|
||||
);
|
||||
|
||||
// Stable refs so mount-effect closures always call the latest version
|
||||
const processTimelineMessageRef = { current: processTimelineMessage };
|
||||
|
||||
Reference in New Issue
Block a user