fix(studio): improve preview loading reliability

This commit is contained in:
ukimsanov
2026-07-27 22:14:23 -07:00
parent 3c857d768b
commit 18b9acac12
8 changed files with 288 additions and 128 deletions
@@ -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 };