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
70 changes: 70 additions & 0 deletions packages/core/src/runtime/captureMotionBlur/filterPlan.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import type { MoverFrame } from "./composite";
import { planFilter } from "./filterPlan";
import { type Affine, IDENTITY, translate } from "./pose";

const mat = (a: number, b: number, c: number, d: number, e: number, f: number): Affine => ({
a,
b,
c,
d,
e,
f,
});
const rot = (deg: number, e = 0, f = 0): Affine => {
const r = (deg * Math.PI) / 180;
return mat(Math.cos(r), Math.sin(r), -Math.sin(r), Math.cos(r), e, f);
};
const frame = (now: Affine, taps: Affine[], w = 100, h = 50): MoverFrame => ({ w, h, now, taps });
const slide = (from: number, to: number, n = 32, scale = 1): Affine[] =>
Array.from({ length: n }, (_, k) =>
mat(scale, 0, 0, scale, from + ((to - from) * k) / (n - 1), 0),
);
/** Turned by `deg` about the middle of a w x h box whose middle is at (cx, cy). */
const spin = (deg: number, cx: number, cy: number, w: number, h: number): Affine => {
const m = rot(deg);
return { ...m, e: cx - (m.a * w) / 2 - (m.c * h) / 2, f: cy - (m.b * w) / 2 - (m.d * h) / 2 };
};

describe("planFilter", () => {
it("turns a slide into one offset per tap, centred on where the element is", () => {
const plan = planFilter(frame(translate(IDENTITY, 100, 0), slide(50, 150)), 0, 1920);
expect(plan?.offsets).toHaveLength(32);
expect(plan?.offsets[0]?.[0]).toBeCloseTo(-50, 5);
expect(plan?.offsets[31]?.[0]).toBeCloseTo(50, 5);
expect(plan?.offsets.every(([, y]) => Math.abs(y) < 1e-9)).toBe(true);
});

it("takes the offsets back through the element's own scale", () => {
const plan = planFilter(frame(mat(2, 0, 0, 2, 100, 0), slide(50, 150, 32, 2)), 0, 1920);
expect(plan?.offsets[0]?.[0]).toBeCloseTo(-25, 5);
});

it("softens along the motion only, not across it", () => {
const plan = planFilter(frame(translate(IDENTITY, 100, 0), slide(50, 150)), 0, 1920);
const [sx, sy] = plan?.sigma ?? [0, 0];
expect(sx).toBeGreaterThan(0.5);
expect(sy).toBeLessThan(1e-9);
});

it("softens a turn on the spot, with no offsets", () => {
const taps = [-6, -2, 2, 6].map((d) => spin(108 + d, 140, 80, 80, 80));
const plan = planFilter(frame(spin(108, 140, 80, 80, 80), taps, 80, 80), 0, 1920);
expect(plan?.offsets.every(([x, y]) => Math.hypot(x, y) < 1e-6)).toBe(true);
expect(plan?.sigma[0]).toBeGreaterThan(0.5);
expect(plan?.sigma[1]).toBeGreaterThan(0.5);
});

it("covers every copy, the softening and the ink around the box", () => {
const plan = planFilter(frame(translate(IDENTITY, 100, 0), slide(50, 150)), 20, 1920);
const r = plan?.region;
expect(r?.x).toBeLessThanOrEqual(-50 - 20);
expect((r?.x ?? 0) + (r?.w ?? 0)).toBeGreaterThanOrEqual(100 + 50 + 20);
expect(r?.y).toBeLessThanOrEqual(-20);
});

it("leaves a mover that does not move, and one with a collapsed box", () => {
expect(planFilter(frame(IDENTITY, slide(0, 0.1)), 0, 1920)).toBeNull();
expect(planFilter(frame(mat(0, 0, 0, 0, 0, 0), slide(0, 100)), 0, 1920)).toBeNull();
});
});
97 changes: 97 additions & 0 deletions packages/core/src/runtime/captureMotionBlur/filterPlan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* The filter tier's arithmetic: what an SVG filter on a mover needs to smear it along its sampled
* path. Pure, so it can be checked without a browser.
*
* A filter runs in the element's own space, before its transform. The box centre's displacement
* across the shutter becomes one offset per tap, taken back through the element's linear part.
* Turn and scale cannot be offsets, so they soften the copies instead; so does the step between
* copies, along the motion only.
*/

