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
This commit is contained in:
Miguel Ángel
2026-09-28 03:30:06 +00:00
committed by GitHub
parent 2ebfcc04a3
commit 4094c7be4c
3 changed files with 344 additions and 90 deletions
@@ -60,7 +60,7 @@ function waveWarpChain(params: Record<string, number>): 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 `<!doctype html>
@@ -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<string, readonly [start: string, duration: string]>,
) {
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<string, string[]> = {
stage: ["pulse", "intro", "::before glow", "::before pulse"],
scene: ["pulse"],
};
const LIVE_TIMES: Record<string, Record<string, number>> = {
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<string, string> = {};
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<string, string> }> = [];
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<string, string>; [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<string, unknown> = {
__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<boolean>)();
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__");
@@ -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 `<body>`, 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<Element, number>();
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<string, CSSNumberish | null>();
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<HTMLElement>("[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,