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
2 changes: 1 addition & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,4 +437,4 @@ export {
type HfVfxParam,
type HfVfxParamValues,
} from "./vfx.js";
export { PAGE_MOTION_BLUR_OFF_GLOBAL } from "./pageMotionBlur.js";
export { PAGE_DEVICE_SCALE_GLOBAL, PAGE_MOTION_BLUR_OFF_GLOBAL } from "./pageMotionBlur.js";
2 changes: 2 additions & 0 deletions packages/core/src/pageMotionBlur.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// 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 const PAGE_DEVICE_SCALE_GLOBAL = "__HF_DEVICE_SCALE_FACTOR__";

export function supportsCaptureMotionBlur(win: Window = window): boolean {
const w = win as Window & {
CanvasRenderingContext2D?: { prototype: object };
Expand Down
36 changes: 36 additions & 0 deletions packages/core/src/runtime/adapters/waapi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,42 @@ describe("waapi adapter", () => {
expect(animation.pauseCalls).toBe(1);
});

describe("an animation that went idle", () => {
class FakeCssAnimation {}
const idleScenario = (make: () => ReturnType<typeof makeAnimation> & { playState: string }) => {
const old = make();
const fresh = makeAnimation();
const live: unknown[] = [old, fresh];
(document as any).getAnimations = vi.fn(() => live);
const adapter = createWaapiAdapter();
adapter.seek({ time: 1 });
old.playState = "idle";
live.shift();
old.pause.mockClear();
adapter.seek({ time: 2 });
return { old, fresh };
};

it("drops a browser-created CSS animation: it is not revived, a fresh one is driven", () => {
vi.stubGlobal("CSSAnimation", FakeCssAnimation);
const { old, fresh } = idleScenario(() =>
Object.assign(new FakeCssAnimation(), makeAnimation(), { playState: "running" }),
);
expect(old.pause).not.toHaveBeenCalled();
expect(old.currentTime).toBe(1000);
expect(fresh.currentTime).toBe(2000);
vi.unstubAllGlobals();
});

it("still drives an idle script-created animation, as before", () => {
vi.stubGlobal("CSSAnimation", FakeCssAnimation);
const { old } = idleScenario(() => ({ ...makeAnimation(), playState: "running" }));
expect(old.pause).toHaveBeenCalled();
expect(old.currentTime).toBe(2000);
vi.unstubAllGlobals();
});
});

it("seek clamps negative time to 0", () => {
const mockAnim = makeAnimation();
setAnimations([mockAnim]);
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/runtime/adapters/waapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import type { RuntimeDeterministicAdapter } from "../types";
import { swallow } from "../diagnostics";
import { holdAnimationAt } from "./_holdAnimationAt";

const isBrowserCreatedCss = (animation: Animation): boolean =>
(typeof CSSAnimation !== "undefined" && animation instanceof CSSAnimation) ||
(typeof CSSTransition !== "undefined" && animation instanceof CSSTransition);

export function createWaapiAdapter(): RuntimeDeterministicAdapter {
let didDiscover = false;
let lastSeekTimeMs = 0;
Expand Down Expand Up @@ -158,6 +162,10 @@ export function createWaapiAdapter(): RuntimeDeterministicAdapter {
trackAnimations(snapshotAnimations(ctx.pageAnimations), didDiscover ? timeMs : 0);
}
for (const animation of animations) {
if (animation.playState === "idle" && isBrowserCreatedCss(animation)) {
animations.delete(animation);
continue;
}
const baseline = didDiscover
? ensureBaseline(animation, timeMs)
: ensureBaseline(animation, 0);
Expand Down
25 changes: 24 additions & 1 deletion packages/core/src/runtime/captureMotionBlur/composite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ describe("createCompositor", () => {
const kept = c.crop(capture, movers);
expect(kept).toEqual([true]);
const crop = offscreen.at(-1);
expect(crop?.drawImage).toHaveBeenCalledWith(capture, 0, 0);
expect(crop?.drawImage).toHaveBeenCalledWith(capture, 0, 0, 1000, 100);
const out = fakeContext();
const stats = c.draw(background, out as never, movers, kept);
const accum = offscreen[0];
Expand All @@ -77,4 +77,27 @@ describe("createCompositor", () => {
const c = createCompositor(100, 100);
expect(c.crop(capture, [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 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([
2, 0, 0, 2, -100, 0,
]);
expect(crop?.clearRect).toHaveBeenCalledWith(0, 0, 20, 20);
expect(crop?.drawImage).toHaveBeenCalledWith(capture, 0, 0, 1000, 100);
const out = fakeContext();
c.draw(background, out as never, movers, kept);
const accum = offscreen[0];
const lastTap = accum?.setTransform.mock.calls.at(-1);
expect(lastTap?.slice(0, 4)).toEqual([2, 0, 0, 2]);
expect(accum?.drawImage.mock.calls[0]).toEqual([expect.anything(), 0, 0, 20, 20, 0, 0, 10, 10]);
const [, sx, sy, sw, sh, dx, dy, dw, dh] = out.drawImage.mock.calls[1] ?? [];
expect([sx, sy, sh, dx, dy, dh]).toEqual([2, 0, 24, 2, 0, 24]);
expect(sw).toBe(dw);
expect(sw).toBeGreaterThan(200);
});
});
41 changes: 29 additions & 12 deletions packages/core/src/runtime/captureMotionBlur/composite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,18 +51,23 @@ function moves(mover: MoverFrame, frameW: number, frameH: number): boolean {
return rect !== null && travel >= 1;
}

export function createCompositor(width: number, height: number) {
const accum = makeLayer(width, height);
/** `width`/`height` are CSS px; the buffers hold `dpr` device pixels per CSS px. */
export function createCompositor(width: number, height: number, dpr = 1) {
const accumW = Math.max(1, Math.round(width * dpr));
const accumH = Math.max(1, Math.round(height * dpr));
const accum = makeLayer(accumW, accumH);
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 => {
const dw = Math.ceil(w * dpr);
const dh = Math.ceil(h * dpr);
let layer = crops[i];
if (!layer || layer.canvas.width < w || layer.canvas.height < h) {
if (!layer || layer.canvas.width < dw || layer.canvas.height < dh) {
layer = makeLayer(
Math.max(w, layer?.canvas.width ?? 1),
Math.max(h, layer?.canvas.height ?? 1),
Math.max(dw, layer?.canvas.width ?? 1),
Math.max(dh, layer?.canvas.height ?? 1),
);
crops[i] = layer;
}
Expand All @@ -79,9 +84,16 @@ export function createCompositor(width: number, height: number) {
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);
c.clearRect(0, 0, Math.ceil(w * dpr), Math.ceil(h * dpr));
c.setTransform(
dpr * back.a,
dpr * back.b,
dpr * back.c,
dpr * back.d,
dpr * back.e,
dpr * back.f,
);
c.drawImage(src, 0, 0, width, height);
return true;
});
}
Expand All @@ -91,18 +103,23 @@ export function createCompositor(width: number, height: number) {
const { travel, rect } = sweep(mover.taps, w, h, width, height);
if (!rect) return 0;
const taps = thinTaps(mover.taps, travel);
const rx = Math.max(0, Math.floor(rect.x * dpr));
const ry = Math.max(0, Math.floor(rect.y * dpr));
const rw = Math.min(accumW, Math.ceil((rect.x + rect.w) * dpr)) - rx;
const rh = Math.min(accumH, Math.ceil((rect.y + rect.h) * dpr)) - ry;
if (rw <= 0 || rh <= 0) return 0;
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.clearRect(rx, ry, rw, rh);
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);
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, rect.x, rect.y, rect.w, rect.h, rect.x, rect.y, rect.w, rect.h);
out.drawImage(accum.canvas, rx, ry, rw, rh, rx, ry, rw, rh);
return taps.length;
}

Expand Down
Loading
Loading