diff --git a/packages/studio/src/components/sidebar/CompositionsTab.drag.test.tsx b/packages/studio/src/components/sidebar/CompositionsTab.drag.test.tsx index dbe9727e1..862a35722 100644 --- a/packages/studio/src/components/sidebar/CompositionsTab.drag.test.tsx +++ b/packages/studio/src/components/sidebar/CompositionsTab.drag.test.tsx @@ -40,6 +40,47 @@ 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(); + 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(() => {}); + 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", () => { const { card, onSelect } = mount(); act(() => card.click()); diff --git a/packages/studio/src/components/sidebar/CompositionsTab.tsx b/packages/studio/src/components/sidebar/CompositionsTab.tsx index f5fb0099e..5de9703db 100644 --- a/packages/studio/src/components/sidebar/CompositionsTab.tsx +++ b/packages/studio/src/components/sidebar/CompositionsTab.tsx @@ -1,5 +1,6 @@ import { memo, useCallback, useEffect, useRef, useState } from "react"; import { setPreviewMediaMuted } from "../../player/lib/timelineIframeHelpers"; +import { buildCompositionThumbnailUrl } from "../../player/components/CompositionThumbnail"; import { TIMELINE_COMPOSITION_MIME } from "../../utils/timelineCompositionDrop"; interface CompositionsTabProps { @@ -130,6 +131,8 @@ 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); @@ -158,10 +161,21 @@ function CompCard({ clearTimeout(hoverTimer.current); hoverTimer.current = null; } + if (syncTimer.current) { + clearTimeout(syncTimer.current); + syncTimer.current = null; + } setHovered(false); + setLivePreviewLoaded(false); }; const name = comp.replace(/^compositions\//, "").replace(/\.html$/, ""); 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({ cardWidth: CARD_W, cardHeight: CARD_H, @@ -172,7 +186,7 @@ function CompCard({ const thumbnailOffsetY = (CARD_H - stageSize.height * previewScale) / 2; useEffect(() => { - requestIframePlaybackSync(hovered); + if (hovered) requestIframePlaybackSync(true); }, [hovered, requestIframePlaybackSync]); useEffect(() => { @@ -216,36 +230,56 @@ function CompCard({ }`} >
-