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/composite.test.ts b/packages/core/src/runtime/captureMotionBlur/composite.test.ts index efcdc190ee9..925e78db296 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> = []; @@ -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); @@ -58,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(); @@ -67,7 +74,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); @@ -75,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 49da8ad5e2d..bd2b53a053c 100644 --- a/packages/core/src/runtime/captureMotionBlur/composite.ts +++ b/packages/core/src/runtime/captureMotionBlur/composite.ts @@ -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; @@ -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; }); } @@ -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; } @@ -148,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..e1fa84823e0 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(); @@ -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()); @@ -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 = { "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 f3b19b0d2f8..0dbe5c4cacf 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,14 @@ 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, + translate, +} from "./pose"; export interface CaptureMotionBlurHost { root: HTMLElement; @@ -52,7 +59,7 @@ export interface CaptureMotionBlurHost { interface Config { shutterDeg: number; - samples: number; + samples: number | null; events: boolean; } @@ -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. */ @@ -118,7 +129,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", }; } @@ -190,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. @@ -212,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; @@ -231,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. */ @@ -256,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(); @@ -268,13 +286,14 @@ 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; + pads = found.pads; reader = createPoseReader(root, found.dynamic); scanMs = Math.round(performance.now() - s0); } @@ -298,12 +317,23 @@ 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) => { + // 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: grow(r.pose(el)), + taps: + config.samples === null + ? autoTaps(reads, w, h, width, height) + : tapsFromReads(reads, config.samples), + }; + }); r.reset(); return { els: visible, frames: result }; } @@ -313,7 +343,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, ""); + }); }; /** @@ -354,24 +401,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; @@ -474,7 +517,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). */ @@ -508,6 +550,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) => { @@ -523,7 +566,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, @@ -532,6 +576,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(/(? { 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))); diff --git a/packages/engine/src/services/captureMotionBlur.pixels.browser.test.ts b/packages/engine/src/services/captureMotionBlur.pixels.browser.test.ts index 7e2eae55ef1..413b14b392d 100644 --- a/packages/engine/src/services/captureMotionBlur.pixels.browser.test.ts +++ b/packages/engine/src/services/captureMotionBlur.pixels.browser.test.ts @@ -154,6 +154,27 @@ describe.skipIf(!hasBrowser)("capture motion blur pixels in Chrome", () => { 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,