From c62bd4c454d7a53aac39e03b10da8eeb2a93d77e Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Mon, 27 Jul 2026 23:55:06 -0700 Subject: [PATCH] fix(studio): harden preview recovery --- .../sidebar/CompositionsTab.drag.test.tsx | 16 ++- .../components/sidebar/CompositionsTab.tsx | 38 +++--- .../src/player/components/Player.test.ts | 110 +++++++++++++++++- scripts/studio-runtime-smoke.test.mjs | 4 +- 4 files changed, 151 insertions(+), 17 deletions(-) diff --git a/packages/studio/src/components/sidebar/CompositionsTab.drag.test.tsx b/packages/studio/src/components/sidebar/CompositionsTab.drag.test.tsx index 1a5dcd2d1..862a35722 100644 --- a/packages/studio/src/components/sidebar/CompositionsTab.drag.test.tsx +++ b/packages/studio/src/components/sidebar/CompositionsTab.drag.test.tsx @@ -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('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('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(); diff --git a/packages/studio/src/components/sidebar/CompositionsTab.tsx b/packages/studio/src/components/sidebar/CompositionsTab.tsx index d061fd600..5de9703db 100644 --- a/packages/studio/src/components/sidebar/CompositionsTab.tsx +++ b/packages/studio/src/components/sidebar/CompositionsTab.tsx @@ -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(null); const hoverTimer = useRef | null>(null); const syncTimer = useRef | 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({ }`} >
- + {thumbnailFailed ? ( +
+ Preview unavailable +
+ ) : ( + setThumbnailFailed(true)} + className={`absolute inset-0 h-full w-full object-contain transition-opacity ${ + livePreviewLoaded ? "opacity-0" : "opacity-100" + }`} + /> + )} {hovered && (