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/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); } }); 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,