mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
refactor(studio): reuse player probe errors
This commit is contained in:
@@ -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 };
|
||||||
|
|||||||
@@ -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)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user