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; togglePlay: () => void;
seek: (time: number, options?: { keepPlaying?: boolean }) => boolean; seek: (time: number, options?: { keepPlaying?: boolean }) => boolean;
refreshPlayer: () => void; refreshPlayer: () => void;
onIframeLoad: (reportError?: (message: string) => void) => void; onIframeLoad: () => void;
// composition stack (from useCompositionStack) // composition stack (from useCompositionStack)
compositionStack: CompositionLevel[]; compositionStack: CompositionLevel[];
updateCompositionStack: React.Dispatch<React.SetStateAction<CompositionLevel[]>>; updateCompositionStack: React.Dispatch<React.SetStateAction<CompositionLevel[]>>;
@@ -122,17 +122,14 @@ export function NLEProvider({
refreshPlayer(); refreshPlayer();
}, [refreshKey, refreshPlayer]); }, [refreshKey, refreshPlayer]);
const onIframeLoad = useCallback( const onIframeLoad = useCallback(() => {
(reportError?: (message: string) => void) => { baseOnIframeLoad();
baseOnIframeLoad(reportError); // Pre-load + register MotionPathPlugin once so adding a motion path in the
// 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
// 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).
// reload (the comp may not ship the plugin until it actually uses one). ensureMotionPathPluginLoaded(iframeRef.current);
ensureMotionPathPluginLoaded(iframeRef.current); onIframeRef?.(iframeRef.current);
onIframeRef?.(iframeRef.current); }, [baseOnIframeLoad, iframeRef, onIframeRef]);
},
[baseOnIframeLoad, iframeRef, onIframeRef],
);
const { const {
compositionStack, compositionStack,
@@ -15,7 +15,7 @@ import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/s
interface NLEPreviewProps { interface NLEPreviewProps {
projectId: string; projectId: string;
iframeRef: RefObject<HTMLIFrameElement | null>; iframeRef: RefObject<HTMLIFrameElement | null>;
onIframeLoad: (reportError?: (message: string) => void) => void; onIframeLoad: () => void;
onCompositionLoadingChange?: (loading: boolean) => void; onCompositionLoadingChange?: (loading: boolean) => void;
portrait?: boolean; portrait?: boolean;
directUrl?: string; directUrl?: string;
@@ -491,9 +491,9 @@ export const NLEPreview = memo(function NLEPreview({
ref={setPreviewIframeRef} ref={setPreviewIframeRef}
projectId={directUrl ? undefined : projectId} projectId={directUrl ? undefined : projectId}
directUrl={directUrl} directUrl={directUrl}
onLoad={(reportError) => { onLoad={() => {
updateCompositionSizeFromPreview(); updateCompositionSizeFromPreview();
onIframeLoad(reportError); onIframeLoad();
applyInitialZoom(); applyInitialZoom();
}} }}
onCompositionLoadingChange={onCompositionLoadingChange} onCompositionLoadingChange={onCompositionLoadingChange}
@@ -7,6 +7,15 @@ import {
shouldShowCompositionLoadingOverlay, shouldShowCompositionLoadingOverlay,
} from "./Player"; } 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", () => { describe("preview errors", () => {
it("reads the player probe error for the visible retry state", () => { it("reads the player probe error for the visible retry state", () => {
expect( expect(
@@ -35,10 +44,7 @@ describe("composition loading overlay", () => {
}); });
it("keeps the asset overlay up while media is still buffering", () => { it("keeps the asset overlay up while media is still buffering", () => {
const iframe = document.createElement("iframe"); const { audio, iframe } = createAudioIframe();
document.body.appendChild(iframe);
const audio = iframe.contentDocument?.createElement("audio");
expect(audio).toBeDefined();
Object.defineProperty(audio, "readyState", { Object.defineProperty(audio, "readyState", {
value: 0, value: 0,
configurable: true, configurable: true,
@@ -47,7 +53,6 @@ describe("composition loading overlay", () => {
value: 2, value: 2,
configurable: true, configurable: true,
}); });
iframe.contentDocument?.body.appendChild(audio!);
expect(hasUnloadedAssets(iframe, false)).toBe(true); 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", () => { it("does not keep the asset overlay stuck on failed media sources", () => {
const iframe = document.createElement("iframe"); const { audio, iframe } = createAudioIframe();
document.body.appendChild(iframe);
const audio = iframe.contentDocument?.createElement("audio");
expect(audio).toBeDefined();
Object.defineProperty(audio, "error", { Object.defineProperty(audio, "error", {
value: { code: 4, message: "format error" }, value: { code: 4, message: "format error" },
configurable: true, configurable: true,
@@ -71,7 +73,6 @@ describe("composition loading overlay", () => {
value: 3, value: 3,
configurable: true, configurable: true,
}); });
iframe.contentDocument?.body.appendChild(audio!);
expect(hasUnloadedAssets(iframe, false)).toBe(false); expect(hasUnloadedAssets(iframe, false)).toBe(false);
@@ -10,7 +10,7 @@ import { HyperframesLoader } from "../../components/ui";
interface PlayerProps { interface PlayerProps {
projectId?: string; projectId?: string;
directUrl?: string; directUrl?: string;
onLoad: (reportError: (message: string) => void) => void; onLoad: () => void;
onCompositionLoadingChange?: (loading: boolean) => void; onCompositionLoadingChange?: (loading: boolean) => void;
portrait?: boolean; portrait?: boolean;
style?: React.CSSProperties; style?: React.CSSProperties;
@@ -208,10 +208,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
const onEnd = () => container.classList.remove("preview-revealing"); const onEnd = () => container.classList.remove("preview-revealing");
container.addEventListener("animationend", onEnd, { once: true }); container.addEventListener("animationend", onEnd, { once: true });
} }
onLoad((message) => { onLoad();
setPreviewError(message);
setCompositionLoading(false);
});
// Show a loading overlay until every `<video>`/`<audio>` and Lottie // Show a loading overlay until every `<video>`/`<audio>` and Lottie
// asset is ready. Without this users can click play before audio has // asset is ready. Without this users can click play before audio has
@@ -129,39 +129,6 @@ function expectStorePlaybackState(
} }
describe("useTimelinePlayer seek hydration", () => { 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", () => { it("keeps an external seek request until the iframe adapter is ready", () => {
const observedTimes: number[] = []; const observedTimes: number[] = [];
const unsubscribe = liveTime.subscribe((time) => { const unsubscribe = liveTime.subscribe((time) => {
@@ -399,60 +399,51 @@ export function useTimelineSyncCallbacks({
pendingSeekRef, pendingSeekRef,
]); ]);
const onIframeLoad = useCallback( const onIframeLoad = useCallback(() => {
(reportError?: (message: string) => void) => { applyPreviewAudioState();
applyPreviewAudioState(); if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
// Fast path: adapter already available (in-place reloads, cached compositions) // Fast path: adapter already available (in-place reloads, cached compositions)
if (initializeAdapter()) return; if (initializeAdapter()) return;
// The runtime posts "state" or "timeline" messages once ready. // The runtime posts "state" or "timeline" messages once ready.
// Listen for those instead of polling. // Listen for those instead of polling.
const iframe = iframeRef.current; const iframe = iframeRef.current;
let settled = false; let settled = false;
const trySettle = () => { const trySettle = () => {
if (settled) return; if (settled) return;
if (initializeAdapter()) { if (initializeAdapter()) {
settled = true; 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); window.removeEventListener("message", onMessage);
// Never leave the preview stuck invisible if the runtime never settled if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
// (initializeAdapter reveals on success; this covers the give-up case). }
revealIframe(iframeRef.current); };
}, 5000) as unknown as ReturnType<typeof setInterval>;
}, const onMessage = (e: MessageEvent) => {
[initializeAdapter, iframeRef, probeIntervalRef, applyPreviewAudioState], 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 // Stable refs so mount-effect closures always call the latest version
const processTimelineMessageRef = { current: processTimelineMessage }; const processTimelineMessageRef = { current: processTimelineMessage };
+1 -6
View File
@@ -38,6 +38,7 @@ const GET_RESPONSES = new Map([
}), }),
], ],
[`${PROJECT_PATH}/preview`, text(SMOKE_COMPOSITION_HTML, "text/html")], [`${PROJECT_PATH}/preview`, text(SMOKE_COMPOSITION_HTML, "text/html")],
[`${PROJECT_PATH}/thumbnail/index.html`, text(SMOKE_THUMBNAIL_SVG, "image/svg+xml")],
[`${PROJECT_PATH}/renders`, json({ renders: [] })], [`${PROJECT_PATH}/renders`, json({ renders: [] })],
[`${PROJECT_PATH}/lint`, json({ findings: [] })], [`${PROJECT_PATH}/lint`, json({ findings: [] })],
[ [
@@ -77,11 +78,6 @@ function projectPreviewResponse(pathname) {
: text(SMOKE_COMPOSITION_HTML, "text/html"); : text(SMOKE_COMPOSITION_HTML, "text/html");
} }
function projectThumbnailResponse(pathname) {
if (!pathname.startsWith(`${PROJECT_PATH}/thumbnail/`)) return undefined;
return text(SMOKE_THUMBNAIL_SVG, "image/svg+xml");
}
function gsapAnimationsResponse(pathname) { function gsapAnimationsResponse(pathname) {
if (!pathname.startsWith(`${PROJECT_PATH}/gsap-animations/`)) return undefined; if (!pathname.startsWith(`${PROJECT_PATH}/gsap-animations/`)) return undefined;
return json({ animations: [], timelineVar: "tl", preamble: "", postamble: "" }); return json({ animations: [], timelineVar: "tl", preamble: "", postamble: "" });
@@ -92,7 +88,6 @@ function getStudioSmokeResponse(pathname) {
GET_RESPONSES.get(pathname) ?? GET_RESPONSES.get(pathname) ??
projectFileResponse(pathname) ?? projectFileResponse(pathname) ??
projectPreviewResponse(pathname) ?? projectPreviewResponse(pathname) ??
projectThumbnailResponse(pathname) ??
gsapAnimationsResponse(pathname) gsapAnimationsResponse(pathname)
); );
} }