refactor(studio): reuse player probe errors

This commit is contained in:
ukimsanov
2026-07-27 22:35:09 -07:00
parent 0faeb42f86
commit 3af9a7df2d
7 changed files with 67 additions and 119 deletions
@@ -32,7 +32,7 @@ export interface NLEContextValue {
togglePlay: () => void;
seek: (time: number, options?: { keepPlaying?: boolean }) => boolean;
refreshPlayer: () => void;
onIframeLoad: (reportError?: (message: string) => void) => void;
onIframeLoad: () => void;
// composition stack (from useCompositionStack)
compositionStack: CompositionLevel[];
updateCompositionStack: React.Dispatch<React.SetStateAction<CompositionLevel[]>>;
@@ -122,17 +122,14 @@ export function NLEProvider({
refreshPlayer();
}, [refreshKey, refreshPlayer]);
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 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 {
compositionStack,
@@ -15,7 +15,7 @@ import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/s
interface NLEPreviewProps {
projectId: string;
iframeRef: RefObject<HTMLIFrameElement | null>;
onIframeLoad: (reportError?: (message: string) => void) => void;
onIframeLoad: () => 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={(reportError) => {
onLoad={() => {
updateCompositionSizeFromPreview();
onIframeLoad(reportError);
onIframeLoad();
applyInitialZoom();
}}
onCompositionLoadingChange={onCompositionLoadingChange}
@@ -7,6 +7,15 @@ import {
shouldShowCompositionLoadingOverlay,
} from "./Player";
function createAudioIframe() {
const iframe = document.createElement("iframe");
document.body.appendChild(iframe);
const audio = iframe.contentDocument?.createElement("audio");
expect(audio).toBeDefined();
iframe.contentDocument?.body.appendChild(audio!);
return { audio: audio!, iframe };
}
describe("preview errors", () => {
it("reads the player probe error for the visible retry state", () => {
expect(
@@ -35,10 +44,7 @@ describe("composition loading overlay", () => {
});
it("keeps the asset overlay up while media is still buffering", () => {
const iframe = document.createElement("iframe");
document.body.appendChild(iframe);
const audio = iframe.contentDocument?.createElement("audio");
expect(audio).toBeDefined();
const { audio, iframe } = createAudioIframe();
Object.defineProperty(audio, "readyState", {
value: 0,
configurable: true,
@@ -47,7 +53,6 @@ describe("composition loading overlay", () => {
value: 2,
configurable: true,
});
iframe.contentDocument?.body.appendChild(audio!);
expect(hasUnloadedAssets(iframe, false)).toBe(true);
@@ -55,10 +60,7 @@ describe("composition loading overlay", () => {
});
it("does not keep the asset overlay stuck on failed media sources", () => {
const iframe = document.createElement("iframe");
document.body.appendChild(iframe);
const audio = iframe.contentDocument?.createElement("audio");
expect(audio).toBeDefined();
const { audio, iframe } = createAudioIframe();
Object.defineProperty(audio, "error", {
value: { code: 4, message: "format error" },
configurable: true,
@@ -71,7 +73,6 @@ describe("composition loading overlay", () => {
value: 3,
configurable: true,
});
iframe.contentDocument?.body.appendChild(audio!);
expect(hasUnloadedAssets(iframe, false)).toBe(false);
@@ -10,7 +10,7 @@ import { HyperframesLoader } from "../../components/ui";
interface PlayerProps {
projectId?: string;
directUrl?: string;
onLoad: (reportError: (message: string) => void) => void;
onLoad: () => void;
onCompositionLoadingChange?: (loading: boolean) => void;
portrait?: boolean;
style?: React.CSSProperties;
@@ -208,10 +208,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
const onEnd = () => container.classList.remove("preview-revealing");
container.addEventListener("animationend", onEnd, { once: true });
}
onLoad((message) => {
setPreviewError(message);
setCompositionLoading(false);
});
onLoad();
// Show a loading overlay until every `<video>`/`<audio>` and Lottie
// asset is ready. Without this users can click play before audio has
@@ -129,39 +129,6 @@ 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,60 +399,51 @@ export function useTimelineSyncCallbacks({
pendingSeekRef,
]);
const onIframeLoad = useCallback(
(reportError?: (message: string) => void) => {
applyPreviewAudioState();
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
const onIframeLoad = useCallback(() => {
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;
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.");
}
}
const trySettle = () => {
if (settled) return;
if (initializeAdapter()) {
settled = true;
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],
);
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]);
// Stable refs so mount-effect closures always call the latest version
const processTimelineMessageRef = { current: processTimelineMessage };