From 11e6e609ea2c0937a1edd7ed62aa18e44e63f1e7 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 20 Oct 2026 18:13:53 -0700 Subject: [PATCH] feat(core,producer): decide blur defects once per timeline, off on routes that cannot host it Decide the html-in-canvas defects once for the whole timeline instead of at the frame being drawn. The mover scan already seeks the timeline, so the defect scan samples along it and a defect that only appears mid-timeline (a rotated root, a flipped card) turns blur off from frame 0. Defects now block on every Chrome version, so a render matches the preview it came from, and GSAP's identity transform on the root no longer counts as one. A per-frame check stays as a backstop for what scripts add later, and it costs only that frame: a defect the scan missed (a short root shake, a backface flip between samples) renders that frame sharp and logs once, and blur resumes when the defect is gone. A block raised by the scan is lifted when its cause is removed or the timeline changes; a capture failure stays for the life of the instance. The candidates a scan checks include faces whose backface-visibility: hidden arrives through an animation. Each rescan adds to them and the per-frame backstop reads the same set, so an animated-only flip card is caught by both. Look for WebGL canvases in the whole document: a render's shader-transition compositor appends its canvas to body, while preview puts it inside the root. Turn page blur off on routes that cannot host it. The HDR and layered compositors screenshot each DOM layer after hiding the others, which does not reach blur's canvases, and a distributed (cloud or Lambda) chunk render has no Studio preview to match and no guarantee of html-in-canvas in the worker's Chrome. The producer sets pageMotionBlur to hdr-render, layered-composite or distributed-render there, the distributed plan's compile-time hint no longer forces screenshot capture, and the runtime says which route turned it off. Find the same movers whatever time a scan starts: the baseline is read at t=0, so each worker of a parallel render picks the same set. An ancestor's pan is not counted as its child's move. Folded review fixes: R5, R10, R15, R19. Co-Authored-By: Claude Sonnet 5.5 --- packages/core/src/htmlInCanvasDefects.test.ts | 230 +++++++++++++-- packages/core/src/htmlInCanvasDefects.ts | 149 ++++++---- .../captureMotionBlur/discover.test.ts | 61 ++++ .../src/runtime/captureMotionBlur/discover.ts | 46 ++- .../runtime/captureMotionBlur/index.test.ts | 274 ++++++++++++++++++ .../src/runtime/captureMotionBlur/index.ts | 82 ++++-- packages/engine/src/index.ts | 1 + .../src/services/pageMotionBlur.test.ts | 12 + .../engine/src/services/pageMotionBlur.ts | 14 +- packages/engine/src/types.ts | 5 +- .../src/services/distributed/chunkFrames.ts | 1 + .../src/services/distributed/plan.test.ts | 33 +++ .../producer/src/services/distributed/plan.ts | 1 + .../src/services/distributed/shared.ts | 1 + .../services/render/motionBlurRoute.test.ts | 33 ++- .../src/services/render/motionBlurRoute.ts | 16 +- .../src/services/renderOrchestrator.ts | 7 +- 17 files changed, 855 insertions(+), 111 deletions(-) diff --git a/packages/core/src/htmlInCanvasDefects.test.ts b/packages/core/src/htmlInCanvasDefects.test.ts index b7965abfdf7..5a6cca81d28 100644 --- a/packages/core/src/htmlInCanvasDefects.test.ts +++ b/packages/core/src/htmlInCanvasDefects.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { chromeMajor, + createDefectScan, findCaptureDefect, scanBackfaceDefect, type DefectEnv, @@ -30,6 +31,21 @@ const env = (over: Partial = {}): DefectEnv => ({ const code = (root: HTMLElement, e: DefectEnv) => findCaptureDefect(root, e)?.code ?? null; +/** Every Chrome the render and preview browsers can be, and an unknown one. */ +const VERSIONS = [152, 155, 156, 157, 158, null] as const; + +/** The defect found with the page's user agent set to `major`. */ +function codeOn(major: number | null, root: HTMLElement, e: DefectEnv): string | null { + const spy = vi + .spyOn(window.navigator, "userAgent", "get") + .mockReturnValue(ua(major).navigator.userAgent); + try { + return code(root, e); + } finally { + spy.mockRestore(); + } +} + afterEach(() => { document.body.innerHTML = document.head.innerHTML = ""; vi.unstubAllGlobals(); @@ -57,9 +73,8 @@ 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"); - } + for (const major of VERSIONS) + expect(codeOn(major, root, env())).toBe("nested-capture-canvas"); } }); }); @@ -67,57 +82,174 @@ describe("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", () => { + it("applies on every version, 157 and 158 included", () => { 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 VERSIONS) { + expect(codeOn(major, root, gl(root))).toBe("accelerated-canvas"); + } + }); + + it("finds a WebGL canvas outside the root, where a render's shader compositor puts it", () => { + const root = mount(""); + const outside = document.createElement("canvas"); + document.body.append(outside); + for (const major of VERSIONS) { + expect(codeOn(major, root, env({ isAccelerated: (c) => c === outside }))).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(); + expect(code(mount(""), env())).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"); + for (const major of VERSIONS) expect(codeOn(major, root, env())).toBe("iframe-in-root"); }); it("is not reported for a composition without an iframe", () => { expect(code(mount("
"), env())).toBeNull(); }); + + it("also covers , and defined custom elements, which are re-initialised too", () => { + customElements.define("x-defect-card", class extends HTMLElement {}); + for (const html of ["", "", ""]) + expect(findCaptureDefect(mount(html), env())).toMatchObject({ + code: "iframe-in-root", + reason: expect.stringContaining("re-initialised when blur wraps the root"), + }); + expect(code(mount(""), env())).toBeNull(); + }); + + it("is found by the timeline scan at any step, and says where it first appeared", () => { + const root = mount(""); + const scan = createDefectScan(root, () => false); + scan.sample(0, 10, 0); + root.append(document.createElement("iframe")); + scan.sample(3, 10, 1.5); + expect(scan.result()).toMatchObject({ code: "iframe-in-root" }); + expect(scan.result()?.reason).toContain("t=1.5s"); + }); +}); + +describe("facesNow: the scan's candidates, read at the current time", () => { + it("sees a hidden face flip into 3D after the scan, without rescanning", () => { + const root = mount('
'); + const scan = createDefectScan(root, () => false); + scan.sample(0, 10, 0); + expect(scan.facesNow()).toBe(false); + const card = document.getElementById("card"); + if (card) card.style.transform = "rotateY(100deg)"; + expect(scan.facesNow()).toBe(true); + if (card) card.style.transform = "none"; + expect(scan.facesNow()).toBe(false); + }); + + it("takes in a face an animation keys, and keeps it once the animation is gone", () => { + const root = mount('
'); + const face = root.querySelector("#f") ?? root; + const scan = createDefectScan(root, () => false); + animating(face, [{ backfaceVisibility: "hidden" }]); + scan.sample(0, 10, 0); + expect(scan.result()).toBeNull(); + expect(scan.facesNow()).toBe(false); + // The rule comes after the scan read the sheets: only the animation made the face a candidate. + document.head.innerHTML = ""; + face.className = "hide"; + expect(scan.facesNow()).toBe(true); + Object.assign(document, { getAnimations: () => [] }); + scan.sample(10, 10, 1); + expect(scan.result()?.code).toBe("backface-visibility"); + }); + + it("knows only the elements the scan's last face check found", () => { + const root = mount(""); + const scan = createDefectScan(root, () => false); + scan.sample(0, 10, 0); + const late = document.createElement("div"); + late.setAttribute("style", "backface-visibility: hidden; transform: rotateY(100deg)"); + root.append(late); + expect(scan.facesNow()).toBe(false); + }); }); describe("root-transform", () => { - it("applies below 157 and when the version is unknown, not from 157", () => { + it("applies on every version, 157 and 158 included", () => { 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(); + for (const major of VERSIONS) expect(codeOn(major, root, env())).toBe("root-transform"); }); it("ignores a root without a transform", () => { - expect(code(mount(""), env({ win: ua(152) }))).toBeNull(); + expect(code(mount(""), env())).toBeNull(); + }); + + it("ignores an identity transform, 2D or 3D, which changes nothing on screen", () => { + for (const t of [ + "matrix(1, 0, 0, 1, 0, 0)", + "matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)", + ]) { + for (const major of VERSIONS) + expect(codeOn(major, mount("", `transform: ${t}`), env())).toBeNull(); + } + }); + + it("reports a real translate, scale or rotate, 2D or 3D", () => { + for (const t of [ + "translateX(3px)", + "scale(1.01)", + "rotate(1deg)", + "matrix(1, 0, 0, 1, 0, 2)", + "matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 5, 1)", + ]) { + expect(code(mount("", `transform: ${t}`), env())).toBe("root-transform"); + } + }); +}); + +describe("root individual transform properties and blend mode", () => { + it("reports a root moved by scale, rotate or translate, not an identity one", () => { + for (const t of ["scale: 0.5", "rotate: 3deg", "translate: 5px 0"]) + expect(code(mount("", t), env())).toBe("root-transform"); + for (const t of ["scale: 1", "rotate: 0deg", "translate: 0px"]) + expect(code(mount("", t), env())).toBeNull(); + }); + + it("reports a root mix-blend-mode other than normal", () => { + for (const major of VERSIONS) + expect(codeOn(major, mount("", "mix-blend-mode: multiply"), env())).toBe("root-blend-mode"); + expect(code(mount("", "mix-blend-mode: normal"), env())).toBeNull(); }); }); describe("root-opacity", () => { - it("applies from 155 and when the version is unknown, not on 152", () => { + it("applies on every version, 152 included", () => { 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"); + for (const major of VERSIONS) expect(codeOn(major, root, env())).toBe("root-opacity"); }); it("ignores a fully opaque root", () => { - expect(code(mount("", "opacity: 1"), env({ win: ua(158) }))).toBeNull(); + expect(code(mount("", "opacity: 1"), env())).toBeNull(); + }); +}); + +describe("no version gating", () => { + // Mutation guard: a defect that once applied only below 157 (or from 155) must still block on + // 158 (or 152). Reintroducing any from/until test on the Chrome major fails this. + it("blocks a WebGL canvas, a root transform and a root fade on every version alike", () => { + const fixtures: Array<[string, () => HTMLElement, DefectEnv]> = [ + ["accelerated-canvas", () => mount(""), env({ isAccelerated: () => true })], + ["root-transform", () => mount("", "transform: translateX(5px)"), env()], + ["root-opacity", () => mount("", "opacity: 0.2"), env()], + ]; + for (const [expected, build, e] of fixtures) { + const root = build(); + const seen = new Set(VERSIONS.map((major) => codeOn(major, root, e))); + expect([...seen]).toEqual([expected]); + } }); }); @@ -126,8 +258,8 @@ describe("backface-visibility", () => { 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"); + for (const major of VERSIONS) { + expect(codeOn(major, root, env({ backface: true }))).toBe("backface-visibility"); } }); @@ -138,6 +270,12 @@ describe("backface-visibility", () => { expect(scanBackfaceDefect(root)).toBe(true); }); + it("scan: a face turned in 3D by the individual rotate or translate properties", () => { + for (const own of ["rotate: y 180deg", "rotate: 0 1 0 180deg", "translate: 0 0 10px"]) + expect(scanBackfaceDefect(mount(`
`))).toBe(true); + expect(scanBackfaceDefect(mount(`
`))).toBe(false); + }); + it("scan: a face under a perspective ancestor", () => { const root = mount(`
`); expect(scanBackfaceDefect(root)).toBe(true); @@ -318,6 +456,46 @@ describe("backface-visibility rules the page reaches indirectly", () => { 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"); + expect(code(root, env({ backface: true }))).toBe("nested-capture-canvas"); + }); +}); + +describe("createDefectScan", () => { + const face = "backface-visibility: hidden"; + const run = (root: HTMLElement, steps: number, each?: (i: number) => void) => { + const scan = createDefectScan(root, () => false); + for (let i = 0; i <= steps; i++) { + each?.(i); + scan.sample(i, steps, i / 10); + } + return scan.result(); + }; + + it("is clean for a quiet timeline", () => { + expect(run(mount(""), 20)).toBeNull(); + }); + + it("names the defect and when it was first seen", () => { + const root = mount(""); + const found = run(root, 20, (i) => { + if (i === 7) root.style.opacity = "0.5"; + }); + expect(found?.code).toBe("root-opacity"); + expect(found?.reason).toContain("first seen at t=0.7s"); + }); + + it("sees a rotation that a later step applies to a face with no 3D context at the start", () => { + const root = mount(`
`); + const el = root.querySelector("#f"); + const found = run(root, 20, (i) => { + if (i === 12 && el) el.style.transform = "rotateY(90deg)"; + }); + expect(found?.code).toBe("backface-visibility"); + expect(found?.reason).toContain("first seen at t=2.0s"); + }); + + it("leaves a flat face alone however long the timeline", () => { + const root = mount(`
`); + expect(run(root, 40)).toBeNull(); }); }); diff --git a/packages/core/src/htmlInCanvasDefects.ts b/packages/core/src/htmlInCanvasDefects.ts index fa30b9d2697..a667231eb12 100644 --- a/packages/core/src/htmlInCanvasDefects.ts +++ b/packages/core/src/htmlInCanvasDefects.ts @@ -17,96 +17,94 @@ export interface DefectEnv { isAccelerated: (canvas: HTMLCanvasElement) => boolean; /** Result of `scanBackfaceDefect`, refreshed whenever the scene is rescanned. */ backface: boolean; - win?: UaWindow; } +// Every defect blocks on every Chrome version: render and preview browsers can differ. 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; +// The whole document: a render's shader-transition canvas sits in , preview's in the root. const hasAcceleratedCanvas = (root: HTMLElement, env: DefectEnv): boolean => - Array.from(root.querySelectorAll("canvas")).some(env.isAccelerated); + Array.from(root.ownerDocument.querySelectorAll("canvas")).some(env.isAccelerated); +const IDENTITY_TRANSFORM = + /^matrix\(\s*1,\s*0,\s*0,\s*1,\s*0,\s*0\s*\)$|^matrix3d\(\s*1,\s*0,\s*0,\s*0,\s*0,\s*1,\s*0,\s*0,\s*0,\s*0,\s*1,\s*0,\s*0,\s*0,\s*0,\s*1\s*\)$/; + +const moves = (value: string | undefined, identity: number): boolean => + !!value && value !== "none" && !value.split(/\s+/).every((p) => parseFloat(p) === identity); + +// `scale`, `rotate` and `translate` transform the root just as `transform` does. const hasRootTransform = (root: HTMLElement): boolean => { - const t = getComputedStyle(root).transform; - return t !== "none" && t !== ""; + const cs = getComputedStyle(root); + const t = cs.transform.trim(); + if (moves(cs.scale, 1) || moves(cs.rotate, 0) || moves(cs.translate, 0)) return true; + return t !== "none" && t !== "" && !IDENTITY_TRANSFORM.test(t); +}; + +const hasRootBlend = (root: HTMLElement): boolean => { + const mode = getComputedStyle(root).mixBlendMode; + return !!mode && mode !== "normal"; }; const hasRootOpacity = (root: HTMLElement): boolean => Number.parseFloat(getComputedStyle(root).opacity) < 1; -const hasIframe = (root: HTMLElement): boolean => root.querySelector("iframe") !== null; +// Moving the root tears these down and starts them again: a frame reloads, a custom element reconnects. +const hasReinitialised = (root: HTMLElement): boolean => + root.querySelector("iframe, object, embed") !== null || + Array.from(root.querySelectorAll("*")).some( + (el) => el.localName.includes("-") && customElements.get(el.localName) !== undefined, + ); -// 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, }, { + // Chrome <=156 captures the canvas frozen at its pre-wrap frame; 157 fixed it. 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