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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,3 +437,4 @@ export {
type HfVfxParam,
type HfVfxParamValues,
} from "./vfx.js";
export { PAGE_MOTION_BLUR_OFF_GLOBAL } from "./pageMotionBlur.js";
12 changes: 12 additions & 0 deletions packages/core/src/pageMotionBlur.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Set by the engine: its own sub-frame blur runs, or the Studio preview could not blur.
export const PAGE_MOTION_BLUR_OFF_GLOBAL = "__HF_PAGE_MOTION_BLUR_OFF__";

export function supportsCaptureMotionBlur(win: Window = window): boolean {
const w = win as Window & {
CanvasRenderingContext2D?: { prototype: object };
HTMLCanvasElement?: { prototype: object };
};
const ctx = w.CanvasRenderingContext2D?.prototype;
const canvas = w.HTMLCanvasElement?.prototype;
return !!ctx && !!canvas && "drawElementImage" in ctx && "requestPaint" in canvas;
}
80 changes: 80 additions & 0 deletions packages/core/src/runtime/captureMotionBlur/composite.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createCompositor, type MoverFrame } from "./composite";
import { IDENTITY, translate } from "./pose";

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

beforeEach(() => {
offscreen = [];
vi.stubGlobal(
"OffscreenCanvas",
class {
constructor(
public width: number,
public height: number,
) {}
getContext() {
const ctx = fakeContext();
offscreen.push(ctx);
return ctx;
}
},
);
});
afterEach(() => vi.unstubAllGlobals());

const capture = {} as CanvasImageSource;
const background = {} as CanvasImageSource;
const slide = (from: number, to: number, n = 16) =>
Array.from({ length: n }, (_, k) => translate(IDENTITY, from + ((to - from) * (k + 0.5)) / n, 0));
const mover = (w: number, h: number, taps: ReturnType<typeof slide>): MoverFrame => ({
w,
h,
now: translate(IDENTITY, 50, 0),
taps,
});

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);
expect(kept).toEqual([false]);
const out = fakeContext();
expect(c.draw(background, out as never, movers, kept)).toEqual({ blurred: 0, taps: 0 });
expect(out.drawImage).toHaveBeenCalledTimes(1);
expect(out.drawImage.mock.calls[0]?.[0]).toBe(background);
});

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);
expect(kept).toEqual([true]);
const crop = offscreen.at(-1);
expect(crop?.drawImage).toHaveBeenCalledWith(capture, 0, 0);
const out = fakeContext();
const stats = c.draw(background, out as never, movers, kept);
const accum = offscreen[0];
expect(stats.blurred).toBe(1);
expect(accum?.drawImage).toHaveBeenCalledTimes(stats.taps);
expect(accum?.globalAlpha).toBeCloseTo(1 / stats.taps, 9);
// Background first, then the trail over it.
expect(out.drawImage.mock.calls[0]).toEqual([background, 0, 0]);
expect(out.drawImage).toHaveBeenCalledTimes(2);
});

it("leaves a mover sharp when its box is too big to copy every frame", () => {
const c = createCompositor(100, 100);
expect(c.crop(capture, [mover(1000, 1000, slide(0, 100))])).toEqual([false]);
});
});
135 changes: 135 additions & 0 deletions packages/core/src/runtime/captureMotionBlur/composite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/**
* 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.
*
* 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";

export interface MoverFrame {
w: number;
h: number;
/** The pose the capture shows (frame time). */
now: Affine;
/** Poses across the shutter. */
taps: Affine[];
}

interface CompositeStats {
blurred: number;
taps: number;
}

type Context2D = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
interface Layer {
canvas: OffscreenCanvas;
ctx: OffscreenCanvasRenderingContext2D;
}

// Float16 keeps n additive 1/n-weighted taps exact; 8-bit loses up to ~n/2 levels per channel.
function makeLayer(w: number, h: number): Layer {
const canvas = new OffscreenCanvas(w, h);
let ctx: OffscreenCanvasRenderingContext2D | null = null;
try {
ctx = canvas.getContext("2d", { colorType: "float16" } as CanvasRenderingContext2DSettings);
} catch {
ctx = null;
}
ctx = ctx ?? canvas.getContext("2d");
if (!ctx) throw new Error("capture motion blur: no 2D context for the accumulation buffer");
return { canvas, ctx };
}

/** Whether a mover travels far enough within this shutter to draw a trail at all. */
function moves(mover: MoverFrame, frameW: number, frameH: number): boolean {
const { travel, rect } = sweep(mover.taps, mover.w, mover.h, frameW, frameH);
return rect !== null && travel >= 1;
}

export function createCompositor(width: number, height: number) {
const accum = makeLayer(width, height);
const crops: Layer[] = [];
// A mover box this many times the frame is too big to copy every frame; it is left sharp.
const maxCropArea = width * height * 4;

const cropLayer = (i: number, w: number, h: number): Layer => {
let layer = crops[i];
if (!layer || layer.canvas.width < w || layer.canvas.height < h) {
layer = makeLayer(
Math.max(w, layer?.canvas.width ?? 1),
Math.max(h, layer?.canvas.height ?? 1),
);
crops[i] = layer;
}
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;
const back = invert(mover.now);
if (!back || w * h > maxCropArea || !moves(mover, width, height)) return false;
const c = cropLayer(i, w, h).ctx;
c.setTransform(1, 0, 0, 1, 0, 0);
c.globalCompositeOperation = "source-over";
c.globalAlpha = 1;
c.clearRect(0, 0, w, h);
c.setTransform(back.a, back.b, back.c, back.d, back.e, back.f);
c.drawImage(src, 0, 0);
return true;
});
}

