feat: automatic tier, 240°/32 defaults, check finding, and Studio stops opting out (#6108)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* feat(core): blur defaults match a 240 degree shutter and at least 32 samples

The shutter opens 240 degrees by default (was 180) and each mover gets at least 32 taps (was
sized to the smear alone, down to 2), growing with the smear as before. These are the settings
the shutter blur Jake built uses; on the card whip the default render is now 51 dB from his
(34.6 dB at the old defaults). The guide's attribute table says so.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* chore(core): trim tier.ts comments to the ratchet

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* chore: allow deleting the Studio previewMotionBlur render test

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-10-23 14:20:17 -07:00
committed by GitHub
co-authored by Claude Sonnet 5.5
parent 73718a251b
commit 79b57ab12d
20 changed files with 552 additions and 200 deletions
+28 -29
View File
@@ -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
</div>
```
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-shutter` | `240` | Shutter angle in degrees, 0–720. Larger is a longer smear. |
| `data-motion-blur-samples` | at least 32 | A fixed number of samples per frame, 2–64. Left unset, each mover gets at least 32, and about one per 2 px of smear when that is more, 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. |
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.
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`.
### Find out why it is off
Blur is off, and stays off, for HDR renders, distributed renders, and renders that use the engine's own sub-frame blur.
### 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 `<video>` drawn through the capture looks about 2–3% darker in Studio preview than in normal playback. Renders are not affected.
- **Render time.** On an 8 s test composition with four whips, a render took 9.8 s with capture, 5.2–5.4 s with the filter tier and 5.3–5.9 s with no blur (two runs each, including startup). Capture adds a second paint per blurred frame; the filter tier adds almost nothing.
- **Editing.** With capture the root is wrapped only while playing or rendering; paused, it is plain DOM, so click-to-select works. The filter tier never wraps anything.
- **Capture only.** Blurred preview can run at half the frame rate on heavy compositions, and a `<video>` drawn through the capture looks about 2–3% darker in Studio preview than in normal playback. Renders are not affected.
- **Preview and render.** A browser without HTML in Canvas previews with the filter tier. A render then uses capture where the composition needs it, so a spin or zoom looks softer in that preview than in the render. Force one tier with `data-motion-blur-tier` for an exact match.
- **Worker count.** Output is not bit-identical between worker counts (around 60 dB PSNR), about the size of the difference chunking already causes.
- **Not handled.** Pose errors under CSS `zoom`, `position: fixed` and `foreignObject`; a very short Web Animations API animation that falls between scan samples; movers made see-through only by CSS backgrounds, masks or `clip-path`.
- **Not handled.** Pose errors under CSS `zoom`, `position: fixed` and `foreignObject`; a very short Web Animations API animation that falls between scan samples; a large see-through mover (a full-frame translucent sweep), which is left sharp while its fast children still blur. With capture, a mover is drawn above the static content, so a static element that should cover a mover does not.
@@ -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;
+33 -8
View File
@@ -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,40 @@ 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",
);
expect(motionBlurDrafts(filter("translation-only"))).toEqual([]);
expect(motionBlurDrafts(filter("tier-attribute"))).toEqual([]);
});
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",
+17 -2
View File
@@ -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;
@@ -31,6 +32,7 @@ const OFF_HINTS: Record<string, string> = {
"nested-capture-canvas":
"remove the inner <canvas layoutsubtree> / 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';
@@ -43,9 +45,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 +63,17 @@ export function motionBlurDrafts(probe: MotionBlurProbe | null): MotionBlurDraft
},
];
}
if (probe.tier === "filter" && probe.movers > 0 && !CHOSEN.has(probe.reasonCode ?? "")) {
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 +101,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,
@@ -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);
});
});
@@ -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<Element>;
/** Px each mover's ink reaches past its box (shadows, glows), the largest seen. */
pads: Map<HTMLElement, number>;
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<HTMLElement> {
const out = new Set<HTMLElement>();
@@ -184,8 +201,15 @@ export function discoverMovers({
const fast = new Set<HTMLElement>();
const threeD = new Set<HTMLElement>();
const pads = new Map<HTMLElement, number>();
const signatures = new Map<HTMLElement, string>();
const notPlain = new Set<HTMLElement>();
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<HTMLElement>();
const alphaCache = new Map<Bitmap, boolean>();
@@ -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 };
}
@@ -1204,6 +1204,17 @@ describe("suspend (R13)", () => {
expect(root.parentElement).toBe(document.body);
});
it("opens the shutter 240 degrees unless the root says otherwise", () => {
installHtmlInCanvas();
initCaptureMotionBlur(host(makeRoot()));
expect(win.__hfMotionBlur?.report().shutterDeg).toBe(240);
resetCaptureMotionBlurForTests();
document.body.innerHTML = "";
const root = makeRoot({ "data-motion-blur": "on", "data-motion-blur-shutter": "360" });
initCaptureMotionBlur(host(root));
expect(win.__hfMotionBlur?.report().shutterDeg).toBe(360);
});
it("exists, and does nothing, where blur is off", () => {
const root = makeRoot();
initCaptureMotionBlur(host(root));
@@ -1590,8 +1601,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 +1656,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 +1688,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<string, string>) {
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)";
@@ -1,6 +1,6 @@
/**
* Capture motion blur, in preview and render. Root attributes: data-motion-blur="on"|"off",
* -shutter (0–720°, default 180), -samples (2–64; unset sizes the taps to the smear),
* -shutter (0–720°, default 240), -samples (2–64; unset: at least 32, more for a long smear),
* -events="on" (run timeline callbacks at sub-frame samples). PAGE_MOTION_BLUR_OFF_GLOBAL (engine blur, or a preview that
* could not blur) turns all off.
*
@@ -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 => {
@@ -92,11 +102,12 @@ function readConfig(root: HTMLElement): Config | null {
return root.hasAttribute(name) && Number.isFinite(v) ? v : fallback;
};
return {
shutterDeg: clamp(num("data-motion-blur-shutter", 180), 0, 720),
shutterDeg: clamp(num("data-motion-blur-shutter", 240), 0, 720),
samples: root.hasAttribute("data-motion-blur-samples")
? 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<string>();
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<void> {
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);
@@ -103,10 +103,17 @@ describe("taps", () => {
expect(Math.max(...steps)).toBeLessThanOrEqual(2.3);
});
it("uses two taps for a move too small to show and caps a huge one", () => {
it("never uses fewer than 32 taps, and caps a huge move at 128", () => {
expect(
autoTaps([translate(IDENTITY, 0, 0), translate(IDENTITY, 0.5, 0)], 100, 50, 1920, 1080),
).toHaveLength(2);
).toHaveLength(32);
expect(
autoTaps([translate(IDENTITY, 0, 0), translate(IDENTITY, 40, 0)], 100, 50, 1920, 1080),
).toHaveLength(32);
expect(
autoTaps([translate(IDENTITY, 0, 0), translate(IDENTITY, 200, 0)], 100, 50, 1920, 1080)
.length,
).toBeGreaterThan(90);
expect(
autoTaps([translate(IDENTITY, 0, 0), translate(IDENTITY, 5000, 0)], 100, 50, 1920, 1080),
).toHaveLength(128);
@@ -183,7 +183,7 @@ export function tapWeights(n: number): number[] {
const PROBE_TAPS = 16;
/** Taps sized to the smear: about one per `spacing` px of travel, 2 to `max`. */
/** Taps sized to the smear: about one per `spacing` px of travel, `min` to `max`. */
export function autoTaps(
reads: Affine[],
w: number,
@@ -192,9 +192,10 @@ export function autoTaps(
frameH: number,
spacing = 2,
max = 128,
min = 32,
): Affine[] {
const { travel } = sweep(tapsFromReads(reads, PROBE_TAPS), w, h, frameW, frameH);
return tapsFromReads(reads, Math.min(max, Math.max(2, Math.ceil(travel / spacing))));
return tapsFromReads(reads, Math.min(max, Math.max(min, Math.ceil(travel / spacing))));
}
/** About one tap per `spacing` px of travel (no visible steps), at least 2, at most all. */
@@ -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");
});
});
@@ -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,7 @@ export interface Config {
shutterDeg: number;
samples: number | null;
events: boolean;
tier: "auto" | "capture" | "filter";
}
export interface Report {
@@ -48,3 +51,18 @@ export interface Tier {
busy: () => boolean;
rediscover: () => void;
}
export interface TierChoice {
tier: Config["tier"];
form: boolean;
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";
}
@@ -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<boolean>;
__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). */
@@ -52,8 +55,12 @@ const card = (animation: string, perspective = "perspective:400px") =>
const WAAPI = `<script>document.getElementById("card").animate([{backfaceVisibility:"hidden"},{backfaceVisibility:"hidden"}],1000)</script>`;
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 `<!doctype html><html><head><style>html,body{margin:0}${STYLE}</style></head><body>
<div id="root" data-composition-id="root" data-start="0" data-duration="1" data-width="${W}" data-height="${H}"${attrs}
style="position:relative;width:${W}px;height:${H}px;background:#fff;overflow:hidden">${body}</div></body></html>`;
@@ -101,10 +108,11 @@ describe.skipIf(!hasBrowser)("capture motion blur pixels in Chrome", () => {
}, 30_000);
afterAll(() => closeTestChrome(browser));
async function open(body: string, blur: boolean): Promise<Page> {
async function open(body: string, blur: boolean, tier?: Tier): Promise<Page> {
const page = await browser.newPage();
await page.setViewport({ width: W, height: H, deviceScaleFactor: 1 });
await page.setContent(composition(body, blur));
await page.evaluate(acceleratedCanvasTrackerSource());
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(() => {
@@ -141,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);
@@ -165,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);
@@ -175,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);
@@ -202,66 +212,96 @@ 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 = `<div style="position:absolute;left:0;top:0;width:${W}px;height:100px;background:repeating-linear-gradient(90deg,#000 0 2px,#fff 2px 4px)"></div>
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 = `<div style="position:absolute;left:0;top:0;width:${W}px;height:100px;background:repeating-linear-gradient(90deg,#000 0 2px,#fff 2px 4px)"></div>
<div class="box" style="left:20px;top:30px;background:transparent">
<div style="position:absolute;left:16px;top:16px;width:8px;height:8px;background:#f00"></div>
</div>`;
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 = `<div class="box" style="left:20px;top:80px;box-shadow:0 0 24px 0 #f00"></div>`;
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 = `<div class="box" style="left:20px;top:80px;box-shadow:0 0 24px 0 #f00"></div>`;
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 a box centred with translate around where it really is", async (ctx) => {
it("blurs with filters where capture cannot run, over a page that has a WebGL canvas", 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);
const gl = `<canvas id="gl" width="64" height="36" style="position:absolute;left:0;top:0;width:${W}px;height:${H}px"></canvas><script>document.getElementById("gl").getContext("webgl")</script>`;
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);
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);
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.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();
const blurred = await frameOf(FLIP, true, 0.4);
@@ -276,7 +316,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);
}
});
@@ -289,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);
}
@@ -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: {
@@ -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<Page, "evaluate">): Promise<string | null> {
@@ -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
@@ -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<Record<string, unknown>> {
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<string, unknown>;
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<string, unknown>)[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);
});
});
+4
View File
@@ -741,6 +741,10 @@ const FALLOW_PRUNE_REASON =
"fallow 3 reuses one audit snapshot per checkout and the hook prunes with `fallow audit-cache prune`, replacing this script";
export const ALLOWED_DELETIONS = new Map([
[
"packages/studio/src/components/renders/useRenderQueueMotionBlur.test.tsx",
"Studio no longer sends previewMotionBlur with renders: blur picks its tier from the page, so the test of that field goes with it",
],
[
"packages/cli/src/beats/headlessAnalyzer.ts",
"the TypeScript beat tracker replaces the Chrome analyzer; the beats command decodes with ffmpeg directly",
+1 -1
View File
@@ -6,7 +6,7 @@
"files": 2
},
"hyperframes": {
"hash": "66d328f5c449b403",
"hash": "7a5df5b496053c19",
"files": 77
},
"hyperframes-cli": {
+1 -1
View File
@@ -5,7 +5,7 @@
Measured frame by frame from the official HyperFrames launch films. These are defaults: a reference the person gave, or a Moment that does otherwise on purpose, wins for its own beat.
- **Moves**: a fast move takes 0.3–0.6 s and carries motion blur on its own ease, peaking at its fastest frame: 6–12 px on text-sized elements, 15–20 px full-frame, horizontal-only on a horizontal move. One move is one tween.
- **Render-time blur**: for fast 2D movers, `data-motion-blur="on"` on the composition root smears them along their real path in preview and render (opt-in; also `data-motion-blur-shutter`, `-samples`, `-events="on"` for callback-driven motion). Suggest it when the person asks for blur or a filmic look and the piece moves fast; skip it when the composition has a WebGL canvas, a 3D flip with `backface-visibility`, a root transform or opacity, or an HDR render, because blur turns itself off there. After adding it run `hyperframes check`: `motion_blur_off` names the cause. About 1.7x render time. Guide: /guides/motion-blur.
- **Render-time blur**: for fast 2D movers, `data-motion-blur="on"` on the composition root smears them along their real path in preview and render (opt-in; also `data-motion-blur-shutter` for a longer smear, 360 is strong; `-events="on"` when the motion is set from a timeline callback such as a `render(t)` function). Suggest it when the person asks for blur or a filmic look and the piece moves fast. It picks how to draw on its own: a cheap per-element filter for translation, the truer capture for spins and zooms; it works over WebGL and in any Chrome, and is off only for HDR and distributed renders. Opt a subtree out with `data-motion-blur="off"`, e.g. a seam that already carries its own blur. After adding it run `hyperframes check`. Guide: /guides/motion-blur.
- **Arrive slow, leave clean**: long-tailed ease-outs on arrivals; landing takes longer than leaving. An exit is one smooth motion, never slide, stop, then leave. Entrances that are too snappy read as glitches; slowing the whole video by about a second often fixes it.
- **Ease by role**: an arrival lands on a long tail (`expo.out`, `power4.out`), a camera move or a colour flood eases both ways (`power2.inOut`), a zoom through or an exit accelerates (`power3.in`). The same `power2.out` at 0.5 s on everything is the flat default that makes a film feel generated. Linear only for rotation, progress and constant speed that is the point.
- **Springs** are named by damping ratio: 1.0 never bounces, 0.7-0.85 reads premium, 0.4-0.6 playful, under 0.3 a cartoon. Position and size may bounce; opacity and colour never do. GSAP's `elastic.out(1, 0.5)` and `back.out(1.4)` are the nearest built-ins; a closed-form spring of `t` keeps any frame renderable on its own.