From 4094c7be4c4f5aa63e1641fe028dbff4826513a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 28 Sep 2026 03:30:06 +0000 Subject: [PATCH] fix(shader-transitions): shader transitions in renders keep scene layout, styles and animations (#4615) * fix(shader-transitions): page-side shader renders keep scene layout and fonts and leave no ghost * refactor(shader-transitions): one loop stages both scene copies * fix(shader-transitions): stage scene copies inside copies of their ancestors * refactor(shader-transitions): pin the staged root in one assignment * fix(shader-transitions): keep ancestor copies out of scene layout in renders * fix(shader-transitions): scene copies keep wrapper effects and live animation times * fix(shader-transitions): hold copied animations with one lookup per scene and drop the no-op shift * test(shader-transitions): pin the element and pseudo-element parts of the animation match * docs(shader-transitions): keep the staging comments to two lines --- .../src/services/vfxDeterminism.test.ts | 2 +- .../src/engineModePageComposite.test.ts | 293 ++++++++++++++++-- .../src/engineModePageComposite.ts | 139 +++++---- 3 files changed, 344 insertions(+), 90 deletions(-) diff --git a/packages/producer/src/services/vfxDeterminism.test.ts b/packages/producer/src/services/vfxDeterminism.test.ts index eaa3318fec..7c0624c190 100644 --- a/packages/producer/src/services/vfxDeterminism.test.ts +++ b/packages/producer/src/services/vfxDeterminism.test.ts @@ -60,7 +60,7 @@ function waveWarpChain(params: Record): string { * `.hf-vfx-in` carries an explicit pixel box. Inside a `layoutsubtree` canvas * there is no containing block to resolve `inset: 0` against, so the wrapper * collapses to 0×0 and `drawElementImage` silently draws nothing — the same - * collapse `engineModePageComposite.clonePinStyleFor` exists to undo. + * collapse the page-side compositor avoids by staging scene copies in a sized frame. */ function fixture(chain: string, innerStyle = "", hostStyle = ""): string { return ` diff --git a/packages/shader-transitions/src/engineModePageComposite.test.ts b/packages/shader-transitions/src/engineModePageComposite.test.ts index fc491fb2d7..e19287a247 100644 --- a/packages/shader-transitions/src/engineModePageComposite.test.ts +++ b/packages/shader-transitions/src/engineModePageComposite.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { - clonePinStyleFor, installPageSideCompositor, isPageSideCompositingSupported, PAGE_COMPOSITOR_BUILD_CANARY, @@ -71,25 +70,19 @@ 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" }); - }); -}); +// A WebGL context whose every call succeeds. +function fakeWebGl(): object { + return new Proxy( + {}, + { + get: (_target, key) => { + if (key === "getShaderParameter" || key === "getProgramParameter") return () => true; + if (key === "getExtension") return () => ({ loseContext: () => undefined }); + return () => ({}); + }, + }, + ); +} describe("page-side compositor seek", () => { afterEach(() => { @@ -108,16 +101,7 @@ describe("page-side compositor seek", () => { function installWithHiddenScenes( timing: Record, ) { - const gl = new Proxy( - {}, - { - get: (_target, key) => { - if (key === "getShaderParameter" || key === "getProgramParameter") return () => true; - if (key === "getExtension") return () => ({ loseContext: () => undefined }); - return () => ({}); - }, - }, - ); + const gl = fakeWebGl(); const canvas = () => ({ style: {}, width: 0, @@ -180,6 +164,255 @@ describe("page-side compositor seek", () => { }); }); +// The transparent inset case: 640x360 #main holding scenes inset 90px 160px, as in the +// page-side-shader-compositor-render-compat fixture with a transparent page. +describe("page-side compositor scene copies", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + // CSS animations the stylesheet gives an element ("::before glow" runs on its pseudo-element), + // and the live times of those still running. + const CSS_ANIMATIONS: Record = { + stage: ["pulse", "intro", "::before glow", "::before pulse"], + scene: ["pulse"], + }; + const LIVE_TIMES: Record> = { + stage: { pulse: 400, "::before glow": 250, "::before pulse": 150 }, + scene: { pulse: 900 }, + }; + + class FakeAnimation { + currentTime: number | null = 0; + state = "running"; + readonly animationName: string; + readonly effect: { target: FakeEl; pseudoElement: string | null }; + constructor(spec: string, target: FakeEl) { + const [pseudo, name] = spec.includes(" ") ? spec.split(" ") : [null, spec]; + this.animationName = name!; + this.effect = { target, pseudoElement: pseudo ?? null }; + } + pause() { + this.state = "paused"; + } + cancel() { + this.state = "cancelled"; + } + } + + class FakeEl { + style: Record = {}; + children: FakeEl[] = []; + parentElement: FakeEl | null = null; + animations: FakeAnimation[] = []; + constructor( + readonly id: string, + readonly copyOf: FakeEl | null = null, + ) {} + getAttribute() { + return null; + } + get key(): string { + return (this.copyOf ?? this).id.replace(/^scene-.*/, "scene"); + } + ownAnimations(): FakeAnimation[] { + if (this.copyOf) return this.animations; + return Object.entries(LIVE_TIMES[this.key] ?? {}).map(([spec, time]) => { + const live = new FakeAnimation(spec, this); + live.currentTime = time; + return live; + }); + } + getAnimations(options?: { subtree?: boolean }): FakeAnimation[] { + if (!options?.subtree) return this.ownAnimations(); + return [...this.ownAnimations(), ...this.children.flatMap((c) => c.getAnimations(options))]; + } + cloneNode(deep: boolean) { + const copy = new FakeEl(`${this.id}-copy`, this); + copy.animations = (CSS_ANIMATIONS[this.key] ?? []).map( + (spec) => new FakeAnimation(spec, copy), + ); + if (deep) copy.children = this.children.map((c) => c.cloneNode(true)); + return copy; + } + appendChild(child: FakeEl) { + this.children.push(child); + child.parentElement = this; + return child; + } + querySelectorAll() { + return []; + } + } + + function installTransparentInsetFilm(opts: { failDraw?: boolean; underBody?: boolean } = {}) { + const calls: Array<{ canvas: number; op: string; args: unknown[] }> = []; + const gl = new Proxy(fakeWebGl(), { + get: (target, key) => { + if (key === "texImage2D") { + return (...args: unknown[]) => calls.push({ canvas: -1, op: "texImage2D", args }); + } + return Reflect.get(target, key); + }, + }); + const canvases: Array<{ id?: string; style: Record }> = []; + const createCanvas = () => { + const index = canvases.length; + const children: FakeEl[] = []; + const record = + (op: string) => + (...args: unknown[]) => { + calls.push({ canvas: index, op, args }); + if (op === "drawElementImage" && opts.failDraw) throw new Error("No cached paint record"); + }; + const ctx = { + fillStyle: "", + fillRect: record("fillRect"), + clearRect: record("clearRect"), + drawElementImage: record("drawElementImage"), + }; + const canvas: { id?: string; style: Record; [key: string]: unknown } = { + style: {}, + width: 0, + height: 0, + layoutSubtree: true, + setAttribute: () => undefined, + remove: () => undefined, + get firstChild() { + return children[0] ?? null; + }, + get firstElementChild() { + return children[0] ?? null; + }, + appendChild: (child: FakeEl) => children.push(child), + removeChild: () => children.shift(), + querySelectorAll: () => [], + getContext: (type: string) => (type === "2d" ? ctx : gl), + }; + canvases.push(canvas); + return canvas; + }; + const body = new FakeEl("body"); + // An unsized wrapper between the composition root and the scenes, as in authored films. + const parent = opts.underBody + ? body + : body.appendChild(new FakeEl("main")).appendChild(new FakeEl("stage")); + const scenes = new Map([ + ["scene-a", parent.appendChild(new FakeEl("scene-a"))], + ["scene-b", parent.appendChild(new FakeEl("scene-b"))], + ]); + let startPolling: (() => void) | undefined; + const hf = { seek: vi.fn() }; + const win: Record = { + __hf: hf, + setInterval: (poll: () => void) => { + startPolling = poll; + return 1; + }, + clearInterval: () => undefined, + }; + vi.stubGlobal("window", win); + vi.stubGlobal("HTMLElement", FakeEl); + vi.stubGlobal("document", { + createElement: (tag: string) => (tag === "canvas" ? createCanvas() : new FakeEl(tag)), + getElementById: (id: string) => scenes.get(id) ?? null, + body, + documentElement: null, + }); + installPageSideCompositor({ + scenes: ["scene-a", "scene-b"], + transitions: [{ time: 0.75, duration: 0.85, shader: "glitch" }], + bgColor: "transparent", + accentColors: { accent: [1, 1, 1], dark: [0, 0, 0], bright: [1, 1, 1] }, + width: 640, + height: 360, + defaultDuration: 0.85, + }); + startPolling?.(); + const composite = async (time: number) => { + hf.seek(time); + await (win.__hf_page_composite_prepare as () => Promise)(); + return (win.__hf_page_composite_resolve as () => boolean)(); + }; + const overlay = () => canvases.find((c) => c.id === PAGE_COMPOSITOR_CANVAS_ID)!; + return { calls, composite, overlay }; + } + + it("stages each scene copy inside unaltered ancestor copies in a full-frame box", async () => { + const { calls, composite } = installTransparentInsetFilm(); + expect(await composite(1.2)).toBe(true); + const draws = calls.filter((c) => c.op === "drawElementImage"); + expect(draws).toHaveLength(2); + for (const [index, sceneId] of [ + [0, "scene-a"], + [1, "scene-b"], + ] as const) { + const frame = draws[index]?.args[0] as FakeEl; + expect(draws[index]?.args.slice(1)).toEqual([0, 0, 640, 360]); + expect(frame.style.cssText).toContain("width:640px;height:360px"); + const main = frame.children[0]!; + const stage = main.children[0]!; + const scene = stage.children[0]!; + expect([main, stage, scene].map((el) => el.copyOf?.id)).toEqual(["main", "stage", sceneId]); + expect(main.style).toEqual({}); + expect(stage.style).toEqual({}); + expect(scene.style).toEqual({ opacity: "1", visibility: "visible" }); + } + }); + + it("holds copied CSS animations at the live time and cancels ones finished live", async () => { + const { calls, composite } = installTransparentInsetFilm(); + await composite(1.2); + const frame = calls.find((c) => c.op === "drawElementImage")?.args[0] as FakeEl; + const stage = frame.children[0]!.children[0]!; + const [pulse, intro, glow, pseudoPulse] = stage.animations; + expect(pulse).toMatchObject({ animationName: "pulse", state: "paused", currentTime: 400 }); + expect(intro).toMatchObject({ animationName: "intro", state: "cancelled" }); + expect(glow).toMatchObject({ animationName: "glow", state: "paused", currentTime: 250 }); + // The same name on the stage's ::before and on the scene keeps each one's own time. + expect(pseudoPulse).toMatchObject({ state: "paused", currentTime: 150 }); + expect(stage.children[0]!.animations[0]).toMatchObject({ state: "paused", currentTime: 900 }); + }); + + it("stages a scene directly under body in the full-frame box", async () => { + const { calls, composite } = installTransparentInsetFilm({ underBody: true }); + expect(await composite(1.2)).toBe(true); + const draws = calls.filter((c) => c.op === "drawElementImage"); + for (const draw of draws) { + const frame = draw.args[0] as FakeEl; + expect(frame.children.map((c) => c.copyOf?.id.slice(0, 5))).toEqual(["scene"]); + expect(draw.args.slice(1)).toEqual([0, 0, 640, 360]); + } + }); + + it("clears both staging bitmaps after the textures are uploaded", async () => { + const { calls, composite } = installTransparentInsetFilm(); + await composite(1.2); + const lastUpload = calls.map((c) => c.op).lastIndexOf("texImage2D"); + const staging = new Set(calls.filter((c) => c.op === "drawElementImage").map((c) => c.canvas)); + expect(staging.size).toBe(2); + for (const canvas of staging) { + const lastClear = Math.max( + ...calls.flatMap((c, n) => (c.canvas === canvas && c.op === "clearRect" ? [n] : [])), + ); + expect(lastClear).toBeGreaterThan(lastUpload); + const drawn = calls.findIndex((c) => c.canvas === canvas && c.op === "drawElementImage"); + const clearedBeforeUpload = calls.some( + (c, n) => c.canvas === canvas && c.op === "clearRect" && n > drawn && n < lastUpload, + ); + expect(clearedBeforeUpload).toBe(false); + } + }); + + it("clears the staging bitmaps and hides the overlay when a scene capture fails", async () => { + const { calls, composite, overlay } = installTransparentInsetFilm({ failDraw: true }); + expect(await composite(1.2)).toBe(false); + const drawn = calls.find((c) => c.op === "drawElementImage")?.canvas; + expect(calls.some((c) => c.canvas === drawn && c.op === "clearRect")).toBe(true); + expect(overlay().style.display).toBe("none"); + }); +}); + 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 e0bcd6a7fb..7c1ef2c6b7 100644 --- a/packages/shader-transitions/src/engineModePageComposite.ts +++ b/packages/shader-transitions/src/engineModePageComposite.ts @@ -76,34 +76,61 @@ 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; +/** Wraps a scene clone in childless copies of its ancestors below ``, so inherited styles, + * scoped selectors and ancestor effects apply as they do live. */ +function stageWithAncestors( + scene: HTMLElement, + clone: HTMLElement, +): { root: HTMLElement; liveRoot: HTMLElement; pairs: Array<[Element, Element]> } { + const liveDescendants = scene.querySelectorAll("*"); + const cloneDescendants = clone.querySelectorAll("*"); + const pairs: Array<[Element, Element]> = [[scene, clone]]; + liveDescendants.forEach((el, n) => pairs.push([el, cloneDescendants[n]!])); + let root = clone; + let liveRoot = scene; + for ( + let el = scene.parentElement; + el && el !== document.body && el !== document.documentElement; + el = el.parentElement + ) { + const shell = el.cloneNode(false) as HTMLElement; + shell.appendChild(root); + pairs.push([el, shell]); + root = shell; + liveRoot = el; + } + return { root, liveRoot, pairs }; } -/** - * 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`, +/** Pauses each copied CSS animation at its live counterpart's time instead of restarting it; + * one with no live counterpart has finished live, so it is cancelled. */ +function holdAnimations(liveRoot: Element, copyRoot: Element, pairs: Array<[Element, Element]>) { + const slot = new Map(); + pairs.forEach(([live, copy], n) => { + slot.set(live, n); + slot.set(copy, n); + }); + const keyOf = (a: Animation): string | null => { + if (!("animationName" in a)) return null; + const effect = a.effect as KeyframeEffect; + return `${slot.get(effect.target!)}|${effect.pseudoElement ?? ""}|${(a as CSSAnimation).animationName}`; }; + const liveTimes = new Map(); + for (const a of liveRoot.getAnimations({ subtree: true })) { + const key = keyOf(a); + if (key) liveTimes.set(key, a.currentTime); + } + for (const a of copyRoot.getAnimations({ subtree: true })) { + const key = keyOf(a); + if (!key) continue; + const time = liveTimes.get(key); + if (time === undefined) { + a.cancel(); + } else { + a.pause(); + a.currentTime = time; + } + } } export function isPageSideCompositingSupported(): boolean { @@ -254,44 +281,32 @@ 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; - const toClone = toEl.cloneNode(true) as HTMLElement; - fromStaging.appendChild(fromClone); - toStaging.appendChild(toClone); // cloneNode copies the GSAP opacity-fade (opacity:0 / hidden data-start), and // Chrome won't paint hidden elements — drawElementImage then throws "No cached // 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, pin] of [ - [fromClone, fromPin], - [toClone, toPin], + for (const [live, staging] of [ + [fromEl, fromStaging], + [toEl, toStaging], ] as const) { + const clone = live.cloneNode(true) as HTMLElement; 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"; }); + const { root, liveRoot, pairs } = stageWithAncestors(live, clone); + // A full-frame box gives the copies a sized containing block to lay out against. + const frame = document.createElement("div"); + frame.style.cssText = `position:absolute;left:0;top:0;width:${width}px;height:${height}px;margin:0;`; + frame.appendChild(root); + staging.appendChild(frame); + holdAnimations(liveRoot, root, pairs); } // Decode any data-URI images in clones so the browser has current @@ -333,24 +348,30 @@ export function installPageSideCompositor(options: PageCompositorInstallOptions) return false; } + const staged = [ + [fromCtx, fromChild], + [toCtx, toChild], + ] as const; try { - fromCtx.fillStyle = options.bgColor; - fromCtx.fillRect(0, 0, width, height); - fromCtx.drawElementImage(fromChild, 0, 0, width, height); - - toCtx.fillStyle = options.bgColor; - toCtx.fillRect(0, 0, width, height); - toCtx.drawElementImage(toChild, 0, 0, width, height); + for (const [ctx, child] of staged) { + ctx.fillStyle = options.bgColor; + ctx.fillRect(0, 0, width, height); + ctx.drawElementImage(child, 0, 0, width, height); + } + uploadTextureSource(gl as WebGLRenderingContext, fromTex, fromStaging); + uploadTextureSource(gl as WebGLRenderingContext, toTex, toStaging); } catch (err) { // eslint-disable-next-line no-console - console.warn("[HyperShader] page-side compositor: drawElementImage failed:", err); + console.warn("[HyperShader] page-side compositor: scene capture failed:", err); + glCanvas.style.display = "none"; pWin.__hf_page_composite_pending = false; return false; + } finally { + // The staging canvases sit behind the page, so a bitmap left on them would show + // through any transparent area of the composition for the rest of the film. + for (const [ctx] of staged) ctx.clearRect(0, 0, width, height); } - uploadTextureSource(gl as WebGLRenderingContext, fromTex, fromStaging); - uploadTextureSource(gl as WebGLRenderingContext, toTex, toStaging); - try { renderShader( gl as WebGLRenderingContext,