mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
Merge pull request #2837 from heygen-com/feat/studio-preview-reliability
fix(studio): improve preview loading reliability
This commit is contained in:
@@ -40,6 +40,47 @@ function mount(onSelect = vi.fn(), onAddToTimeline = vi.fn()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("composition card drag", () => {
|
describe("composition card drag", () => {
|
||||||
|
it("uses a cached image instead of eagerly mounting a live preview iframe", () => {
|
||||||
|
const { host } = mount();
|
||||||
|
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(() => {});
|
||||||
|
try {
|
||||||
|
const { host, card } = mount();
|
||||||
|
act(() => {
|
||||||
|
card.dispatchEvent(new Event("pointerover", { bubbles: true }));
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
});
|
||||||
|
expect(host.querySelectorAll("iframe")).toHaveLength(1);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
card.dispatchEvent(new Event("pointerout", { bubbles: true }));
|
||||||
|
});
|
||||||
|
expect(host.querySelector("iframe")).toBeNull();
|
||||||
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
|
} finally {
|
||||||
|
consoleError.mockRestore();
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps ordinary click navigation", () => {
|
it("keeps ordinary click navigation", () => {
|
||||||
const { card, onSelect } = mount();
|
const { card, onSelect } = mount();
|
||||||
act(() => card.click());
|
act(() => card.click());
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { setPreviewMediaMuted } from "../../player/lib/timelineIframeHelpers";
|
import { setPreviewMediaMuted } from "../../player/lib/timelineIframeHelpers";
|
||||||
|
import { buildCompositionThumbnailUrl } from "../../player/components/CompositionThumbnail";
|
||||||
import { TIMELINE_COMPOSITION_MIME } from "../../utils/timelineCompositionDrop";
|
import { TIMELINE_COMPOSITION_MIME } from "../../utils/timelineCompositionDrop";
|
||||||
|
|
||||||
interface CompositionsTabProps {
|
interface CompositionsTabProps {
|
||||||
@@ -130,6 +131,8 @@ function CompCard({
|
|||||||
}) {
|
}) {
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
const [stageSize, setStageSize] = useState(DEFAULT_PREVIEW_STAGE);
|
const [stageSize, setStageSize] = useState(DEFAULT_PREVIEW_STAGE);
|
||||||
|
const [livePreviewLoaded, setLivePreviewLoaded] = useState(false);
|
||||||
|
const [thumbnailFailed, setThumbnailFailed] = useState(false);
|
||||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||||
const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const syncTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const syncTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@@ -158,10 +161,21 @@ function CompCard({
|
|||||||
clearTimeout(hoverTimer.current);
|
clearTimeout(hoverTimer.current);
|
||||||
hoverTimer.current = null;
|
hoverTimer.current = null;
|
||||||
}
|
}
|
||||||
|
if (syncTimer.current) {
|
||||||
|
clearTimeout(syncTimer.current);
|
||||||
|
syncTimer.current = null;
|
||||||
|
}
|
||||||
setHovered(false);
|
setHovered(false);
|
||||||
|
setLivePreviewLoaded(false);
|
||||||
};
|
};
|
||||||
const name = comp.replace(/^compositions\//, "").replace(/\.html$/, "");
|
const name = comp.replace(/^compositions\//, "").replace(/\.html$/, "");
|
||||||
const previewUrl = `/api/projects/${projectId}/preview/comp/${comp}`;
|
const previewUrl = `/api/projects/${projectId}/preview/comp/${comp}`;
|
||||||
|
const thumbnailUrl = buildCompositionThumbnailUrl({
|
||||||
|
previewUrl,
|
||||||
|
seekTime: THUMBNAIL_SEEK_TIME_SECONDS,
|
||||||
|
duration: 0,
|
||||||
|
origin: window.location.origin,
|
||||||
|
});
|
||||||
const previewScale = resolveCompositionPreviewScale({
|
const previewScale = resolveCompositionPreviewScale({
|
||||||
cardWidth: CARD_W,
|
cardWidth: CARD_W,
|
||||||
cardHeight: CARD_H,
|
cardHeight: CARD_H,
|
||||||
@@ -172,7 +186,7 @@ function CompCard({
|
|||||||
const thumbnailOffsetY = (CARD_H - stageSize.height * previewScale) / 2;
|
const thumbnailOffsetY = (CARD_H - stageSize.height * previewScale) / 2;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
requestIframePlaybackSync(hovered);
|
if (hovered) requestIframePlaybackSync(true);
|
||||||
}, [hovered, requestIframePlaybackSync]);
|
}, [hovered, requestIframePlaybackSync]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -216,11 +230,28 @@ function CompCard({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="w-20 h-[45px] rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
|
<div className="w-20 h-[45px] rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
|
||||||
|
{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
|
<iframe
|
||||||
ref={iframeRef}
|
ref={iframeRef}
|
||||||
src={previewUrl}
|
src={previewUrl}
|
||||||
sandbox="allow-scripts allow-same-origin"
|
sandbox="allow-scripts allow-same-origin"
|
||||||
loading="lazy"
|
|
||||||
className="absolute border-none pointer-events-none"
|
className="absolute border-none pointer-events-none"
|
||||||
style={{
|
style={{
|
||||||
transformOrigin: "0 0",
|
transformOrigin: "0 0",
|
||||||
@@ -234,11 +265,13 @@ function CompCard({
|
|||||||
try {
|
try {
|
||||||
const iframe = e.currentTarget;
|
const iframe = e.currentTarget;
|
||||||
const root = iframe.contentDocument?.querySelector("[data-composition-id]");
|
const root = iframe.contentDocument?.querySelector("[data-composition-id]");
|
||||||
const width = Number(root?.getAttribute("data-width")) || DEFAULT_PREVIEW_STAGE.width;
|
const width =
|
||||||
|
Number(root?.getAttribute("data-width")) || DEFAULT_PREVIEW_STAGE.width;
|
||||||
const height =
|
const height =
|
||||||
Number(root?.getAttribute("data-height")) || DEFAULT_PREVIEW_STAGE.height;
|
Number(root?.getAttribute("data-height")) || DEFAULT_PREVIEW_STAGE.height;
|
||||||
setStageSize({ width, height });
|
setStageSize({ width, height });
|
||||||
requestIframePlaybackSync(hovered);
|
setLivePreviewLoaded(true);
|
||||||
|
requestIframePlaybackSync(true);
|
||||||
} catch {
|
} catch {
|
||||||
setStageSize(DEFAULT_PREVIEW_STAGE);
|
setStageSize(DEFAULT_PREVIEW_STAGE);
|
||||||
}
|
}
|
||||||
@@ -246,6 +279,7 @@ function CompCard({
|
|||||||
title={`${name} preview`}
|
title={`${name} preview`}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="min-w-0 flex-1"
|
className="min-w-0 flex-1"
|
||||||
@@ -331,7 +365,7 @@ export const CompositionsTab = memo(function CompositionsTab({
|
|||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{compositions.map((comp) => (
|
{compositions.map((comp) => (
|
||||||
<CompCard
|
<CompCard
|
||||||
key={comp}
|
key={`${projectId}:${comp}`}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
comp={comp}
|
comp={comp}
|
||||||
isActive={activeComposition === comp}
|
isActive={activeComposition === comp}
|
||||||
|
|||||||
@@ -1,7 +1,146 @@
|
|||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { act, createElement } from "react";
|
||||||
import { hasUnloadedAssets, shouldShowCompositionLoadingOverlay } from "./Player";
|
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);
|
||||||
|
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(
|
||||||
|
readPreviewErrorMessage(
|
||||||
|
new CustomEvent("error", {
|
||||||
|
detail: { message: "Composition timeline not found after 8s" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe("Composition timeline not found after 8s");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back when the player emits an unstructured error", () => {
|
||||||
|
expect(readPreviewErrorMessage(new Event("error"))).toBe(
|
||||||
|
"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", () => {
|
describe("composition loading overlay", () => {
|
||||||
it("shows while the composition is loading", () => {
|
it("shows while the composition is loading", () => {
|
||||||
@@ -13,10 +152,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,
|
||||||
@@ -25,7 +161,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);
|
||||||
|
|
||||||
@@ -33,10 +168,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,
|
||||||
@@ -49,7 +181,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);
|
||||||
|
|
||||||
|
|||||||
@@ -38,11 +38,19 @@ function getShaderTransitionLoading(event: Event): boolean | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const COMPOSITION_LOADING_OVERLAY_DELAY_MS = 400;
|
const COMPOSITION_LOADING_OVERLAY_DELAY_MS = 400;
|
||||||
|
const DEFAULT_PREVIEW_ERROR = "The composition preview did not become ready.";
|
||||||
|
|
||||||
export function shouldShowCompositionLoadingOverlay(compositionLoading: boolean): boolean {
|
export function shouldShowCompositionLoadingOverlay(compositionLoading: boolean): boolean {
|
||||||
return compositionLoading;
|
return compositionLoading;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function readPreviewErrorMessage(event: Event): string {
|
||||||
|
if (!(event instanceof CustomEvent) || !isRecord(event.detail)) return DEFAULT_PREVIEW_ERROR;
|
||||||
|
return typeof event.detail.message === "string" && event.detail.message.trim()
|
||||||
|
? event.detail.message
|
||||||
|
: DEFAULT_PREVIEW_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
function enableInteractiveIframe(player: HyperframesPlayerElement): void {
|
function enableInteractiveIframe(player: HyperframesPlayerElement): void {
|
||||||
const root = player.shadowRoot;
|
const root = player.shadowRoot;
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
@@ -122,12 +130,15 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|||||||
const loadCountRef = useRef(0);
|
const loadCountRef = useRef(0);
|
||||||
const assetPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const assetPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const assetFadeRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const assetFadeRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const retryPreviewRef = useRef<(() => void) | null>(null);
|
||||||
|
const retryCountRef = useRef(0);
|
||||||
const [assetsLoading, setAssetsLoading] = useState(false);
|
const [assetsLoading, setAssetsLoading] = useState(false);
|
||||||
const [assetOverlayVisible, setAssetOverlayVisible] = useState(false);
|
const [assetOverlayVisible, setAssetOverlayVisible] = useState(false);
|
||||||
const [assetOverlayFading, setAssetOverlayFading] = useState(false);
|
const [assetOverlayFading, setAssetOverlayFading] = useState(false);
|
||||||
const [shaderTransitionLoading, setShaderTransitionLoading] = useState(false);
|
const [shaderTransitionLoading, setShaderTransitionLoading] = useState(false);
|
||||||
const [compositionLoading, setCompositionLoading] = useState(true);
|
const [compositionLoading, setCompositionLoading] = useState(true);
|
||||||
const [compositionOverlayDeferred, setCompositionOverlayDeferred] = useState(true);
|
const [compositionOverlayDeferred, setCompositionOverlayDeferred] = useState(true);
|
||||||
|
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||||
|
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -161,62 +172,32 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|||||||
);
|
);
|
||||||
applyPreviewVariablesToUrl(srcUrl);
|
applyPreviewVariablesToUrl(srcUrl);
|
||||||
const src = srcUrl.pathname + srcUrl.search;
|
const src = srcUrl.pathname + srcUrl.search;
|
||||||
player.setAttribute("shader-capture-scale", "1");
|
const retryPreview = () => {
|
||||||
player.setAttribute("shader-loading", "player");
|
retryCountRef.current += 1;
|
||||||
player.setAttribute("src", src);
|
const retryUrl = new URL(src, window.location.origin);
|
||||||
player.setAttribute("width", String(portrait ? 1080 : 1920));
|
retryUrl.searchParams.set("_hfStudioRetry", String(retryCountRef.current));
|
||||||
player.setAttribute("height", String(portrait ? 1920 : 1080));
|
setPreviewError(null);
|
||||||
player.style.width = "100%";
|
setCompositionLoading(true);
|
||||||
player.style.height = "100%";
|
player.setAttribute("src", retryUrl.pathname + retryUrl.search);
|
||||||
player.style.display = "block";
|
};
|
||||||
player.style.background = "transparent";
|
retryPreviewRef.current = retryPreview;
|
||||||
container.appendChild(player);
|
|
||||||
|
|
||||||
// Inject pasteboard shadow: let the shadow around the canvas bleed
|
|
||||||
// into the surrounding pasteboard area (overflow: visible on the container)
|
|
||||||
// and add a subtle outline + drop-shadow so the canvas boundary reads
|
|
||||||
// against the gray pasteboard, consistent with professional editors.
|
|
||||||
if (player.shadowRoot) {
|
|
||||||
const pasteboardStyle = document.createElement("style");
|
|
||||||
pasteboardStyle.textContent =
|
|
||||||
".hfp-container{overflow:visible}" +
|
|
||||||
".hfp-iframe{box-shadow:0 0 0 1px rgba(255,255,255,0.08),0 4px 32px rgba(0,0,0,.7)}";
|
|
||||||
player.shadowRoot.appendChild(pasteboardStyle);
|
|
||||||
}
|
|
||||||
|
|
||||||
enableInteractiveIframe(player);
|
|
||||||
|
|
||||||
// Bridge the inner iframe to the forwarded ref for useTimelinePlayer.
|
|
||||||
const iframe = player.iframeElement;
|
const iframe = player.iframeElement;
|
||||||
if (typeof ref === "function") {
|
|
||||||
ref(iframe);
|
|
||||||
} else if (ref) {
|
|
||||||
(ref as React.MutableRefObject<HTMLIFrameElement | null>).current = iframe;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prevent the web component's built-in click-to-toggle behavior.
|
|
||||||
// The studio manages playback exclusively via useTimelinePlayer.
|
|
||||||
const preventToggle = (e: Event) => e.stopImmediatePropagation();
|
const preventToggle = (e: Event) => e.stopImmediatePropagation();
|
||||||
player.addEventListener("click", preventToggle, { capture: true });
|
|
||||||
|
|
||||||
const handleShaderTransitionState = (event: Event) => {
|
const handleShaderTransitionState = (event: Event) => {
|
||||||
const loading = getShaderTransitionLoading(event);
|
const loading = getShaderTransitionLoading(event);
|
||||||
if (loading !== null) setShaderTransitionLoading(loading);
|
if (loading !== null) setShaderTransitionLoading(loading);
|
||||||
};
|
};
|
||||||
player.addEventListener("shadertransitionstate", handleShaderTransitionState);
|
|
||||||
|
|
||||||
const handleReady = () => {
|
const handleReady = () => {
|
||||||
|
setPreviewError(null);
|
||||||
setCompositionLoading(false);
|
setCompositionLoading(false);
|
||||||
};
|
};
|
||||||
const handleError = () => {
|
const handleError = (event: Event) => {
|
||||||
|
setPreviewError(readPreviewErrorMessage(event));
|
||||||
setCompositionLoading(false);
|
setCompositionLoading(false);
|
||||||
};
|
};
|
||||||
player.addEventListener("ready", handleReady);
|
|
||||||
player.addEventListener("error", handleError);
|
|
||||||
|
|
||||||
// Forward the iframe's native load event to the studio's onIframeLoad.
|
|
||||||
const handleLoad = () => {
|
const handleLoad = () => {
|
||||||
loadCountRef.current++;
|
loadCountRef.current++;
|
||||||
|
setPreviewError(null);
|
||||||
setShaderTransitionLoading(false);
|
setShaderTransitionLoading(false);
|
||||||
setCompositionLoading(true);
|
setCompositionLoading(true);
|
||||||
// Reveal animation on reload (hot-reload, composition switch)
|
// Reveal animation on reload (hot-reload, composition switch)
|
||||||
@@ -264,7 +245,47 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|||||||
setAssetsLoading(false);
|
setAssetsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Attach lifecycle listeners before assigning src or connecting the
|
||||||
|
// custom element. A warm local iframe can otherwise finish before
|
||||||
|
// Studio observes its load and never initialize the timeline.
|
||||||
iframe.addEventListener("load", handleLoad);
|
iframe.addEventListener("load", handleLoad);
|
||||||
|
player.addEventListener("click", preventToggle, { capture: true });
|
||||||
|
player.addEventListener("shadertransitionstate", handleShaderTransitionState);
|
||||||
|
player.addEventListener("ready", handleReady);
|
||||||
|
player.addEventListener("error", handleError);
|
||||||
|
|
||||||
|
// Bridge the inner iframe to the forwarded ref for useTimelinePlayer.
|
||||||
|
if (typeof ref === "function") {
|
||||||
|
ref(iframe);
|
||||||
|
} else if (ref) {
|
||||||
|
(ref as React.MutableRefObject<HTMLIFrameElement | null>).current = iframe;
|
||||||
|
}
|
||||||
|
|
||||||
|
player.setAttribute("shader-capture-scale", "1");
|
||||||
|
player.setAttribute("shader-loading", "player");
|
||||||
|
player.setAttribute("width", String(portrait ? 1080 : 1920));
|
||||||
|
player.setAttribute("height", String(portrait ? 1920 : 1080));
|
||||||
|
player.style.width = "100%";
|
||||||
|
player.style.height = "100%";
|
||||||
|
player.style.display = "block";
|
||||||
|
player.style.background = "transparent";
|
||||||
|
player.setAttribute("src", src);
|
||||||
|
container.appendChild(player);
|
||||||
|
|
||||||
|
// Inject pasteboard shadow: let the shadow around the canvas bleed
|
||||||
|
// into the surrounding pasteboard area (overflow: visible on the container)
|
||||||
|
// and add a subtle outline + drop-shadow so the canvas boundary reads
|
||||||
|
// against the gray pasteboard, consistent with professional editors.
|
||||||
|
if (player.shadowRoot) {
|
||||||
|
const pasteboardStyle = document.createElement("style");
|
||||||
|
pasteboardStyle.textContent =
|
||||||
|
".hfp-container{overflow:visible}" +
|
||||||
|
".hfp-iframe{box-shadow:0 0 0 1px rgba(255,255,255,0.08),0 4px 32px rgba(0,0,0,.7)}";
|
||||||
|
player.shadowRoot.appendChild(pasteboardStyle);
|
||||||
|
}
|
||||||
|
|
||||||
|
enableInteractiveIframe(player);
|
||||||
|
|
||||||
cleanup = () => {
|
cleanup = () => {
|
||||||
iframe.removeEventListener("load", handleLoad);
|
iframe.removeEventListener("load", handleLoad);
|
||||||
@@ -275,6 +296,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|||||||
if (assetPollRef.current) clearInterval(assetPollRef.current);
|
if (assetPollRef.current) clearInterval(assetPollRef.current);
|
||||||
assetPollRef.current = null;
|
assetPollRef.current = null;
|
||||||
container.removeChild(player);
|
container.removeChild(player);
|
||||||
|
if (retryPreviewRef.current === retryPreview) retryPreviewRef.current = null;
|
||||||
// Clear the forwarded ref only if it still points to THIS iframe.
|
// Clear the forwarded ref only if it still points to THIS iframe.
|
||||||
// During crossfade refreshes the retiring Player unmounts after the
|
// During crossfade refreshes the retiring Player unmounts after the
|
||||||
// new Player has already assigned its iframe to the same ref — blindly
|
// new Player has already assigned its iframe to the same ref — blindly
|
||||||
@@ -381,6 +403,25 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{previewError && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 z-40 flex items-center justify-center bg-black/90 px-6 text-center"
|
||||||
|
data-hyperframes-ignore=""
|
||||||
|
data-testid="composition-preview-error"
|
||||||
|
>
|
||||||
|
<div className="max-w-sm">
|
||||||
|
<p className="text-sm font-semibold text-white">Preview failed to load</p>
|
||||||
|
<p className="mt-1 text-xs text-neutral-400">{previewError}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mt-4 rounded-md bg-white px-3 py-1.5 text-xs font-semibold text-black transition-colors hover:bg-neutral-200"
|
||||||
|
onClick={() => retryPreviewRef.current?.()}
|
||||||
|
>
|
||||||
|
Retry preview
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export const SMOKE_COMPOSITION_HTML =
|
|||||||
'<!doctype html><html><body><div data-composition-id="root" data-width="1920" ' +
|
'<!doctype html><html><body><div data-composition-id="root" data-width="1920" ' +
|
||||||
'data-height="1080" data-duration="1" data-start="0"><div class="clip" ' +
|
'data-height="1080" data-duration="1" data-start="0"><div class="clip" ' +
|
||||||
'data-hf-id="title" data-start="0" data-duration="1">Test</div></div></body></html>';
|
'data-hf-id="title" data-start="0" data-duration="1">Test</div></div></body></html>';
|
||||||
|
const SMOKE_THUMBNAIL_SVG =
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="9"><rect width="16" height="9"/></svg>';
|
||||||
|
|
||||||
function json(body, status = 200) {
|
function json(body, status = 200) {
|
||||||
return { status, contentType: "application/json", body: JSON.stringify(body) };
|
return { status, contentType: "application/json", body: JSON.stringify(body) };
|
||||||
@@ -36,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: [] })],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
|
|
||||||
function bodyOf(method, path) {
|
function bodyOf(method, path) {
|
||||||
const response = studioSmokeApiResponse(method, `http://localhost:5199${path}`);
|
const response = studioSmokeApiResponse(method, `http://localhost:5199${path}`);
|
||||||
assert.ok(response && response !== null);
|
assert.ok(response);
|
||||||
return JSON.parse(response.body);
|
return JSON.parse(response.body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +44,15 @@ describe("Studio runtime smoke fixtures", () => {
|
|||||||
assert.deepEqual(bodyOf("GET", "/api/projects/smoke-test/lint"), { findings: [] });
|
assert.deepEqual(bodyOf("GET", "/api/projects/smoke-test/lint"), { findings: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns an image for composition thumbnail requests", () => {
|
||||||
|
const response = studioSmokeApiResponse(
|
||||||
|
"GET",
|
||||||
|
"http://localhost:5199/api/projects/smoke-test/thumbnail/index.html?t=3",
|
||||||
|
);
|
||||||
|
assert.ok(response);
|
||||||
|
assert.equal(response.contentType, "image/svg+xml");
|
||||||
|
});
|
||||||
|
|
||||||
it("marks unknown API requests as fixture failures", () => {
|
it("marks unknown API requests as fixture failures", () => {
|
||||||
assert.equal(studioSmokeApiResponse("GET", "http://localhost:5199/api/new-endpoint"), null);
|
assert.equal(studioSmokeApiResponse("GET", "http://localhost:5199/api/new-endpoint"), null);
|
||||||
assert.equal(studioSmokeApiResponse("GET", "http://localhost:5199/src/main.tsx"), undefined);
|
assert.equal(studioSmokeApiResponse("GET", "http://localhost:5199/src/main.tsx"), undefined);
|
||||||
|
|||||||
Reference in New Issue
Block a user