mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(core): capture blur strobing, backdrop box, glow drop-out and callback motion (#6106)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
784e1d01d9
commit
e85f56e86c
@@ -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"),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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>> = [];
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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<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.
|
||||
@@ -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();
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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<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", () => {
|
||||
|
||||
@@ -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<HTMLElement, number>();
|
||||
let reader: ReturnType<typeof createPoseReader> | 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<string>();
|
||||
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<void>((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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(/(?<!-)blur\(\s*([\d.]+)px\s*\)/g))
|
||||
reach = Math.max(reach, Number(m[1]) * 3);
|
||||
return reach;
|
||||
}
|
||||
|
||||
function nodeReach(el: Element): number {
|
||||
const cs = getComputedStyle(el);
|
||||
const outline =
|
||||
cs.outlineStyle !== "none" ? parseFloat(cs.outlineWidth) + parseFloat(cs.outlineOffset) : 0;
|
||||
return Math.max(
|
||||
shadowReach(cs.boxShadow),
|
||||
shadowReach(cs.textShadow),
|
||||
filterReach(cs.filter),
|
||||
Number.isFinite(outline) ? Math.max(0, outline) : 0,
|
||||
);
|
||||
}
|
||||
|
||||
/** Px the mover's ink extends beyond its box in its own space, from it and its first descendants. */
|
||||
export function inkPad(el: HTMLElement): number {
|
||||
let reach = nodeReach(el);
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_ELEMENT);
|
||||
for (let n = 0; n < NODE_LIMIT && walker.nextNode(); n++) {
|
||||
const node = walker.currentNode;
|
||||
if (isHtmlElement(node)) reach = Math.max(reach, nodeReach(node));
|
||||
}
|
||||
return Math.min(MAX_PAD, Math.ceil(reach));
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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 = `<style>@keyframes spin{to{transform:rotate(360deg)}}@keyframes grow{to{transform:scale(3)}}</style>`;
|
||||
const tile = (animation: string) =>
|
||||
`<div style="position:absolute;left:120px;top:50px;width:80px;height:80px;background:#131109;box-shadow:0 8px 16px -8px rgba(19,17,9,.4);animation:${animation} 1s linear both"></div>${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 = `<div style="position:absolute;left:0;top:0;width:${W}px;height:100px;background:repeating-linear-gradient(90deg,#000 0 2px,#fff 2px 4px)"></div>
|
||||
<div class="box" style="left:20px;top:30px;background:transparent">
|
||||
<div style="position:absolute;left:16px;top:16px;width:8px;height:8px;background:#f00"></div>
|
||||
</div>`;
|
||||
const blurred = await frameOf(body, true, FRAME);
|
||||
const sharp = await frameOf(body, false, FRAME);
|
||||
expect(blurred.report).toMatchObject({ state: "on", movers: 1 });
|
||||
// Row 34 is inside the mover's box but clear of the dot it carries: its backdrop must not move.
|
||||
let moved = 0;
|
||||
for (let x = 0; x < W; x++) {
|
||||
if (px(blurred.frame, x, 34).join() !== px(sharp.frame, x, 34).join()) moved++;
|
||||
}
|
||||
expect(moved).toBe(0);
|
||||
expect(Math.max(...row(blurred.frame, 50))).toBeGreaterThan(120);
|
||||
});
|
||||
|
||||
it("carries a glow that paints outside the mover's box along with it", async (ctx) => {
|
||||
if (!supported) ctx.skip();
|
||||
const body = `<div class="box" style="left:20px;top:80px;box-shadow:0 0 24px 0 #f00"></div>`;
|
||||
const blurred = await frameOf(body, true, FRAME);
|
||||
const sharp = await frameOf(body, false, FRAME);
|
||||
expect(blurred.report).toMatchObject({ state: "on", movers: 1 });
|
||||
// 10 px above the box, mid-trail: the glow is there in the sharp frame, so it must be here too.
|
||||
const glow = (f: Frame): number => Math.max(...row(f, 70).slice(120, 200));
|
||||
expect(glow(sharp.frame)).toBeGreaterThan(40);
|
||||
expect(glow(blurred.frame)).toBeGreaterThan(glow(sharp.frame) * 0.5);
|
||||
});
|
||||
|
||||
it("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);
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
},
|
||||
"@hyperframes/core": {
|
||||
"packed": 1636442,
|
||||
"unpacked": 3942463
|
||||
"unpacked": 3950000
|
||||
},
|
||||
"@hyperframes/engine": {
|
||||
"packed": 428347,
|
||||
|
||||
Reference in New Issue
Block a user