From 83b3ebabf33c08917399c3a66c7c9db6bdb8d5cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 16 May 2026 09:46:33 +0200 Subject: [PATCH] perf(studio): lightweight preview reload, skip asset overlay (#895) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(studio): add clipboard payload types and ID deduplication * feat(studio): add Ctrl+C/V/X copy/paste for timeline clips and DOM elements * fix(studio): use duck-typing for cross-frame element access in clipboard Elements from the preview iframe are from a different window context, so `el instanceof HTMLElement` always returns false. Use `"outerHTML" in el` instead to correctly detect elements across frame boundaries. * fix(studio): preserve playhead position after paste reloadPreview() used location.reload() which bypassed the NLELayout saveSeekPosition effect, causing the playhead to reset to 0:00 after paste. Switch to setRefreshKey which triggers the effect and restores the seek position after the iframe reloads. * fix(studio): paste DOM elements as siblings, not at composition root DOM element paste was inserting at the composition root, losing the parent context that provides CSS styles and positioning. Now stores the origin selector on copy and inserts the paste as a sibling immediately after the original element, preserving style inheritance. Falls back to root insertion if the selector can't be matched. * fix(studio): address review — deduplicateIds, native copy, altKey guard - deduplicateIds regex used \b which matched data-composition-id, data-clip-id, etc. Switch to lookbehind (?<=\s) so only standalone id="..." attributes are rewritten. Add test pinning this. - Ctrl+C no longer calls preventDefault() before confirming there's a selected element. Native browser copy (text selections outside inputs) is preserved when nothing is selected in the Studio. - Add !event.altKey guard on C/V/X to avoid intercepting Cmd+Alt+V (paste-as-plain-text) and similar OS gestures. - Remove no-op .replace(/"/g, '"') flagged by CodeQL. * fix(studio): address review round 2 — Cmd+X guard, data-start scope, revert drive-by - Cmd+X now pre-checks selection state before preventDefault, mirroring the Cmd+C fix. Native cut preserved when nothing is selected. - handleCut returns Promise so the caller can gate on it. - data-start rewrite scoped to the outermost opening tag only, so nested clip timing is preserved on paste. - Removed system clipboard write (cross-tab paste unsupported, in-memory ref is the only read path). - Reverted the reloadPreview drive-by (setRefreshKey→location.reload); the perf branch (#895) handles this properly via refreshPlayer(). * 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). * perf(studio): skip asset-loading overlay on content refreshes 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. --- .../studio/src/components/nle/NLELayout.tsx | 11 ++-- .../studio/src/components/nle/NLEPreview.tsx | 53 ++++--------------- .../studio/src/player/components/Player.tsx | 8 ++- 3 files changed, 23 insertions(+), 49 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} /> 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;