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.
This commit is contained in:
Miguel Ángel
2026-09-24 07:22:26 -04:00
committed by GitHub
parent 261af22c16
commit 326df8def0
2 changed files with 41 additions and 8 deletions
@@ -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" },
@@ -36,6 +36,8 @@ interface NLEPreviewProps {
onStageRef?: (ref: React.RefObject<HTMLDivElement | null>) => 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<HTMLDivElement>(null);
@@ -151,6 +155,7 @@ export const NLEPreview = memo(function NLEPreview({
const reportPreviewFirstFrame = usePreviewFirstFrameTelemetry(previewSlots);
const [compositionSize, setCompositionSize] = useState<PreviewCompositionSize | null>(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<PreviewZoomState>(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({
<div className="flex flex-col h-full min-h-0">
<div
ref={viewportRef}
className="relative flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0 outline-hidden focus:ring-1 focus:ring-studio-accent/40 bg-[var(--studio-preview-bg,var(--color-neutral-950))]"
className="relative flex-1 flex items-center justify-center overflow-hidden min-h-0 outline-hidden focus:ring-1 focus:ring-studio-accent/40 bg-[var(--studio-preview-bg,var(--color-neutral-950))]"
tabIndex={0}
aria-label="Composition preview"
>
<div className="absolute inset-2 flex items-center justify-center pointer-events-none">
<div
className="absolute flex items-center justify-center pointer-events-none"
style={{ inset: insetPx }}
>
<div
ref={stageRef}
className="relative shrink-0 pointer-events-auto"