Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 28 additions & 29 deletions docs/guides/motion-blur.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -22,66 +22,65 @@ 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). |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: capture does not strictly force. chooseTier returns filter for tier: "capture" when capture cannot run, and a later scan defect hands off too. So the advice under Cost and limits ("Force one tier … for an exact match") does not hold in a preview browser without HTML in Canvas. Suggest wording such as "capture prefers capture and falls back to filter where it cannot run".


## 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. |
| A transform on the root element | Ignored by the capture. |
| 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
```

| 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.
8 changes: 7 additions & 1 deletion packages/cli/src/utils/checkMotionBlur.page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
41 changes: 33 additions & 8 deletions packages/cli/src/utils/checkMotionBlur.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {

const off = (reasonCode: string | null, reason = "because"): MotionBlurProbe => ({
state: "off",
tier: null,
reason,
reasonCode,
movers: 0,
Expand All @@ -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([
Expand All @@ -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",
Expand Down
19 changes: 17 additions & 2 deletions packages/cli/src/utils/checkMotionBlur.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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';
Expand All @@ -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,
},
];
Expand All @@ -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 [
{
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading