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
8 changes: 7 additions & 1 deletion packages/cli/src/utils/checkMotionBlur.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,13 @@ describe("motionBlurDrafts", () => {
it("informs when blur is on but nothing moves", () => {
expect(
motionBlurDrafts({ state: "on", reason: null, reasonCode: null, movers: 0 }),
).toMatchObject([{ code: "motion_blur_no_movers", severity: "info" }]);
).toMatchObject([
{
code: "motion_blur_no_movers",
severity: "info",
fixHint: expect.stringContaining("data-motion-blur-events"),
},
]);
});
});

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/utils/checkMotionBlur.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export function motionBlurDrafts(probe: MotionBlurProbe | null): MotionBlurDraft
severity: "info",
message: `${ASKED} nothing in the composition moves fast enough to blur, so it has no effect`,
fixHint:
"animate an element's transform or position by 2+ px per frame, or drop data-motion-blur",
'animate an element\'s transform or position by 2+ px per frame; if the motion is set from a timeline callback (onUpdate, render(t)), add data-motion-blur-events="on"; otherwise drop data-motion-blur',
time: 0,
},
];
Expand Down
25 changes: 18 additions & 7 deletions packages/core/src/runtime/captureMotionBlur/composite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,18 @@ import { createCompositor, type MoverFrame } from "./composite";
import { IDENTITY, translate } from "./pose";

