From 326df8def036e708d25a0ca0e069f91172767b0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 24 Sep 2026 07:22:26 -0400 Subject: [PATCH] feat(studio): let an embedder draw the preview edge to edge with fillBox (#4408) NLEPreview always drew its picture 8px in from each side on a dark band, so an embedder that sizes the box to the film saw a smaller picture in a black frame. fillBox drops that inset from both the layout and the stage-size math. Default look is unchanged. --- .../src/components/nle/NLEPreview.test.ts | 20 ++++++++++++- .../studio/src/components/nle/NLEPreview.tsx | 29 ++++++++++++++----- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/packages/studio/src/components/nle/NLEPreview.test.ts b/packages/studio/src/components/nle/NLEPreview.test.ts index e498d792c2..165c9f25cc 100644 --- a/packages/studio/src/components/nle/NLEPreview.test.ts +++ b/packages/studio/src/components/nle/NLEPreview.test.ts @@ -80,6 +80,10 @@ function renderPreview( previewSlots: Array<{ gen: number; role: "live" | "shadow"; url?: string }> = [ { gen: 0, role: "live" }, ], + { + box = { width: 800, height: 600 }, + fillBox, + }: { box?: { width: number; height: number }; fillBox?: boolean } = {}, ) { resizeCallbacks = []; const host = document.createElement("div"); @@ -99,6 +103,7 @@ function renderPreview( onShadowError: () => {}, setShadowIframeNode: () => {}, resetPreviewSlots: () => {}, + fillBox, }), ); }); @@ -108,7 +113,7 @@ function renderPreview( expect(viewport).toBeTruthy(); expect(stage).toBeTruthy(); - setRect(viewport, { width: 800, height: 600 }); + setRect(viewport, box); act(() => { for (const fire of resizeCallbacks) fire(); }); @@ -251,6 +256,19 @@ describe("NLEPreview", () => { view.cleanup(); }); + it("insets the picture by default and fills a same-shape box when fillBox is on", () => { + const box = { width: 640, height: 360 }; + const inset = renderPreview(undefined, { box }); + expect([inset.stage.style.width, inset.stage.style.height]).toEqual(["611.5556px", "344px"]); + expect(parseFloat(inset.stage.parentElement!.style.inset)).toBe(8); + inset.cleanup(); + + const filled = renderPreview(undefined, { box, fillBox: true }); + expect([filled.stage.style.width, filled.stage.style.height]).toEqual(["640px", "360px"]); + expect(parseFloat(filled.stage.parentElement!.style.inset)).toBe(0); + filled.cleanup(); + }); + it("clips a shadow reload so its own loading overlay cannot paint over the live frame", () => { const view = renderPreview([ { gen: 0, role: "live" }, diff --git a/packages/studio/src/components/nle/NLEPreview.tsx b/packages/studio/src/components/nle/NLEPreview.tsx index 8afe33ed65..55888f62c5 100644 --- a/packages/studio/src/components/nle/NLEPreview.tsx +++ b/packages/studio/src/components/nle/NLEPreview.tsx @@ -36,6 +36,8 @@ interface NLEPreviewProps { onStageRef?: (ref: React.RefObject) => void; /** Reports the authored composition size measured from the loaded preview. */ onCompositionSizeChange?: (size: PreviewCompositionSize | null) => void; + /** Draws the picture edge to edge in this box, without Studio's inset band. */ + fillBox?: boolean; } export function getPreviewPlayerKey({ @@ -50,7 +52,7 @@ export function getPreviewPlayerKey({ const ZOOM_HUD_TIMEOUT_MS = 1200; const ZOOM_SETTLE_MS = 200; -const PREVIEW_STAGE_INSET_PX = 16; +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. @@ -87,9 +89,10 @@ export function resolvePreviewStageSize( compositionSize: PreviewCompositionSize | null, portrait: boolean | undefined, gutterPx = 0, + insetPx = PREVIEW_STAGE_INSET_PX, ): { width: number; height: number } { - const availableWidth = Math.max(0, viewportWidth - PREVIEW_STAGE_INSET_PX - 2 * gutterPx); - const availableHeight = Math.max(0, viewportHeight - PREVIEW_STAGE_INSET_PX - 2 * gutterPx); + 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 @@ -130,6 +133,7 @@ export const NLEPreview = memo(function NLEPreview({ suppressLoadingOverlay, onStageRef, onCompositionSizeChange, + fillBox, }: NLEPreviewProps) { const activeKey = getPreviewPlayerKey({ projectId, directUrl }); const viewportRef = useRef(null); @@ -151,6 +155,7 @@ export const NLEPreview = memo(function NLEPreview({ const reportPreviewFirstFrame = usePreviewFirstFrameTelemetry(previewSlots); const [compositionSize, setCompositionSize] = useState(null); const gutterPx = usePreviewGuidesStore((s) => (s.rulerVisible ? RULER_GUTTER_PX : 0)); + const insetPx = fillBox ? 0 : PREVIEW_STAGE_INSET_PX; const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, null, portrait)); const zoomRef = useRef(loadInitialZoom()); @@ -181,7 +186,14 @@ export const NLEPreview = memo(function NLEPreview({ const updateStageSize = () => { const rect = viewport.getBoundingClientRect(); setStageSize( - resolvePreviewStageSize(rect.width, rect.height, compositionSize, portrait, gutterPx), + resolvePreviewStageSize( + rect.width, + rect.height, + compositionSize, + portrait, + gutterPx, + insetPx, + ), ); }; @@ -189,7 +201,7 @@ export const NLEPreview = memo(function NLEPreview({ const observer = new ResizeObserver(updateStageSize); observer.observe(viewport); return () => observer.disconnect(); - }, [compositionSize, portrait, gutterPx]); + }, [compositionSize, portrait, gutterPx, insetPx]); const onCompositionSizeChangeRef = useRef(onCompositionSizeChange); onCompositionSizeChangeRef.current = onCompositionSizeChange; @@ -467,11 +479,14 @@ export const NLEPreview = memo(function NLEPreview({
-
+