fix(studio): harden preview recovery

This commit is contained in:
ukimsanov
2026-07-27 23:55:06 -07:00
parent 3af9a7df2d
commit c62bd4c454
4 changed files with 151 additions and 17 deletions
@@ -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}