From 4ec7e09577c7c8ee8f13a946710514cd7d80e871 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 22 Oct 2026 19:17:39 -0700 Subject: [PATCH 1/6] feat: check names the filter fallback, the render log names the tier, Studio stops opting out hyperframes check reports motion_blur_filter_fallback (info) with the reason capture could not run and the way back to it. The engine's per-session log line gains tier=, and its pixel test covers the fallback over a WebGL page. Studio no longer sends previewMotionBlur: every Chrome can preview blur now, through the fallback where it lacks html-in-canvas; the server still accepts false from an older Studio. Co-Authored-By: Claude Sonnet 5.5 --- .../src/utils/checkMotionBlur.page.test.ts | 8 ++- .../cli/src/utils/checkMotionBlur.test.ts | 39 +++++++++--- packages/cli/src/utils/checkMotionBlur.ts | 18 +++++- .../captureMotionBlur.pixels.browser.test.ts | 33 +++++++++- .../src/services/pageMotionBlur.test.ts | 16 +++++ .../engine/src/services/pageMotionBlur.ts | 5 +- .../src/components/renders/useRenderQueue.ts | 8 --- .../renders/useRenderQueueMotionBlur.test.tsx | 61 ------------------- 8 files changed, 105 insertions(+), 83 deletions(-) delete mode 100644 packages/studio/src/components/renders/useRenderQueueMotionBlur.test.tsx diff --git a/packages/cli/src/utils/checkMotionBlur.page.test.ts b/packages/cli/src/utils/checkMotionBlur.page.test.ts index 1800d4926d0..ad1aad78b39 100644 --- a/packages/cli/src/utils/checkMotionBlur.page.test.ts +++ b/packages/cli/src/utils/checkMotionBlur.page.test.ts @@ -2,7 +2,13 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { readMotionBlurProbe } from "./checkMotionBlur.js"; -const report = { state: "off", reason: "why", reasonCode: "root-transform", movers: 3 }; +const report = { + state: "off", + tier: "filter", + reason: "why", + reasonCode: "root-transform", + movers: 3, +}; function page(html: string, withApi = true, order: string[] = []) { document.body.innerHTML = html; diff --git a/packages/cli/src/utils/checkMotionBlur.test.ts b/packages/cli/src/utils/checkMotionBlur.test.ts index 3c69184f696..15a3635cbdc 100644 --- a/packages/cli/src/utils/checkMotionBlur.test.ts +++ b/packages/cli/src/utils/checkMotionBlur.test.ts @@ -7,6 +7,7 @@ import { const off = (reasonCode: string | null, reason = "because"): MotionBlurProbe => ({ state: "off", + tier: null, reason, reasonCode, movers: 0, @@ -15,12 +16,12 @@ const off = (reasonCode: string | null, reason = "because"): MotionBlurProbe => describe("motionBlurDrafts", () => { it("says nothing without an opt-in to read, or when blur works", () => { expect(motionBlurDrafts(null)).toEqual([]); - expect(motionBlurDrafts({ state: "on", reason: null, reasonCode: null, movers: 2 })).toEqual( - [], - ); - expect(motionBlurDrafts({ state: "on", reason: null, reasonCode: null, movers: 1 })).toEqual( - [], - ); + expect( + motionBlurDrafts({ state: "on", tier: "capture", reason: null, reasonCode: null, movers: 2 }), + ).toEqual([]); + expect( + motionBlurDrafts({ state: "on", tier: "capture", reason: null, reasonCode: null, movers: 1 }), + ).toEqual([]); }); it.each([ @@ -46,16 +47,38 @@ describe("motionBlurDrafts", () => { expect(finding?.fixHint).toMatch(hint); }); + it("informs, with the reason and a way back to capture, when the filter fallback draws it", () => { + const filter = (reasonCode: string, movers = 2): MotionBlurProbe => ({ + state: "on", + tier: "filter", + reason: "a WebGL canvas is in the page", + reasonCode, + movers, + }); + const [finding, ...rest] = motionBlurDrafts(filter("accelerated-canvas")); + expect(rest).toEqual([]); + expect(finding).toMatchObject({ + code: "motion_blur_filter_fallback", + severity: "info", + message: expect.stringContaining("a WebGL canvas is in the page"), + fixHint: expect.stringContaining("only softens zoom and rotation"), + }); + expect(finding?.fixHint).toContain("render that effect separately"); + expect(motionBlurDrafts(filter("accelerated-canvas", 0))[0]?.code).toBe( + "motion_blur_no_movers", + ); + }); + it("only informs, once, when the browser cannot run html-in-canvas", () => { const drafts = motionBlurDrafts(off("unsupported-browser", "html-in-canvas is unavailable")); expect(drafts).toHaveLength(1); expect(drafts[0]).toMatchObject({ code: "motion_blur_unverified", severity: "info" }); - expect(drafts[0]?.message).toContain("could not be evaluated in this browser"); + expect(drafts[0]?.message).toContain("drawn with the filter fallback"); }); it("informs when blur is on but nothing moves", () => { expect( - motionBlurDrafts({ state: "on", reason: null, reasonCode: null, movers: 0 }), + motionBlurDrafts({ state: "on", tier: "capture", reason: null, reasonCode: null, movers: 0 }), ).toMatchObject([ { code: "motion_blur_no_movers", diff --git a/packages/cli/src/utils/checkMotionBlur.ts b/packages/cli/src/utils/checkMotionBlur.ts index 9f45473da72..efb82183caa 100644 --- a/packages/cli/src/utils/checkMotionBlur.ts +++ b/packages/cli/src/utils/checkMotionBlur.ts @@ -4,6 +4,7 @@ import type { CheckSeverity } from "./checkTypes.js"; /** What the core runtime's `__hfMotionBlur.probe()` reports for a root that asked for blur. */ export interface MotionBlurProbe { state: "on" | "off"; + tier: "capture" | "filter" | null; reason: string | null; reasonCode: string | null; movers: number; @@ -43,9 +44,9 @@ export function motionBlurDrafts(probe: MotionBlurProbe | null): MotionBlurDraft { code: "motion_blur_unverified", severity: "info", - message: `${ASKED} blur could not be evaluated in this browser`, + message: `${ASKED} the check browser cannot run capture blur, so it was drawn with the filter fallback`, fixHint: - "the check browser has no html-in-canvas (drawElementImage); render once to see whether blur applies", + "the check browser has no html-in-canvas (drawElementImage); render once to see how capture blur looks", time: 0, }, ]; @@ -61,6 +62,17 @@ export function motionBlurDrafts(probe: MotionBlurProbe | null): MotionBlurDraft }, ]; } + if (probe.tier === "filter" && probe.movers > 0) { + return [ + { + code: "motion_blur_filter_fallback", + severity: "info", + message: `data-motion-blur="on" is drawn by the filter fallback: ${probe.reason ?? "capture blur cannot run here"}`, + fixHint: `the fallback smears movement but only softens zoom and rotation; ${OFF_HINTS[probe.reasonCode ?? ""] ?? GENERIC_OFF_HINT}`, + time: 0, + }, + ]; + } if (probe.movers === 0) { return [ { @@ -88,10 +100,12 @@ export function readMotionBlurProbe(): MotionBlurProbe | null { const reason: unknown = Reflect.get(report, "reason"); const code: unknown = Reflect.get(report, "reasonCode"); const movers: unknown = Reflect.get(report, "movers"); + const tier: unknown = Reflect.get(report, "tier"); const suspend: unknown = Reflect.get(api, "suspend"); if (typeof suspend === "function") Reflect.apply(suspend, api, []); return { state: Reflect.get(report, "state") === "off" ? "off" : "on", + tier: tier === "capture" || tier === "filter" ? tier : null, reason: typeof reason === "string" ? reason : null, reasonCode: typeof code === "string" ? code : null, movers: typeof movers === "number" ? movers : 0, diff --git a/packages/engine/src/services/captureMotionBlur.pixels.browser.test.ts b/packages/engine/src/services/captureMotionBlur.pixels.browser.test.ts index 8fd8a9f9021..5217d429262 100644 --- a/packages/engine/src/services/captureMotionBlur.pixels.browser.test.ts +++ b/packages/engine/src/services/captureMotionBlur.pixels.browser.test.ts @@ -6,6 +6,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { decodePng } from "../utils/alphaBlit.js"; import { resolveHeadlessShellPath } from "./browserManager.js"; import { closeTestChrome } from "./__fixtures__/loopback.js"; +import { acceleratedCanvasTrackerSource } from "@hyperframes/core"; import { waitForPendingSeekCompletion } from "./frameCapture.js"; const RUNTIME = resolve(import.meta.dirname, "../../../core/dist/hyperframe.runtime.iife.js"); @@ -30,7 +31,9 @@ type Win = Window & { __hf_page_composite_pending?: boolean; __hf_page_composite_prepare?: () => Promise; __hf_page_composite_resolve?: () => boolean; - __hfMotionBlur?: { report: () => { state: string; reasonCode: string | null; movers: number } }; + __hfMotionBlur?: { + report: () => { state: string; tier: string | null; reasonCode: string | null; movers: number }; + }; }; /** 40 px box moving 60 px per frame (1800 px over the 1 s composition). */ @@ -104,6 +107,7 @@ describe.skipIf(!hasBrowser)("capture motion blur pixels in Chrome", () => { async function open(body: string, blur: boolean): Promise { const page = await browser.newPage(); await page.setViewport({ width: W, height: H, deviceScaleFactor: 1 }); + await page.evaluate(acceleratedCanvasTrackerSource()); await page.setContent(composition(body, blur)); await page.addScriptTag({ content: `window.__HF_EXPORT_RENDER_SEEK_CONFIG={fps:${FPS}};` }); await page.addScriptTag({ content: readFileSync(RUNTIME, "utf8") }); @@ -249,6 +253,24 @@ describe.skipIf(!hasBrowser)("capture motion blur pixels in Chrome", () => { expect(glow(blurred.frame)).toBeGreaterThan(glow(sharp.frame) * 0.5); }); + it("blurs with filters where capture cannot run, over a page that has a WebGL canvas", async (ctx) => { + if (!supported) ctx.skip(); + const gl = ``; + const blurred = await frameOf(gl + BOX, true, FRAME); + const sharp = await frameOf(gl + BOX, false, FRAME); + expect(blurred.report).toMatchObject({ + state: "on", + tier: "filter", + reasonCode: "accelerated-canvas", + movers: 1, + }); + const trail = row(blurred.frame, 40); + expect(intermediate(trail).length).toBeGreaterThanOrEqual(40); + expect(Math.abs(centroid(trail) - centroid(row(sharp.frame, 40)))).toBeLessThan(2); + expect(ink(trail) / ink(row(sharp.frame, 40))).toBeGreaterThan(0.9); + expect(ink(trail) / ink(row(sharp.frame, 40))).toBeLessThan(1.1); + }); + it("blurs a box centred with translate around where it really is", async (ctx) => { if (!supported) ctx.skip(); const blurred = await frameOf(CENTERED, true, 1 / FPS); @@ -276,7 +298,14 @@ describe.skipIf(!hasBrowser)("capture motion blur pixels in Chrome", () => { 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({ tier: "filter", reasonCode: "backface-visibility" }); - expect(same(blurred.frame, sharp.frame)).toBe(true); + // The 3D card is not a mover for either tier: it is drawn as the sharp frame has it. + let differing = 0; + for (let y = 40; y < 140; y++) { + for (let x = 100; x < 200; x++) { + if (px(blurred.frame, x, y).join() !== px(sharp.frame, x, y).join()) differing++; + } + } + expect(differing).toBe(0); } }); diff --git a/packages/engine/src/services/pageMotionBlur.test.ts b/packages/engine/src/services/pageMotionBlur.test.ts index 048c024ee3d..596a7385692 100644 --- a/packages/engine/src/services/pageMotionBlur.test.ts +++ b/packages/engine/src/services/pageMotionBlur.test.ts @@ -87,6 +87,22 @@ describe("describePageMotionBlur", () => { ); }); + it("names the tier that drew the blur, so a fallback is visible in the log", async () => { + vi.stubGlobal("window", { + __hfMotionBlur: { + report: () => ({ + state: "on", + tier: "filter", + reasonCode: "accelerated-canvas", + movers: 2, + }), + }, + }); + expect(await describePageMotionBlur(inPage)).toBe( + "[motion-blur] on reasonCode=accelerated-canvas movers=2 failedFrames=0 tier=filter", + ); + }); + it('carries the frames that fell back to plain DOM, so "on" cannot hide them', async () => { vi.stubGlobal("window", { __hfMotionBlur: { diff --git a/packages/engine/src/services/pageMotionBlur.ts b/packages/engine/src/services/pageMotionBlur.ts index 844f5f0a55d..d3255e2f807 100644 --- a/packages/engine/src/services/pageMotionBlur.ts +++ b/packages/engine/src/services/pageMotionBlur.ts @@ -51,6 +51,7 @@ interface PageMotionBlurState { code: string; movers: number; failedFrames: number; + tier: string | null; } async function readPageMotionBlur( @@ -61,17 +62,19 @@ async function readPageMotionBlur( const report: unknown = api && typeof api === "object" ? Reflect.get(api, "report") : null; const got: unknown = typeof report === "function" ? report() : null; if (!got || typeof got !== "object") return null; + const tier: unknown = Reflect.get(got, "tier"); return { state: String(Reflect.get(got, "state")), code: String(Reflect.get(got, "reasonCode")), movers: Number(Reflect.get(got, "movers")), failedFrames: Number(Reflect.get(got, "failedFrames")) || 0, + tier: typeof tier === "string" ? tier : null, }; }); } const formatPageMotionBlur = (r: PageMotionBlurState): string => - `[motion-blur] ${r.state} reasonCode=${r.code} movers=${r.movers} failedFrames=${r.failedFrames}`; + `[motion-blur] ${r.state} reasonCode=${r.code} movers=${r.movers} failedFrames=${r.failedFrames}${r.tier ? ` tier=${r.tier}` : ""}`; /** One line saying what the page's blur did for this session; null when the page never asked for blur. */ export async function describePageMotionBlur(page: Pick): Promise { diff --git a/packages/studio/src/components/renders/useRenderQueue.ts b/packages/studio/src/components/renders/useRenderQueue.ts index 73a7f10726a..860bd542ba3 100644 --- a/packages/studio/src/components/renders/useRenderQueue.ts +++ b/packages/studio/src/components/renders/useRenderQueue.ts @@ -1,4 +1,3 @@ -import { htmlInCanvasForm } from "@hyperframes/core/html-in-canvas"; import { buildProjectApiPath } from "../../utils/projectRouting"; import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import type { CanvasResolution } from "@hyperframes/parsers"; @@ -11,11 +10,6 @@ import { ffmpegInstallMessage, useFfmpegStatus } from "./useFfmpegStatus"; import { requestStudioFeedback, type FeedbackContext } from "../feedback/feedbackTrigger"; import { studioApiFetch } from "../../utils/studioApiFetch"; -// WYSIWYG: the preview iframe runs in this browser, so a render blurs only if this one can. -function canPreviewMotionBlur(): boolean { - return typeof document !== "undefined" && htmlInCanvasForm() !== null; -} - export interface RenderJob { id: string; status: "rendering" | "complete" | "failed" | "cancelled"; @@ -244,12 +238,10 @@ export function useRenderQueue( gpu?: boolean; telemetryDistinctId?: string; telemetryOptOut?: boolean; - previewMotionBlur: boolean; } = { fps, quality, format, - previewMotionBlur: canPreviewMotionBlur(), }; // The id is MINTED by getAnonymousId(), so calling it unconditionally // created a telemetry identity for a profile that had opted out — and diff --git a/packages/studio/src/components/renders/useRenderQueueMotionBlur.test.tsx b/packages/studio/src/components/renders/useRenderQueueMotionBlur.test.tsx deleted file mode 100644 index 743445625ad..00000000000 --- a/packages/studio/src/components/renders/useRenderQueueMotionBlur.test.tsx +++ /dev/null @@ -1,61 +0,0 @@ -// @vitest-environment happy-dom - -// WYSIWYG: a render only blurs what the Studio preview could show, and only the browser knows -// whether it has html-in-canvas. The request body is how it tells the server. - -import { afterEach, describe, expect, it, vi } from "vitest"; -import { startRenderAndReadBody, type MountedQueue } from "./renderQueueTestHarness"; - -vi.mock("../../telemetry/events", () => ({ trackStudioRenderStart: vi.fn() })); - -const { useRenderQueue } = await import("./useRenderQueue"); - -let queue: MountedQueue | null = null; - -async function startRenderBody(): Promise> { - const started = await startRenderAndReadBody(useRenderQueue, { - opts: { fps: 30, quality: "standard", format: "mp4" }, - }); - queue = started.queue; - return started.body; -} - -function stubHtmlInCanvas(members: string[]): void { - vi.stubGlobal( - "CanvasRenderingContext2D", - class { - drawElementImage(): void {} - }, - ); - const proto = HTMLCanvasElement.prototype as unknown as Record; - for (const name of ["requestPaint", ...members]) proto[name] = () => {}; -} - -afterEach(() => { - queue?.unmount(); - queue = null; - document.body.innerHTML = ""; - for (const name of ["requestPaint", "layoutSubtree", "updateElementGeometry"]) { - delete (HTMLCanvasElement.prototype as unknown as Record)[name]; - } - vi.unstubAllGlobals(); -}); - -describe("render request previewMotionBlur", () => { - it("is false in a browser without html-in-canvas", async () => { - expect((await startRenderBody())["previewMotionBlur"]).toBe(false); - }); - - it.each([ - ["the layoutsubtree form", "layoutSubtree"], - ["the content=drawable form", "updateElementGeometry"], - ])("is true in a browser with html-in-canvas in %s", async (_name, member) => { - stubHtmlInCanvas([member]); - expect((await startRenderBody())["previewMotionBlur"]).toBe(true); - }); - - it("is false when html-in-canvas has neither known form", async () => { - stubHtmlInCanvas([]); - expect((await startRenderBody())["previewMotionBlur"]).toBe(false); - }); -}); From f261413151ccde1afd81a6bf3610f9f1e050dc9e Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 22 Oct 2026 19:30:46 -0700 Subject: [PATCH 2/6] feat(core): blur picks the cheaper filter tier when every mover only translates The scan now notes whether any mover turns or scales (the linear part of its transform chain changes) and whether it already forms a stacking context while it moves. When every mover only translates, the filter tier draws as well as capture and costs about what no blur costs: on an 8 s bench shot a render took 7.0 s against 9.9 s with capture and 7.3 s with no blur. Anything that turns or scales, or moves without a transform, stays on capture. data-motion-blur-tier="capture" or "filter" forces a tier. The report says which one drew and why (translation-only, tier-attribute); hyperframes check stays quiet for those two, which are choices, not fallbacks. The pixel tests run the shared assertions through both tiers. Co-Authored-By: Claude Sonnet 5.5 --- .../cli/src/utils/checkMotionBlur.test.ts | 2 + packages/cli/src/utils/checkMotionBlur.ts | 3 +- .../captureMotionBlur/discover.test.ts | 96 +++++++++++- .../src/runtime/captureMotionBlur/discover.ts | 29 +++- .../runtime/captureMotionBlur/index.test.ts | 80 +++++++++- .../src/runtime/captureMotionBlur/index.ts | 67 ++++++-- .../runtime/captureMotionBlur/tier.test.ts | 34 ++++ .../src/runtime/captureMotionBlur/tier.ts | 20 +++ .../captureMotionBlur.pixels.browser.test.ts | 147 ++++++++++-------- skills-manifest.json | 2 +- 10 files changed, 395 insertions(+), 85 deletions(-) create mode 100644 packages/core/src/runtime/captureMotionBlur/tier.test.ts diff --git a/packages/cli/src/utils/checkMotionBlur.test.ts b/packages/cli/src/utils/checkMotionBlur.test.ts index 15a3635cbdc..13007588082 100644 --- a/packages/cli/src/utils/checkMotionBlur.test.ts +++ b/packages/cli/src/utils/checkMotionBlur.test.ts @@ -67,6 +67,8 @@ describe("motionBlurDrafts", () => { expect(motionBlurDrafts(filter("accelerated-canvas", 0))[0]?.code).toBe( "motion_blur_no_movers", ); + expect(motionBlurDrafts(filter("translation-only"))).toEqual([]); + expect(motionBlurDrafts(filter("tier-attribute"))).toEqual([]); }); it("only informs, once, when the browser cannot run html-in-canvas", () => { diff --git a/packages/cli/src/utils/checkMotionBlur.ts b/packages/cli/src/utils/checkMotionBlur.ts index efb82183caa..9177133d322 100644 --- a/packages/cli/src/utils/checkMotionBlur.ts +++ b/packages/cli/src/utils/checkMotionBlur.ts @@ -32,6 +32,7 @@ const OFF_HINTS: Record = { "nested-capture-canvas": "remove the inner / content=drawable, or drop motion blur", }; +const CHOSEN = new Set(["translation-only", "tier-attribute"]); const GENERIC_OFF_HINT = "remove what the reason names, or drop data-motion-blur"; const ASKED = 'data-motion-blur="on" is set but'; @@ -62,7 +63,7 @@ export function motionBlurDrafts(probe: MotionBlurProbe | null): MotionBlurDraft }, ]; } - if (probe.tier === "filter" && probe.movers > 0) { + if (probe.tier === "filter" && probe.movers > 0 && !CHOSEN.has(probe.reasonCode ?? "")) { return [ { code: "motion_blur_filter_fallback", diff --git a/packages/core/src/runtime/captureMotionBlur/discover.test.ts b/packages/core/src/runtime/captureMotionBlur/discover.test.ts index 323ef0ffcdd..348f401db73 100644 --- a/packages/core/src/runtime/captureMotionBlur/discover.test.ts +++ b/packages/core/src/runtime/captureMotionBlur/discover.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { discoverMovers } from "./discover"; // jsdom lays nothing out: each element's rect follows the x its fake timeline writes. @@ -303,3 +303,97 @@ describe("discoverMovers", () => { expect(discoverMovers({ scene, durationSeconds: 1, fps: 30, seek }).movers).toEqual([fast]); }); }); + +describe("discoverMovers translationOnly", () => { + // jsdom resolves no transforms: give computed style the inline matrix, as Chrome's would be. + beforeEach(() => { + const real = window.getComputedStyle.bind(window); + vi.spyOn(window, "getComputedStyle").mockImplementation((el: Element) => { + const cs = real(el); + return new Proxy(cs, { + get: (target, key) => { + if (key === "transform") + return el instanceof HTMLElement ? el.style.transform || "none" : "none"; + const value = Reflect.get(target, key); + return typeof value === "function" ? value.bind(target) : value; + }, + }); + }); + }); + afterEach(() => vi.restoreAllMocks()); + + // The box follows the matrix's x, or `left` when it has no transform. + function mover(parent: Element, size = 10): HTMLElement { + const el = document.createElement("div"); + parent.append(el); + el.getBoundingClientRect = () => { + const m = /matrix\(([^)]*)\)/.exec(el.style.transform); + const x = m ? Number(m[1]?.split(",")[4]) : parseFloat(el.style.left) || 0; + return { + left: x, + top: 0, + right: x + size, + bottom: size, + width: size, + height: size, + } as DOMRect; + }; + return el; + } + const scan = (scene: HTMLElement, seek: (t: number) => void) => { + seek(0); + return discoverMovers({ scene, durationSeconds: 1, fps: 30, seek }); + }; + + it("is true when movers only translate, with their own transform", () => { + const scene = document.createElement("div"); + const el = mover(scene); + const found = scan(scene, (t) => { + el.style.transform = `matrix(1, 0, 0, 1, ${300 * t}, 0)`; + }); + expect(found.movers).toEqual([el]); + expect(found.translationOnly).toBe(true); + }); + + it("is still true under a parent that scales by a constant", () => { + const scene = document.createElement("div"); + const wrapper = document.createElement("div"); + wrapper.style.transform = "matrix(1.5, 0, 0, 1.5, 0, 0)"; + scene.append(wrapper); + const el = mover(wrapper); + const found = scan(scene, (t) => { + el.style.transform = `matrix(1, 0, 0, 1, ${300 * t}, 0)`; + }); + expect(found.translationOnly).toBe(true); + }); + + it("is false when a mover turns or scales while it moves", () => { + for (const linear of ["rotate", "scale"]) { + const scene = document.createElement("div"); + const el = mover(scene); + const found = scan(scene, (t) => { + const a = linear === "scale" ? 1 + t : Math.cos(t); + const b = linear === "scale" ? 0 : Math.sin(t); + el.style.transform = `matrix(${a}, ${b}, ${-b}, ${a}, ${300 * t}, 0)`; + }); + expect(found.movers).toEqual([el]); + expect(found.translationOnly).toBe(false); + } + }); + + it("is false when a mover is moved without a transform, which a filter would re-stack", () => { + const scene = document.createElement("div"); + const el = mover(scene); + const found = scan(scene, (t) => { + el.style.left = `${300 * t}px`; + }); + expect(found.movers).toEqual([el]); + expect(found.translationOnly).toBe(false); + }); + + it("is false when nothing moves", () => { + const scene = document.createElement("div"); + mover(scene); + expect(scan(scene, () => {}).translationOnly).toBe(false); + }); +}); diff --git a/packages/core/src/runtime/captureMotionBlur/discover.ts b/packages/core/src/runtime/captureMotionBlur/discover.ts index 93358d9ec1d..e397d360c2b 100644 --- a/packages/core/src/runtime/captureMotionBlur/discover.ts +++ b/packages/core/src/runtime/captureMotionBlur/discover.ts @@ -9,7 +9,7 @@ import { isHtmlElement } from "../domRealm"; import { inkPad } from "./ink"; -import { individualIs3d } from "./pose"; +import { individualIs3d, parseTransform } from "./pose"; /** Under this peak speed (px per frame) a smear is invisible, so the element stays sharp. */ const MIN_SPEED = 2; @@ -46,6 +46,7 @@ export interface Discovery { dynamic: Set; /** Px each mover's ink reaches past its box (shadows, glows), the largest seen. */ pads: Map; + translationOnly: boolean; } // The whole inline style in one read; opacity or colour tweens only cost a rect check. @@ -74,6 +75,22 @@ function in3d(el: Element, scene: Element): boolean { return false; } +function linearSignature(el: Element, scene: Element): string { + const parts: string[] = []; + for (let n: Element | null = el; n && n !== scene; n = n.parentElement) { + const cs = getComputedStyle(n); + const m = parseTransform(cs.transform); + parts.push(m ? [m.a, m.b, m.c, m.d].map((v) => v.toFixed(3)).join(",") : "1,0,0,1"); + parts.push(cs.rotate ?? "none", cs.scale ?? "none"); + } + return parts.join("|"); +} + +function hasOwnContext(el: Element): boolean { + const cs = getComputedStyle(el); + return cs.transform !== "none" || (cs.translate ?? "none") !== "none"; +} + /** Elements a CSS animation or WAAPI animation moves (no inline style to diff). */ function pageAnimated(scene: Element): Set { const out = new Set(); @@ -184,8 +201,15 @@ export function discoverMovers({ const fast = new Set(); const threeD = new Set(); const pads = new Map(); + const signatures = new Map(); + const notPlain = new Set(); const measure = (el: HTMLElement): void => { pads.set(el, Math.max(pads.get(el) ?? 0, inkPad(el))); + const sig = linearSignature(el, scene); + const first = signatures.get(el); + if (first === undefined) signatures.set(el, sig); + else if (first !== sig) notPlain.add(el); + if (!hasOwnContext(el)) notPlain.add(el); }; const seeThrough = new Set(); const alphaCache = new Map(); @@ -243,5 +267,6 @@ export function discoverMovers({ (el) => fast.has(el) && !threeD.has(el) && !seeThrough.has(el) && !optedOut(el, scene), ); const movers = candidates.filter((el) => !candidates.some((o) => o !== el && o.contains(el))); - return { movers, dynamic, pads }; + const translationOnly = movers.length > 0 && movers.every((el) => !notPlain.has(el)); + return { movers, dynamic, pads, translationOnly }; } diff --git a/packages/core/src/runtime/captureMotionBlur/index.test.ts b/packages/core/src/runtime/captureMotionBlur/index.test.ts index 80b25ec8af7..6abb0e6b678 100644 --- a/packages/core/src/runtime/captureMotionBlur/index.test.ts +++ b/packages/core/src/runtime/captureMotionBlur/index.test.ts @@ -1590,8 +1590,9 @@ describe("filter fallback", () => { beforeEach(() => setFilterFallbackForTests(true)); /** A root whose opacity tweens late, which a scan sees and capture cannot draw. */ + const CAPTURE = { "data-motion-blur": "on", "data-motion-blur-tier": "capture" }; function fadingRoot(fade: { on: boolean }) { - const { root, el, h } = movingHost(); + const { root, el, h } = movingHost(CAPTURE); const slide = h.seek; const seek = vi.fn((t: number, quiet: boolean) => { slide(t, quiet); @@ -1644,7 +1645,7 @@ describe("filter fallback", () => { it("falls back after repeated capture failures in preview", async () => { installHtmlInCanvas(); - const { root, el, h } = movingHost(); + const { root, el, h } = movingHost(CAPTURE); initCaptureMotionBlur(h); for (let i = 0; i < 3; i++) { const done = paintCaptureMotionBlur(i / 30, PLAY); @@ -1676,6 +1677,81 @@ describe("filter fallback", () => { expect(root.parentElement).toBe(captureCanvas(root)); }); + describe("choosing the tier", () => { + /** Turns while it moves: the one thing the filter tier only softens. */ + function turningHost(attrs: Record) { + const { root, el, h } = movingHost(attrs); + const slide = h.seek; + const seek = vi.fn((t: number, quiet: boolean) => { + slide(t, quiet); + el.style.transform = `matrix(${Math.cos(t)}, ${Math.sin(t)}, ${-Math.sin(t)}, ${Math.cos(t)}, ${300 * t}, 0)`; + }); + return { root, el, h: { ...h, seek } }; + } + beforeEach(() => { + const real = window.getComputedStyle.bind(window); + vi.spyOn(window, "getComputedStyle").mockImplementation((el: Element) => { + const cs = real(el); + return new Proxy(cs, { + get: (target, key) => { + if (key === "transform") + return el instanceof HTMLElement ? el.style.transform || "none" : "none"; + const value = Reflect.get(target, key); + return typeof value === "function" ? value.bind(target) : value; + }, + }); + }); + }); + + it("takes the cheaper filter tier when every mover only translates", () => { + installHtmlInCanvas(); + const { root, el, h } = movingHost(); + initCaptureMotionBlur(h); + void paintCaptureMotionBlur(0.5, PLAY); + expect(win.__hfMotionBlur?.report()).toMatchObject({ + state: "on", + tier: "filter", + reasonCode: "translation-only", + }); + expect(root.parentElement).toBe(document.body); + expect(el.style.filter).toMatch(/url\("?#hf-mb-0"?\)/); + }); + + it("keeps capture for a mover that turns", () => { + installHtmlInCanvas(); + const { root, h } = turningHost({ "data-motion-blur": "on" }); + initCaptureMotionBlur(h); + void paintCaptureMotionBlur(0.5, PLAY); + expect(win.__hfMotionBlur?.report()).toMatchObject({ tier: "capture", reasonCode: null }); + expect(root.parentElement).toBe(captureCanvas(root)); + }); + + it('keeps capture when asked with data-motion-blur-tier="capture"', () => { + installHtmlInCanvas(); + const { root, h } = movingHost(CAPTURE); + initCaptureMotionBlur(h); + void paintCaptureMotionBlur(0.5, PLAY); + expect(win.__hfMotionBlur?.report().tier).toBe("capture"); + expect(root.parentElement).toBe(captureCanvas(root)); + }); + + it('takes the filter tier when asked with data-motion-blur-tier="filter", even for a turn', () => { + installHtmlInCanvas(); + const { root, el, h } = turningHost({ + "data-motion-blur": "on", + "data-motion-blur-tier": "filter", + }); + initCaptureMotionBlur(h); + void paintCaptureMotionBlur(0.5, PLAY); + expect(win.__hfMotionBlur?.report()).toMatchObject({ + tier: "filter", + reasonCode: "tier-attribute", + }); + expect(root.parentElement).toBe(document.body); + expect(el.style.filter).toMatch(/url\("?#hf-mb-0"?\)/); + }); + }); + 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)"; diff --git a/packages/core/src/runtime/captureMotionBlur/index.ts b/packages/core/src/runtime/captureMotionBlur/index.ts index edb8fa4e9b7..2ce87fb29fc 100644 --- a/packages/core/src/runtime/captureMotionBlur/index.ts +++ b/packages/core/src/runtime/captureMotionBlur/index.ts @@ -39,7 +39,13 @@ import { discoverMovers } from "./discover"; import { createFilterTier } from "./filterTier"; import { readMoverFrames } from "./frames"; import { createPoseReader } from "./pose"; -import type { CaptureMotionBlurHost, Config, Report, Tier } from "./tier"; +import { + type CaptureMotionBlurHost, + type Config, + type Report, + type Tier, + chooseTier, +} from "./tier"; export type { CaptureMotionBlurHost }; @@ -85,6 +91,10 @@ const LOG_EVERY = 25; const clamp = (x: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, x)); +function tierAttribute(value: string | null): Config["tier"] { + return value === "capture" || value === "filter" ? value : "auto"; +} + function readConfig(root: HTMLElement): Config | null { if (root.getAttribute("data-motion-blur") !== "on") return null; const num = (name: string, fallback: number): number => { @@ -97,6 +107,7 @@ function readConfig(root: HTMLElement): Config | null { ? Math.round(clamp(num("data-motion-blur-samples", 16), 2, 64)) : null, events: root.getAttribute("data-motion-blur-events") === "on", + tier: tierAttribute(root.getAttribute("data-motion-blur-tier")), }; } @@ -194,6 +205,7 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI let scannedTimeline: unknown = undefined; let scanDefect: CaptureDefect | null = null; let faceNow: () => boolean = () => false; + let translationOnly = false; const loggedBackstops = new Set(); let wrapped = false; // Once the engine drives frames, paused-style requests come from the transport, not a user. @@ -258,6 +270,7 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI faceNow = defects.facesNow; movers = found.movers; pads = found.pads; + translationOnly = found.translationOnly; reader = createPoseReader(root, found.dynamic); scanMs = Math.round(performance.now() - s0); } @@ -459,6 +472,18 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI compositor = createCompositor(width, height, dpr); } + /** Who should draw instead of capture: it turned itself off, or the filter tier does as well for less. */ + function handOff(): CaptureDefect | null { + if (disabled) return { code: disabledCode ?? "capture-failures", reason: disabled }; + if (config.tier === "auto" && reader && translationOnly && filterTierAllowed()) { + return { + code: "translation-only", + reason: "every mover only translates, which the filter tier draws as well and far cheaper", + }; + } + return null; + } + /** Scan when the timeline is new; true when the scan found a defect (blur is then off). */ function blockedByScan(t: number): boolean { if (!reader || host.timeline() !== scannedTimeline) discover(t); @@ -480,7 +505,7 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI function start(t: number, engineMode: boolean): Promise { try { syncDensity(); - if (blockedByScan(t)) return Promise.resolve(); + if (blockedByScan(t) || handOff()) return Promise.resolve(); if (movers.length === 0) { unwrap(); return Promise.resolve(); @@ -575,9 +600,7 @@ 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, + handOff, report, busy: (): boolean => pending !== null, rediscover: () => { @@ -587,13 +610,14 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI }; } -type Active = Tier & { defect?: () => CaptureDefect | null }; +type Active = Tier & { handOff?: () => 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; +const filterTierAllowed = (): boolean => fallbackEnabled; let suspended = false; let priorResolver: (() => boolean) | null = null; let ourResolver: (() => boolean) | null = null; @@ -622,15 +646,28 @@ export function initCaptureMotionBlur(host: CaptureMotionBlurHost): void { } activeHost = host; activeConfig = config; + selectTier(host, config, w); + bindApi(w, config); +} + +const TIER_ATTRIBUTE: CaptureDefect = { + code: "tier-attribute", + reason: 'data-motion-blur-tier="filter"', +}; + +function selectTier(host: CaptureMotionBlurHost, config: Config, w: CompositeWindow): void { const form = htmlInCanvasForm(); const why = unsupported(host.root); - if (!form || why) { - if (fallbackEnabled && why?.code !== "renderer-off") startFallback(host, config, why); - else standDown(w, config, why); - } else { - instance = createInstance(host, config, form); - } - bindApi(w, config); + const choice = chooseTier({ + tier: config.tier, + form: form !== null, + defect: why, + fallback: fallbackEnabled, + }); + if (choice === "filter") + startFallback(host, config, config.tier === "filter" ? TIER_ATTRIBUTE : why); + else if (choice === "capture" && form) instance = createInstance(host, config, form); + else standDown(w, config, why); } function startFallback( @@ -688,7 +725,7 @@ function bindApi(w: CompositeWindow, config: Config): void { /** A scan that found a defect turned capture off for this timeline: draw with filters instead. */ function settleTier(): void { - const defect = instance?.defect?.(); + const defect = instance?.handOff?.(); if (!fallbackEnabled || !instance || !defect || !activeHost || !activeConfig) return; instance.teardown(); instance = createFilterTier(activeHost, activeConfig, defect); @@ -711,7 +748,7 @@ export function paintCaptureMotionBlur( const engineMode = options?.engineMode === true; if (!engineMode && !options?.playing && instance.rest()) return null; let done = instance.paint(t, engineMode); - if (instance.defect?.()) { + if (instance.handOff?.()) { settleTier(); if (!engineMode && !options?.playing && instance.rest()) return null; done = instance.paint(t, engineMode); diff --git a/packages/core/src/runtime/captureMotionBlur/tier.test.ts b/packages/core/src/runtime/captureMotionBlur/tier.test.ts new file mode 100644 index 00000000000..20cf88b669d --- /dev/null +++ b/packages/core/src/runtime/captureMotionBlur/tier.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { chooseTier } from "./tier"; + +const defect = { code: "root-opacity", reason: "why" }; +const base = { tier: "auto", form: true, defect: null, fallback: true } as const; + +describe("chooseTier", () => { + it("draws with capture where it can run", () => { + expect(chooseTier(base)).toBe("capture"); + expect(chooseTier({ ...base, tier: "capture" })).toBe("capture"); + }); + + it("draws with the filter tier where capture cannot run", () => { + expect(chooseTier({ ...base, defect })).toBe("filter"); + expect(chooseTier({ ...base, form: false })).toBe("filter"); + }); + + it("draws with the filter tier when the page asks for it", () => { + expect(chooseTier({ ...base, tier: "filter" })).toBe("filter"); + }); + + it("stays off where the renderer said so, whatever the tier", () => { + const renderer = { code: "renderer-off", reason: "hdr-render" }; + for (const tier of ["auto", "capture", "filter"] as const) { + expect(chooseTier({ ...base, tier, defect: renderer })).toBe("off"); + } + }); + + it("is off, not filter, when the fallback is not allowed", () => { + expect(chooseTier({ ...base, defect, fallback: false })).toBe("off"); + expect(chooseTier({ ...base, tier: "filter", fallback: false })).toBe("capture"); + expect(chooseTier({ ...base, form: false, fallback: false })).toBe("off"); + }); +}); diff --git a/packages/core/src/runtime/captureMotionBlur/tier.ts b/packages/core/src/runtime/captureMotionBlur/tier.ts index 791d239e226..162d908751c 100644 --- a/packages/core/src/runtime/captureMotionBlur/tier.ts +++ b/packages/core/src/runtime/captureMotionBlur/tier.ts @@ -1,3 +1,5 @@ +import type { CaptureDefect } from "../../htmlInCanvasDefects"; + /** What the runtime drives: capture blur or the filter fallback, the same way. */ export interface CaptureMotionBlurHost { @@ -16,6 +18,8 @@ export interface Config { shutterDeg: number; samples: number | null; events: boolean; + /** Which tier draws: `auto` takes the cheaper filter tier when every mover only translates. */ + tier: "auto" | "capture" | "filter"; } export interface Report { @@ -48,3 +52,19 @@ export interface Tier { busy: () => boolean; rediscover: () => void; } + +export interface TierChoice { + tier: Config["tier"]; + form: boolean; + /** Why capture cannot run here, if it cannot; `renderer-off` means blur must not run at all. */ + defect: CaptureDefect | null; + fallback: boolean; +} + +/** Capture where it can run; the filter tier where it cannot or the page asked for it; off if told to be. */ +export function chooseTier(c: TierChoice): "capture" | "filter" | "off" { + if (c.defect?.code === "renderer-off") return "off"; + const capturable = c.form && !c.defect; + if (c.fallback && (c.tier === "filter" || !capturable)) return "filter"; + return capturable ? "capture" : "off"; +} diff --git a/packages/engine/src/services/captureMotionBlur.pixels.browser.test.ts b/packages/engine/src/services/captureMotionBlur.pixels.browser.test.ts index 5217d429262..c7dc0667a2d 100644 --- a/packages/engine/src/services/captureMotionBlur.pixels.browser.test.ts +++ b/packages/engine/src/services/captureMotionBlur.pixels.browser.test.ts @@ -55,8 +55,12 @@ const card = (animation: string, perspective = "perspective:400px") => const WAAPI = ``; const same = (a: Frame, b: Frame): boolean => Buffer.from(a.data).equals(Buffer.from(b.data)); -function composition(body: string, blur: boolean): string { - const attrs = blur ? ' data-motion-blur="on" data-motion-blur-shutter="360"' : ""; +type Tier = "capture" | "filter"; + +function composition(body: string, blur: boolean, tier?: Tier): string { + const attrs = blur + ? ` data-motion-blur="on" data-motion-blur-shutter="360"${tier ? ` data-motion-blur-tier="${tier}"` : ""}` + : ""; return `
${body}
`; @@ -104,11 +108,11 @@ describe.skipIf(!hasBrowser)("capture motion blur pixels in Chrome", () => { }, 30_000); afterAll(() => closeTestChrome(browser)); - async function open(body: string, blur: boolean): Promise { + async function open(body: string, blur: boolean, tier?: Tier): Promise { const page = await browser.newPage(); await page.setViewport({ width: W, height: H, deviceScaleFactor: 1 }); await page.evaluate(acceleratedCanvasTrackerSource()); - await page.setContent(composition(body, blur)); + await page.setContent(composition(body, blur, tier)); await page.addScriptTag({ content: `window.__HF_EXPORT_RENDER_SEEK_CONFIG={fps:${FPS}};` }); await page.addScriptTag({ content: readFileSync(RUNTIME, "utf8") }); await page.waitForFunction(() => { @@ -145,8 +149,8 @@ describe.skipIf(!hasBrowser)("capture motion blur pixels in Chrome", () => { return { width, data }; } - async function frameOf(body: string, blur: boolean, t: number) { - const page = await open(body, blur); + async function frameOf(body: string, blur: boolean, t: number, tier?: Tier) { + const page = await open(body, blur, tier); try { const frame = await capture(page, t); const report = await page.evaluate(() => (window as Win).__hfMotionBlur?.report() ?? null); @@ -169,7 +173,7 @@ describe.skipIf(!hasBrowser)("capture motion blur pixels in Chrome", () => { return n; }; for (const animation of ["spin", "grow"]) { - const blurred = await frameOf(tile(animation), true, 0.3); + const blurred = await frameOf(tile(animation), true, 0.3, "capture"); const sharp = await frameOf(tile(animation), false, 0.3); expect(blurred.report).toMatchObject({ state: "on", movers: 1, failedFrames: 0 }); expect(same(blurred.frame, sharp.frame)).toBe(false); @@ -179,11 +183,13 @@ describe.skipIf(!hasBrowser)("capture motion blur pixels in Chrome", () => { } }); - it("leaves a trail along the motion line where the sharp frame has hard edges", async (ctx) => { + const TIERS: Tier[] = ["capture", "filter"]; + + it.each(TIERS)("leaves a trail along the motion line, in the %s tier", async (tier, ctx) => { if (!supported) ctx.skip(); - const blurred = await frameOf(BOX, true, FRAME); + const blurred = await frameOf(BOX, true, FRAME, tier); const sharp = await frameOf(BOX, false, FRAME); - expect(blurred.report).toMatchObject({ state: "on", movers: 1 }); + expect(blurred.report).toMatchObject({ state: "on", tier, movers: 1 }); const sharpRow = row(sharp.frame, 40); expect(sharpRow.filter((v) => v === 255)).toHaveLength(40); @@ -206,52 +212,61 @@ describe.skipIf(!hasBrowser)("capture motion blur pixels in Chrome", () => { expect(ink(trail) / ink(sharpRow)).toBeLessThan(1.03); }); - it("leaves what does not move exactly as the sharp frame has it", async (ctx) => { - if (!supported) ctx.skip(); - const blurred = await frameOf(BOX + STRIPES, true, FRAME); - const sharp = await frameOf(BOX + STRIPES, false, FRAME); - expect(blurred.report).toMatchObject({ state: "on" }); - let differing = 0; - for (let y = 100; y < 140; y++) { - for (let x = 200; x < 240; x++) { - if (px(blurred.frame, x, y).join() !== px(sharp.frame, x, y).join()) differing++; + it.each(TIERS)( + "leaves what does not move exactly as the sharp frame has it, in the %s tier", + async (tier, ctx) => { + if (!supported) ctx.skip(); + const blurred = await frameOf(BOX + STRIPES, true, FRAME, tier); + const sharp = await frameOf(BOX + STRIPES, false, FRAME); + expect(blurred.report).toMatchObject({ state: "on" }); + let differing = 0; + for (let y = 100; y < 140; y++) { + for (let x = 200; x < 240; x++) { + if (px(blurred.frame, x, y).join() !== px(sharp.frame, x, y).join()) differing++; + } } - } - expect(differing).toBe(0); - expect(new Set(Array.from({ length: 40 }, (_, x) => px(sharp.frame, 200 + x, 110)[0]))).toEqual( - new Set([0, 255]), - ); - }); + expect(differing).toBe(0); + expect( + new Set(Array.from({ length: 40 }, (_, x) => px(sharp.frame, 200 + x, 110)[0])), + ).toEqual(new Set([0, 255])); + }, + ); - it("leaves the backdrop under a see-through mover where it is", async (ctx) => { - if (!supported) ctx.skip(); - const body = `
+ it.each(TIERS)( + "leaves the backdrop under a see-through mover where it is, in the %s tier", + async (tier, ctx) => { + if (!supported) ctx.skip(); + const body = `
`; - const blurred = await frameOf(body, true, FRAME); - const sharp = await frameOf(body, false, FRAME); - expect(blurred.report).toMatchObject({ state: "on", movers: 1 }); - // Row 34 is inside the mover's box but clear of the dot it carries: its backdrop must not move. - let moved = 0; - for (let x = 0; x < W; x++) { - if (px(blurred.frame, x, 34).join() !== px(sharp.frame, x, 34).join()) moved++; - } - expect(moved).toBe(0); - expect(Math.max(...row(blurred.frame, 50))).toBeGreaterThan(120); - }); + const blurred = await frameOf(body, true, FRAME, tier); + const sharp = await frameOf(body, false, FRAME); + expect(blurred.report).toMatchObject({ state: "on", tier, movers: 1 }); + // Row 34 is inside the mover's box but clear of the dot it carries: its backdrop must not move. + let moved = 0; + for (let x = 0; x < W; x++) { + if (px(blurred.frame, x, 34).join() !== px(sharp.frame, x, 34).join()) moved++; + } + expect(moved).toBe(0); + expect(Math.max(...row(blurred.frame, 50))).toBeGreaterThan(120); + }, + ); - it("carries a glow that paints outside the mover's box along with it", async (ctx) => { - if (!supported) ctx.skip(); - const body = `
`; - const blurred = await frameOf(body, true, FRAME); - const sharp = await frameOf(body, false, FRAME); - expect(blurred.report).toMatchObject({ state: "on", movers: 1 }); - // 10 px above the box, mid-trail: the glow is there in the sharp frame, so it must be here too. - const glow = (f: Frame): number => Math.max(...row(f, 70).slice(120, 200)); - expect(glow(sharp.frame)).toBeGreaterThan(40); - expect(glow(blurred.frame)).toBeGreaterThan(glow(sharp.frame) * 0.5); - }); + it.each(TIERS)( + "carries a glow that paints outside the mover's box along with it, in the %s tier", + async (tier, ctx) => { + if (!supported) ctx.skip(); + const body = `
`; + const blurred = await frameOf(body, true, FRAME, tier); + const sharp = await frameOf(body, false, FRAME); + expect(blurred.report).toMatchObject({ state: "on", tier, movers: 1 }); + // 10 px above the box, mid-trail: the glow is there in the sharp frame, so it must be here too. + const glow = (f: Frame): number => Math.max(...row(f, 70).slice(120, 200)); + expect(glow(sharp.frame)).toBeGreaterThan(40); + expect(glow(blurred.frame)).toBeGreaterThan(glow(sharp.frame) * 0.5); + }, + ); it("blurs with filters where capture cannot run, over a page that has a WebGL canvas", async (ctx) => { if (!supported) ctx.skip(); @@ -271,18 +286,21 @@ describe.skipIf(!hasBrowser)("capture motion blur pixels in Chrome", () => { expect(ink(trail) / ink(row(sharp.frame, 40))).toBeLessThan(1.1); }); - it("blurs a box centred with translate around where it really is", async (ctx) => { - if (!supported) ctx.skip(); - const blurred = await frameOf(CENTERED, true, 1 / FPS); - const sharp = await frameOf(CENTERED, false, 1 / FPS); - const trail = row(blurred.frame, 90); - expect(intermediate(trail).length).toBeGreaterThanOrEqual(40); - const trueCentre = centroid(row(sharp.frame, 90)); - expect(trueCentre).toBeCloseTo(220, 0); - expect(Math.abs(centroid(trail) - trueCentre)).toBeLessThan(0.5); - const column = Array.from({ length: H }, (_, y) => redness(blurred.frame, 220, y)); - expect(Math.abs(centroid(column) - 90)).toBeLessThan(0.5); - }); + it.each(TIERS)( + "blurs a box centred with translate around where it really is, in the %s tier", + async (tier, ctx) => { + if (!supported) ctx.skip(); + const blurred = await frameOf(CENTERED, true, 1 / FPS, tier); + const sharp = await frameOf(CENTERED, false, 1 / FPS); + const trail = row(blurred.frame, 90); + expect(intermediate(trail).length).toBeGreaterThanOrEqual(40); + const trueCentre = centroid(row(sharp.frame, 90)); + expect(trueCentre).toBeCloseTo(220, 0); + expect(Math.abs(centroid(trail) - trueCentre)).toBeLessThan(0.5); + const column = Array.from({ length: H }, (_, y) => redness(blurred.frame, 220, y)); + expect(Math.abs(centroid(column) - 90)).toBeLessThan(0.5); + }, + ); it("blocks a backface-hidden card in a 3D flip and renders the plain frame", async (ctx) => { if (!supported) ctx.skip(); @@ -318,7 +336,10 @@ describe.skipIf(!hasBrowser)("capture motion blur pixels in Chrome", () => { [5, false], ] as const) { const t = frame / FPS; - const [blurred, sharp] = [await frameOf(body, true, t), await frameOf(body, false, t)]; + const [blurred, sharp] = [ + await frameOf(body, true, t, "capture"), + await frameOf(body, false, t), + ]; expect(blurred.report).toMatchObject({ state: "on" }); expect(same(blurred.frame, sharp.frame)).toBe(plain); } diff --git a/skills-manifest.json b/skills-manifest.json index 81cd98aec93..1022e5dadeb 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -6,7 +6,7 @@ "files": 2 }, "hyperframes": { - "hash": "66d328f5c449b403", + "hash": "7a5df5b496053c19", "files": 77 }, "hyperframes-cli": { From 78037330a94a4a0f26ac5b77660fded26554e6ea Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 22 Oct 2026 19:30:55 -0700 Subject: [PATCH 3/6] docs(motion-blur): the two tiers, the tier attribute, and what changed in limits The guide described blur turning off for WebGL, a root transform and similar. It now says the filter tier draws instead, how the tier is chosen and forced, what each does with a turn or a zoom, the measured cost of each, and which findings check emits. The skill note drops the off-for-WebGL advice and the 1.7x figure. Co-Authored-By: Claude Sonnet 5.5 --- docs/guides/motion-blur.mdx | 55 ++++++++++++------------- skills/hyperframes/references/motion.md | 2 +- 2 files changed, 28 insertions(+), 29 deletions(-) diff --git a/docs/guides/motion-blur.mdx b/docs/guides/motion-blur.mdx index dc83da5e3ae..bf888826359 100644 --- a/docs/guides/motion-blur.mdx +++ b/docs/guides/motion-blur.mdx @@ -3,7 +3,7 @@ title: "Motion Blur" description: "Smear fast-moving elements along their path, in Studio preview and in renders. Off until you turn it on." --- -Capture motion blur gives fast-moving elements a real shutter smear. For each frame, HyperFrames reads where every mover was across the shutter interval and redraws it along that path. A box sliding across the frame leaves a streak instead of a sharp copy at one position. +Motion blur gives fast-moving elements a real shutter smear. For each frame, HyperFrames reads where every mover was across the shutter interval and blurs it along that path. A box sliding across the frame leaves a streak instead of a sharp copy at one position. It is **opt-in**. Nothing blurs until you add `data-motion-blur="on"` to the composition root. @@ -22,35 +22,33 @@ It is **opt-in**. Nothing blurs until you add `data-motion-blur="on"` to the com ``` -That is all. The same attribute drives Studio preview and `hyperframes render`, so what you see is what you render. +That is all. The same attribute drives Studio preview and `hyperframes render`. | Attribute | Default | Meaning | | --- | --- | --- | | `data-motion-blur` | none | `on` enables blur. `off` opts out, on the root or on any subtree. | | `data-motion-blur-shutter` | `180` | Shutter angle in degrees, 0–720. Larger is a longer smear. | -| `data-motion-blur-samples` | `16` | Sub-frame samples per frame, 2–64. More is smoother and slower. | -| `data-motion-blur-events` | off | `on` also runs timeline callbacks (`onUpdate` and similar) at each sample. Use it when the motion is driven by callbacks, which blur cannot otherwise see. | +| `data-motion-blur-samples` | sized to the smear | A fixed number of samples per frame, 2–64. Left unset, each mover gets about one sample per 2 px of smear, so a fast move never shows steps. | +| `data-motion-blur-events` | off | `on` runs timeline callbacks (`onUpdate` and similar) while blur looks for movers and at each sample. Needed when the motion is set from a callback, such as a `render(t)` function, which blur cannot otherwise see. | +| `data-motion-blur-tier` | `auto` | `capture` or `filter` forces one of the two ways blur is drawn (below). | ## What it blurs -Elements that move quickly in 2D: position, scale and rotation changes from GSAP, CSS animations and transitions, and the Web Animations API. Slow movers and static content are left sharp. A composition with nothing moving is never wrapped and does no per-frame blur work; the one-time discovery scan still runs on first play. +Elements that move quickly in 2D: position, scale and rotation changes from GSAP, CSS animations and transitions, and the Web Animations API. Slow movers and static content are left sharp. A mover's glow and shadow travel with it, and the background behind it stays where it is. -Blur is an image-space approximation. A mover's box and its contents move together, and glows or shadows outside its box stay sharp. 3D movers are skipped. +## Two ways it is drawn -## Requirements +**Capture** copies each mover out of the page with Chrome's [HTML in Canvas](/guides/html-in-canvas) API and redraws it along its path. It draws turns and zooms truthfully: a spinning tile smears along its arc and its centre stays sharp, and a zoom streaks outward. -Blur draws through Chrome's [HTML in Canvas](/guides/html-in-canvas) API. +**Filter** puts an SVG filter on each mover: copies of the element along its path, averaged, and softened along the motion. It works in any Chrome, over a WebGL canvas, and costs about what a render without blur costs. Turns and zooms are only softened, not streaked. -- **Renders** use the render browser, which HyperFrames launches with the API enabled. -- **Studio preview** uses your own browser. We saw Chrome 157 and 158 expose the API without a flag, but only in headless and Chrome-for-Testing builds; whether it ships in stable Chrome is unverified. Chrome 155 and earlier need `chrome://flags/#html-in-canvas`. Preview detects the API and plays sharp when it is missing, so check your own browser rather than relying on a version number. +With `data-motion-blur-tier="auto"` (the default), blur uses the filter tier when every mover only translates and capture when anything turns or scales, because for translation the two look the same and the filter tier is much cheaper. -If Studio's browser cannot blur, Studio tells the render, and the render also skips blur so the output matches what you saw. +Capture needs a browser with HTML in Canvas. Chrome 157 and 158 builds exposed it without a flag in headless and Chrome-for-Testing builds; whether stable Chrome does is unverified. Chrome 155 and earlier need `chrome://flags/#html-in-canvas`. -## When blur turns itself off +Blur uses the filter tier instead of capture when capture cannot run: -html-in-canvas captures a few things wrongly. A wrongly blurred frame is worse than a sharp one, so when HyperFrames finds one of these it turns blur off **for the whole composition, from the first frame**, instead of switching mid-render. This covers every defect the timeline scan can see. A defect the scan misses (a WebGL canvas added late, a root shake between scan samples, a face that flips hidden briefly) is caught per frame instead: that one frame renders sharp, blur resumes on the next, and the count is reported as `failedFrames`. - -| Found in the composition | Why | +| Found in the composition | Why capture cannot draw it | | --- | --- | | A WebGL or WebGPU canvas anywhere in the document | Captured frozen. | | `backface-visibility: hidden` inside a 3D context (including from an animation) | Drawn mirrored. | @@ -58,12 +56,13 @@ html-in-canvas captures a few things wrongly. A wrongly blurred frame is worse t | Opacity below 1, or `mix-blend-mode`, on the root element | Not applied by the capture. | | An `iframe`, `object`, `embed` or custom element in the root | Not captured faithfully. | | A nested capture canvas, such as a VFX block | Hangs the renderer. | -| An HDR render | The HDR compositor's layer mask does not reach the blur canvases. | -| A distributed render | Chunks cannot share the single-page capture. | +| A browser without HTML in Canvas, or three failed captures in a row | Capture is unavailable. | + +This is decided once for the whole composition, from the first frame. A defect the scan misses (a WebGL canvas added late, say) is caught per frame instead: that one frame is rendered sharp and counted in `failedFrames`. -Large see-through movers (a full-frame translucent sweep, say) are skipped too, because blurring them would drag the content underneath along. Their fast children still blur. +Blur is off, and stays off, for HDR renders, distributed renders, and renders that use the engine's own sub-frame blur. -### Find out why it is off +### Find out which one is drawing ```bash npx hyperframes check @@ -71,17 +70,17 @@ npx hyperframes check | Finding | Severity | Meaning | | --- | --- | --- | -| `motion_blur_off` | warning | Blur is on but was switched off. The message names the cause and a fix. | -| `motion_blur_no_movers` | info | Blur is on but nothing moves fast enough to blur. | -| `motion_blur_unverified` | info | The check could not confirm blur in this browser. | +| `motion_blur_filter_fallback` | info | Capture could not run, so the filter tier draws. The message names the cause and how to get capture back. | +| `motion_blur_no_movers` | info | Blur is on but nothing moves fast enough to blur. If the motion is set from a callback, add `data-motion-blur-events="on"`. | +| `motion_blur_unverified` | info | The check browser cannot run capture, so it drew with the filter tier. Render once to see capture. | -In the page, `window.__hfMotionBlur.report()` returns `state`, `reason`, `reasonCode` and `movers`. A render also logs one line per session saying whether blur ran and why not, plus a session-end line when any frames fell back to sharp (`failedFrames > 0`). +In the page, `window.__hfMotionBlur.report()` returns `state`, `tier`, `reason`, `reasonCode` and `movers`. A render logs one line per session with the tier, and a session-end line when any frames fell back to sharp (`failedFrames > 0`). ## Cost and limits -- **Render time.** About 1.7× for a blurred composition (median over 20 real compositions). Compositions where blur is off or nothing moves add only the one-time discovery scan (tens of milliseconds on a 25-mover composition). -- **Editing.** The root is wrapped in the capture canvas only while playing or rendering. Paused, it is plain DOM, so click-to-select in Studio works as usual. -- **Preview rate.** Blurred preview can run at half the frame rate on heavy compositions. -- **Video.** A `