function fakeContext() {
return {
drawImage: vi.fn(),
const alphas: number[] = [];
const ctx = {
drawImage: vi.fn(() => {
alphas.push(ctx.globalAlpha);
}),
alphas,
clearRect: vi.fn(),
setTransform: vi.fn(),
globalAlpha: 1,
globalCompositeOperation: "source-over",
};
return ctx;
}
let offscreen: Array<ReturnType<typeof fakeContext>> = [];

Expand Down Expand Up @@ -47,8 +52,9 @@ describe("createCompositor", () => {
it("keeps no crop for a mover that does not travel a pixel, and draws only the background", () => {
const c = createCompositor(100, 100);
const movers = [mover(10, 10, slide(0, 0.5))];
const kept = c.crop(capture, movers);
const kept = c.plan(movers);
expect(kept).toEqual([false]);
c.crop(capture, movers, kept);
const out = fakeContext();
expect(c.draw(background, out as never, movers, kept)).toEqual({ blurred: 0, taps: 0 });
expect(out.drawImage).toHaveBeenCalledTimes(1);
Expand All @@ -58,30 +64,35 @@ describe("createCompositor", () => {
it("crops a moving mover once and lays one tap per draw over the background", () => {
const c = createCompositor(1000, 100);
const movers = [mover(10, 10, slide(0, 100))];
const kept = c.crop(capture, movers);
const kept = c.plan(movers);
expect(kept).toEqual([true]);
c.crop(capture, movers, kept);
const crop = offscreen.at(-1);
expect(crop?.drawImage).toHaveBeenCalledWith(capture, 0, 0, 1000, 100);
const out = fakeContext();
const stats = c.draw(background, out as never, movers, kept);
const accum = offscreen[0];
expect(stats.blurred).toBe(1);
expect(accum?.drawImage).toHaveBeenCalledTimes(stats.taps);
expect(accum?.globalAlpha).toBeCloseTo(1 / stats.taps, 9);
// Whole 1/255 units, because canvas quantizes image alpha to them: the taps sum to 1 exactly.
const units = (accum?.alphas ?? []).map((a) => Math.round(a * 255));
expect(units).toHaveLength(stats.taps);
expect(units.reduce((sum, u) => sum + u, 0)).toBe(255);
// Background first, then the trail over it.
expect(out.drawImage.mock.calls[0]).toEqual([background, 0, 0]);
expect(out.drawImage).toHaveBeenCalledTimes(2);
});

it("leaves a mover sharp when its box is too big to copy every frame", () => {
const c = createCompositor(100, 100);
expect(c.crop(capture, [mover(1000, 1000, slide(0, 100))])).toEqual([false]);
expect(c.plan([mover(1000, 1000, slide(0, 100))])).toEqual([false]);
});

it("works in device pixels: scaled buffers, transforms and dirty rect", () => {
const c = createCompositor(1000, 100, 2);
const movers = [mover(10, 10, slide(0, 100))];
const kept = c.crop(capture, movers);
const kept = c.plan(movers);
c.crop(capture, movers, kept);
const crop = offscreen.at(-1);
// now = translate(50, 0): the inverse maps the capture into the mover's local space.
expect(crop?.setTransform.mock.calls.at(-1)?.map((v: number) => v + 0)).toEqual([
Expand Down
41 changes: 25 additions & 16 deletions packages/core/src/runtime/captureMotionBlur/composite.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
/**
* The per-frame draw: a background, then each mover's own pixels redrawn along its path.
*
* Each mover is cropped from a capture of the scene and drawn at every tap pose into a float
* buffer, additive at weight 1/n: the shutter integral. Its coverage fades at the path ends, so
* the trail needs a background without the mover (a second capture, movers hidden); falling back
* to the sharp frame leaves thin lines looking unblurred.
* Each mover is cropped from a capture of the movers alone, on a transparent background, and
* drawn at every tap pose into a float buffer, additive at weight 1/n: the shutter integral. Its
* coverage fades at the path ends, so the trail goes over a background without the movers (a
* second capture, movers hidden).
*
* Image-space approximation: a mover's box contents move with it; ink outside it is not redrawn.
* Image-space approximation: a mover's box and the glow around it move together.
*/

import { type Affine, invert, sweep, thinTaps } from "./pose";
import { type Affine, invert, sweep, tapWeights, thinTaps } from "./pose";

export interface MoverFrame {
w: number;
Expand Down Expand Up @@ -74,12 +74,21 @@ export function createCompositor(width: number, height: number, dpr = 1) {
return layer;
};

/** Copy each mover's frame-time pixels out of `src`; false marks a mover to leave sharp. */
function crop(src: CanvasImageSource, movers: MoverFrame[]): boolean[] {
return movers.map((mover, i) => {
const { w, h } = mover;
function plan(movers: MoverFrame[]): boolean[] {
return movers.map(
(mover) =>
invert(mover.now) !== null &&
mover.w * mover.h <= maxCropArea &&
moves(mover, width, height),
);
}

/** Copy each kept mover's frame-time pixels out of `src`. */
function crop(src: CanvasImageSource, movers: MoverFrame[], kept: boolean[]): void {
movers.forEach((mover, i) => {
const back = invert(mover.now);
if (!back || w * h > maxCropArea || !moves(mover, width, height)) return false;
if (!kept[i] || !back) return;
const { w, h } = mover;
const c = cropLayer(i, w, h).ctx;
c.setTransform(1, 0, 0, 1, 0, 0);
c.globalCompositeOperation = "source-over";
Expand All @@ -94,7 +103,6 @@ export function createCompositor(width: number, height: number, dpr = 1) {
dpr * back.f,
);
c.drawImage(src, 0, 0, width, height);
return true;
});
}

Expand All @@ -114,11 +122,12 @@ export function createCompositor(width: number, height: number, dpr = 1) {
a.globalAlpha = 1;
a.clearRect(rx, ry, rw, rh);
a.globalCompositeOperation = "lighter";
a.globalAlpha = 1 / taps.length;
for (const m of taps) {
const weights = tapWeights(taps.length);
taps.forEach((m, k) => {
a.globalAlpha = weights[k] ?? 0;
a.setTransform(dpr * m.a, dpr * m.b, dpr * m.c, dpr * m.d, dpr * m.e, dpr * m.f);
a.drawImage(layer.canvas, 0, 0, w * dpr, h * dpr, 0, 0, w, h);
}
});
out.drawImage(accum.canvas, rx, ry, rw, rh, rx, ry, rw, rh);
return taps.length;
}
Expand Down Expand Up @@ -148,5 +157,5 @@ export function createCompositor(width: number, height: number, dpr = 1) {
return stats;
}

return { crop, draw };
return { plan, crop, draw };
}
17 changes: 15 additions & 2 deletions packages/core/src/runtime/captureMotionBlur/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*/

import { isHtmlElement } from "../domRealm";
import { inkPad } from "./ink";
import { individualIs3d } from "./pose";

/** Under this peak speed (px per frame) a smear is invisible, so the element stays sharp. */
Expand Down Expand Up @@ -43,6 +44,8 @@ export interface Discovery {
movers: HTMLElement[];
/** Every element whose pose changes at all; their transforms are never cached. */
dynamic: Set<Element>;
/** Px each mover's ink reaches past its box (shadows, glows), the largest seen. */
pads: Map<HTMLElement, number>;
}

// The whole inline style in one read; opacity or colour tweens only cost a rect check.
Expand Down Expand Up @@ -180,6 +183,10 @@ export function discoverMovers({
const framesPerStep = frames / steps;
const fast = new Set<HTMLElement>();
const threeD = new Set<HTMLElement>();
const pads = new Map<HTMLElement, number>();
const measure = (el: HTMLElement): void => {
pads.set(el, Math.max(pads.get(el) ?? 0, inkPad(el)));
};
const seeThrough = new Set<HTMLElement>();
const alphaCache = new Map<Bitmap, boolean>();
const sceneRect = scene.getBoundingClientRect();
Expand Down Expand Up @@ -212,6 +219,7 @@ export function discoverMovers({
rects.forEach((box, j) => {
const el = all[j];
if (!el || edgeShift(prev[j], box) / framesPerStep < MIN_SPEED) return;
if (!fast.has(el)) measure(el);
fast.add(el);
if (
box &&
Expand All @@ -222,13 +230,18 @@ export function discoverMovers({
seeThrough.add(el);
});
// Style reads up the chain are the costly part; every 10th step is enough to see a 3D turn.
if (i % 10 === 0 || i === steps) for (const el of fast) if (in3d(el, scene)) threeD.add(el);
if (i % 10 === 0 || i === steps) {
for (const el of fast) {
if (in3d(el, scene)) threeD.add(el);
measure(el);
}
}
prev = rects;
}

const candidates = all.filter(
(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 };
return { movers, dynamic, pads };
}
99 changes: 82 additions & 17 deletions packages/core/src/runtime/captureMotionBlur/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -642,17 +642,19 @@ describe("wrapping", () => {
expect(captureCanvas(root).tagName).toBe("CANVAS");
});

it("shows the first frame after wrapping sharp, not the last one from before", () => {
it("clears the output when wrapping, so the last frame of a previous wrap never shows", () => {
installHtmlInCanvas();
const { root, h } = movingHost();
initCaptureMotionBlur(h);
void paintCaptureMotionBlur(0.5, PLAY);
captureCanvas(root).dispatchEvent(new Event("paint"));
expect(contexts[1]?.drawImage).toHaveBeenCalledWith(captureCanvas(root), 0, 0);
expect(contexts[1]?.clearRect).toHaveBeenCalledWith(0, 0, 640, 360);
expect(captureCanvas(root).tagName).toBe("CANVAS");
});
});

const HIDE = "data-hf-motion-blur-hide";
const ONLY = "data-hf-motion-blur-only";
const MOVER = "data-hf-motion-blur-mover";

describe("two-phase frames", () => {
it("hides the movers for a background capture, then reveals them and settles", async () => {
Expand All @@ -670,6 +672,51 @@ describe("two-phase frames", () => {
expect(captureContext()?.drawElementImage).toHaveBeenCalledTimes(2);
});

it("captures the movers alone first, then swaps to hiding them for the background", async () => {
installHtmlInCanvas();
const { root, el, h } = movingHost();
initCaptureMotionBlur(h);
const done = paintCaptureMotionBlur(0.5, PLAY);
expect(root.hasAttribute(ONLY)).toBe(true);
expect(el.hasAttribute(MOVER)).toBe(true);
expect(el.hasAttribute(HIDE)).toBe(false);
captureCanvas(root).dispatchEvent(new Event("paint"));
expect(root.hasAttribute(ONLY)).toBe(false);
expect(el.hasAttribute(MOVER)).toBe(false);
expect(el.hasAttribute(HIDE)).toBe(true);
captureCanvas(root).dispatchEvent(new Event("paint"));
await done;
expect(root.hasAttribute(ONLY)).toBe(false);
expect(el.hasAttribute(HIDE)).toBe(false);
});

it("clears the isolation if the frame is given up on mid-way", async () => {
vi.useFakeTimers();
installHtmlInCanvas();
const { root, el, h } = movingHost();
initCaptureMotionBlur(h);
void paintCaptureMotionBlur(0.5, PLAY);
expect(root.hasAttribute(ONLY)).toBe(true);
await vi.advanceTimersByTimeAsync(2000);
expect(root.hasAttribute(ONLY)).toBe(false);
expect(el.hasAttribute(MOVER)).toBe(false);
});

it("shows plain DOM, not a half frame, when the engine's step runs before the background capture", async () => {
installHtmlInCanvas();
const { root, el, h } = movingHost();
initCaptureMotionBlur(h);
const done = paintCaptureMotionBlur(0.5, { engineMode: true });
releaseCaptureMotionBlurFrame();
captureCanvas(root).dispatchEvent(new Event("paint"));
expect(el.hasAttribute(HIDE)).toBe(true);
win.__hf_page_composite_resolve?.();
await done;
expect(win.__hfMotionBlur?.report()).toMatchObject({ failedFrames: 1 });
expect(root.parentElement).toBe(document.body);
expect(el.hasAttribute(HIDE)).toBe(false);
});

it("is busy from the first paint request until the frame is drawn (R11)", async () => {
installHtmlInCanvas();
const { root, h } = movingHost();
Expand Down Expand Up @@ -817,6 +864,18 @@ describe("painting", () => {
expect(h.seek.mock.calls.at(-1)?.[0]).toBe(0.5);
});

it("scans with callbacks on when the root allows events, so callback-driven movers are found", () => {
installHtmlInCanvas();
const eventful = movingHost({
"data-motion-blur": "on",
"data-motion-blur-events": "on",
}).h;
initCaptureMotionBlur(eventful);
win.__hfMotionBlur?.probe();
expect(eventful.seek.mock.calls.length).toBeGreaterThan(10);
expect(eventful.seek.mock.calls.every(([, suppress]) => suppress === false)).toBe(true);
});

it("seeks pose reads with events suppressed unless the root allows them", () => {
installHtmlInCanvas();
const quiet = host(makeRoot());
Expand All @@ -836,20 +895,26 @@ describe("painting", () => {
expect(calls.some(([, suppress]) => suppress === false)).toBe(true);
});

it("never fires timeline callbacks during discovery, even with events on", () => {
installHtmlInCanvas();
const { h } = movingHost({ "data-motion-blur": "on", "data-motion-blur-events": "on" });
const fired: number[] = [];
const callAtOnePointSeven = (t: number, suppress: boolean) => {
h.seek(t, suppress);
if (!suppress && t >= 0.7) fired.push(t);
};
initCaptureMotionBlur({ ...h, seek: callAtOnePointSeven });
void paintCaptureMotionBlur(0.1, PLAY);
expect(win.__hfMotionBlur?.report().movers).toBe(1);
expect(fired).toEqual([]);
const quiet = h.seek.mock.calls.filter(([, suppress]) => suppress === true);
expect(quiet.some(([t]) => t === 0.1)).toBe(true);
it("fires timeline callbacks during discovery only when the root allows events", () => {
for (const events of [true, false]) {
installHtmlInCanvas();
const attrs: Record<string, string> = { "data-motion-blur": "on" };
if (events) attrs["data-motion-blur-events"] = "on";
const { h } = movingHost(attrs);
const fired: number[] = [];
const callAtOnePointSeven = (t: number, suppress: boolean) => {
h.seek(t, suppress);
if (!suppress && t >= 0.7) fired.push(t);
};
initCaptureMotionBlur({ ...h, seek: callAtOnePointSeven });
void paintCaptureMotionBlur(0.1, PLAY);
expect(win.__hfMotionBlur?.report().movers).toBe(1);
expect(fired.length > 0).toBe(events);
resetCaptureMotionBlurForTests();
vi.unstubAllGlobals();
vi.restoreAllMocks();
document.body.innerHTML = "";
}
});

it("in engine mode composes with the prior composite owner and draws even without a paint event", () => {
Expand Down
Loading
Loading