From 18b9acac12269899b0bfacb289d9bde3e1b065e4 Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Mon, 27 Jul 2026 22:14:23 -0700 Subject: [PATCH 1/4] fix(studio): improve preview loading reliability --- .../studio/src/components/nle/NLEContext.tsx | 21 +-- .../studio/src/components/nle/NLEPreview.tsx | 6 +- .../sidebar/CompositionsTab.drag.test.tsx | 27 ++++ .../components/sidebar/CompositionsTab.tsx | 80 +++++++---- .../src/player/components/Player.test.ts | 24 +++- .../studio/src/player/components/Player.tsx | 134 ++++++++++++------ .../hooks/useTimelinePlayer.seek.test.ts | 33 +++++ .../player/hooks/useTimelineSyncCallbacks.ts | 91 ++++++------ 8 files changed, 288 insertions(+), 128 deletions(-) diff --git a/packages/studio/src/components/nle/NLEContext.tsx b/packages/studio/src/components/nle/NLEContext.tsx index 5bdece645..55b96a092 100644 --- a/packages/studio/src/components/nle/NLEContext.tsx +++ b/packages/studio/src/components/nle/NLEContext.tsx @@ -32,7 +32,7 @@ export interface NLEContextValue { togglePlay: () => void; seek: (time: number, options?: { keepPlaying?: boolean }) => boolean; refreshPlayer: () => void; - onIframeLoad: () => void; + onIframeLoad: (reportError?: (message: string) => void) => void; // composition stack (from useCompositionStack) compositionStack: CompositionLevel[]; updateCompositionStack: React.Dispatch>; @@ -122,14 +122,17 @@ export function NLEProvider({ refreshPlayer(); }, [refreshKey, refreshPlayer]); - const onIframeLoad = useCallback(() => { - baseOnIframeLoad(); - // Pre-load + register MotionPathPlugin once so adding a motion path in the - // studio doesn't take the async plugin-load flash path on the first soft - // reload (the comp may not ship the plugin until it actually uses one). - ensureMotionPathPluginLoaded(iframeRef.current); - onIframeRef?.(iframeRef.current); - }, [baseOnIframeLoad, iframeRef, onIframeRef]); + const onIframeLoad = useCallback( + (reportError?: (message: string) => void) => { + baseOnIframeLoad(reportError); + // Pre-load + register MotionPathPlugin once so adding a motion path in the + // studio doesn't take the async plugin-load flash path on the first soft + // reload (the comp may not ship the plugin until it actually uses one). + ensureMotionPathPluginLoaded(iframeRef.current); + onIframeRef?.(iframeRef.current); + }, + [baseOnIframeLoad, iframeRef, onIframeRef], + ); const { compositionStack, diff --git a/packages/studio/src/components/nle/NLEPreview.tsx b/packages/studio/src/components/nle/NLEPreview.tsx index 8e634f767..a11d062e9 100644 --- a/packages/studio/src/components/nle/NLEPreview.tsx +++ b/packages/studio/src/components/nle/NLEPreview.tsx @@ -15,7 +15,7 @@ import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/s interface NLEPreviewProps { projectId: string; iframeRef: RefObject; - onIframeLoad: () => void; + onIframeLoad: (reportError?: (message: string) => void) => void; onCompositionLoadingChange?: (loading: boolean) => void; portrait?: boolean; directUrl?: string; @@ -491,9 +491,9 @@ export const NLEPreview = memo(function NLEPreview({ ref={setPreviewIframeRef} projectId={directUrl ? undefined : projectId} directUrl={directUrl} - onLoad={() => { + onLoad={(reportError) => { updateCompositionSizeFromPreview(); - onIframeLoad(); + onIframeLoad(reportError); applyInitialZoom(); }} onCompositionLoadingChange={onCompositionLoadingChange} diff --git a/packages/studio/src/components/sidebar/CompositionsTab.drag.test.tsx b/packages/studio/src/components/sidebar/CompositionsTab.drag.test.tsx index dbe9727e1..1a5dcd2d1 100644 --- a/packages/studio/src/components/sidebar/CompositionsTab.drag.test.tsx +++ b/packages/studio/src/components/sidebar/CompositionsTab.drag.test.tsx @@ -40,6 +40,33 @@ 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(); + expect(host.querySelector("iframe")).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(); + } 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..d061fd600 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,7 @@ function CompCard({ }) { const [hovered, setHovered] = useState(false); const [stageSize, setStageSize] = useState(DEFAULT_PREVIEW_STAGE); + const [livePreviewLoaded, setLivePreviewLoaded] = useState(false); const iframeRef = useRef(null); const hoverTimer = useRef | null>(null); const syncTimer = useRef | null>(null); @@ -159,9 +161,16 @@ function CompCard({ hoverTimer.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: 0, + duration: THUMBNAIL_SEEK_TIME_SECONDS * 2, + origin: window.location.origin, + }); const previewScale = resolveCompositionPreviewScale({ cardWidth: CARD_W, cardHeight: CARD_H, @@ -172,7 +181,7 @@ function CompCard({ const thumbnailOffsetY = (CARD_H - stageSize.height * previewScale) / 2; useEffect(() => { - requestIframePlaybackSync(hovered); + if (hovered) requestIframePlaybackSync(true); }, [hovered, requestIframePlaybackSync]); useEffect(() => { @@ -216,36 +225,49 @@ function CompCard({ }`} >
-