diff --git a/packages/shader-transitions/src/engineModePageComposite.test.ts b/packages/shader-transitions/src/engineModePageComposite.test.ts index f812db7422..4b6a6b1d50 100644 --- a/packages/shader-transitions/src/engineModePageComposite.test.ts +++ b/packages/shader-transitions/src/engineModePageComposite.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { + clonePinStyleFor, isPageSideCompositingSupported, PAGE_COMPOSITOR_BUILD_CANARY, PAGE_COMPOSITOR_CANVAS_ID, @@ -69,6 +70,26 @@ describe("isPageSideCompositingSupported", () => { }); }); +describe("clonePinStyleFor", () => { + it("fixes a 0x0 inset:0 scene root to its live-measured box (the collapse this exists to prevent)", () => { + // A scene root sized only by `position:absolute; inset:0` measures as + // the full composition frame in the live document (its containing block + // there is the real ancestor chain) — collapses to 0x0 only once cloned + // into the staging canvas's own layout subtree. + const pin = clonePinStyleFor({ left: 0, top: 0, width: 1080, height: 1920 }); + expect(pin).toEqual({ left: "0px", top: "0px", width: "1080px", height: "1920px" }); + }); + + it("preserves an authored explicit width/height and offset instead of overriding it", () => { + // A scene root with its own explicit size/position (e.g. a picture-in- + // picture panel) measures as that exact box in the live document — + // clonePinStyleFor must reproduce it verbatim, not the full composition + // frame, or the clone would silently grow to fill the canvas. + const pin = clonePinStyleFor({ left: 120, top: 240, width: 400, height: 300 }); + expect(pin).toEqual({ left: "120px", top: "240px", width: "400px", height: "300px" }); + }); +}); + describe("page-side compositor exported constants", () => { it("exports a stable canary string used by the bundled-CLI smoke", () => { expect(PAGE_COMPOSITOR_BUILD_CANARY).toBe("__hf_page_compositor_v1__"); diff --git a/packages/shader-transitions/src/engineModePageComposite.ts b/packages/shader-transitions/src/engineModePageComposite.ts index ab9445a896..409c5f52f0 100644 --- a/packages/shader-transitions/src/engineModePageComposite.ts +++ b/packages/shader-transitions/src/engineModePageComposite.ts @@ -76,6 +76,36 @@ interface ResolvedTransition { export const PAGE_COMPOSITOR_CANVAS_ID = "__hf-page-side-compositor"; export const PAGE_COMPOSITOR_BUILD_CANARY = "__hf_page_compositor_v1__"; +export interface ClonePinStyle { + left: string; + top: string; + width: string; + height: string; +} + +/** + * Style values to pin a cloned scene root to the box its source measured + * WHILE STILL LIVE in the document — never the composition's full pixel size. + * A live-document `getBoundingClientRect()` already resolves `inset:0` (and + * any authored explicit width/height) correctly against the real ancestor + * chain; reapplying that exact box to the clone fixes the 0x0 collapse a + * detached `inset:0` clone would otherwise have inside the staging canvas's + * layout subtree, without ever overriding an author's own sizing. + */ +export function clonePinStyleFor(rect: { + left: number; + top: number; + width: number; + height: number; +}): ClonePinStyle { + return { + left: `${rect.left}px`, + top: `${rect.top}px`, + width: `${rect.width}px`, + height: `${rect.height}px`, + }; +} + export function isPageSideCompositingSupported(): boolean { if (typeof window === "undefined" || typeof document === "undefined") return false; if (!isHtmlInCanvasCaptureSupported()) return false; @@ -232,6 +262,17 @@ export function installPageSideCompositor(options: PageCompositorInstallOptions) pWin.__hf_page_composite_pending = false; return false; } + // Measure each scene's rendered box WHILE STILL LIVE — a scene root sized + // only by `position:absolute; inset:0` resolves to 0x0 once cloned into + // the staging canvas's layout subtree (no containing-block dimensions + // there), and the transition textures blank out (wild report: explicit + // 1080x1920 anchors fixed both transitions). The live document already + // resolves inset:0 (and any authored explicit width/height) correctly + // against the real ancestor chain, so pinning the clone to THIS measured + // box fixes the collapse without ever overriding an author's own sizing. + const fromPin = clonePinStyleFor(fromEl.getBoundingClientRect()); + const toPin = clonePinStyleFor(toEl.getBoundingClientRect()); + while (fromStaging.firstChild) fromStaging.removeChild(fromStaging.firstChild); while (toStaging.firstChild) toStaging.removeChild(toStaging.firstChild); const fromClone = fromEl.cloneNode(true) as HTMLElement; @@ -244,9 +285,17 @@ export function installPageSideCompositor(options: PageCompositorInstallOptions) // paint record" and the shader degrades to a hard cut. The shader blends from // full-opacity textures via u_progress, so force the clones visible. Cf. // forceSceneVisibleInClone (html2canvas path). - for (const clone of [fromClone, toClone]) { + for (const [clone, pin] of [ + [fromClone, fromPin], + [toClone, toPin], + ] as const) { clone.style.opacity = "1"; clone.style.visibility = "visible"; + clone.style.position = "absolute"; + clone.style.left = pin.left; + clone.style.top = pin.top; + clone.style.width = pin.width; + clone.style.height = pin.height; clone.querySelectorAll("[data-start]").forEach((el) => { el.style.opacity = "1"; el.style.visibility = "visible";