fix(studio): improve preview loading reliability

This commit is contained in:
ukimsanov
2026-07-27 22:14:23 -07:00
parent 3c857d768b
commit 18b9acac12
8 changed files with 288 additions and 128 deletions
@@ -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<React.SetStateAction<CompositionLevel[]>>;
@@ -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,
@@ -15,7 +15,7 @@ import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/s
interface NLEPreviewProps {
projectId: string;
iframeRef: RefObject<HTMLIFrameElement | null>;
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}
@@ -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());
@@ -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<HTMLIFrameElement | null>(null);
const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const syncTimer = useRef<ReturnType<typeof setTimeout> | 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({
}`}
>
<div className="w-20 h-[45px] rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
<iframe
ref={iframeRef}
src={previewUrl}
sandbox="allow-scripts allow-same-origin"
<img
src={thumbnailUrl}
alt=""
draggable={false}
loading="lazy"
className="absolute border-none pointer-events-none"
style={{
transformOrigin: "0 0",
width: stageSize.width,
height: stageSize.height,
left: thumbnailOffsetX,
top: thumbnailOffsetY,
transform: `scale(${previewScale})`,
}}
onLoad={(e) => {
try {
const iframe = e.currentTarget;
const root = iframe.contentDocument?.querySelector("[data-composition-id]");
const width = Number(root?.getAttribute("data-width")) || DEFAULT_PREVIEW_STAGE.width;
const height =
Number(root?.getAttribute("data-height")) || DEFAULT_PREVIEW_STAGE.height;
setStageSize({ width, height });
requestIframePlaybackSync(hovered);
} catch {
setStageSize(DEFAULT_PREVIEW_STAGE);
}
}}
title={`${name} preview`}
tabIndex={-1}
decoding="async"
className={`absolute inset-0 h-full w-full object-contain transition-opacity ${
livePreviewLoaded ? "opacity-0" : "opacity-100"
}`}
/>
{hovered && (
<iframe
ref={iframeRef}
src={previewUrl}
sandbox="allow-scripts allow-same-origin"
className="absolute border-none pointer-events-none"
style={{
transformOrigin: "0 0",
width: stageSize.width,
height: stageSize.height,
left: thumbnailOffsetX,
top: thumbnailOffsetY,
transform: `scale(${previewScale})`,
}}
onLoad={(e) => {
try {
const iframe = e.currentTarget;
const root = iframe.contentDocument?.querySelector("[data-composition-id]");
const width =
Number(root?.getAttribute("data-width")) || DEFAULT_PREVIEW_STAGE.width;
const height =
Number(root?.getAttribute("data-height")) || DEFAULT_PREVIEW_STAGE.height;
setStageSize({ width, height });
setLivePreviewLoaded(true);
requestIframePlaybackSync(true);
} catch {
setStageSize(DEFAULT_PREVIEW_STAGE);
}
}}
title={`${name} preview`}
tabIndex={-1}
/>
)}
</div>
<div
className="min-w-0 flex-1"