mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
fix(studio): keep a preview reload waiting while a slow project boots (#5823)
* fix(studio): keep a preview reload waiting while a slow project boots Studio gives a reload up to three 15 s budgets, but the player failed it at 8 s and Studio's own 5 s safety net stopped listening for the runtime. A host can now own the load deadline (no-load-timeout); script and runtime errors still fail at once. * fix(player): send the 8 s load deadline through the probe's one error path
This commit is contained in:
@@ -64,6 +64,7 @@ Set `autoplay muted` only when playback should start without a user gesture.
|
||||
| `assets-loading-ui` | `player` or `none` | `player` | `none` hides the loading-assets card; asset events still fire |
|
||||
| `low-power-idle` | boolean | false | While paused, check in once a second, not every 80 ms (many-player pages) |
|
||||
| `disable-click-to-play` | boolean | false | A click on the player no longer plays or pauses, so the page can handle it |
|
||||
| `no-load-timeout` | boolean | false | A slow load never fails at 8 s, so the page owns the deadline; script and runtime errors still fail at once |
|
||||
| `range-start` | number | — | Film second where playback starts, loops back to and parks when paused (`rangeStart`) |
|
||||
| `range-end` | number | — | Film second the range ends before: it stops or loops on the frame before it (`rangeEnd`) |
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ Show a static image before playback starts:
|
||||
| `assets-loading-ui` | `player \| none` | `player` | `none` never shows the loading-assets card; asset events still fire |
|
||||
| `low-power-idle` | boolean | false | While paused, check in once a second, not every 80 ms (many-player pages) |
|
||||
| `disable-click-to-play` | boolean | false | A click on the player no longer plays or pauses (host overlays own clicks) |
|
||||
| `no-load-timeout` | boolean | false | Slow loads never fail at 8 s; the page owns the deadline. Errors still fail |
|
||||
| `range-start` | number | — | Film second where playback starts, loops back to and parks when paused |
|
||||
| `range-end` | number | — | Film second the range ends before: it stops or loops on the frame before it |
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* The probe interval polls every 200 ms until one of:
|
||||
* - An adapter or the document resolves with a positive duration, or
|
||||
* - 40 attempts (~8 s) expire without a result.
|
||||
* - 40 attempts (~8 s) expire without a result, unless the host owns the deadline.
|
||||
*
|
||||
* The `CompositionProbe` class owns the interval; the caller must call
|
||||
* `stop()` on disconnect or src change.
|
||||
@@ -38,6 +38,7 @@ export interface ProbeCallbacks {
|
||||
/** Called when runtime is successfully injected (informational). */
|
||||
onRuntimeInjected?: () => void;
|
||||
resolveRuntimeUrl?: () => string;
|
||||
hostOwnsDeadline?: () => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -121,22 +122,22 @@ export class CompositionProbe {
|
||||
};
|
||||
}
|
||||
}
|
||||
outcome ??= this._deadlineOutcome(attempts);
|
||||
if (outcome) {
|
||||
this.stop();
|
||||
if (outcome.kind === "error") {
|
||||
this._failure = { document: this._iframe.contentDocument };
|
||||
this._callbacks.onError(outcome.message);
|
||||
} else this._callbacks.onReady(outcome.result);
|
||||
return;
|
||||
}
|
||||
if (attempts >= 40) {
|
||||
this.stop();
|
||||
this._failure = { document: this._iframe.contentDocument };
|
||||
this._callbacks.onError("Composition timeline not found after 8s");
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
private _deadlineOutcome(attempts: number): ProbeOutcome {
|
||||
if (attempts < 40 || this._callbacks.hostOwnsDeadline?.()) return null;
|
||||
return { kind: "error", message: "Composition timeline not found after 8s" };
|
||||
}
|
||||
|
||||
private _poll(attempts: number): ProbeOutcome {
|
||||
const win = this._iframe.contentWindow;
|
||||
if (!win) return null;
|
||||
|
||||
@@ -206,6 +206,7 @@ class HyperframesPlayer extends HTMLElement {
|
||||
|
||||
this.probe = new CompositionProbe(this.iframe, {
|
||||
resolveRuntimeUrl: () => runtimeSrcFromElement(this),
|
||||
hostOwnsDeadline: () => this.hasAttribute("no-load-timeout"),
|
||||
onReady: (result) => this._onProbeReady(result),
|
||||
onError: (message) => this._emit(new CustomEvent("error", { detail: { message } })),
|
||||
});
|
||||
|
||||
@@ -61,6 +61,24 @@ describe("terminal probe readiness", () => {
|
||||
expectUnready(player, onReady);
|
||||
});
|
||||
|
||||
it("waits past 8 s for a slow runtime when the host owns the deadline", () => {
|
||||
const { player, iframe, onError, onReady } = mountPlayer();
|
||||
player.setAttribute("no-load-timeout", "");
|
||||
vi.advanceTimersByTime(20_000);
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
Object.assign(iframe.contentWindow!, { __player: { getDuration: () => 9 } });
|
||||
vi.advanceTimersByTime(200);
|
||||
expectTimelineReady(player, onReady);
|
||||
});
|
||||
|
||||
it("still fails an author error at once when the host owns the deadline", () => {
|
||||
const { player, iframe, onError } = mountPlayer();
|
||||
player.setAttribute("no-load-timeout", "");
|
||||
Object.assign(iframe.contentWindow!, { __hfPreviewErrors: ["Uncaught Error: author failed"] });
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(onError.mock.calls[0][0].detail.message).toBe("Uncaught Error: author failed");
|
||||
});
|
||||
|
||||
it("rejects the failed document while its replacement is still navigating", () => {
|
||||
const { player, iframe, onReady } = mountPlayer();
|
||||
vi.advanceTimersByTime(8000);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// @vitest-environment happy-dom
|
||||
// @vitest-environment-options {"happyDOM":{"settings":{"disableIframePageLoading":true}}}
|
||||
|
||||
import React, { act, createRef } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { NLEPreview } from "./NLEPreview";
|
||||
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("keeps a shadow reload whose runtime boots after 8 s, and its player comes ready", async () => {
|
||||
await import("@hyperframes/player");
|
||||
vi.useFakeTimers();
|
||||
const onShadowError = vi.fn();
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(NLEPreview, {
|
||||
projectId: "p",
|
||||
iframeRef: createRef<HTMLIFrameElement>(),
|
||||
onIframeLoad: () => {},
|
||||
previewSlots: [
|
||||
{ gen: 0, role: "live" },
|
||||
{ gen: 1, role: "shadow", url: "/api/projects/p/preview?_t=1" },
|
||||
],
|
||||
onShadowIframeLoad: () => {},
|
||||
onShadowReadyChange: () => {},
|
||||
onShadowError,
|
||||
setShadowIframeNode: () => {},
|
||||
resetPreviewSlots: () => {},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const shadow = host.querySelectorAll<
|
||||
HTMLElement & { iframeElement: HTMLIFrameElement; ready: boolean }
|
||||
>("hyperframes-player")[1];
|
||||
const iframe = shadow.iframeElement;
|
||||
act(() => {
|
||||
iframe.dispatchEvent(new Event("load"));
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(10_000);
|
||||
});
|
||||
expect(onShadowError).not.toHaveBeenCalled();
|
||||
|
||||
Object.assign(iframe.contentWindow!, { __player: { getDuration: () => 9 } });
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1_000);
|
||||
});
|
||||
expect(onShadowError).not.toHaveBeenCalled();
|
||||
expect(shadow.ready).toBe(true);
|
||||
// The live slot keeps the player's own 8 s deadline.
|
||||
expect(host.querySelector("hyperframes-player")?.hasAttribute("no-load-timeout")).toBe(false);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
@@ -209,8 +209,7 @@ export const NLEPreview = memo(function NLEPreview({
|
||||
|
||||
const updateCompositionSizeFromPreview = useCallback(() => {
|
||||
const next = readPreviewCompositionSize(previewIframeRef.current);
|
||||
// Pure updater — the parent notification happens in the effect below
|
||||
// (updaters may run more than once under Strict Mode / concurrent React).
|
||||
// Pure updater; the effect below notifies the parent (Strict Mode may run updaters twice).
|
||||
setCompositionSize((prev) =>
|
||||
prev?.width === next?.width && prev?.height === next?.height ? prev : next,
|
||||
);
|
||||
@@ -563,6 +562,7 @@ export const NLEPreview = memo(function NLEPreview({
|
||||
onPreviewError={(message) => onShadowError(slot.gen, message)}
|
||||
portrait={portrait}
|
||||
suppressLoadingOverlay
|
||||
noLoadTimeout
|
||||
style={SHADOW_IFRAME_STYLE}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -37,6 +37,7 @@ interface PlayerProps {
|
||||
portrait?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
suppressLoadingOverlay?: boolean;
|
||||
noLoadTimeout?: boolean;
|
||||
}
|
||||
|
||||
interface HyperframesPlayerElement extends HTMLElement {
|
||||
@@ -154,6 +155,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
||||
portrait,
|
||||
style,
|
||||
suppressLoadingOverlay,
|
||||
noLoadTimeout,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
@@ -224,6 +226,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
||||
setCompositionLoading(true);
|
||||
loadStartedAtRef.current = performance.now();
|
||||
loadIdRef.current += 1;
|
||||
player.removeAttribute("no-load-timeout");
|
||||
player.setAttribute("src", retryUrl.pathname + retryUrl.search);
|
||||
};
|
||||
retryPreviewRef.current = retryPreview;
|
||||
@@ -331,6 +334,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
||||
player.setAttribute("shader-capture-scale", "1");
|
||||
player.setAttribute("shader-loading", "player");
|
||||
player.setAttribute("low-power-idle", "");
|
||||
player.toggleAttribute("no-load-timeout", noLoadTimeout === true);
|
||||
player.setAttribute("width", String(portrait ? 1080 : 1920));
|
||||
player.setAttribute("height", String(portrait ? 1920 : 1080));
|
||||
player.style.width = "100%";
|
||||
|
||||
@@ -49,7 +49,12 @@ function isDocumentHidden(): boolean {
|
||||
|
||||
type UseShadowPreviewReloadParams = Omit<
|
||||
UseTimelineSyncCallbacksParams,
|
||||
"probeIntervalRef" | "onAdapterReady" | "getAdapter" | "isCurrent" | "onLoadGiveUp"
|
||||
| "probeIntervalRef"
|
||||
| "onAdapterReady"
|
||||
| "getAdapter"
|
||||
| "isCurrent"
|
||||
| "onLoadGiveUp"
|
||||
| "hostOwnsDeadline"
|
||||
> & {
|
||||
getAdapter: (overrideIframe?: HTMLIFrameElement | null) => PlaybackAdapter | null;
|
||||
/** Runs right after a shadow becomes the live iframe (iframeRef already points at it). */
|
||||
@@ -249,6 +254,7 @@ export function useShadowPreviewReload({
|
||||
applyPreviewAudioState: () => {},
|
||||
onAdapterReady: markAdapterReady,
|
||||
isCurrent: isCurrentShadow,
|
||||
hostOwnsDeadline: true,
|
||||
});
|
||||
cancelPendingLoadRef.current = cancelPendingLoad;
|
||||
|
||||
|
||||
@@ -461,6 +461,31 @@ describe("shadow reload readiness and failure", () => {
|
||||
unmount(root);
|
||||
});
|
||||
|
||||
it("promotes a shadow whose runtime boots after Studio's own 5 s readiness wait", () => {
|
||||
vi.useFakeTimers();
|
||||
const onPreviewReloadFailed = vi.fn();
|
||||
const { getApi, gen, root } = beginReload({ onPreviewReloadFailed });
|
||||
const { win } = makeAdapterWindow();
|
||||
const booting: Record<string, unknown> = { ...win, __player: undefined };
|
||||
const iframe = makeFakeIframe(booting);
|
||||
act(() => {
|
||||
getApi().setShadowIframeNode(iframe);
|
||||
getApi().onShadowIframeLoad(gen);
|
||||
});
|
||||
|
||||
act(() => void vi.advanceTimersByTime(10_000));
|
||||
booting.__player = win.__player;
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", { data: { source: "hf-preview", type: "state" } }),
|
||||
);
|
||||
getApi().onShadowReadyChange(gen, true);
|
||||
});
|
||||
expect(getApi().iframeRef.current).toBe(iframe);
|
||||
expect(onPreviewReloadFailed).not.toHaveBeenCalled();
|
||||
unmount(root);
|
||||
});
|
||||
|
||||
it("gives a reload that replaces a slow one its own full set of budgets", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
@@ -60,6 +60,7 @@ export interface UseTimelineSyncCallbacksParams {
|
||||
isCurrent?: (context?: number) => boolean;
|
||||
/** Fires when no adapter appeared within the wait window. */
|
||||
onLoadGiveUp?: (context?: number) => void;
|
||||
hostOwnsDeadline?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -163,6 +164,7 @@ export function useTimelineSyncCallbacks({
|
||||
onAdapterReady = revealAndCommit,
|
||||
isCurrent,
|
||||
onLoadGiveUp,
|
||||
hostOwnsDeadline,
|
||||
}: UseTimelineSyncCallbacksParams) {
|
||||
// Convert a runtime timeline message (from iframe postMessage) into TimelineElements
|
||||
const processTimelineMessage = useCallback(
|
||||
@@ -335,6 +337,7 @@ export function useTimelineSyncCallbacks({
|
||||
};
|
||||
window.addEventListener("message", onMessage);
|
||||
stopWaitingRef.current = stopWaiting;
|
||||
if (hostOwnsDeadline) return;
|
||||
|
||||
// Safety net: if no message arrives within 5s, try one last time then give up.
|
||||
probeIntervalRef.current = setTimeout(() => {
|
||||
@@ -346,7 +349,14 @@ export function useTimelineSyncCallbacks({
|
||||
revealIframe(iframeRef.current);
|
||||
}, 5000) as unknown as ReturnType<typeof setInterval>;
|
||||
},
|
||||
[initializeAdapter, iframeRef, probeIntervalRef, applyPreviewAudioState, onLoadGiveUp],
|
||||
[
|
||||
initializeAdapter,
|
||||
iframeRef,
|
||||
probeIntervalRef,
|
||||
applyPreviewAudioState,
|
||||
onLoadGiveUp,
|
||||
hostOwnsDeadline,
|
||||
],
|
||||
);
|
||||
|
||||
const cancelPendingLoad = useCallback(() => {
|
||||
|
||||
Reference in New Issue
Block a user