mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
fix(studio): harden preview recovery
This commit is contained in:
@@ -42,10 +42,23 @@ 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();
|
||||
const thumbnail = host.querySelector<HTMLImageElement>('img[src*="/thumbnail/"]');
|
||||
expect(thumbnail).not.toBeNull();
|
||||
expect(new URL(thumbnail?.src ?? "").searchParams.get("t")).toBe("3.00");
|
||||
expect(host.querySelector("iframe")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a fallback when the cached thumbnail fails", () => {
|
||||
const { host } = mount();
|
||||
const thumbnail = host.querySelector<HTMLImageElement>('img[src*="/thumbnail/"]');
|
||||
if (!thumbnail) throw new Error("composition thumbnail did not render");
|
||||
|
||||
act(() => thumbnail.dispatchEvent(new Event("error")));
|
||||
|
||||
expect(host.textContent).toContain("Preview unavailable");
|
||||
expect(host.querySelector('img[src*="/thumbnail/"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("mounts one live preview only after sustained hover and removes it on leave", () => {
|
||||
vi.useFakeTimers();
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
@@ -61,6 +74,7 @@ describe("composition card drag", () => {
|
||||
card.dispatchEvent(new Event("pointerout", { bubbles: true }));
|
||||
});
|
||||
expect(host.querySelector("iframe")).toBeNull();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
vi.useRealTimers();
|
||||
|
||||
@@ -132,6 +132,7 @@ function CompCard({
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [stageSize, setStageSize] = useState(DEFAULT_PREVIEW_STAGE);
|
||||
const [livePreviewLoaded, setLivePreviewLoaded] = useState(false);
|
||||
const [thumbnailFailed, setThumbnailFailed] = useState(false);
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const syncTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -160,6 +161,10 @@ function CompCard({
|
||||
clearTimeout(hoverTimer.current);
|
||||
hoverTimer.current = null;
|
||||
}
|
||||
if (syncTimer.current) {
|
||||
clearTimeout(syncTimer.current);
|
||||
syncTimer.current = null;
|
||||
}
|
||||
setHovered(false);
|
||||
setLivePreviewLoaded(false);
|
||||
};
|
||||
@@ -167,8 +172,8 @@ function CompCard({
|
||||
const previewUrl = `/api/projects/${projectId}/preview/comp/${comp}`;
|
||||
const thumbnailUrl = buildCompositionThumbnailUrl({
|
||||
previewUrl,
|
||||
seekTime: 0,
|
||||
duration: THUMBNAIL_SEEK_TIME_SECONDS * 2,
|
||||
seekTime: THUMBNAIL_SEEK_TIME_SECONDS,
|
||||
duration: 0,
|
||||
origin: window.location.origin,
|
||||
});
|
||||
const previewScale = resolveCompositionPreviewScale({
|
||||
@@ -225,16 +230,23 @@ function CompCard({
|
||||
}`}
|
||||
>
|
||||
<div className="w-20 h-[45px] rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className={`absolute inset-0 h-full w-full object-contain transition-opacity ${
|
||||
livePreviewLoaded ? "opacity-0" : "opacity-100"
|
||||
}`}
|
||||
/>
|
||||
{thumbnailFailed ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center px-1 text-center text-[8px] leading-tight text-neutral-600">
|
||||
Preview unavailable
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={() => setThumbnailFailed(true)}
|
||||
className={`absolute inset-0 h-full w-full object-contain transition-opacity ${
|
||||
livePreviewLoaded ? "opacity-0" : "opacity-100"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
{hovered && (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
@@ -353,7 +365,7 @@ export const CompositionsTab = memo(function CompositionsTab({
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{compositions.map((comp) => (
|
||||
<CompCard
|
||||
key={comp}
|
||||
key={`${projectId}:${comp}`}
|
||||
projectId={projectId}
|
||||
comp={comp}
|
||||
isActive={activeComposition === comp}
|
||||
|
||||
@@ -1,12 +1,79 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { act, createElement } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
hasUnloadedAssets,
|
||||
Player,
|
||||
readPreviewErrorMessage,
|
||||
shouldShowCompositionLoadingOverlay,
|
||||
} from "./Player";
|
||||
|
||||
vi.mock("@hyperframes/player", () => ({}));
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
let root: Root | null = null;
|
||||
let lifecycleLog: string[] = [];
|
||||
|
||||
class TestHyperframesPlayer extends HTMLElement {
|
||||
readonly iframeElement = document.createElement("iframe");
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
const addIframeListener = this.iframeElement.addEventListener.bind(this.iframeElement);
|
||||
this.iframeElement.addEventListener = ((type, listener, options) => {
|
||||
lifecycleLog.push(`iframe:${type}`);
|
||||
addIframeListener(type, listener, options);
|
||||
}) as typeof this.iframeElement.addEventListener;
|
||||
|
||||
const addPlayerListener = this.addEventListener.bind(this);
|
||||
this.addEventListener = ((type, listener, options) => {
|
||||
lifecycleLog.push(`player:${type}`);
|
||||
addPlayerListener(type, listener, options);
|
||||
}) as typeof this.addEventListener;
|
||||
|
||||
const setPlayerAttribute = this.setAttribute.bind(this);
|
||||
this.setAttribute = (name, value) => {
|
||||
if (name === "src") lifecycleLog.push("src");
|
||||
setPlayerAttribute(name, value);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get("hyperframes-player")) {
|
||||
customElements.define("hyperframes-player", TestHyperframesPlayer);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (root) act(() => root?.unmount());
|
||||
root = null;
|
||||
lifecycleLog = [];
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
async function mountPlayer() {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
createElement(Player, {
|
||||
directUrl: "/api/projects/demo/preview",
|
||||
onLoad: vi.fn(),
|
||||
suppressLoadingOverlay: true,
|
||||
}),
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const player = host.querySelector<TestHyperframesPlayer>("hyperframes-player");
|
||||
if (!player) throw new Error("player did not mount");
|
||||
return { host, player };
|
||||
}
|
||||
|
||||
function createAudioIframe() {
|
||||
const iframe = document.createElement("iframe");
|
||||
document.body.appendChild(iframe);
|
||||
@@ -32,6 +99,47 @@ describe("preview errors", () => {
|
||||
"The composition preview did not become ready.",
|
||||
);
|
||||
});
|
||||
|
||||
it("attaches lifecycle listeners before navigating the player", async () => {
|
||||
await mountPlayer();
|
||||
const srcIndex = lifecycleLog.indexOf("src");
|
||||
|
||||
expect(srcIndex).toBeGreaterThan(-1);
|
||||
for (const listener of [
|
||||
"iframe:load",
|
||||
"player:click",
|
||||
"player:shadertransitionstate",
|
||||
"player:ready",
|
||||
"player:error",
|
||||
]) {
|
||||
expect(lifecycleLog.indexOf(listener)).toBeGreaterThan(-1);
|
||||
expect(lifecycleLog.indexOf(listener)).toBeLessThan(srcIndex);
|
||||
}
|
||||
});
|
||||
|
||||
it("retries a failed preview with a fresh player URL", async () => {
|
||||
const { host, player } = await mountPlayer();
|
||||
|
||||
act(() => {
|
||||
player.dispatchEvent(
|
||||
new CustomEvent("error", {
|
||||
detail: { message: "Composition timeline not found after 8s" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(host.querySelector('[data-testid="composition-preview-error"]')).not.toBeNull();
|
||||
const retry = Array.from(host.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Retry preview",
|
||||
);
|
||||
if (!retry) throw new Error("retry action did not render");
|
||||
|
||||
act(() => retry.click());
|
||||
|
||||
const retryUrl = new URL(player.getAttribute("src") ?? "", window.location.origin);
|
||||
expect(retryUrl.searchParams.get("_hfStudioRetry")).toBe("1");
|
||||
expect(host.querySelector('[data-testid="composition-preview-error"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("composition loading overlay", () => {
|
||||
|
||||
Reference in New Issue
Block a user