From 8e15e477ffc020a26875766971449327dfa57b7e Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 22 Oct 2026 19:17:30 -0700 Subject: [PATCH 1/2] feat(core): capture blur falls back to per-element filters where capture cannot run html-in-canvas cannot capture a WebGL canvas, an iframe or a transform on the root, and some browsers lack it. Blur used to turn off for the whole composition. Now the runtime hands the composition to a filter tier: an SVG filter on each mover, 32 offset copies averaged along its sampled path, softened along the motion so long smears do not step. It filters the live DOM, so it works over a WebGL canvas, in any Chrome, and leaves glows and shadows on the element. Turn and scale only soften; capture draws those truthfully, so it stays the first choice and a scene re-init tries it again. Capture turning itself off for a defect or three failed captures hands over; the renderer's explicit off (HDR, distributed, engine blur) stays off. The report names the tier. Core's unpacked size budget goes to 3,965,000 for the tier. Co-Authored-By: Claude Sonnet 5.5 --- .../captureMotionBlur/filterPlan.test.ts | 70 +++++ .../runtime/captureMotionBlur/filterPlan.ts | 97 +++++++ .../runtime/captureMotionBlur/filterTier.ts | 267 ++++++++++++++++++ .../src/runtime/captureMotionBlur/frames.ts | 63 +++++ .../runtime/captureMotionBlur/index.test.ts | 114 ++++++++ .../src/runtime/captureMotionBlur/index.ts | 215 +++++++------- .../src/runtime/captureMotionBlur/tier.ts | 50 ++++ scripts/package-size-budgets.json | 4 +- 8 files changed, 777 insertions(+), 103 deletions(-) create mode 100644 packages/core/src/runtime/captureMotionBlur/filterPlan.test.ts create mode 100644 packages/core/src/runtime/captureMotionBlur/filterPlan.ts create mode 100644 packages/core/src/runtime/captureMotionBlur/filterTier.ts create mode 100644 packages/core/src/runtime/captureMotionBlur/frames.ts create mode 100644 packages/core/src/runtime/captureMotionBlur/tier.ts diff --git a/packages/core/src/runtime/captureMotionBlur/filterPlan.test.ts b/packages/core/src/runtime/captureMotionBlur/filterPlan.test.ts new file mode 100644 index 00000000000..1caf2101324 --- /dev/null +++ b/packages/core/src/runtime/captureMotionBlur/filterPlan.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import type { MoverFrame } from "./composite"; +import { planFilter } from "./filterPlan"; +import { type Affine, IDENTITY, translate } from "./pose"; + +const mat = (a: number, b: number, c: number, d: number, e: number, f: number): Affine => ({ + a, + b, + c, + d, + e, + f, +}); +const rot = (deg: number, e = 0, f = 0): Affine => { + const r = (deg * Math.PI) / 180; + return mat(Math.cos(r), Math.sin(r), -Math.sin(r), Math.cos(r), e, f); +}; +const frame = (now: Affine, taps: Affine[], w = 100, h = 50): MoverFrame => ({ w, h, now, taps }); +const slide = (from: number, to: number, n = 32, scale = 1): Affine[] => + Array.from({ length: n }, (_, k) => + mat(scale, 0, 0, scale, from + ((to - from) * k) / (n - 1), 0), + ); +/** Turned by `deg` about the middle of a w x h box whose middle is at (cx, cy). */ +const spin = (deg: number, cx: number, cy: number, w: number, h: number): Affine => { + const m = rot(deg); + return { ...m, e: cx - (m.a * w) / 2 - (m.c * h) / 2, f: cy - (m.b * w) / 2 - (m.d * h) / 2 }; +}; + +describe("planFilter", () => { + it("turns a slide into one offset per tap, centred on where the element is", () => { + const plan = planFilter(frame(translate(IDENTITY, 100, 0), slide(50, 150)), 0, 1920); + expect(plan?.offsets).toHaveLength(32); + expect(plan?.offsets[0]?.[0]).toBeCloseTo(-50, 5); + expect(plan?.offsets[31]?.[0]).toBeCloseTo(50, 5); + expect(plan?.offsets.every(([, y]) => Math.abs(y) < 1e-9)).toBe(true); + }); + + it("takes the offsets back through the element's own scale", () => { + const plan = planFilter(frame(mat(2, 0, 0, 2, 100, 0), slide(50, 150, 32, 2)), 0, 1920); + expect(plan?.offsets[0]?.[0]).toBeCloseTo(-25, 5); + }); + + it("softens along the motion only, not across it", () => { + const plan = planFilter(frame(translate(IDENTITY, 100, 0), slide(50, 150)), 0, 1920); + const [sx, sy] = plan?.sigma ?? [0, 0]; + expect(sx).toBeGreaterThan(0.5); + expect(sy).toBeLessThan(1e-9); + }); + + it("softens a turn on the spot, with no offsets", () => { + const taps = [-6, -2, 2, 6].map((d) => spin(108 + d, 140, 80, 80, 80)); + const plan = planFilter(frame(spin(108, 140, 80, 80, 80), taps, 80, 80), 0, 1920); + expect(plan?.offsets.every(([x, y]) => Math.hypot(x, y) < 1e-6)).toBe(true); + expect(plan?.sigma[0]).toBeGreaterThan(0.5); + expect(plan?.sigma[1]).toBeGreaterThan(0.5); + }); + + it("covers every copy, the softening and the ink around the box", () => { + const plan = planFilter(frame(translate(IDENTITY, 100, 0), slide(50, 150)), 20, 1920); + const r = plan?.region; + expect(r?.x).toBeLessThanOrEqual(-50 - 20); + expect((r?.x ?? 0) + (r?.w ?? 0)).toBeGreaterThanOrEqual(100 + 50 + 20); + expect(r?.y).toBeLessThanOrEqual(-20); + }); + + it("leaves a mover that does not move, and one with a collapsed box", () => { + expect(planFilter(frame(IDENTITY, slide(0, 0.1)), 0, 1920)).toBeNull(); + expect(planFilter(frame(mat(0, 0, 0, 0, 0, 0), slide(0, 100)), 0, 1920)).toBeNull(); + }); +}); diff --git a/packages/core/src/runtime/captureMotionBlur/filterPlan.ts b/packages/core/src/runtime/captureMotionBlur/filterPlan.ts new file mode 100644 index 00000000000..80a3ba695bc --- /dev/null +++ b/packages/core/src/runtime/captureMotionBlur/filterPlan.ts @@ -0,0 +1,97 @@ +/** + * The filter tier's arithmetic: what an SVG filter on a mover needs to smear it along its sampled + * path. Pure, so it can be checked without a browser. + * + * A filter runs in the element's own space, before its transform. The box centre's displacement + * across the shutter becomes one offset per tap, taken back through the element's linear part. + * Turn and scale cannot be offsets, so they soften the copies instead; so does the step between + * copies, along the motion only. + */ + +import type { MoverFrame } from "./composite"; +import type { Affine } from "./pose"; + +export interface FilterPlan { + /** One per tap: where that copy of the element sits, in its own px. */ + offsets: Array<[number, number]>; + /** Gaussian softening, in its own px, along x and y. */ + sigma: [number, number]; + /** The filter region in the element's own px: every copy, the softening and the ink around it. */ + region: { x: number; y: number; w: number; h: number }; +} + +/** Softening as a share of the step between copies; enough to close the steps, no more. */ +const STEP_SOFTEN = 0.35; +const TURN_SOFTEN = 0.29; +/** Below this, nothing is visible: the mover is left as it is. */ +const MIN_TRAVEL = 0.5; +const MIN_SIGMA = 0.35; + +const wrap = (a: number): number => Math.atan2(Math.sin(a), Math.cos(a)); +const centre = (m: Affine, w: number, h: number): [number, number] => [ + m.a * (w / 2) + m.c * (h / 2) + m.e, + m.b * (w / 2) + m.d * (h / 2) + m.f, +]; + +export function planFilter(frame: MoverFrame, pad: number, frameW: number): FilterPlan | null { + const { w, h, now, taps } = frame; + const det = now.a * now.d - now.b * now.c; + const scale = Math.sqrt(Math.abs(det)); + if (taps.length < 2 || !(scale > 1e-4)) return null; + + const here = centre(now, w, h); + const points = taps.map((m) => centre(m, w, h)); + const offsets = points.map(([x, y]): [number, number] => { + const dx = x - here[0]; + const dy = y - here[1]; + return [(now.d * dx - now.c * dy) / det, (-now.b * dx + now.a * dy) / det]; + }); + + let path = 0; + for (let i = 1; i < points.length; i++) { + path += Math.hypot( + (points[i]?.[0] ?? 0) - (points[i - 1]?.[0] ?? 0), + (points[i]?.[1] ?? 0) - (points[i - 1]?.[1] ?? 0), + ); + } + const step = path / (points.length - 1); + + const turn = Math.max( + ...taps.map((m) => Math.abs(wrap(Math.atan2(m.b, m.a) - Math.atan2(now.b, now.a)))), + ); + const grow = Math.max( + ...taps.map((m) => Math.abs(Math.log(Math.sqrt(Math.abs(m.a * m.d - m.b * m.c)) / scale))), + ); + const size = Math.sqrt(w * h) * scale; + const turnSigma = Math.min( + 24 * (frameW / 1920), + TURN_SOFTEN * Math.min(frameW / 4, size / 2) * (turn + grow), + ); + + const first = offsets[0] ?? [0, 0]; + const last = offsets[offsets.length - 1] ?? [0, 0]; + const along = Math.hypot(last[0] - first[0], last[1] - first[1]) || 1; + const stepSigma = (STEP_SOFTEN * step) / scale; + const sigma: [number, number] = [ + Math.hypot(turnSigma / scale, (stepSigma * Math.abs(last[0] - first[0])) / along), + Math.hypot(turnSigma / scale, (stepSigma * Math.abs(last[1] - first[1])) / along), + ]; + + if (path < MIN_TRAVEL && Math.max(...sigma) * scale < MIN_SIGMA) return null; + + const xs = offsets.map((o) => o[0]); + const ys = offsets.map((o) => o[1]); + const margin = pad + 3 * Math.max(...sigma) + 2; + const x = Math.min(0, ...xs) - margin; + const y = Math.min(0, ...ys) - margin; + return { + offsets, + sigma, + region: { + x, + y, + w: w + Math.max(0, ...xs) + margin - x, + h: h + Math.max(0, ...ys) + margin - y, + }, + }; +} diff --git a/packages/core/src/runtime/captureMotionBlur/filterTier.ts b/packages/core/src/runtime/captureMotionBlur/filterTier.ts new file mode 100644 index 00000000000..53e06d6c590 --- /dev/null +++ b/packages/core/src/runtime/captureMotionBlur/filterTier.ts @@ -0,0 +1,267 @@ +/** + * Motion blur without html-in-canvas: an SVG filter on each mover, offset copies of the element + * averaged along its sampled path. It works in any Chrome and where capture cannot (a WebGL canvas + * in the page, a transform on the root), because it filters the live DOM instead of copying it. + * Turn and scale only soften; capture draws those truthfully, so it stays the first choice. + */ + +import { swallow } from "../diagnostics"; +import { discoverMovers } from "./discover"; +import { planFilter, type FilterPlan } from "./filterPlan"; +import { readMoverFrames } from "./frames"; +import { createPoseReader } from "./pose"; +import type { CaptureMotionBlurHost, Config, Report, Tier } from "./tier"; + +const NS = "http://www.w3.org/2000/svg"; +/** Copies per mover; the softening along the motion closes the steps between them. */ +const COPIES = 32; +const PREVIEW_READS = 4; +const RENDER_READS = 8; +const FILTER_ID = "hf-mb"; +const OWN = new RegExp(`\\s*url\\("?#${FILTER_ID}-\\d+"?\\)`, "g"); + +interface Layer { + filter: SVGFilterElement; + offsets: SVGFEOffsetElement[]; + soften: SVGFEGaussianBlurElement; + url: string; + /** The mover's own stylesheet filter, kept under the smear. */ + css: string; +} + +function node( + parent: Element, + tag: K, + attrs: Record, +): SVGElementTagNameMap[K] { + const el = document.createElementNS(NS, tag); + for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v); + parent.append(el); + return el; +} + +/** 32 offset copies, averaged in pairs so 8-bit filter buffers do not band, then softened. */ +function buildLayer(defs: SVGDefsElement, index: number, el: HTMLElement): Layer { + const id = `${FILTER_ID}-${index}`; + const filter = node(defs, "filter", { + id, + "color-interpolation-filters": "sRGB", + filterUnits: "userSpaceOnUse", + }); + const offsets = Array.from({ length: COPIES }, (_, k) => + node(filter, "feOffset", { in: "SourceGraphic", result: `s${k}` }), + ); + let level = offsets.map((_, k) => `s${k}`); + for (let depth = 0; level.length > 1; depth++) { + const next: string[] = []; + for (let i = 0; i < level.length; i += 2) { + const result = `l${depth}-${i / 2}`; + node(filter, "feComposite", { + operator: "arithmetic", + in: level[i] ?? "", + in2: level[i + 1] ?? "", + k1: "0", + k2: "0.5", + k3: "0.5", + k4: "0", + result, + }); + next.push(result); + } + level = next; + } + const soften = node(filter, "feGaussianBlur", { in: level[0] ?? "", stdDeviation: "0" }); + const kept = el.style.filter; + el.style.filter = ""; + const css = getComputedStyle(el).filter; + el.style.filter = kept; + return { filter, offsets, soften, url: `url(#${id})`, css: css && css !== "none" ? css : "" }; +} + +function aim(layer: Layer, plan: FilterPlan): void { + plan.offsets.forEach(([dx, dy], k) => { + layer.offsets[k]?.setAttribute("dx", dx.toFixed(2)); + layer.offsets[k]?.setAttribute("dy", dy.toFixed(2)); + }); + layer.soften.setAttribute( + "stdDeviation", + `${plan.sigma[0].toFixed(3)} ${plan.sigma[1].toFixed(3)}`, + ); + const { x, y, w, h } = plan.region; + layer.filter.setAttribute("x", x.toFixed(1)); + layer.filter.setAttribute("y", y.toFixed(1)); + layer.filter.setAttribute("width", w.toFixed(1)); + layer.filter.setAttribute("height", h.toFixed(1)); +} + +const strip = (el: HTMLElement): string => el.style.filter.replace(OWN, "").trim(); + +export function createFilterTier( + host: CaptureMotionBlurHost, + config: Config, + why: { code: string; reason: string } | null, +): Tier { + const { root } = host; + const width = Number(root.getAttribute("data-width")) || root.offsetWidth; + const height = Number(root.getAttribute("data-height")) || root.offsetHeight; + const seek = (t: number): void => host.seek(t, !config.events); + let defs: SVGSVGElement | null = null; + let movers: HTMLElement[] = []; + let pads = new Map(); + let reader: ReturnType | null = null; + let scanned: unknown; + let scanMs = 0; + let frames = 0; + let totalMs = 0; + let rendering = false; + let suspended = false; + const layers = new Map(); + const styled = new Set(); + + function discover(restoreTo: number): void { + scanned = host.timeline(); + const s0 = performance.now(); + const found = discoverMovers({ + scene: root, + durationSeconds: host.durationSeconds(), + fps: host.fps, + seek, + }); + seek(restoreTo); + movers = found.movers; + pads = found.pads; + reader = createPoseReader(root, found.dynamic); + scanMs = Math.round(performance.now() - s0); + } + + function drop(el: HTMLElement): void { + const rest = strip(el); + if (rest) el.style.filter = rest; + else el.style.removeProperty("filter"); + styled.delete(el); + } + + function clear(): void { + for (const el of [...styled]) drop(el); + } + + function layerOf(el: HTMLElement): Layer { + let layer = layers.get(el); + if (layer) return layer; + if (!defs) { + defs = document.createElementNS(NS, "svg"); + defs.setAttribute("width", "0"); + defs.setAttribute("height", "0"); + defs.setAttribute("aria-hidden", "true"); + defs.setAttribute("data-hf-ignore", ""); + defs.style.cssText = "position:absolute;left:0;top:0;pointer-events:none"; + node(defs, "defs", {}); + root.append(defs); + } + layer = buildLayer(defs.querySelector("defs") ?? defs, layers.size, el); + layers.set(el, layer); + return layer; + } + + function show(el: HTMLElement, plan: FilterPlan): void { + const layer = layerOf(el); + aim(layer, plan); + const base = strip(el) || layer.css; + el.style.filter = base ? `${base} ${layer.url}` : layer.url; + styled.add(el); + } + + /** What each mover needs this frame; none for one that is still. */ + function plansAt(t: number, engineMode: boolean): Array<[HTMLElement, FilterPlan]> { + if (!reader || host.timeline() !== scanned) discover(t); + if (!reader) return []; + const read = readMoverFrames({ + movers, + reader, + seek, + t, + reads: engineMode ? RENDER_READS : PREVIEW_READS, + shutterSeconds: config.shutterDeg / 360 / host.fps, + taps: COPIES, + frameW: width, + frameH: height, + }); + const plans: Array<[HTMLElement, FilterPlan]> = []; + read.els.forEach((el, i) => { + const frame = read.frames[i]; + const plan = frame && planFilter(frame, pads.get(el) ?? 0, width); + if (plan) plans.push([el, plan]); + }); + return plans; + } + + function paint(t: number, engineMode: boolean): Promise { + if (engineMode) rendering = true; + if (suspended) return Promise.resolve(); + const t0 = performance.now(); + try { + const plans = plansAt(t, engineMode); + const live = new Set(plans.map(([el]) => el)); + for (const [el, plan] of plans) show(el, plan); + for (const el of [...styled]) if (!live.has(el)) drop(el); + frames++; + totalMs += performance.now() - t0; + } catch (err) { + swallow("runtime.captureMotionBlur.filter", err); + clear(); + } + return Promise.resolve(); + } + + function report(): Report { + return { + state: "on", + tier: "filter", + reason: why?.reason ?? null, + reasonCode: why?.code ?? null, + movers: movers.length, + moverNames: movers.map((el) => `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ""}`), + scanMs, + shutterDeg: config.shutterDeg, + frames, + meanMs: frames ? Math.round((totalMs / frames) * 10) / 10 : 0, + failures: 0, + failedFrames: 0, + }; + } + + function teardown(): void { + clear(); + defs?.remove(); + defs = null; + layers.clear(); + } + + return { + root, + paint, + probe: () => { + if (!reader || host.timeline() !== scanned) { + discover(host.now()); + } + return report(); + }, + rest: () => { + if (rendering) return false; + clear(); + return true; + }, + arm: () => {}, + finish: () => {}, + teardown, + suspend: () => { + suspended = true; + teardown(); + }, + report, + busy: () => false, + rediscover: () => { + reader = null; + }, + }; +} diff --git a/packages/core/src/runtime/captureMotionBlur/frames.ts b/packages/core/src/runtime/captureMotionBlur/frames.ts new file mode 100644 index 00000000000..624c48a3ea2 --- /dev/null +++ b/packages/core/src/runtime/captureMotionBlur/frames.ts @@ -0,0 +1,63 @@ +/** Where each mover is across the shutter: its pose read at several times, then tapped. */ + +import type { MoverFrame } from "./composite"; +import { + type Affine, + type createPoseReader, + IDENTITY, + autoTaps, + tapsFromReads, + translate, +} from "./pose"; + +export interface FrameRequest { + movers: HTMLElement[]; + reader: ReturnType; + seek: (t: number) => void; + t: number; + /** Pose reads across the shutter; the taps between them are interpolated. */ + reads: number; + shutterSeconds: number; + /** A fixed tap count, or null to size the taps to each smear. */ + taps: number | null; + /** Px each mover's box grows by on every side (its glow); none for a mover not listed. */ + pads?: ReadonlyMap; + frameW: number; + frameH: number; +} + +export function readMoverFrames(req: FrameRequest): { els: HTMLElement[]; frames: MoverFrame[] } { + const { reader: r, shutterSeconds: shutter, t, reads, seek } = req; + const visible = req.movers.filter( + (el) => + typeof el.checkVisibility !== "function" || el.checkVisibility({ visibilityProperty: true }), + ); + if (visible.length === 0 || shutter <= 0) return { els: [], frames: [] }; + const start = t - shutter / 2; + const poses = visible.map((): Affine[] => []); + for (let j = 0; j < reads; j++) { + seek(Math.max(0, start + (j / (reads - 1)) * shutter)); + r.reset(); + visible.forEach((el, i) => poses[i]?.push(r.pose(el))); + } + seek(t); + r.reset(); + const frames = visible.map((el, i) => { + const pad = req.pads?.get(el) ?? 0; + const w = el.offsetWidth + 2 * pad; + const h = el.offsetHeight + 2 * pad; + const grow = (m: Affine): Affine => translate(m, -pad, -pad); + const path = (poses[i] ?? [IDENTITY]).map(grow); + return { + w, + h, + now: grow(r.pose(el)), + taps: + req.taps === null + ? autoTaps(path, w, h, req.frameW, req.frameH) + : tapsFromReads(path, req.taps), + }; + }); + r.reset(); + return { els: visible, frames }; +} diff --git a/packages/core/src/runtime/captureMotionBlur/index.test.ts b/packages/core/src/runtime/captureMotionBlur/index.test.ts index e1fa84823e0..80b25ec8af7 100644 --- a/packages/core/src/runtime/captureMotionBlur/index.test.ts +++ b/packages/core/src/runtime/captureMotionBlur/index.test.ts @@ -6,6 +6,7 @@ import { paintCaptureMotionBlur, releaseCaptureMotionBlurFrame, resetCaptureMotionBlurForTests, + setFilterFallbackForTests, trackAcceleratedCanvases, } from "."; @@ -93,6 +94,7 @@ function host( const captureCanvas = (root: HTMLElement) => root.parentElement as HTMLCanvasElement; beforeEach(() => { + setFilterFallbackForTests(false); contexts = []; vi.spyOn(console, "info").mockImplementation(() => {}); vi.spyOn(console, "warn").mockImplementation(() => {}); @@ -100,6 +102,7 @@ beforeEach(() => { afterEach(() => { resetCaptureMotionBlurForTests(); + setFilterFallbackForTests(true); vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.useRealTimers(); @@ -1582,3 +1585,114 @@ describe("paint timeouts", () => { expect(contexts[0]?.drawElementImage).toHaveBeenCalled(); }); }); + +describe("filter fallback", () => { + beforeEach(() => setFilterFallbackForTests(true)); + + /** A root whose opacity tweens late, which a scan sees and capture cannot draw. */ + function fadingRoot(fade: { on: boolean }) { + const { root, el, h } = movingHost(); + const slide = h.seek; + const seek = vi.fn((t: number, quiet: boolean) => { + slide(t, quiet); + root.style.opacity = fade.on && t > 0.5 ? "0.5" : "1"; + }); + return { root, el, h: { ...h, seek } }; + } + + it("draws with filters where html-in-canvas is missing, and says why", () => { + const { root, el, h } = movingHost(); + initCaptureMotionBlur(h); + expect(win.__hfMotionBlur?.report()).toMatchObject({ + state: "on", + tier: "filter", + reasonCode: "unsupported-browser", + }); + void paintCaptureMotionBlur(0.5, PLAY); + expect(el.style.filter).toMatch(/url\("?#hf-mb-0"?\)/); + expect(root.parentElement).toBe(document.body); + expect(document.querySelectorAll("feOffset")).toHaveLength(32); + expect(document.querySelectorAll("feComposite")).toHaveLength(31); + expect(document.querySelectorAll("feGaussianBlur")).toHaveLength(1); + }); + + it("stays off when the renderer turned blur off", () => { + win.__HF_PAGE_MOTION_BLUR_OFF__ = "hdr-render"; + const { el, h } = movingHost(); + initCaptureMotionBlur(h); + expect(win.__hfMotionBlur?.report()).toMatchObject({ + state: "off", + reasonCode: "renderer-off", + }); + void paintCaptureMotionBlur(0.5, PLAY); + expect(el.style.filter).toBe(""); + }); + + it("takes over from capture when a scan finds a defect, keeping the reason", () => { + installHtmlInCanvas(); + const { root, el, h } = fadingRoot({ on: true }); + initCaptureMotionBlur(h); + void paintCaptureMotionBlur(0.2, { engineMode: true }); + expect(win.__hfMotionBlur?.report()).toMatchObject({ + state: "on", + tier: "filter", + reasonCode: "root-opacity", + }); + expect(root.parentElement).toBe(document.body); + expect(el.style.filter).toMatch(/url\("?#hf-mb-0"?\)/); + }); + + it("falls back after repeated capture failures in preview", async () => { + installHtmlInCanvas(); + const { root, el, h } = movingHost(); + initCaptureMotionBlur(h); + for (let i = 0; i < 3; i++) { + const done = paintCaptureMotionBlur(i / 30, PLAY); + captureContext()?.drawElementImage.mockImplementation(() => { + throw new Error("No cached paint record"); + }); + captureCanvas(root).dispatchEvent(new Event("paint")); + await done; + } + void paintCaptureMotionBlur(0.5, PLAY); + expect(win.__hfMotionBlur?.report()).toMatchObject({ + tier: "filter", + reasonCode: "capture-failures", + }); + expect(el.style.filter).toMatch(/url\("?#hf-mb-\d+"?\)/); + }); + + it("tries capture again when the scene is initialised after the cause is gone", () => { + installHtmlInCanvas(); + const fade = { on: true }; + const { root, h } = fadingRoot(fade); + initCaptureMotionBlur(h); + void paintCaptureMotionBlur(0.2, { engineMode: true }); + expect(win.__hfMotionBlur?.report().tier).toBe("filter"); + fade.on = false; + initCaptureMotionBlur(h); + void paintCaptureMotionBlur(0.2, { engineMode: true }); + expect(win.__hfMotionBlur?.report()).toMatchObject({ tier: "capture", reasonCode: null }); + expect(root.parentElement).toBe(captureCanvas(root)); + }); + + it("keeps the mover's own filter under the smear, and puts it back when paused", () => { + const { el, h } = movingHost(); + el.style.filter = "drop-shadow(0px 0px 4px red)"; + initCaptureMotionBlur(h); + void paintCaptureMotionBlur(0.5, PLAY); + expect(el.style.filter).toContain("drop-shadow"); + expect(el.style.filter).toMatch(/url\("?#hf-mb-0"?\)/); + expect(paintCaptureMotionBlur(0.5)).toBeNull(); + expect(el.style.filter).toBe("drop-shadow(0px 0px 4px red)"); + }); + + it("removes its filters and its SVG on teardown", () => { + const { el, h } = movingHost(); + initCaptureMotionBlur(h); + void paintCaptureMotionBlur(0.5, PLAY); + resetCaptureMotionBlurForTests(); + expect(el.style.filter).toBe(""); + expect(document.querySelectorAll("filter")).toHaveLength(0); + }); +}); diff --git a/packages/core/src/runtime/captureMotionBlur/index.ts b/packages/core/src/runtime/captureMotionBlur/index.ts index 0dbe5c4cacf..edb8fa4e9b7 100644 --- a/packages/core/src/runtime/captureMotionBlur/index.ts +++ b/packages/core/src/runtime/captureMotionBlur/index.ts @@ -36,48 +36,12 @@ import { import { swallow } from "../diagnostics"; import { createCompositor, type MoverFrame } from "./composite"; import { discoverMovers } from "./discover"; -import { - type Affine, - autoTaps, - createPoseReader, - IDENTITY, - tapsFromReads, - translate, -} from "./pose"; - -export interface CaptureMotionBlurHost { - root: HTMLElement; - fps: number; - durationSeconds: () => number; - /** The bound root timeline; movers are re-scanned when it changes. */ - timeline: () => unknown; - now: () => number; - /** Seek the timeline and adapters (not media) for a pose read. */ - seek: (t: number, suppressEvents: boolean) => void; - ignoreMutations?: (write: () => void) => void; -} - -interface Config { - shutterDeg: number; - samples: number | null; - events: boolean; -} +import { createFilterTier } from "./filterTier"; +import { readMoverFrames } from "./frames"; +import { createPoseReader } from "./pose"; +import type { CaptureMotionBlurHost, Config, Report, Tier } from "./tier"; -interface Report { - state: "on" | "off"; - reason: string | null; - reasonCode: string | null; - movers: number; - /** The blurred elements, as `tag#id.class`, for diagnosing what was (not) picked. */ - moverNames: string[]; - /** One-time cost of finding the movers, in ms. */ - scanMs: number; - shutterDeg: number; - frames: number; - meanMs: number; - failures: number; - failedFrames: number; -} +export type { CaptureMotionBlurHost }; type DrawElementContext = CanvasRenderingContext2D & { drawElementImage: (el: Element, x: number, y: number) => unknown; @@ -299,43 +263,19 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI } function frames(t: number, reads: number): { els: HTMLElement[]; frames: MoverFrame[] } { - const r = reader; - if (!r) return { els: [], frames: [] }; - const visible = movers.filter( - (el) => - typeof el.checkVisibility !== "function" || - el.checkVisibility({ visibilityProperty: true }), - ); - const shutter = config.shutterDeg / 360 / host.fps; - if (visible.length === 0 || shutter <= 0) return { els: [], frames: [] }; - const start = t - shutter / 2; - const poses = visible.map((): Affine[] => []); - for (let j = 0; j < reads; j++) { - seek(Math.max(0, start + (j / (reads - 1)) * shutter)); - r.reset(); - visible.forEach((el, i) => poses[i]?.push(r.pose(el))); - } - seek(t); - r.reset(); - const result = visible.map((el, i) => { - // The mover's ink box: its own box grown by the glow and shadow that paint outside it. - const pad = pads.get(el) ?? 0; - const w = el.offsetWidth + 2 * pad; - const h = el.offsetHeight + 2 * pad; - const grow = (m: Affine): Affine => translate(m, -pad, -pad); - const reads = (poses[i] ?? [IDENTITY]).map(grow); - return { - w, - h, - now: grow(r.pose(el)), - taps: - config.samples === null - ? autoTaps(reads, w, h, width, height) - : tapsFromReads(reads, config.samples), - }; + if (!reader) return { els: [], frames: [] }; + return readMoverFrames({ + movers, + reader, + seek, + t, + reads, + shutterSeconds: config.shutterDeg / 360 / host.fps, + taps: config.samples, + pads, + frameW: width, + frameH: height, }); - r.reset(); - return { els: visible, frames: result }; } const capture = (): void => { @@ -592,6 +532,7 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI const mean = timings.length ? timings.reduce((s, x) => s + x, 0) / timings.length : 0; return { state: disabled ? "off" : "on", + tier: disabled ? null : "capture", reason: disabled, reasonCode: disabledCode, movers: movers.length, @@ -634,6 +575,9 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI }, teardown, suspend: unwrap, + /** Why capture turned itself off (a scan defect, repeated capture failures), if it did. */ + defect: (): CaptureDefect | null => + disabled ? { code: disabledCode ?? "capture-failures", reason: disabled } : null, report, busy: (): boolean => pending !== null, rediscover: () => { @@ -643,8 +587,13 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI }; } -type Instance = ReturnType; -let instance: Instance | null = null; +type Active = Tier & { defect?: () => CaptureDefect | null }; +let instance: Active | null = null; +let activeHost: CaptureMotionBlurHost | null = null; +let activeConfig: Config | null = null; +/** The filter tier stands in for capture; a scene re-init tries capture again. */ +let onFallback = false; +let fallbackEnabled = true; let suspended = false; let priorResolver: (() => boolean) | null = null; let ourResolver: (() => boolean) | null = null; @@ -662,40 +611,91 @@ export function initCaptureMotionBlur(host: CaptureMotionBlurHost): void { delete w.__hfMotionBlur; } if (!config) return; + if (instance && onFallback) { + instance.teardown(); + instance = null; + onFallback = false; + } if (instance) { instance.rediscover(); return; } + activeHost = host; + activeConfig = config; const form = htmlInCanvasForm(); const why = unsupported(host.root); if (!form || why) { - const reason = why?.reason ?? null; - console.info(`[motion-blur] off: ${reason} (Chrome ${chromeMajor() ?? "unknown"})`); - const report = (): Report => ({ - state: "off", - reason, - reasonCode: why?.code ?? null, - movers: 0, - moverNames: [], - scanMs: 0, - shutterDeg: config.shutterDeg, - frames: 0, - meanMs: 0, - failures: 0, - failedFrames: 0, - }); - w.__hfMotionBlur = { report, probe: report, suspend: suspendCaptureMotionBlur }; - return; + if (fallbackEnabled && why?.code !== "renderer-off") startFallback(host, config, why); + else standDown(w, config, why); + } else { + instance = createInstance(host, config, form); } - instance = createInstance(host, config, form); - const current = instance; + bindApi(w, config); +} + +function startFallback( + host: CaptureMotionBlurHost, + config: Config, + why: CaptureDefect | null, +): void { + const reason = why ?? { + code: "unsupported-browser", + reason: "html-in-canvas (drawElementImage) is unavailable in this browser", + }; + instance = createFilterTier(host, config, reason); + onFallback = true; + console.info( + `[motion-blur] filter fallback: ${reason.reason} (Chrome ${chromeMajor() ?? "unknown"})`, + ); +} + +/** Blur is off for good on this page (the renderer said so): report why, draw nothing. */ +function standDown(w: CompositeWindow, config: Config, why: CaptureDefect | null): void { + console.info(`[motion-blur] off: ${why?.reason ?? null} (Chrome ${chromeMajor() ?? "unknown"})`); + const report = (): Report => offReport(config, why); + w.__hfMotionBlur = { report, probe: report, suspend: suspendCaptureMotionBlur }; +} + +function offReport(config: Config, why: CaptureDefect | null): Report { + return { + state: "off", + tier: null, + reason: why?.reason ?? null, + reasonCode: why?.code ?? null, + movers: 0, + moverNames: [], + scanMs: 0, + shutterDeg: config.shutterDeg, + frames: 0, + meanMs: 0, + failures: 0, + failedFrames: 0, + }; +} + +function bindApi(w: CompositeWindow, config: Config): void { + if (!instance) return; w.__hfMotionBlur = { - report: () => current.report(), - probe: () => current.probe(), + report: () => instance?.report() ?? offReport(config, null), + probe: () => { + instance?.probe(); + settleTier(); + return instance?.probe() ?? offReport(config, null); + }, suspend: suspendCaptureMotionBlur, }; } +/** A scan that found a defect turned capture off for this timeline: draw with filters instead. */ +function settleTier(): void { + const defect = instance?.defect?.(); + if (!fallbackEnabled || !instance || !defect || !activeHost || !activeConfig) return; + instance.teardown(); + instance = createFilterTier(activeHost, activeConfig, defect); + onFallback = true; + console.info(`[motion-blur] filter fallback: ${defect.reason}`); +} + /** * Draw the blurred frame for `t` after a seek or a playback tick. In preview only playback blurs: * a paused request (no `playing`) unwraps the composition instead and returns null. In engine @@ -710,7 +710,12 @@ export function paintCaptureMotionBlur( if (!instance || suspended) return null; const engineMode = options?.engineMode === true; if (!engineMode && !options?.playing && instance.rest()) return null; - const done = instance.paint(t, engineMode); + let done = instance.paint(t, engineMode); + if (instance.defect?.()) { + settleTier(); + if (!engineMode && !options?.playing && instance.rest()) return null; + done = instance.paint(t, engineMode); + } // Nothing in flight (nothing moves, or blur turned off): leave the engine's composite step idle. if (engineMode && instance.busy()) armPageComposite(); return done; @@ -754,7 +759,15 @@ function suspendCaptureMotionBlur(): void { export function resetCaptureMotionBlurForTests(): void { instance?.teardown(); instance = null; + activeHost = null; + activeConfig = null; + onFallback = false; suspended = false; priorResolver = null; ourResolver = null; } + +/** Test-only: judge the capture tier alone, without the filter fallback taking over. */ +export function setFilterFallbackForTests(on: boolean): void { + fallbackEnabled = on; +} diff --git a/packages/core/src/runtime/captureMotionBlur/tier.ts b/packages/core/src/runtime/captureMotionBlur/tier.ts new file mode 100644 index 00000000000..791d239e226 --- /dev/null +++ b/packages/core/src/runtime/captureMotionBlur/tier.ts @@ -0,0 +1,50 @@ +/** What the runtime drives: capture blur or the filter fallback, the same way. */ + +export interface CaptureMotionBlurHost { + root: HTMLElement; + fps: number; + durationSeconds: () => number; + /** The bound root timeline; movers are re-scanned when it changes. */ + timeline: () => unknown; + now: () => number; + /** Seek the timeline and adapters (not media) for a pose read. */ + seek: (t: number, suppressEvents: boolean) => void; + ignoreMutations?: (write: () => void) => void; +} + +export interface Config { + shutterDeg: number; + samples: number | null; + events: boolean; +} + +export interface Report { + state: "on" | "off"; + tier: "capture" | "filter" | null; + /** Why blur is off, or why the fallback is in use. */ + reason: string | null; + reasonCode: string | null; + movers: number; + /** The blurred elements, as `tag#id.class`, for diagnosing what was (not) picked. */ + moverNames: string[]; + scanMs: number; + shutterDeg: number; + frames: number; + meanMs: number; + failures: number; + failedFrames: number; +} + +export interface Tier { + root: HTMLElement; + paint: (t: number, engineMode: boolean) => Promise; + probe: () => Report; + rest: () => boolean; + arm: () => void; + finish: () => void; + teardown: () => void; + suspend: () => void; + report: () => Report; + busy: () => boolean; + rediscover: () => void; +} diff --git a/scripts/package-size-budgets.json b/scripts/package-size-budgets.json index 60136d0215d..a152445cee9 100644 --- a/scripts/package-size-budgets.json +++ b/scripts/package-size-budgets.json @@ -8,8 +8,8 @@ "unpacked": 23722874 }, "@hyperframes/core": { - "packed": 1636442, - "unpacked": 3950000 + "packed": 1645000, + "unpacked": 3965000 }, "@hyperframes/engine": { "packed": 428347, From 73cb91d75cbe85d93673a208bded2b84caf9970f Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Fri, 23 Oct 2026 12:33:16 -0700 Subject: [PATCH 2/2] test(engine): the backface pages now take the filter tier The two backface tests still expected blur off; with the filter tier a backface page reports tier filter. Fails on CI without this. Co-Authored-By: Claude Sonnet 5.5 --- .../src/services/captureMotionBlur.pixels.browser.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/engine/src/services/captureMotionBlur.pixels.browser.test.ts b/packages/engine/src/services/captureMotionBlur.pixels.browser.test.ts index 413b14b392d..8fd8a9f9021 100644 --- a/packages/engine/src/services/captureMotionBlur.pixels.browser.test.ts +++ b/packages/engine/src/services/captureMotionBlur.pixels.browser.test.ts @@ -266,7 +266,7 @@ describe.skipIf(!hasBrowser)("capture motion blur pixels in Chrome", () => { if (!supported) ctx.skip(); const blurred = await frameOf(FLIP, true, 0.4); const sharp = await frameOf(FLIP, false, 0.4); - expect(blurred.report).toMatchObject({ state: "off", reasonCode: "backface-visibility" }); + expect(blurred.report).toMatchObject({ tier: "filter", reasonCode: "backface-visibility" }); expect(Buffer.from(blurred.frame.data).equals(Buffer.from(sharp.frame.data))).toBe(true); }); @@ -275,7 +275,7 @@ describe.skipIf(!hasBrowser)("capture motion blur pixels in Chrome", () => { const sharp = await frameOf(BOX + card(""), false, 0.4); for (const body of [BOX + card("animation: hide 1s both"), BOX + card("") + WAAPI]) { const blurred = await frameOf(body, true, 0.4); - expect(blurred.report).toMatchObject({ state: "off", reasonCode: "backface-visibility" }); + expect(blurred.report).toMatchObject({ tier: "filter", reasonCode: "backface-visibility" }); expect(same(blurred.frame, sharp.frame)).toBe(true); } });