From 9d8d063b4f49080b70305b01ac8649e5966bcaec Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 22 Oct 2026 17:29:37 -0700 Subject: [PATCH 1/3] fix(core): capture blur sizes its taps to the smear and weights them exactly A fast move strobed at the default 16 taps (8 px steps on a 1560 px whip). With no data-motion-blur-samples set, taps are now about one per 2 px of travel (2 to 128); an explicit samples count still wins. Canvas quantizes image globalAlpha to 1/255, so 1/n weights left coverage short (n = 104 summed to 0.81, a lighter card body). Each tap now carries a whole number of 1/255 units that sum to exactly 255. Co-Authored-By: Claude Sonnet 5.5 --- .../captureMotionBlur/composite.test.ts | 14 ++++++-- .../runtime/captureMotionBlur/composite.ts | 9 +++--- .../src/runtime/captureMotionBlur/index.ts | 32 ++++++++++++------- .../runtime/captureMotionBlur/pose.test.ts | 30 +++++++++++++++++ .../src/runtime/captureMotionBlur/pose.ts | 26 +++++++++++++++ 5 files changed, 93 insertions(+), 18 deletions(-) diff --git a/packages/core/src/runtime/captureMotionBlur/composite.test.ts b/packages/core/src/runtime/captureMotionBlur/composite.test.ts index efcdc190ee9..be258b6197f 100644 --- a/packages/core/src/runtime/captureMotionBlur/composite.test.ts +++ b/packages/core/src/runtime/captureMotionBlur/composite.test.ts @@ -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> = []; @@ -67,7 +72,10 @@ describe("createCompositor", () => { 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); diff --git a/packages/core/src/runtime/captureMotionBlur/composite.ts b/packages/core/src/runtime/captureMotionBlur/composite.ts index 49da8ad5e2d..762bd8fdc5c 100644 --- a/packages/core/src/runtime/captureMotionBlur/composite.ts +++ b/packages/core/src/runtime/captureMotionBlur/composite.ts @@ -9,7 +9,7 @@ * Image-space approximation: a mover's box contents move with it; ink outside it is not redrawn. */ -import { type Affine, invert, sweep, thinTaps } from "./pose"; +import { type Affine, invert, sweep, tapWeights, thinTaps } from "./pose"; export interface MoverFrame { w: number; @@ -114,11 +114,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; } diff --git a/packages/core/src/runtime/captureMotionBlur/index.ts b/packages/core/src/runtime/captureMotionBlur/index.ts index f3b19b0d2f8..cdb20cfb989 100644 --- a/packages/core/src/runtime/captureMotionBlur/index.ts +++ b/packages/core/src/runtime/captureMotionBlur/index.ts @@ -1,7 +1,7 @@ /** * Capture motion blur, in preview and render. Root attributes: data-motion-blur="on"|"off", - * -shutter (0–720°, default 180), -samples (2–64, default 16), -events="on" (run timeline - * callbacks at sub-frame samples). PAGE_MOTION_BLUR_OFF_GLOBAL (engine blur, or a preview that + * -shutter (0–720°, default 180), -samples (2–64; unset sizes the taps to the 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. * * Per frame: read movers' poses across the shutter (seeks, no paint), capture the root and crop @@ -36,7 +36,7 @@ import { import { swallow } from "../diagnostics"; import { createCompositor, type MoverFrame } from "./composite"; import { discoverMovers } from "./discover"; -import { type Affine, createPoseReader, IDENTITY, tapsFromReads } from "./pose"; +import { type Affine, autoTaps, createPoseReader, IDENTITY, tapsFromReads } from "./pose"; export interface CaptureMotionBlurHost { root: HTMLElement; @@ -52,7 +52,7 @@ export interface CaptureMotionBlurHost { interface Config { shutterDeg: number; - samples: number; + samples: number | null; events: boolean; } @@ -118,7 +118,9 @@ function readConfig(root: HTMLElement): Config | null { }; return { shutterDeg: clamp(num("data-motion-blur-shutter", 180), 0, 720), - samples: Math.round(clamp(num("data-motion-blur-samples", 16), 2, 64)), + 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", }; } @@ -298,12 +300,20 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI } seek(t); r.reset(); - const result = visible.map((el, i) => ({ - w: el.offsetWidth, - h: el.offsetHeight, - now: r.pose(el), - taps: tapsFromReads(poses[i] ?? [IDENTITY], config.samples), - })); + const result = visible.map((el, i) => { + const w = el.offsetWidth; + const h = el.offsetHeight; + const reads = poses[i] ?? [IDENTITY]; + return { + w, + h, + now: r.pose(el), + taps: + config.samples === null + ? autoTaps(reads, w, h, width, height) + : tapsFromReads(reads, config.samples), + }; + }); r.reset(); return { els: visible, frames: result }; } diff --git a/packages/core/src/runtime/captureMotionBlur/pose.test.ts b/packages/core/src/runtime/captureMotionBlur/pose.test.ts index 42c8967108d..369e35ce751 100644 --- a/packages/core/src/runtime/captureMotionBlur/pose.test.ts +++ b/packages/core/src/runtime/captureMotionBlur/pose.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { type Affine, IDENTITY, + autoTaps, corners, createPoseReader, individualIs3d, @@ -11,6 +12,7 @@ import { multiply, parseTransform, sweep, + tapWeights, tapsFromReads, thinTaps, translate, @@ -92,6 +94,34 @@ describe("taps", () => { expect(thinTaps(taps, 300)).toHaveLength(32); }); + it("sizes taps to the smear: about one per 2 px, so a fast move has no steps", () => { + const reads = [translate(IDENTITY, 0, 0), translate(IDENTITY, 200, 0)]; + const taps = autoTaps(reads, 100, 50, 1920, 1080); + expect(taps.length).toBeGreaterThanOrEqual(90); + expect(taps.length).toBeLessThanOrEqual(105); + const steps = taps.slice(1).map((m, i) => m.e - (taps[i] as Affine).e); + expect(Math.max(...steps)).toBeLessThanOrEqual(2.3); + }); + + it("uses two taps for a move too small to show and caps a huge one", () => { + expect( + autoTaps([translate(IDENTITY, 0, 0), translate(IDENTITY, 0.5, 0)], 100, 50, 1920, 1080), + ).toHaveLength(2); + expect( + autoTaps([translate(IDENTITY, 0, 0), translate(IDENTITY, 5000, 0)], 100, 50, 1920, 1080), + ).toHaveLength(128); + }); + + it("weights taps in whole 1/255 units that sum to exactly 1, for any count", () => { + for (const n of [2, 3, 16, 40, 60, 80, 94, 104, 113, 127, 128, 255]) { + const w = tapWeights(n); + expect(w).toHaveLength(n); + const units = w.map((x) => Math.round(x * 255)); + expect(units.reduce((s, u) => s + u, 0)).toBe(255); + expect(Math.max(...units) - Math.min(...units)).toBeLessThanOrEqual(1); + } + }); + it("places a rotated box's corners", () => { const [, tr] = corners(rot(90), 10, 0); expect(tr?.[0]).toBeCloseTo(0, 9); diff --git a/packages/core/src/runtime/captureMotionBlur/pose.ts b/packages/core/src/runtime/captureMotionBlur/pose.ts index 84956767916..4709ee64bd4 100644 --- a/packages/core/src/runtime/captureMotionBlur/pose.ts +++ b/packages/core/src/runtime/captureMotionBlur/pose.ts @@ -171,6 +171,32 @@ export function sweep(taps: Affine[], w: number, h: number, frameW: number, fram return { travel, rect }; } +/** Per-tap alphas, whole 1/255 units summing to 1: canvas quantizes image alpha to them. */ +export function tapWeights(n: number): number[] { + const base = Math.floor(255 / n); + const extra = 255 - base * n; + return Array.from( + { length: n }, + (_, i) => (base + Math.floor(((i + 1) * extra) / n) - Math.floor((i * extra) / n)) / 255, + ); +} + +const PROBE_TAPS = 16; + +/** Taps sized to the smear: about one per `spacing` px of travel, 2 to `max`. */ +export function autoTaps( + reads: Affine[], + w: number, + h: number, + frameW: number, + frameH: number, + spacing = 2, + max = 128, +): 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)))); +} + /** About one tap per `spacing` px of travel (no visible steps), at least 2, at most all. */ export function thinTaps(taps: Affine[], travel: number, spacing = 1.5): Affine[] { const n = Math.min(taps.length, Math.max(2, Math.ceil(travel / spacing))); From 9c1d418f16714ebd63d49fcf1079d297aee45646 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 22 Oct 2026 17:43:31 -0700 Subject: [PATCH 2/3] feat(core): capture blur draws the movers from their own transparent layer The crop came from a capture of the whole scene, so the backdrop under a see-through mover smeared with it (a lighter box with the dotted background erased) and a glow or shadow outside the mover's box was cut off, leaving a hard-edged box. Each frame now captures the movers alone, everything else visibility:hidden, so a mover's pixels carry their true alpha and no backdrop; the background capture is unchanged. The crop grows by the mover's ink reach (box-shadow, text-shadow, filter, outline, measured during the scan), so glows travel with it. Two paints per frame, as before. The output is cleared on wrap instead of showing the previous wrap's last frame, since the first paint is no longer a drawable sharp frame. Core's unpacked size budget goes to 3,950,000 for the new module. Co-Authored-By: Claude Sonnet 5.5 --- .../captureMotionBlur/composite.test.ts | 11 ++- .../runtime/captureMotionBlur/composite.ts | 32 +++--- .../src/runtime/captureMotionBlur/discover.ts | 17 +++- .../runtime/captureMotionBlur/index.test.ts | 53 +++++++++- .../src/runtime/captureMotionBlur/index.ts | 97 +++++++++++++------ .../src/runtime/captureMotionBlur/ink.test.ts | 55 +++++++++++ .../core/src/runtime/captureMotionBlur/ink.ts | 83 ++++++++++++++++ .../captureMotionBlur.pixels.browser.test.ts | 51 ++++++++++ scripts/package-size-budgets.json | 2 +- 9 files changed, 349 insertions(+), 52 deletions(-) create mode 100644 packages/core/src/runtime/captureMotionBlur/ink.test.ts create mode 100644 packages/core/src/runtime/captureMotionBlur/ink.ts diff --git a/packages/core/src/runtime/captureMotionBlur/composite.test.ts b/packages/core/src/runtime/captureMotionBlur/composite.test.ts index be258b6197f..925e78db296 100644 --- a/packages/core/src/runtime/captureMotionBlur/composite.test.ts +++ b/packages/core/src/runtime/captureMotionBlur/composite.test.ts @@ -52,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); @@ -63,8 +64,9 @@ 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(); @@ -83,13 +85,14 @@ describe("createCompositor", () => { 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([ diff --git a/packages/core/src/runtime/captureMotionBlur/composite.ts b/packages/core/src/runtime/captureMotionBlur/composite.ts index 762bd8fdc5c..bd2b53a053c 100644 --- a/packages/core/src/runtime/captureMotionBlur/composite.ts +++ b/packages/core/src/runtime/captureMotionBlur/composite.ts @@ -1,12 +1,12 @@ /** * 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, tapWeights, thinTaps } from "./pose"; @@ -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"; @@ -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; }); } @@ -149,5 +157,5 @@ export function createCompositor(width: number, height: number, dpr = 1) { return stats; } - return { crop, draw }; + return { plan, crop, draw }; } diff --git a/packages/core/src/runtime/captureMotionBlur/discover.ts b/packages/core/src/runtime/captureMotionBlur/discover.ts index 6276adb0973..93358d9ec1d 100644 --- a/packages/core/src/runtime/captureMotionBlur/discover.ts +++ b/packages/core/src/runtime/captureMotionBlur/discover.ts @@ -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. */ @@ -43,6 +44,8 @@ export interface Discovery { movers: HTMLElement[]; /** Every element whose pose changes at all; their transforms are never cached. */ dynamic: Set; + /** Px each mover's ink reaches past its box (shadows, glows), the largest seen. */ + pads: Map; } // The whole inline style in one read; opacity or colour tweens only cost a rect check. @@ -180,6 +183,10 @@ export function discoverMovers({ const framesPerStep = frames / steps; const fast = new Set(); const threeD = new Set(); + const pads = new Map(); + const measure = (el: HTMLElement): void => { + pads.set(el, Math.max(pads.get(el) ?? 0, inkPad(el))); + }; const seeThrough = new Set(); const alphaCache = new Map(); const sceneRect = scene.getBoundingClientRect(); @@ -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 && @@ -222,7 +230,12 @@ 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; } @@ -230,5 +243,5 @@ 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 }; + return { movers, dynamic, pads }; } diff --git a/packages/core/src/runtime/captureMotionBlur/index.test.ts b/packages/core/src/runtime/captureMotionBlur/index.test.ts index 66e8105d28f..d27509bd975 100644 --- a/packages/core/src/runtime/captureMotionBlur/index.test.ts +++ b/packages/core/src/runtime/captureMotionBlur/index.test.ts @@ -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 () => { @@ -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(); diff --git a/packages/core/src/runtime/captureMotionBlur/index.ts b/packages/core/src/runtime/captureMotionBlur/index.ts index cdb20cfb989..f984c08fec5 100644 --- a/packages/core/src/runtime/captureMotionBlur/index.ts +++ b/packages/core/src/runtime/captureMotionBlur/index.ts @@ -36,7 +36,14 @@ import { import { swallow } from "../diagnostics"; import { createCompositor, type MoverFrame } from "./composite"; import { discoverMovers } from "./discover"; -import { type Affine, autoTaps, createPoseReader, IDENTITY, tapsFromReads } from "./pose"; +import { + type Affine, + autoTaps, + createPoseReader, + IDENTITY, + tapsFromReads, + translate, +} from "./pose"; export interface CaptureMotionBlurHost { root: HTMLElement; @@ -82,15 +89,19 @@ type CompositeWindow = Window & { __hf_page_composite_resolve?: () => boolean; }; -/** Marks a mover hidden for the background capture; set only between a frame's two paints. */ +/** Marks a mover hidden for the background capture. */ const HIDE_ATTR = "data-hf-motion-blur-hide"; +/** On the root while the movers alone are captured; the movers carry MOVER_ATTR. */ +const ONLY_ATTR = "data-hf-motion-blur-only"; +const MOVER_ATTR = "data-hf-motion-blur-mover"; interface Job { seq: number; els: HTMLElement[]; frames: MoverFrame[]; - /** Which movers were cropped (null until the first capture of this frame). */ - kept: boolean[] | null; + kept: boolean[]; + /** What the next paint captures: the sharp frame, the movers alone, or the background. */ + phase: "sharp" | "movers" | "background"; /** Engine frames wait for a release: video frames are injected after the seek. */ armed: boolean; /** The paint wait starts on arming, so slow video injection cannot time a frame out unseen. */ @@ -192,9 +203,16 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI // Hides movers for the background capture without touching author or GSAP opacity. const hideRule = document.createElement("style"); hideRule.setAttribute("data-hf-ignore", ""); - hideRule.textContent = `[${HIDE_ATTR}] { opacity: 0 !important; }`; + hideRule.textContent = [ + `[${HIDE_ATTR}] { opacity: 0 !important; }`, + // The root stays visible: a rotated or scaled mover under a hidden root is captured at the origin, untransformed. + `[${ONLY_ATTR}] { background: none !important; border-color: transparent !important; box-shadow: none !important; outline-color: transparent !important; }`, + `[${ONLY_ATTR}]::before, [${ONLY_ATTR}]::after, [${ONLY_ATTR}] * { visibility: hidden !important; }`, + `[${ONLY_ATTR}] [${MOVER_ATTR}], [${ONLY_ATTR}] [${MOVER_ATTR}] * { visibility: visible !important; }`, + ].join("\n"); let movers: HTMLElement[] = []; + let pads = new Map(); let reader: ReturnType | null = null; let pending: Job | null = null; // While a frame is in flight (two paints), newer requests collapse into the latest one. @@ -214,8 +232,6 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI let faceNow: () => boolean = () => false; const loggedBackstops = new Set(); let wrapped = false; - // The output still shows the last frame of the previous wrap until this one draws. - let staleOutput = false; // Once the engine drives frames, paused-style requests come from the transport, not a user. let rendering = false; @@ -233,8 +249,10 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI src.after(out); document.head.append(hideRule); }); + // The output holds the last frame of the previous wrap: blank until this wrap draws its own. + octx.setTransform(1, 0, 0, 1, 0, 0); + octx.clearRect(0, 0, out.width, out.height); wrapped = true; - staleOutput = true; } /** Back to plain DOM: drop any frame in flight, put the root where it was. */ @@ -277,6 +295,7 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI scanDefect = defects.result(); faceNow = defects.facesNow; movers = found.movers; + pads = found.pads; reader = createPoseReader(root, found.dynamic); scanMs = Math.round(performance.now() - s0); } @@ -301,13 +320,16 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI seek(t); r.reset(); const result = visible.map((el, i) => { - const w = el.offsetWidth; - const h = el.offsetHeight; - const reads = poses[i] ?? [IDENTITY]; + // The mover's ink box: its own box grown by the glow and shadow that paint outside it. + const pad = pads.get(el) ?? 0; + const w = el.offsetWidth + 2 * pad; + const h = el.offsetHeight + 2 * pad; + const grow = (m: Affine): Affine => translate(m, -pad, -pad); + const reads = (poses[i] ?? [IDENTITY]).map(grow); return { w, h, - now: r.pose(el), + now: grow(r.pose(el)), taps: config.samples === null ? autoTaps(reads, w, h, width, height) @@ -323,7 +345,24 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI sctx.drawElementImage(root, 0, 0); }; const reveal = (job: Job): void => { - for (const el of job.els) el.removeAttribute(HIDE_ATTR); + root.removeAttribute(ONLY_ATTR); + for (const el of job.els) { + el.removeAttribute(HIDE_ATTR); + el.removeAttribute(MOVER_ATTR); + } + }; + /** Show the kept movers alone, on a transparent background, for the next paint. */ + const isolate = (job: Job): void => { + root.setAttribute(ONLY_ATTR, ""); + job.els.forEach((el, i) => job.kept[i] && el.setAttribute(MOVER_ATTR, "")); + }; + /** Swap to the background capture: kept movers hidden, the rest shown as usual. */ + const hideMovers = (job: Job): void => { + root.removeAttribute(ONLY_ATTR); + job.els.forEach((el, i) => { + el.removeAttribute(MOVER_ATTR); + if (job.kept[i]) el.setAttribute(HIDE_ATTR, ""); + }); }; /** @@ -364,24 +403,20 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI disable(`capture failed ${failures} times in a row`, "capture-failures", true); } - /** This paint's half of the frame; false when the background capture is still to come. */ + /** This paint's capture; false when another paint is still to come for this frame. */ function captureAndDraw(job: Job, finalStep: boolean): boolean { capture(); try { - if (!job.kept) { - job.kept = compositor.crop(src, job.frames); - if (staleOutput) { - // First paint since wrapping: show this frame sharp until its trails are drawn. - octx.setTransform(1, 0, 0, 1, 0, 0); - octx.globalAlpha = 1; - octx.globalCompositeOperation = "copy"; - octx.drawImage(src, 0, 0); - staleOutput = false; - } - if (job.kept.some(Boolean) && !finalStep) { - job.els.forEach((el, i) => job.kept?.[i] && el.setAttribute(HIDE_ATTR, "")); - return false; - } + if (job.phase === "sharp") { + compositor.draw(src, octx, job.frames, job.kept); + return true; + } + if (finalStep) throw new Error(`the ${job.phase} capture did not arrive`); + if (job.phase === "movers") { + compositor.crop(src, job.frames, job.kept); + hideMovers(job); + job.phase = "background"; + return false; } compositor.draw(src, octx, job.frames, job.kept); return true; @@ -484,7 +519,6 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI canvas.height = Math.max(1, Math.round(height * dpr)); } compositor = createCompositor(width, height, dpr); - staleOutput = true; } /** Scan when the timeline is new; true when the scan found a defect (blur is then off). */ @@ -518,6 +552,7 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI wrap(); const s0 = performance.now(); const next = frames(t, engineMode ? RENDER_READS : PREVIEW_READS); + const kept = compositor.plan(next.frames); sampleMs = performance.now() - s0; if (pending) finishJob(pending); return new Promise((resolve) => { @@ -533,7 +568,8 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI seq: mine, els: next.els, frames: next.frames, - kept: null, + kept, + phase: kept.some(Boolean) ? "movers" : "sharp", armed: !engineMode, startTimer, ms: 0, @@ -542,6 +578,7 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI resolve(); }, }; + if (pending.phase === "movers") isolate(pending); if (engineMode) return; startTimer(); src.requestPaint(); diff --git a/packages/core/src/runtime/captureMotionBlur/ink.test.ts b/packages/core/src/runtime/captureMotionBlur/ink.test.ts new file mode 100644 index 00000000000..b7733de8ac7 --- /dev/null +++ b/packages/core/src/runtime/captureMotionBlur/ink.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { filterReach, inkPad, shadowReach } from "./ink"; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +describe("shadowReach", () => { + it("adds offset, blur and spread of the widest outer shadow", () => { + expect(shadowReach("rgba(255, 90, 31, 0.5) 0px 8px 24px 4px")).toBe(36); + expect(shadowReach("rgb(0, 0, 0) 2px 3px 5px 0px, rgba(0, 0, 0, 0.2) 0px 20px 40px 0px")).toBe( + 60, + ); + }); + + it("ignores inset shadows and none", () => { + expect(shadowReach("rgba(0, 0, 0, 0.5) 0px 0px 30px 0px inset")).toBe(0); + expect(shadowReach("none")).toBe(0); + }); +}); + +describe("filterReach", () => { + it("reads drop-shadow() like a shadow and blur() as three sigmas", () => { + expect(filterReach("drop-shadow(rgba(0, 0, 0, 0.4) 0px 4px 8px)")).toBe(16); + expect(filterReach("blur(6px)")).toBe(18); + expect(filterReach("none")).toBe(0); + }); + + it("does not read the blur of a drop-shadow as a blur()", () => { + expect(filterReach("drop-shadow(rgb(0, 0, 0) 0px 0px 10px)")).toBe(15); + }); +}); + +describe("inkPad", () => { + it("covers a glow on the mover and on a descendant, and nothing for a plain box", () => { + const plain = document.createElement("div"); + const glow = document.createElement("div"); + const outer = document.createElement("div"); + const inner = document.createElement("span"); + glow.style.boxShadow = "rgba(255, 90, 31, 0.5) 0px 0px 20px 0px"; + inner.style.boxShadow = "rgba(0, 0, 0, 0.5) 0px 0px 30px 0px"; + outer.append(inner); + document.body.append(plain, glow, outer); + expect(inkPad(plain)).toBe(0); + expect(inkPad(glow)).toBe(20); + expect(inkPad(outer)).toBe(30); + }); + + it("is capped", () => { + const el = document.createElement("div"); + el.style.boxShadow = "rgb(0, 0, 0) 0px 0px 900px 0px"; + document.body.append(el); + expect(inkPad(el)).toBe(256); + }); +}); diff --git a/packages/core/src/runtime/captureMotionBlur/ink.ts b/packages/core/src/runtime/captureMotionBlur/ink.ts new file mode 100644 index 00000000000..f2260933e21 --- /dev/null +++ b/packages/core/src/runtime/captureMotionBlur/ink.ts @@ -0,0 +1,83 @@ +/** How far a mover's own paint reaches past its box: shadows, glows, outlines and filters. */ + +import { isHtmlElement } from "../domRealm"; + +const MAX_PAD = 256; +const NODE_LIMIT = 64; + +const COLOR = /(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\([^)]*\)/g; + +/** The px lengths of one shadow value, colour and `inset` removed. */ +function lengths(value: string): number[] { + return [...value.replace(COLOR, " ").matchAll(/-?\d*\.?\d+(?=px|\s|$)/g)].map((m) => + Number(m[0]), + ); +} + +/** Split on commas that are not inside parentheses. */ +function topLevel(value: string): string[] { + const parts: string[] = []; + let depth = 0; + let from = 0; + for (let i = 0; i < value.length; i++) { + const ch = value[i]; + if (ch === "(") depth++; + else if (ch === ")") depth--; + else if (ch === "," && depth === 0) { + parts.push(value.slice(from, i)); + from = i + 1; + } + } + parts.push(value.slice(from)); + return parts; +} + +/** Reach of a box- or text-shadow list: offset, blur and spread, outer shadows only. */ +export function shadowReach(value: string): number { + if (!value || value === "none") return 0; + return Math.max( + 0, + ...topLevel(value) + .filter((s) => !/\binset\b/.test(s)) + .map((s) => { + const [dx = 0, dy = 0, blur = 0, spread = 0] = lengths(s); + return Math.max(Math.abs(dx), Math.abs(dy)) + blur + Math.max(0, spread); + }), + ); +} + +/** Reach of a `filter` value: drop-shadow() like a shadow, blur() three sigmas. */ +export function filterReach(value: string): number { + if (!value || value === "none") return 0; + let reach = 0; + for (const m of value.matchAll(/drop-shadow\(((?:[^()]|\([^)]*\))*)\)/g)) { + const [dx = 0, dy = 0, blur = 0] = lengths(m[1] ?? ""); + reach = Math.max(reach, Math.max(Math.abs(dx), Math.abs(dy)) + blur * 1.5); + } + for (const m of value.matchAll(/(? { const FRAME = 2 / FPS; + it("keeps a rotating or scaling mover where it is, blurred, not at the origin or gone", async (ctx) => { + if (!supported) ctx.skip(); + const style = ``; + const tile = (animation: string) => + `
${style}`; + const inked = (f: Frame): number => { + let n = 0; + for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) if (px(f, x, y)[0] < 128) n++; + return n; + }; + for (const animation of ["spin", "grow"]) { + const blurred = await frameOf(tile(animation), true, 0.3); + 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); + expect(inked(blurred.frame)).toBeGreaterThan(inked(sharp.frame) * 0.5); + expect(px(blurred.frame, 160, 90)[0]).toBeLessThan(60); + expect(px(blurred.frame, 5, 5)).toEqual([255, 255, 255]); + } + }); + it("leaves a trail along the motion line where the sharp frame has hard edges", async (ctx) => { if (!supported) ctx.skip(); const blurred = await frameOf(BOX, true, FRAME); @@ -198,6 +219,36 @@ describe.skipIf(!hasBrowser)("capture motion blur pixels in Chrome", () => { ); }); + it("leaves the backdrop under a see-through mover where it is", async (ctx) => { + if (!supported) ctx.skip(); + const body = `
+
+
+
`; + const blurred = await frameOf(body, true, FRAME); + const sharp = await frameOf(body, false, FRAME); + expect(blurred.report).toMatchObject({ state: "on", movers: 1 }); + // Row 34 is inside the mover's box but clear of the dot it carries: its backdrop must not move. + let moved = 0; + for (let x = 0; x < W; x++) { + if (px(blurred.frame, x, 34).join() !== px(sharp.frame, x, 34).join()) moved++; + } + expect(moved).toBe(0); + expect(Math.max(...row(blurred.frame, 50))).toBeGreaterThan(120); + }); + + it("carries a glow that paints outside the mover's box along with it", async (ctx) => { + if (!supported) ctx.skip(); + const body = `
`; + const blurred = await frameOf(body, true, FRAME); + const sharp = await frameOf(body, false, FRAME); + expect(blurred.report).toMatchObject({ state: "on", movers: 1 }); + // 10 px above the box, mid-trail: the glow is there in the sharp frame, so it must be here too. + const glow = (f: Frame): number => Math.max(...row(f, 70).slice(120, 200)); + expect(glow(sharp.frame)).toBeGreaterThan(40); + expect(glow(blurred.frame)).toBeGreaterThan(glow(sharp.frame) * 0.5); + }); + it("blurs a box centred with translate around where it really is", async (ctx) => { if (!supported) ctx.skip(); const blurred = await frameOf(CENTERED, true, 1 / FPS); diff --git a/scripts/package-size-budgets.json b/scripts/package-size-budgets.json index 28f31d115c6..0946592ee37 100644 --- a/scripts/package-size-budgets.json +++ b/scripts/package-size-budgets.json @@ -9,7 +9,7 @@ }, "@hyperframes/core": { "packed": 1636442, - "unpacked": 3942463 + "unpacked": 3950000 }, "@hyperframes/engine": { "packed": 428347, From aabbb1d563b7c99a31a4038d98c13c9a1ed7e28f Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 22 Oct 2026 17:58:56 -0700 Subject: [PATCH 3/3] fix(core): capture blur finds callback-driven movers when events are on The mover scan seeked with callbacks suppressed even where the root allows them, so a composition whose motion lives in a timeline callback (a render(t) driven from onUpdate) reported 0 movers and rendered sharp. With data-motion-blur-events="on" the scan now seeks the way the frame reads do. Without it nothing changes: callbacks stay suppressed. The no-movers finding in hyperframes check now names the attribute. Co-Authored-By: Claude Sonnet 5.5 --- .../cli/src/utils/checkMotionBlur.test.ts | 8 +++- packages/cli/src/utils/checkMotionBlur.ts | 2 +- .../runtime/captureMotionBlur/index.test.ts | 46 +++++++++++++------ .../src/runtime/captureMotionBlur/index.ts | 6 +-- 4 files changed, 42 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/utils/checkMotionBlur.test.ts b/packages/cli/src/utils/checkMotionBlur.test.ts index 5f438f4ab4e..3c69184f696 100644 --- a/packages/cli/src/utils/checkMotionBlur.test.ts +++ b/packages/cli/src/utils/checkMotionBlur.test.ts @@ -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"), + }, + ]); }); }); diff --git a/packages/cli/src/utils/checkMotionBlur.ts b/packages/cli/src/utils/checkMotionBlur.ts index 09ba7d98bd1..9f45473da72 100644 --- a/packages/cli/src/utils/checkMotionBlur.ts +++ b/packages/cli/src/utils/checkMotionBlur.ts @@ -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, }, ]; diff --git a/packages/core/src/runtime/captureMotionBlur/index.test.ts b/packages/core/src/runtime/captureMotionBlur/index.test.ts index d27509bd975..e1fa84823e0 100644 --- a/packages/core/src/runtime/captureMotionBlur/index.test.ts +++ b/packages/core/src/runtime/captureMotionBlur/index.test.ts @@ -864,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()); @@ -883,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 = { "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", () => { diff --git a/packages/core/src/runtime/captureMotionBlur/index.ts b/packages/core/src/runtime/captureMotionBlur/index.ts index f984c08fec5..0dbe5c4cacf 100644 --- a/packages/core/src/runtime/captureMotionBlur/index.ts +++ b/packages/core/src/runtime/captureMotionBlur/index.ts @@ -276,8 +276,6 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI } const seek = (t: number): void => host.seek(t, !config.events); - // The scan walks the whole timeline: callbacks must not fire (or leave their effects) there. - const scanSeek = (t: number): void => host.seek(t, true); function discover(restoreTo: number): void { scannedTimeline = host.timeline(); @@ -288,10 +286,10 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI scene: root, durationSeconds: duration, fps: host.fps, - seek: scanSeek, + seek, onStep: (i, steps) => defects.sample(i, steps, (i / steps) * duration), }); - scanSeek(restoreTo); + seek(restoreTo); scanDefect = defects.result(); faceNow = defects.facesNow; movers = found.movers;