function trail(out: Context2D, mover: MoverFrame, layer: Layer): number {
const { w, h } = mover;
const { travel, rect } = sweep(mover.taps, w, h, width, height);
if (!rect) return 0;
const taps = thinTaps(mover.taps, travel);
const a = accum.ctx;
a.setTransform(1, 0, 0, 1, 0, 0);
a.globalCompositeOperation = "source-over";
a.globalAlpha = 1;
a.clearRect(rect.x, rect.y, rect.w, rect.h);
a.globalCompositeOperation = "lighter";
a.globalAlpha = 1 / taps.length;
for (const m of taps) {
a.setTransform(m.a, m.b, m.c, m.d, m.e, m.f);
a.drawImage(layer.canvas, 0, 0, w, h, 0, 0, w, h);
}
out.drawImage(accum.canvas, rect.x, rect.y, rect.w, rect.h, rect.x, rect.y, rect.w, rect.h);
return taps.length;
}

/** `background` into `out`, then the trail of every mover `crop` kept. */
function draw(
background: CanvasImageSource,
out: Context2D,
movers: MoverFrame[],
kept: boolean[],
): CompositeStats {
out.setTransform(1, 0, 0, 1, 0, 0);
out.globalAlpha = 1;
out.globalCompositeOperation = "copy";
out.drawImage(background, 0, 0);
out.globalCompositeOperation = "source-over";
const stats: CompositeStats = { blurred: 0, taps: 0 };
movers.forEach((mover, i) => {
const layer = crops[i];
if (!kept[i] || !layer) return;
const n = trail(out, mover, layer);
if (n > 0) {
stats.blurred++;
stats.taps += n;
}
});
return stats;
}

return { crop, draw };
}
89 changes: 89 additions & 0 deletions packages/core/src/runtime/captureMotionBlur/discover.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { discoverMovers } from "./discover";

// jsdom lays nothing out: each element's rect follows the x its fake timeline writes.
function element(parent: Element, id: string): HTMLElement {
const el = document.createElement("div");
el.id = id;
parent.append(el);
el.getBoundingClientRect = () => {
const x = parseFloat(el.style.transform.replace("translateX(", "")) || 0;
return { left: x, top: 0, right: x + 10, bottom: 10, width: 10, height: 10 } as DOMRect;
};
return el;
}

function setup() {
const scene = document.createElement("div");
const fast = element(scene, "fast");
const fastChild = element(fast, "fast-child");
const slow = element(scene, "slow");
const still = element(scene, "still");
const off = element(scene, "off");
off.setAttribute("data-motion-blur", "off");
// 1 s at 30 fps: `fast` and `off` cross 300 px (10 px/frame), `slow` 15 px (0.5 px/frame).
const seek = (t: number): void => {
fast.style.transform = `translateX(${300 * t}px)`;
fastChild.style.transform = `translateX(${200 * t}px)`;
slow.style.transform = `translateX(${15 * t}px)`;
off.style.transform = `translateX(${300 * t}px)`;
};
seek(0);
return { scene, fast, fastChild, slow, still, off, seek };
}

describe("discoverMovers", () => {
afterEach(() => vi.restoreAllMocks());

it("keeps fast, outermost movers that are not opted out", () => {
const { scene, fast, seek } = setup();
const { movers } = discoverMovers({ scene, durationSeconds: 1, fps: 30, seek });
expect(movers).toEqual([fast]);
});

it("marks every changing element dynamic, slow and opted-out ones included", () => {
const { scene, fast, fastChild, slow, still, off, seek } = setup();
const { dynamic } = discoverMovers({ scene, durationSeconds: 1, fps: 30, seek });
expect(
dynamic.has(fast) && dynamic.has(fastChild) && dynamic.has(slow) && dynamic.has(off),
).toBe(true);
expect(dynamic.has(still)).toBe(false);
});

it("ignores a CSS animation of offset-distance, which the pose has no term for", () => {
const scene = document.createElement("div");
const box = document.createElement("div");
scene.append(box);
let x = 0; // moves with no inline style change: only getAnimations can nominate it
box.getBoundingClientRect = () =>
({ left: x, right: x + 10, top: 0, bottom: 10, width: 10 }) as DOMRect;
const scan = (prop: string) => {
const effect = { target: box, getKeyframes: () => [{ [prop]: "0" }] };
Object.defineProperty(document, "getAnimations", {
value: () => [{ effect }],
configurable: true,
});
const seek = (t: number): void => void (x = 300 * t);
return discoverMovers({ scene, durationSeconds: 1, fps: 30, seek }).movers;
};
expect(scan("offsetDistance")).toEqual([]);
expect(scan("translate")).toEqual([box]);
Reflect.deleteProperty(document, "getAnimations");
});

it("drops a mover in a 3D context, or on an offset-path the pose cannot follow", () => {
const { scene, fast, seek } = setup();
const real = window.getComputedStyle;
let own: object = {};
vi.spyOn(window, "getComputedStyle").mockImplementation((el: Element) =>
el === fast
? ({ transform: "none", transformStyle: "flat", perspective: "none", ...own } as never)
: real(el),
);
for (own of [
{ transform: "matrix3d(1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1)" },
{ offsetPath: "path('M 0 0')" },
])
expect(discoverMovers({ scene, durationSeconds: 1, fps: 30, seek }).movers).toEqual([]);
});
});
Loading
Loading