From 0e7b91ac3e7c24fab3b622f86092ab8cb14eafd5 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 31 Oct 2026 21:53:09 -0400 Subject: [PATCH 1/6] feat(studio): a host that draws its own loader can turn off both of Studio's preview loaders Player and NLEPreview take loadingUi: 'none', which hides the composition cover and the 'Preparing preview assets' wait while still reporting that the preview is loading. suppressLoadingOverlay keeps its narrower meaning: skip only the composition cover on a later load. --- .../studio/src/components/nle/NLEPreview.tsx | 4 +++ .../src/player/components/Player.test.ts | 33 +++++++++++++++++++ .../studio/src/player/components/Player.tsx | 16 ++++++--- 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/packages/studio/src/components/nle/NLEPreview.tsx b/packages/studio/src/components/nle/NLEPreview.tsx index e162a36e674..6bdbe62bb4d 100644 --- a/packages/studio/src/components/nle/NLEPreview.tsx +++ b/packages/studio/src/components/nle/NLEPreview.tsx @@ -46,6 +46,8 @@ interface NLEPreviewProps extends Pick< portrait?: boolean; directUrl?: string; suppressLoadingOverlay?: boolean; + /** "none": the host draws its own loader over the preview; Studio draws neither of its own. */ + loadingUi?: "studio" | "none"; onStageRef?: (ref: React.RefObject) => void; /** Reports the authored composition size measured from the loaded preview. */ onCompositionSizeChange?: (size: PreviewCompositionSize | null) => void; @@ -126,6 +128,7 @@ export const NLEPreview = memo(function NLEPreview({ portrait, directUrl, suppressLoadingOverlay, + loadingUi, onStageRef, onCompositionSizeChange, fillBox, @@ -544,6 +547,7 @@ export const NLEPreview = memo(function NLEPreview({ onPainted={(details) => reportPreviewFirstFrame(slot, details)} portrait={portrait} suppressLoadingOverlay={suppressLoadingOverlay} + loadingUi={loadingUi} style={ directUrl?.includes("/components/") ? { position: "absolute", inset: 0, zIndex: 1 } diff --git a/packages/studio/src/player/components/Player.test.ts b/packages/studio/src/player/components/Player.test.ts index 926adafa8c4..13e4c318f68 100644 --- a/packages/studio/src/player/components/Player.test.ts +++ b/packages/studio/src/player/components/Player.test.ts @@ -379,6 +379,39 @@ describe("ready to show", () => { expect(usePlayerStore.getState().previewBooted).toBe(true); }); + /** A first load whose one audio has no data yet: Studio's asset wait. */ + async function loadWithUnloadedMedia(props: Partial) { + const { host, player } = await mountPlayer(props); + const el = player as TestHyperframesPlayer; + document.body.appendChild(el.iframeElement); + const doc = el.iframeElement.contentDocument!; + const audio = doc.createElement("audio"); + Object.defineProperty(audio, "readyState", { value: 0, configurable: true }); + Object.defineProperty(audio, "networkState", { value: 2, configurable: true }); + doc.body.appendChild(audio); + loadAndReady(el); + painted(el); + await flushEffects(); + return host; + } + + it("keeps its asset wait on a later load that only skips the composition cover", async () => { + const host = await loadWithUnloadedMedia({ suppressLoadingOverlay: true }); + expect(host.textContent).toContain("Preparing preview assets"); + }); + + it("draws no loader of its own when the host draws one, and still says the preview is loading", async () => { + const onCompositionLoadingChange = vi.fn(); + const host = await loadWithUnloadedMedia({ + suppressLoadingOverlay: false, + loadingUi: "none", + onCompositionLoadingChange, + }); + expect(host.textContent).not.toContain("Preparing preview assets"); + expect(host.textContent).not.toContain("Loading composition"); + expect(onCompositionLoadingChange).toHaveBeenLastCalledWith(true); + }); + it("stops deferring editing work when the preview never shows", async () => { vi.useFakeTimers(); try { diff --git a/packages/studio/src/player/components/Player.tsx b/packages/studio/src/player/components/Player.tsx index 6b84bc2718c..b678fda2885 100644 --- a/packages/studio/src/player/components/Player.tsx +++ b/packages/studio/src/player/components/Player.tsx @@ -36,7 +36,10 @@ interface PlayerProps { onPainted?: (details: { iframe: HTMLIFrameElement; startedAt: number; loadId: number }) => void; portrait?: boolean; style?: React.CSSProperties; + /** Skips only the composition cover (a later load); the asset wait still shows. A host's own loader: loadingUi. */ suppressLoadingOverlay?: boolean; + /** "none": the host draws its own loader, so Studio draws neither overlay; loading is still reported. */ + loadingUi?: "studio" | "none"; noLoadTimeout?: boolean; } @@ -155,6 +158,7 @@ export const Player = forwardRef( portrait, style, suppressLoadingOverlay, + loadingUi = "studio", noLoadTimeout, }, ref, @@ -470,16 +474,18 @@ export const Player = forwardRef( return () => clearTimeout(timer); }, [projectId]); - const showCompositionOverlay = + const compositionWaitShown = !suppressLoadingOverlay && !compositionOverlayDeferred && shouldShowCompositionLoadingOverlay(compositionLoading); - const showAssetOverlay = - assetOverlayVisible && !shaderTransitionLoading && !showCompositionOverlay && !previewError; + const assetWaitShown = + assetOverlayVisible && !shaderTransitionLoading && !compositionWaitShown && !previewError; + const showCompositionOverlay = compositionWaitShown && loadingUi === "studio"; + const showAssetOverlay = assetWaitShown && loadingUi === "studio"; useEffect(() => { - onCompositionLoadingChange?.(showCompositionOverlay || showAssetOverlay); - }, [onCompositionLoadingChange, showCompositionOverlay, showAssetOverlay]); + onCompositionLoadingChange?.(compositionWaitShown || assetWaitShown); + }, [onCompositionLoadingChange, compositionWaitShown, assetWaitShown]); return (
Date: Sat, 31 Oct 2026 22:03:35 -0400 Subject: [PATCH 2/6] refactor(studio): name who draws the loader in the value, loadingUi 'studio' | 'host' The value says the host draws its own loader, so no doc line has to tell it apart from suppressLoadingOverlay, and the files' comment share does not rise. --- packages/studio/src/components/nle/NLEPreview.tsx | 3 +-- packages/studio/src/player/components/Player.test.ts | 2 +- packages/studio/src/player/components/Player.tsx | 4 +--- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/studio/src/components/nle/NLEPreview.tsx b/packages/studio/src/components/nle/NLEPreview.tsx index 6bdbe62bb4d..80c538df0b8 100644 --- a/packages/studio/src/components/nle/NLEPreview.tsx +++ b/packages/studio/src/components/nle/NLEPreview.tsx @@ -46,8 +46,7 @@ interface NLEPreviewProps extends Pick< portrait?: boolean; directUrl?: string; suppressLoadingOverlay?: boolean; - /** "none": the host draws its own loader over the preview; Studio draws neither of its own. */ - loadingUi?: "studio" | "none"; + loadingUi?: "studio" | "host"; onStageRef?: (ref: React.RefObject) => void; /** Reports the authored composition size measured from the loaded preview. */ onCompositionSizeChange?: (size: PreviewCompositionSize | null) => void; diff --git a/packages/studio/src/player/components/Player.test.ts b/packages/studio/src/player/components/Player.test.ts index 13e4c318f68..6c12a0606f6 100644 --- a/packages/studio/src/player/components/Player.test.ts +++ b/packages/studio/src/player/components/Player.test.ts @@ -404,7 +404,7 @@ describe("ready to show", () => { const onCompositionLoadingChange = vi.fn(); const host = await loadWithUnloadedMedia({ suppressLoadingOverlay: false, - loadingUi: "none", + loadingUi: "host", onCompositionLoadingChange, }); expect(host.textContent).not.toContain("Preparing preview assets"); diff --git a/packages/studio/src/player/components/Player.tsx b/packages/studio/src/player/components/Player.tsx index b678fda2885..d1966cbc13a 100644 --- a/packages/studio/src/player/components/Player.tsx +++ b/packages/studio/src/player/components/Player.tsx @@ -36,10 +36,8 @@ interface PlayerProps { onPainted?: (details: { iframe: HTMLIFrameElement; startedAt: number; loadId: number }) => void; portrait?: boolean; style?: React.CSSProperties; - /** Skips only the composition cover (a later load); the asset wait still shows. A host's own loader: loadingUi. */ suppressLoadingOverlay?: boolean; - /** "none": the host draws its own loader, so Studio draws neither overlay; loading is still reported. */ - loadingUi?: "studio" | "none"; + loadingUi?: "studio" | "host"; noLoadTimeout?: boolean; } From 205bed50193cb7dae9cfd0e1ccba6d447b6ddcf7 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 31 Oct 2026 22:59:59 -0400 Subject: [PATCH 3/6] refactor(studio): the loader choice sits outside Player, the stage size beside NLEPreview Player's draw decision moves to a small helper so the component's complexity does not rise, and resolvePreviewStageSize moves to its own file so NLEPreview stays under the 600-line cap with the new prop. --- .../src/components/nle/NLEPreview.test.ts | 3 +- .../studio/src/components/nle/NLEPreview.tsx | 36 +----------------- .../src/components/nle/previewStageSize.ts | 38 +++++++++++++++++++ .../studio/src/player/components/Player.tsx | 9 +++-- 4 files changed, 47 insertions(+), 39 deletions(-) create mode 100644 packages/studio/src/components/nle/previewStageSize.ts diff --git a/packages/studio/src/components/nle/NLEPreview.test.ts b/packages/studio/src/components/nle/NLEPreview.test.ts index 5c0b088c376..fd6927c460e 100644 --- a/packages/studio/src/components/nle/NLEPreview.test.ts +++ b/packages/studio/src/components/nle/NLEPreview.test.ts @@ -4,7 +4,8 @@ import React, { act, createRef } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useTimelinePlayer } from "../../player/hooks/useTimelinePlayer"; -import { NLEPreview, getPreviewPlayerKey, resolvePreviewStageSize } from "./NLEPreview"; +import { NLEPreview, getPreviewPlayerKey } from "./NLEPreview"; +import { resolvePreviewStageSize } from "./previewStageSize"; import { readPreviewComplexity } from "../../player/hooks/usePreviewFirstFrameTelemetry"; globalThis.IS_REACT_ACT_ENVIRONMENT = true; diff --git a/packages/studio/src/components/nle/NLEPreview.tsx b/packages/studio/src/components/nle/NLEPreview.tsx index 80c538df0b8..89a177870c1 100644 --- a/packages/studio/src/components/nle/NLEPreview.tsx +++ b/packages/studio/src/components/nle/NLEPreview.tsx @@ -28,6 +28,7 @@ import { } from "./previewZoom"; import { RULER_GUTTER_PX, usePreviewGuidesStore } from "../editor/previewGuidesStore"; import { PreviewZoomOverlay, usePreviewNavigator } from "./PreviewZoomOverlay"; +import { PREVIEW_STAGE_INSET_PX, resolvePreviewStageSize } from "./previewStageSize"; import { usePreviewFirstFrameTelemetry } from "../../player/hooks/usePreviewFirstFrameTelemetry"; import { PreviewPoster, usePreviewPoster } from "./PreviewPoster"; interface NLEPreviewProps extends Pick< @@ -67,7 +68,6 @@ export function getPreviewPlayerKey({ const ZOOM_HUD_TIMEOUT_MS = 1200; const ZOOM_SETTLE_MS = 200; -const PREVIEW_STAGE_INSET_PX = 8; // clip-path as well as visibility: the player's loading overlay sets its own // visibility:visible and would otherwise paint over the live frame. @@ -79,40 +79,6 @@ const SHADOW_IFRAME_STYLE: React.CSSProperties = { pointerEvents: "none", }; -export function resolvePreviewStageSize( - viewportWidth: number, - viewportHeight: number, - compositionSize: PreviewCompositionSize | null, - portrait: boolean | undefined, - gutterPx = 0, - insetPx = PREVIEW_STAGE_INSET_PX, -): { width: number; height: number } { - const availableWidth = Math.max(0, viewportWidth - 2 * (insetPx + gutterPx)); - const availableHeight = Math.max(0, viewportHeight - 2 * (insetPx + gutterPx)); - const aspectRatio = - compositionSize && compositionSize.width > 0 && compositionSize.height > 0 - ? compositionSize.width / compositionSize.height - : portrait - ? 9 / 16 - : 16 / 9; - - if (availableWidth === 0 || availableHeight === 0) { - return { width: 0, height: 0 }; - } - - let width = availableWidth; - let height = width / aspectRatio; - if (height > availableHeight) { - height = availableHeight; - width = height * aspectRatio; - } - - return { - width: toDomPrecision(width), - height: toDomPrecision(height), - }; -} - export const NLEPreview = memo(function NLEPreview({ projectId, iframeRef, diff --git a/packages/studio/src/components/nle/previewStageSize.ts b/packages/studio/src/components/nle/previewStageSize.ts new file mode 100644 index 00000000000..2e81bfa9c95 --- /dev/null +++ b/packages/studio/src/components/nle/previewStageSize.ts @@ -0,0 +1,38 @@ +import type { PreviewCompositionSize } from "../../utils/previewCompositionSize"; +import { toDomPrecision } from "./previewZoom"; + +export const PREVIEW_STAGE_INSET_PX = 8; + +export function resolvePreviewStageSize( + viewportWidth: number, + viewportHeight: number, + compositionSize: PreviewCompositionSize | null, + portrait: boolean | undefined, + gutterPx = 0, + insetPx = PREVIEW_STAGE_INSET_PX, +): { width: number; height: number } { + const availableWidth = Math.max(0, viewportWidth - 2 * (insetPx + gutterPx)); + const availableHeight = Math.max(0, viewportHeight - 2 * (insetPx + gutterPx)); + const aspectRatio = + compositionSize && compositionSize.width > 0 && compositionSize.height > 0 + ? compositionSize.width / compositionSize.height + : portrait + ? 9 / 16 + : 16 / 9; + + if (availableWidth === 0 || availableHeight === 0) { + return { width: 0, height: 0 }; + } + + let width = availableWidth; + let height = width / aspectRatio; + if (height > availableHeight) { + height = availableHeight; + width = height * aspectRatio; + } + + return { + width: toDomPrecision(width), + height: toDomPrecision(height), + }; +} diff --git a/packages/studio/src/player/components/Player.tsx b/packages/studio/src/player/components/Player.tsx index d1966cbc13a..64cee3d430a 100644 --- a/packages/studio/src/player/components/Player.tsx +++ b/packages/studio/src/player/components/Player.tsx @@ -89,6 +89,9 @@ function enableInteractiveIframe(player: HyperframesPlayerElement): void { iframe?.style.setProperty("pointer-events", "auto"); } +const studioDraws = (wait: boolean, loadingUi: PlayerProps["loadingUi"]) => + wait && loadingUi !== "host"; + function isPreviewMediaElement(el: Element): el is HTMLMediaElement { const tagName = el.tagName.toLowerCase(); return tagName === "video" || tagName === "audio"; @@ -156,7 +159,7 @@ export const Player = forwardRef( portrait, style, suppressLoadingOverlay, - loadingUi = "studio", + loadingUi, noLoadTimeout, }, ref, @@ -478,8 +481,8 @@ export const Player = forwardRef( shouldShowCompositionLoadingOverlay(compositionLoading); const assetWaitShown = assetOverlayVisible && !shaderTransitionLoading && !compositionWaitShown && !previewError; - const showCompositionOverlay = compositionWaitShown && loadingUi === "studio"; - const showAssetOverlay = assetWaitShown && loadingUi === "studio"; + const showCompositionOverlay = studioDraws(compositionWaitShown, loadingUi); + const showAssetOverlay = studioDraws(assetWaitShown, loadingUi); useEffect(() => { onCompositionLoadingChange?.(compositionWaitShown || assetWaitShown); From d0271e2aba00f9a0f4e3c1c1f43a55df349dccdd Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 31 Oct 2026 23:15:28 -0400 Subject: [PATCH 4/6] refactor(studio): Player reads the host's loader choice without another destructured prop Destructuring one more prop raised Player's cognitive complexity above main's; the rest of the props carries loadingUi instead. --- packages/studio/src/player/components/Player.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/studio/src/player/components/Player.tsx b/packages/studio/src/player/components/Player.tsx index 64cee3d430a..e340f441d9f 100644 --- a/packages/studio/src/player/components/Player.tsx +++ b/packages/studio/src/player/components/Player.tsx @@ -159,8 +159,8 @@ export const Player = forwardRef( portrait, style, suppressLoadingOverlay, - loadingUi, noLoadTimeout, + ...host }, ref, ) => { @@ -481,8 +481,8 @@ export const Player = forwardRef( shouldShowCompositionLoadingOverlay(compositionLoading); const assetWaitShown = assetOverlayVisible && !shaderTransitionLoading && !compositionWaitShown && !previewError; - const showCompositionOverlay = studioDraws(compositionWaitShown, loadingUi); - const showAssetOverlay = studioDraws(assetWaitShown, loadingUi); + const showCompositionOverlay = studioDraws(compositionWaitShown, host.loadingUi); + const showAssetOverlay = studioDraws(assetWaitShown, host.loadingUi); useEffect(() => { onCompositionLoadingChange?.(compositionWaitShown || assetWaitShown); From 5825d4ab6506a83261745244c5f155b8bdc32989 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 31 Oct 2026 23:20:05 -0400 Subject: [PATCH 5/6] refactor(studio): NLEPreview's stage constants live in one small module --- packages/studio/src/components/nle/NLEPreview.test.ts | 2 +- packages/studio/src/components/nle/NLEPreview.tsx | 11 +---------- .../nle/{previewStageSize.ts => previewStage.ts} | 11 +++++++++++ 3 files changed, 13 insertions(+), 11 deletions(-) rename packages/studio/src/components/nle/{previewStageSize.ts => previewStage.ts} (76%) diff --git a/packages/studio/src/components/nle/NLEPreview.test.ts b/packages/studio/src/components/nle/NLEPreview.test.ts index fd6927c460e..102206d9068 100644 --- a/packages/studio/src/components/nle/NLEPreview.test.ts +++ b/packages/studio/src/components/nle/NLEPreview.test.ts @@ -5,7 +5,7 @@ import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useTimelinePlayer } from "../../player/hooks/useTimelinePlayer"; import { NLEPreview, getPreviewPlayerKey } from "./NLEPreview"; -import { resolvePreviewStageSize } from "./previewStageSize"; +import { resolvePreviewStageSize } from "./previewStage"; import { readPreviewComplexity } from "../../player/hooks/usePreviewFirstFrameTelemetry"; globalThis.IS_REACT_ACT_ENVIRONMENT = true; diff --git a/packages/studio/src/components/nle/NLEPreview.tsx b/packages/studio/src/components/nle/NLEPreview.tsx index 89a177870c1..2eba9b7a41c 100644 --- a/packages/studio/src/components/nle/NLEPreview.tsx +++ b/packages/studio/src/components/nle/NLEPreview.tsx @@ -28,7 +28,7 @@ import { } from "./previewZoom"; import { RULER_GUTTER_PX, usePreviewGuidesStore } from "../editor/previewGuidesStore"; import { PreviewZoomOverlay, usePreviewNavigator } from "./PreviewZoomOverlay"; -import { PREVIEW_STAGE_INSET_PX, resolvePreviewStageSize } from "./previewStageSize"; +import { PREVIEW_STAGE_INSET_PX, resolvePreviewStageSize, SHADOW_IFRAME_STYLE } from "./previewStage"; import { usePreviewFirstFrameTelemetry } from "../../player/hooks/usePreviewFirstFrameTelemetry"; import { PreviewPoster, usePreviewPoster } from "./PreviewPoster"; interface NLEPreviewProps extends Pick< @@ -69,15 +69,6 @@ export function getPreviewPlayerKey({ const ZOOM_HUD_TIMEOUT_MS = 1200; const ZOOM_SETTLE_MS = 200; -// clip-path as well as visibility: the player's loading overlay sets its own -// visibility:visible and would otherwise paint over the live frame. -const SHADOW_IFRAME_STYLE: React.CSSProperties = { - position: "absolute", - inset: 0, - visibility: "hidden", - clipPath: "inset(100%)", - pointerEvents: "none", -}; export const NLEPreview = memo(function NLEPreview({ projectId, diff --git a/packages/studio/src/components/nle/previewStageSize.ts b/packages/studio/src/components/nle/previewStage.ts similarity index 76% rename from packages/studio/src/components/nle/previewStageSize.ts rename to packages/studio/src/components/nle/previewStage.ts index 2e81bfa9c95..364cec3da68 100644 --- a/packages/studio/src/components/nle/previewStageSize.ts +++ b/packages/studio/src/components/nle/previewStage.ts @@ -1,8 +1,19 @@ +import type { CSSProperties } from "react"; import type { PreviewCompositionSize } from "../../utils/previewCompositionSize"; import { toDomPrecision } from "./previewZoom"; export const PREVIEW_STAGE_INSET_PX = 8; +// clip-path as well as visibility: the player's loading overlay sets its own +// visibility:visible and would otherwise paint over the live frame. +export const SHADOW_IFRAME_STYLE: CSSProperties = { + position: "absolute", + inset: 0, + visibility: "hidden", + clipPath: "inset(100%)", + pointerEvents: "none", +}; + export function resolvePreviewStageSize( viewportWidth: number, viewportHeight: number, From 3a7d58390b2e1d5873c7241679aaaa048bd89b7b Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 1 Nov 2026 00:07:33 -0400 Subject: [PATCH 6/6] style(studio): format NLEPreview's stage import --- packages/studio/src/components/nle/NLEPreview.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/studio/src/components/nle/NLEPreview.tsx b/packages/studio/src/components/nle/NLEPreview.tsx index 2eba9b7a41c..c2dd8caaa0d 100644 --- a/packages/studio/src/components/nle/NLEPreview.tsx +++ b/packages/studio/src/components/nle/NLEPreview.tsx @@ -28,7 +28,11 @@ import { } from "./previewZoom"; import { RULER_GUTTER_PX, usePreviewGuidesStore } from "../editor/previewGuidesStore"; import { PreviewZoomOverlay, usePreviewNavigator } from "./PreviewZoomOverlay"; -import { PREVIEW_STAGE_INSET_PX, resolvePreviewStageSize, SHADOW_IFRAME_STYLE } from "./previewStage"; +import { + PREVIEW_STAGE_INSET_PX, + resolvePreviewStageSize, + SHADOW_IFRAME_STYLE, +} from "./previewStage"; import { usePreviewFirstFrameTelemetry } from "../../player/hooks/usePreviewFirstFrameTelemetry"; import { PreviewPoster, usePreviewPoster } from "./PreviewPoster"; interface NLEPreviewProps extends Pick< @@ -69,7 +73,6 @@ export function getPreviewPlayerKey({ const ZOOM_HUD_TIMEOUT_MS = 1200; const ZOOM_SETTLE_MS = 200; - export const NLEPreview = memo(function NLEPreview({ projectId, iframeRef,