diff --git a/packages/studio/src/components/nle/NLEPreview.test.ts b/packages/studio/src/components/nle/NLEPreview.test.ts index 5c0b088c376..102206d9068 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 "./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 e162a36e674..c2dd8caaa0d 100644 --- a/packages/studio/src/components/nle/NLEPreview.tsx +++ b/packages/studio/src/components/nle/NLEPreview.tsx @@ -28,6 +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 { usePreviewFirstFrameTelemetry } from "../../player/hooks/usePreviewFirstFrameTelemetry"; import { PreviewPoster, usePreviewPoster } from "./PreviewPoster"; interface NLEPreviewProps extends Pick< @@ -46,6 +51,7 @@ interface NLEPreviewProps extends Pick< portrait?: boolean; directUrl?: string; suppressLoadingOverlay?: boolean; + loadingUi?: "studio" | "host"; onStageRef?: (ref: React.RefObject) => void; /** Reports the authored composition size measured from the loaded preview. */ onCompositionSizeChange?: (size: PreviewCompositionSize | null) => void; @@ -66,51 +72,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. -const SHADOW_IFRAME_STYLE: React.CSSProperties = { - position: "absolute", - inset: 0, - visibility: "hidden", - clipPath: "inset(100%)", - 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, @@ -126,6 +87,7 @@ export const NLEPreview = memo(function NLEPreview({ portrait, directUrl, suppressLoadingOverlay, + loadingUi, onStageRef, onCompositionSizeChange, fillBox, @@ -544,6 +506,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/components/nle/previewStage.ts b/packages/studio/src/components/nle/previewStage.ts new file mode 100644 index 00000000000..364cec3da68 --- /dev/null +++ b/packages/studio/src/components/nle/previewStage.ts @@ -0,0 +1,49 @@ +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, + 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.test.ts b/packages/studio/src/player/components/Player.test.ts index 926adafa8c4..6c12a0606f6 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: "host", + 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..e340f441d9f 100644 --- a/packages/studio/src/player/components/Player.tsx +++ b/packages/studio/src/player/components/Player.tsx @@ -37,6 +37,7 @@ interface PlayerProps { portrait?: boolean; style?: React.CSSProperties; suppressLoadingOverlay?: boolean; + loadingUi?: "studio" | "host"; noLoadTimeout?: boolean; } @@ -88,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,6 +160,7 @@ export const Player = forwardRef( style, suppressLoadingOverlay, noLoadTimeout, + ...host }, ref, ) => { @@ -470,16 +475,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 = studioDraws(compositionWaitShown, host.loadingUi); + const showAssetOverlay = studioDraws(assetWaitShown, host.loadingUi); useEffect(() => { - onCompositionLoadingChange?.(showCompositionOverlay || showAssetOverlay); - }, [onCompositionLoadingChange, showCompositionOverlay, showAssetOverlay]); + onCompositionLoadingChange?.(compositionWaitShown || assetWaitShown); + }, [onCompositionLoadingChange, compositionWaitShown, assetWaitShown]); return (