From 06e7fc626934d36c2abb7b7f9e90b2948d5726c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 15 May 2026 23:24:58 -0700 Subject: [PATCH 1/2] perf(studio): use lightweight iframe.src reload instead of Player teardown Content refreshes (paste, move, resize, delete, asset drop) previously triggered setRefreshKey which changed the Player's React key, causing full web-component destruction + iframe teardown + crossfade animation + re-initialization of all event listeners and asset polling. Now NLELayout intercepts refreshKey changes and calls refreshPlayer() which just appends a cache-busting _t param to the iframe src. The Player web component stays alive, event listeners persist, and the reload is ~10x faster with no "waiting for media" flash. Key-based teardown is preserved for actual structural changes (project switch, composition drill-down via directUrl change). --- .../studio/src/components/nle/NLELayout.tsx | 11 ++-- .../studio/src/components/nle/NLEPreview.tsx | 53 ++++--------------- 2 files changed, 16 insertions(+), 48 deletions(-) diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index e76b23dd2..e211eab0e 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -99,7 +99,7 @@ export const NLELayout = memo(function NLELayout({ togglePlay, seek, onIframeLoad: baseOnIframeLoad, - saveSeekPosition, + refreshPlayer, } = useTimelinePlayer(); // Reset timeline state when the project changes @@ -109,13 +109,16 @@ export const NLELayout = memo(function NLELayout({ usePlayerStore.getState().reset(); } - // Save seek position before refresh + // Lightweight reload: change iframe src instead of destroying the Player. + // refreshPlayer() saves the seek position and appends a cache-busting _t + // param, avoiding the full web-component teardown + crossfade that the + // key-based path uses. const prevRefreshKeyRef = useRef(refreshKey); useEffect(() => { if (refreshKey === prevRefreshKeyRef.current) return; prevRefreshKeyRef.current = refreshKey; - saveSeekPosition(); - }, [refreshKey, saveSeekPosition]); + refreshPlayer(); + }, [refreshKey, refreshPlayer]); const onIframeLoad = useCallback(() => { baseOnIframeLoad(); diff --git a/packages/studio/src/components/nle/NLEPreview.tsx b/packages/studio/src/components/nle/NLEPreview.tsx index 8be285035..c655f6e9b 100644 --- a/packages/studio/src/components/nle/NLEPreview.tsx +++ b/packages/studio/src/components/nle/NLEPreview.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useRef, useState, type Ref } from "react"; +import { memo, useCallback, useEffect, useRef, type Ref } from "react"; import { Player } from "../../player"; import { DEFAULT_PREVIEW_ZOOM, @@ -53,15 +53,14 @@ export const NLEPreview = memo(function NLEPreview({ onCompositionLoadingChange, portrait, directUrl, - refreshKey, suppressLoadingOverlay, }: NLEPreviewProps) { - const baseKey = getPreviewPlayerKey({ projectId, directUrl, refreshKey }); - const prevRefreshKeyRef = useRef(refreshKey); + // Player key only changes for structural changes (project switch, composition + // drill-down), NOT for content refreshes. Content refreshes use the lighter + // iframe.src reload path handled by NLELayout → refreshPlayer(). + const activeKey = getPreviewPlayerKey({ projectId, directUrl }); const viewportRef = useRef(null); const stageRef = useRef(null); - const [retiringKey, setRetiringKey] = useState(null); - const retiringTimerRef = useRef | null>(null); const zoomRef = useRef(loadInitialZoom()); const hudRef = useRef(null); @@ -80,7 +79,6 @@ export const NLEPreview = memo(function NLEPreview({ return () => { if (settleTimerRef.current) clearTimeout(settleTimerRef.current); if (hudTimerRef.current) clearTimeout(hudTimerRef.current); - if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current); }; }, []); @@ -130,14 +128,6 @@ export const NLEPreview = memo(function NLEPreview({ [writeTransform], ); - if (refreshKey !== prevRefreshKeyRef.current) { - const oldKey = `${baseKey}:${prevRefreshKeyRef.current ?? 0}`; - prevRefreshKeyRef.current = refreshKey; - setRetiringKey(oldKey); - } - - const activeKey = `${baseKey}:${refreshKey ?? 0}`; - const applyInitialZoom = useCallback(() => { const z = zoomRef.current; if (Math.abs(z.zoomPercent - 100) > 0.5 || Math.abs(z.panX) > 0.1 || Math.abs(z.panY) > 0.1) { @@ -145,16 +135,6 @@ export const NLEPreview = memo(function NLEPreview({ } }, [writeTransform]); - const handleNewPlayerLoad = () => { - onIframeLoad(); - applyInitialZoom(); - if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current); - retiringTimerRef.current = setTimeout(() => { - setRetiringKey(null); - retiringTimerRef.current = null; - }, 160); - }; - useEffect(() => { const viewport = viewportRef.current; if (!viewport) return; @@ -282,32 +262,17 @@ export const NLEPreview = memo(function NLEPreview({ }} data-testid="preview-zoom-stage" > - {retiringKey && ( - {}} - portrait={portrait} - style={{ position: "absolute", inset: 0, zIndex: 0, opacity: 1 }} - /> - )} { - onIframeLoad(); - applyInitialZoom(); - } - } + onLoad={() => { + onIframeLoad(); + applyInitialZoom(); + }} onCompositionLoadingChange={onCompositionLoadingChange} portrait={portrait} - style={retiringKey ? { position: "absolute", inset: 0, zIndex: 1 } : undefined} suppressLoadingOverlay={suppressLoadingOverlay} /> From 61e91416762eccee6d0403d7fbf193d3a8983464 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 15 May 2026 23:30:02 -0700 Subject: [PATCH 2/2] perf(studio): skip asset-loading overlay on content refreshes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The asset-loading overlay ("Preparing preview assets") polled for video/audio readyState on every iframe load, including content refreshes from paste/move/resize. On reloads the browser serves assets from cache so they resolve near-instantly — the overlay just created a disruptive flash. Now skips the polling on subsequent loads (loadCountRef > 1), only showing it on the initial cold load. --- packages/studio/src/player/components/Player.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/studio/src/player/components/Player.tsx b/packages/studio/src/player/components/Player.tsx index 2ecadc0b4..47d967371 100644 --- a/packages/studio/src/player/components/Player.tsx +++ b/packages/studio/src/player/components/Player.tsx @@ -229,13 +229,19 @@ export const Player = forwardRef( // data arrives), but the overlay communicates why the first frame // or first audio beat may lag. // + // Skip the overlay on subsequent loads (content refreshes via + // refreshPlayer). The browser has already cached the assets from + // the first load, so they resolve near-instantly and the overlay + // just creates a disruptive flash. + // // Poll with a 10 s safety cap (100 ticks × 100 ms). If the cap // trips we hide the overlay so the UI doesn't appear stuck forever, // but we log a debug warning so the case is diagnosable — a long // cold video or a broken asset can legitimately exceed 10 s on a // slow network. if (assetPollRef.current) clearInterval(assetPollRef.current); - let lastUnloaded = hasUnloadedAssets(iframe, false); + const isContentRefresh = loadCountRef.current > 1; + let lastUnloaded = isContentRefresh ? false : hasUnloadedAssets(iframe, false); if (lastUnloaded) { setAssetsLoading(true); let attempts = 0;