From 859cf3a36a445b1ad58316da30f66da1f0eb9585 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 20 Oct 2026 18:10:02 -0700 Subject: [PATCH] feat(core): block capture motion blur on html-in-canvas defects html-in-canvas captures a few things wrongly, and a wrong blurred frame is worse than a sharp one. Add a defect list the runtime checks before it wraps a composition: a WebGL or WebGPU canvas (captured frozen), backface-hidden faces in a 3D context (drawn mirrored), an iframe (reloaded by the wrap and not recoverable), a transform or opacity below 1 on the root (ignored by the capture) and a canvas that is already a capture canvas (nesting hangs the renderer). Any hit turns blur off for the whole composition and the runtime says which one. The backface scan reads rules nested inside a style rule (CSS nesting), sheets pulled in with @import and adopted style sheets. Shadow DOM stays a documented limit. A face whose backface-visibility: hidden arrives through an animation counts too: the scan also takes the elements of running CSS or WAAPI animations that key it, and elements naming a @keyframes rule that does, as candidates. Support Chrome 157's html-in-canvas API alongside the older one: a canvas marked content="drawable" with a drawable attribute on captured children replaces layoutsubtree. Blur, the VFX runtime and Studio's browser check detect both through the new @hyperframes/core/html-in-canvas subpath. Folded review fixes: R14, R9 (the iframe defect entry). Co-Authored-By: Claude Sonnet 5.5 --- packages/core/package-subpaths.json | 6 + packages/core/package.json | 10 + packages/core/src/htmlInCanvas.test.ts | 107 ++++++ packages/core/src/htmlInCanvas.ts | 29 ++ packages/core/src/htmlInCanvasDefects.test.ts | 323 ++++++++++++++++++ packages/core/src/htmlInCanvasDefects.ts | 289 ++++++++++++++++ packages/core/src/pageMotionBlur.ts | 13 +- .../runtime/captureMotionBlur/index.test.ts | 106 +++++- .../src/runtime/captureMotionBlur/index.ts | 39 ++- packages/core/src/runtime/vfx.test.ts | 29 ++ packages/core/src/runtime/vfx.ts | 9 + .../src/components/renders/useRenderQueue.ts | 5 +- .../renders/useRenderQueueMotionBlur.test.tsx | 33 +- 13 files changed, 957 insertions(+), 41 deletions(-) create mode 100644 packages/core/src/htmlInCanvas.test.ts create mode 100644 packages/core/src/htmlInCanvas.ts create mode 100644 packages/core/src/htmlInCanvasDefects.test.ts create mode 100644 packages/core/src/htmlInCanvasDefects.ts diff --git a/packages/core/package-subpaths.json b/packages/core/package-subpaths.json index cc40df35443..1d5cfbef164 100644 --- a/packages/core/package-subpaths.json +++ b/packages/core/package-subpaths.json @@ -104,6 +104,12 @@ "types": "./dist/keyframeDefaults.d.ts", "environments": ["browser", "bun", "node"] }, + "./html-in-canvas": { + "source": "./src/htmlInCanvas.ts", + "runtime": "./dist/htmlInCanvas.js", + "types": "./dist/htmlInCanvas.d.ts", + "environments": ["browser", "bun", "node"] + }, "./gsap-cdn": { "source": "./src/gsapCdn.ts", "runtime": "./dist/gsapCdn.js", diff --git a/packages/core/package.json b/packages/core/package.json index a3c5aa906d5..c085f725438 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -116,6 +116,12 @@ "import": "./src/keyframeDefaults.ts", "types": "./src/keyframeDefaults.ts" }, + "./html-in-canvas": { + "bun": "./src/htmlInCanvas.ts", + "node": "./dist/htmlInCanvas.js", + "import": "./src/htmlInCanvas.ts", + "types": "./src/htmlInCanvas.ts" + }, "./gsap-cdn": { "bun": "./src/gsapCdn.ts", "node": "./dist/gsapCdn.js", @@ -614,6 +620,10 @@ "import": "./dist/keyframeDefaults.js", "types": "./dist/keyframeDefaults.d.ts" }, + "./html-in-canvas": { + "import": "./dist/htmlInCanvas.js", + "types": "./dist/htmlInCanvas.d.ts" + }, "./gsap-cdn": { "import": "./dist/gsapCdn.js", "types": "./dist/gsapCdn.d.ts" diff --git a/packages/core/src/htmlInCanvas.test.ts b/packages/core/src/htmlInCanvas.test.ts new file mode 100644 index 00000000000..084840ff93d --- /dev/null +++ b/packages/core/src/htmlInCanvas.test.ts @@ -0,0 +1,107 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + htmlInCanvasForm, + markCaptureCanvas, + markCaptured, + unmarkCaptured, + CAPTURE_CANVAS_SELECTOR, +} from "./htmlInCanvas"; +import { supportsCaptureMotionBlur } from "./pageMotionBlur"; + +const canvasProto = HTMLCanvasElement.prototype as unknown as Record; +const CANVAS_MEMBERS = ["requestPaint", "layoutSubtree", "updateElementGeometry"]; + +function install(canvasMembers: string[], drawElementImage = true): void { + vi.stubGlobal( + "CanvasRenderingContext2D", + drawElementImage + ? class { + drawElementImage(): void {} + } + : class {}, + ); + for (const name of canvasMembers) canvasProto[name] = () => {}; +} + +afterEach(() => { + vi.unstubAllGlobals(); + for (const name of CANVAS_MEMBERS) delete canvasProto[name]; +}); + +describe("htmlInCanvasForm", () => { + it("is layoutsubtree where the canvas has layoutSubtree, even if it also has updateElementGeometry", () => { + install(["requestPaint", "layoutSubtree", "updateElementGeometry"]); + expect(htmlInCanvasForm()).toBe("layoutsubtree"); + }); + + it("is drawable where layoutSubtree is gone and updateElementGeometry is there", () => { + install(["requestPaint", "updateElementGeometry"]); + expect(htmlInCanvasForm()).toBe("drawable"); + }); + + it("is null when neither form is there", () => { + install(["requestPaint"]); + expect(htmlInCanvasForm()).toBeNull(); + }); + + it("is null without drawElementImage or without requestPaint", () => { + install(["requestPaint", "layoutSubtree"], false); + expect(htmlInCanvasForm()).toBeNull(); + vi.unstubAllGlobals(); + delete canvasProto.requestPaint; + install(["layoutSubtree"]); + expect(htmlInCanvasForm()).toBeNull(); + }); +}); + +describe("supportsCaptureMotionBlur", () => { + it.each([ + ["old form", ["requestPaint", "layoutSubtree"], true], + ["new form", ["requestPaint", "updateElementGeometry"], true], + ["API present but no known form", ["requestPaint"], false], + ])("%s", (_name, members, expected) => { + install(members); + expect(supportsCaptureMotionBlur()).toBe(expected); + }); +}); + +describe("marking", () => { + it("marks the old form with layoutsubtree and leaves children alone", () => { + const canvas = document.createElement("canvas"); + const child = document.createElement("div"); + markCaptureCanvas(canvas, "layoutsubtree"); + markCaptured(child, "layoutsubtree"); + expect(canvas.hasAttribute("layoutsubtree")).toBe(true); + expect(canvas.hasAttribute("content")).toBe(false); + expect(child.hasAttribute("drawable")).toBe(false); + }); + + it("marks the new form with content=drawable and drawable children, and unmarks them", () => { + const canvas = document.createElement("canvas"); + const child = document.createElement("div"); + markCaptureCanvas(canvas, "drawable"); + markCaptured(child, "drawable"); + expect(canvas.getAttribute("content")).toBe("drawable"); + expect(canvas.hasAttribute("layoutsubtree")).toBe(false); + expect(child.hasAttribute("drawable")).toBe(true); + unmarkCaptured(child, "drawable"); + expect(child.hasAttribute("drawable")).toBe(false); + }); + + it("finds a nested capture canvas of either form", () => { + for (const mark of [ + (c: HTMLElement) => c.setAttribute("layoutsubtree", ""), + (c: HTMLElement) => c.setAttribute("content", "drawable"), + ]) { + const root = document.createElement("div"); + const canvas = document.createElement("canvas"); + mark(canvas); + root.append(canvas); + expect(root.querySelector(CAPTURE_CANVAS_SELECTOR)).toBe(canvas); + } + const plain = document.createElement("div"); + plain.append(document.createElement("canvas")); + expect(plain.querySelector(CAPTURE_CANVAS_SELECTOR)).toBeNull(); + }); +}); diff --git a/packages/core/src/htmlInCanvas.ts b/packages/core/src/htmlInCanvas.ts new file mode 100644 index 00000000000..a6f1dc0530f --- /dev/null +++ b/packages/core/src/htmlInCanvas.ts @@ -0,0 +1,29 @@ +/** Chrome <=155 marks a capture canvas with `layoutsubtree`; 157+ with `content="drawable"` and `drawable` on each captured child. */ +export type HtmlInCanvasForm = "layoutsubtree" | "drawable"; + +export const CAPTURE_CANVAS_SELECTOR = 'canvas[layoutsubtree], canvas[content="drawable"]'; + +/** The form this browser's html-in-canvas takes, or null when it has no usable one. */ +export function htmlInCanvasForm(win: Window = window): HtmlInCanvasForm | null { + const ctx = (win as Window & { CanvasRenderingContext2D?: { prototype: object } }) + .CanvasRenderingContext2D?.prototype; + if (!ctx || !("drawElementImage" in ctx)) return null; + const canvas = win.document.createElement("canvas"); + if (!("requestPaint" in canvas)) return null; + // 155 already has updateElementGeometry but still wants layoutsubtree, so this order matters. + if ("layoutSubtree" in canvas) return "layoutsubtree"; + return "updateElementGeometry" in canvas ? "drawable" : null; +} + +export function markCaptureCanvas(canvas: HTMLCanvasElement, form: HtmlInCanvasForm): void { + if (form === "layoutsubtree") canvas.setAttribute("layoutsubtree", ""); + else canvas.setAttribute("content", "drawable"); +} + +export function markCaptured(el: Element, form: HtmlInCanvasForm): void { + if (form === "drawable") el.setAttribute("drawable", ""); +} + +export function unmarkCaptured(el: Element, form: HtmlInCanvasForm): void { + if (form === "drawable") el.removeAttribute("drawable"); +} diff --git a/packages/core/src/htmlInCanvasDefects.test.ts b/packages/core/src/htmlInCanvasDefects.test.ts new file mode 100644 index 00000000000..b7965abfdf7 --- /dev/null +++ b/packages/core/src/htmlInCanvasDefects.test.ts @@ -0,0 +1,323 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + chromeMajor, + findCaptureDefect, + scanBackfaceDefect, + type DefectEnv, +} from "./htmlInCanvasDefects"; + +const ua = (major: number | null, headless = false) => ({ + navigator: { + userAgent: + major === null + ? "Mozilla/5.0 HappyDOM" + : `Mozilla/5.0 (Macintosh) AppleWebKit/537.36 (KHTML, like Gecko) ${headless ? "HeadlessChrome" : "Chrome"}/${major}.0.0.0 Safari/537.36`, + }, +}); + +function mount(html: string, rootStyle = ""): HTMLElement { + document.body.innerHTML = `
${html}
`; + const root = document.getElementById("root"); + if (!root) throw new Error("no root"); + return root; +} + +const env = (over: Partial = {}): DefectEnv => ({ + isAccelerated: () => false, + backface: false, + ...over, +}); + +const code = (root: HTMLElement, e: DefectEnv) => findCaptureDefect(root, e)?.code ?? null; + +afterEach(() => { + document.body.innerHTML = document.head.innerHTML = ""; + vi.unstubAllGlobals(); + Reflect.deleteProperty(document, "getAnimations"); +}); + +const animating = (target: Element, frames: object[]) => { + class Effect { + target = target; + getKeyframes = () => frames; + } + vi.stubGlobal("KeyframeEffect", Effect); + Object.assign(document, { getAnimations: () => [{ effect: new Effect() }] }); +}; + +describe("chromeMajor", () => { + it("reads Chrome and HeadlessChrome, and is null for anything else", () => { + expect(chromeMajor(ua(152, true))).toBe(152); + expect(chromeMajor(ua(157))).toBe(157); + expect(chromeMajor(ua(null))).toBeNull(); + }); +}); + +describe("nested-capture-canvas", () => { + it("is found on every version, in either API form", () => { + for (const attr of ["layoutsubtree", 'content="drawable"']) { + const root = mount(``); + for (const major of [152, 158, null]) { + expect(code(root, env({ win: ua(major) }))).toBe("nested-capture-canvas"); + } + } + }); +}); + +describe("accelerated-canvas", () => { + const gl = (root: HTMLElement) => env({ isAccelerated: (c) => root.contains(c) }); + + it("applies below 157 and when the version is unknown, not from 157", () => { + const root = mount(""); + for (const major of [152, 155, null]) { + expect(code(root, { ...gl(root), win: ua(major) })).toBe("accelerated-canvas"); + } + for (const major of [157, 158]) expect(code(root, { ...gl(root), win: ua(major) })).toBeNull(); + }); + + it("ignores a canvas the page did not give a WebGL context", () => { + expect(code(mount(""), env({ win: ua(152) }))).toBeNull(); + }); +}); + +describe("iframe-in-root", () => { + it("applies on every version: a reload cannot be undone", () => { + const root = mount('
'); + for (const major of [152, 157, 158]) + expect(code(root, env({ win: ua(major) }))).toBe("iframe-in-root"); + }); + + it("is not reported for a composition without an iframe", () => { + expect(code(mount("
"), env())).toBeNull(); + }); +}); + +describe("root-transform", () => { + it("applies below 157 and when the version is unknown, not from 157", () => { + const root = mount("", "transform: scale(0.5)"); + expect(code(root, env({ win: ua(152) }))).toBe("root-transform"); + expect(code(root, env({ win: ua(156) }))).toBe("root-transform"); + expect(code(root, env({ win: ua(null) }))).toBe("root-transform"); + expect(code(root, env({ win: ua(157) }))).toBeNull(); + expect(code(root, env({ win: ua(158) }))).toBeNull(); + }); + + it("ignores a root without a transform", () => { + expect(code(mount(""), env({ win: ua(152) }))).toBeNull(); + }); +}); + +describe("root-opacity", () => { + it("applies from 155 and when the version is unknown, not on 152", () => { + const root = mount("", "opacity: 0.5"); + expect(code(root, env({ win: ua(152) }))).toBeNull(); + expect(code(root, env({ win: ua(155) }))).toBe("root-opacity"); + expect(code(root, env({ win: ua(158) }))).toBe("root-opacity"); + expect(code(root, env({ win: ua(null) }))).toBe("root-opacity"); + }); + + it("ignores a fully opaque root", () => { + expect(code(mount("", "opacity: 1"), env({ win: ua(158) }))).toBeNull(); + }); +}); + +describe("backface-visibility", () => { + const face = "backface-visibility: hidden"; + + it("is found on every version once the scan saw one", () => { + const root = mount(""); + for (const major of [152, 155, 157, 158, null]) { + expect(code(root, env({ backface: true, win: ua(major) }))).toBe("backface-visibility"); + } + }); + + it("scan: a face inside a preserve-3d card", () => { + const root = mount( + `
`, + ); + expect(scanBackfaceDefect(root)).toBe(true); + }); + + it("scan: a face under a perspective ancestor", () => { + const root = mount(`
`); + expect(scanBackfaceDefect(root)).toBe(true); + }); + + it("scan: a face with its own 3D rotation", () => { + const root = mount(`
`); + expect(scanBackfaceDefect(root)).toBe(true); + }); + + it("scan: the -webkit- spelling counts", () => { + const root = mount( + '
', + ); + expect(scanBackfaceDefect(root)).toBe(true); + }); + + it("scan: a face found through a stylesheet rule, including inside @media", () => { + document.head.innerHTML = + ""; + const root = mount('
'); + expect(scanBackfaceDefect(root)).toBe(true); + }); + + it("scan: the flat performance hack is not a defect", () => { + document.head.innerHTML = ""; + const root = mount( + `
`, + ); + expect(scanBackfaceDefect(root)).toBe(false); + }); + + it("scan: a 3D card without a hidden face is fine", () => { + const root = mount('
'); + expect(scanBackfaceDefect(root)).toBe(false); + }); + + it("scan: a selector that cannot be queried is skipped", () => { + document.head.innerHTML = ""; + expect(scanBackfaceDefect(mount("
"))).toBe(false); + }); + + it("scan: a 3D context outside the root does not count", () => { + document.body.innerHTML = `
`; + const root = document.getElementById("root"); + if (!root) throw new Error("no root"); + expect(scanBackfaceDefect(root)).toBe(false); + }); +}); + +describe("backface-visibility rules the page reaches indirectly", () => { + const withProps = (proto: object, props: Record) => + Object.create( + proto, + Object.fromEntries(Object.entries(props).map(([k, value]) => [k, { value }])), + ); + const rule = (selectorText: string, hidden: boolean, cssRules: unknown[] = []) => + withProps(CSSStyleRule.prototype, { + selectorText, + cssRules, + style: { + getPropertyValue: (n: string) => (hidden && n === "backface-visibility" ? "hidden" : ""), + }, + }); + const imported = (styleSheet: unknown) => withProps(CSSImportRule.prototype, { styleSheet }); + const sheetOf = (...cssRules: unknown[]) => ({ cssRules }); + const realComputed = window.getComputedStyle.bind(window); + // jsdom computes no style from the stubbed sheets, so `.face` always computes hidden: what is + // left to prove is that the scan picks the element as a candidate through the sheet. + const useSheets = (...sheets: unknown[]) => { + vi.spyOn(document, "styleSheets", "get").mockReturnValue(sheets as never); + vi.spyOn(window, "getComputedStyle").mockImplementation((el) => { + const cs = realComputed(el); + if (!el.classList.contains("face")) return cs; + return new Proxy(cs, { + get: (target, key) => + key === "getPropertyValue" + ? (n: string) => (n === "backface-visibility" ? "hidden" : target.getPropertyValue(n)) + : Reflect.get(target, key), + }); + }); + }; + const card = (inner = '
') => + mount(`
${inner}
`); + + afterEach(() => vi.restoreAllMocks()); + + it("finds a face hidden by a nested rule, with or without &", () => { + for (const nested of ["& .face", ".face", "&>.face"]) { + useSheets(sheetOf(rule(".card", false, [rule(nested, true)]))); + expect(scanBackfaceDefect(card())).toBe(true); + } + }); + + it("resolves a nested rule against its parent: a face elsewhere is not found", () => { + for (const nested of ["& .face", ".face"]) { + useSheets(sheetOf(rule(".other", false, [rule(nested, true)]))); + expect(scanBackfaceDefect(card())).toBe(false); + } + }); + + it("walks nested rules several levels down, and each selector of a nested list", () => { + useSheets( + sheetOf(rule(".card", false, [rule(".a, .b", false, [rule("& .face, & .x", true)])])), + ); + expect(scanBackfaceDefect(card('
'))).toBe(true); + }); + + it("finds a face hidden by an @imported sheet", () => { + useSheets(sheetOf(imported(sheetOf(rule(".face", true))))); + expect(scanBackfaceDefect(card())).toBe(true); + }); + + it("skips an @import that is not loaded or that the page may not read", () => { + const blocked = { + get cssRules(): never { + throw new DOMException("cross-origin", "SecurityError"); + }, + }; + useSheets(sheetOf(imported(null), imported(blocked), rule(".face", true))); + expect(scanBackfaceDefect(card())).toBe(true); + }); + + it("reads a sheet that imports itself once", () => { + let reads = 0; + const loop = { + get cssRules(): unknown[] { + reads++; + return [imported(loop), rule(".face", true)]; + }, + }; + useSheets(loop); + expect(scanBackfaceDefect(card())).toBe(true); + expect(reads).toBe(1); + }); + + it("stops following @import chains past a fixed depth", () => { + let chain = sheetOf(rule(".face", true)); + for (let i = 0; i < 40; i++) chain = sheetOf(imported(chain)); + useSheets(chain); + expect(scanBackfaceDefect(card())).toBe(false); + }); + + it("finds a face whose backface-visibility comes from @keyframes, named or not yet running", () => { + document.head.innerHTML = ""; + useSheets(...Array.from(document.styleSheets)); + const face = (name: string) => card(`
`); + expect(scanBackfaceDefect(face("flip"))).toBe(true); + expect(scanBackfaceDefect(face("other, flip"))).toBe(true); + expect(scanBackfaceDefect(face("other"))).toBe(false); + }); + + it("finds a face a running animation keys to hidden, inside the root only", () => { + const root = card(); + const face = root.querySelector(".face") ?? root; + const outside = document.body.appendChild(document.createElement("div")); + outside.className = "face"; + outside.style.transformStyle = "preserve-3d"; + useSheets(); + for (const [frames, el, expected] of [ + [{ backfaceVisibility: "hidden" }, face, true], + [{ backfaceVisibility: "visible" }, face, false], + [{ backfaceVisibility: "hidden" }, outside, false], + ] as const) { + animating(el, [frames]); + expect(scanBackfaceDefect(root)).toBe(expected); + } + }); + + it("still leaves the flat backface hack alone when written as a nested rule", () => { + useSheets(sheetOf(rule(".card", false, [rule("& .face", true)]))); + expect(scanBackfaceDefect(mount('
'))).toBe( + false, + ); + }); +}); + +describe("order", () => { + it("reports the first applicable defect", () => { + const root = mount("", "opacity: 0.5"); + expect(code(root, env({ win: ua(158), backface: true }))).toBe("nested-capture-canvas"); + }); +}); diff --git a/packages/core/src/htmlInCanvasDefects.ts b/packages/core/src/htmlInCanvasDefects.ts new file mode 100644 index 00000000000..fa30b9d2697 --- /dev/null +++ b/packages/core/src/htmlInCanvasDefects.ts @@ -0,0 +1,289 @@ +import { CAPTURE_CANVAS_SELECTOR } from "./htmlInCanvas"; + +type UaWindow = { navigator: { userAgent: string } }; + +/** Major Chrome version from the user agent (HeadlessChrome included), or null when unknown. */ +export function chromeMajor(win: UaWindow = window): number | null { + const m = /Chrome\/(\d+)/.exec(win.navigator.userAgent); + return m?.[1] ? Number(m[1]) : null; +} + +export interface CaptureDefect { + code: string; + reason: string; +} + +export interface DefectEnv { + isAccelerated: (canvas: HTMLCanvasElement) => boolean; + /** Result of `scanBackfaceDefect`, refreshed whenever the scene is rescanned. */ + backface: boolean; + win?: UaWindow; +} + +interface Defect { + code: string; + reason: string; + /** Chrome majors the defect applies to; null bounds are open. */ + from: number | null; + until: number | null; + detect: (root: HTMLElement, env: DefectEnv) => boolean; +} + +const hasHidden3dFace = (_root: HTMLElement, env: DefectEnv): boolean => env.backface; + +const hasAcceleratedCanvas = (root: HTMLElement, env: DefectEnv): boolean => + Array.from(root.querySelectorAll("canvas")).some(env.isAccelerated); + +const hasRootTransform = (root: HTMLElement): boolean => { + const t = getComputedStyle(root).transform; + return t !== "none" && t !== ""; +}; + +const hasRootOpacity = (root: HTMLElement): boolean => + Number.parseFloat(getComputedStyle(root).opacity) < 1; + +const hasIframe = (root: HTMLElement): boolean => root.querySelector("iframe") !== null; + +// Chrome <=156 captures a WebGL canvas frozen at its pre-wrap frame; 157 fixed it. +const DEFECTS: readonly Defect[] = [ + { + code: "nested-capture-canvas", + reason: "the composition already uses html-in-canvas", + from: null, + until: null, + detect: (root) => root.querySelector(CAPTURE_CANVAS_SELECTOR) !== null, + }, + { + code: "accelerated-canvas", + reason: "the composition draws a WebGL/WebGPU canvas, which html-in-canvas captures frozen", + from: null, + until: 157, + detect: hasAcceleratedCanvas, + }, + { + code: "backface-visibility", + reason: "backface-visibility: hidden inside a 3D transform is drawn mirrored by html-in-canvas", + from: null, + until: null, + detect: hasHidden3dFace, + }, + { + code: "iframe-in-root", + reason: "the composition contains an