import type { MoverFrame } from "./composite";
import type { Affine } from "./pose";

export interface FilterPlan {
/** One per tap: where that copy of the element sits, in its own px. */
offsets: Array<[number, number]>;
/** Gaussian softening, in its own px, along x and y. */
sigma: [number, number];
/** The filter region in the element's own px: every copy, the softening and the ink around it. */
region: { x: number; y: number; w: number; h: number };
}

/** Softening as a share of the step between copies; enough to close the steps, no more. */
const STEP_SOFTEN = 0.35;
const TURN_SOFTEN = 0.29;
/** Below this, nothing is visible: the mover is left as it is. */
const MIN_TRAVEL = 0.5;
const MIN_SIGMA = 0.35;

const wrap = (a: number): number => Math.atan2(Math.sin(a), Math.cos(a));
const centre = (m: Affine, w: number, h: number): [number, number] => [
m.a * (w / 2) + m.c * (h / 2) + m.e,
m.b * (w / 2) + m.d * (h / 2) + m.f,
];

export function planFilter(frame: MoverFrame, pad: number, frameW: number): FilterPlan | null {
const { w, h, now, taps } = frame;
const det = now.a * now.d - now.b * now.c;
const scale = Math.sqrt(Math.abs(det));
if (taps.length < 2 || !(scale > 1e-4)) return null;

const here = centre(now, w, h);
const points = taps.map((m) => centre(m, w, h));
const offsets = points.map(([x, y]): [number, number] => {
const dx = x - here[0];
const dy = y - here[1];
return [(now.d * dx - now.c * dy) / det, (-now.b * dx + now.a * dy) / det];
});

let path = 0;
for (let i = 1; i < points.length; i++) {
path += Math.hypot(
(points[i]?.[0] ?? 0) - (points[i - 1]?.[0] ?? 0),
(points[i]?.[1] ?? 0) - (points[i - 1]?.[1] ?? 0),
);
}
const step = path / (points.length - 1);

const turn = Math.max(
...taps.map((m) => Math.abs(wrap(Math.atan2(m.b, m.a) - Math.atan2(now.b, now.a)))),
);
const grow = Math.max(
...taps.map((m) => Math.abs(Math.log(Math.sqrt(Math.abs(m.a * m.d - m.b * m.c)) / scale))),
);
const size = Math.sqrt(w * h) * scale;
const turnSigma = Math.min(
24 * (frameW / 1920),
TURN_SOFTEN * Math.min(frameW / 4, size / 2) * (turn + grow),
);

const first = offsets[0] ?? [0, 0];
const last = offsets[offsets.length - 1] ?? [0, 0];
const along = Math.hypot(last[0] - first[0], last[1] - first[1]) || 1;
const stepSigma = (STEP_SOFTEN * step) / scale;
const sigma: [number, number] = [
Math.hypot(turnSigma / scale, (stepSigma * Math.abs(last[0] - first[0])) / along),
Math.hypot(turnSigma / scale, (stepSigma * Math.abs(last[1] - first[1])) / along),
];

if (path < MIN_TRAVEL && Math.max(...sigma) * scale < MIN_SIGMA) return null;

const xs = offsets.map((o) => o[0]);
const ys = offsets.map((o) => o[1]);
const margin = pad + 3 * Math.max(...sigma) + 2;
const x = Math.min(0, ...xs) - margin;
const y = Math.min(0, ...ys) - margin;
return {
offsets,
sigma,
region: {
x,
y,
w: w + Math.max(0, ...xs) + margin - x,
h: h + Math.max(0, ...ys) + margin - y,
},
};
}
Loading
Loading