summaryrefslogtreecommitdiff
path: root/apps/web/src/effects/__tests__/lens-effects.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/effects/__tests__/lens-effects.test.ts')
-rw-r--r--apps/web/src/effects/__tests__/lens-effects.test.ts349
1 files changed, 349 insertions, 0 deletions
diff --git a/apps/web/src/effects/__tests__/lens-effects.test.ts b/apps/web/src/effects/__tests__/lens-effects.test.ts
new file mode 100644
index 00000000..39578514
--- /dev/null
+++ b/apps/web/src/effects/__tests__/lens-effects.test.ts
@@ -0,0 +1,349 @@
+import { describe, expect, test } from "bun:test";
+import { registerDefaultEffects } from "@/effects";
+import { buildDefaultEffectInstance } from "@/effects";
+import { effectsRegistry, resolveEffectPasses } from "@/effects";
+import { applyMotionEffects, sampleEasingBezier } from "@/effects/motion";
+import type { MotionOffset } from "@/effects/types";
+import type { Transform } from "@/rendering";
+import { BUILTIN_PRESETS } from "@/timeline/components/graph-editor/easing-presets";
+import { ColorNode } from "@/services/renderer/nodes/color-node";
+import { EffectLayerNode } from "@/services/renderer/nodes/effect-layer-node";
+import { RootNode } from "@/services/renderer/nodes/root-node";
+import { buildFrameDescriptor } from "@/services/renderer/compositor/frame-descriptor";
+import { resolveRenderTree } from "@/services/renderer/resolve";
+import type { CanvasRenderer } from "@/services/renderer/canvas-renderer";
+
+registerDefaultEffects();
+
+const baseTransform: Transform = {
+ scaleX: 1,
+ scaleY: 1,
+ position: { x: 0, y: 0 },
+ rotate: 0,
+};
+
+function evaluateMotion({
+ effectType,
+ intensity = 100,
+ progress,
+}: {
+ effectType: string;
+ intensity?: number;
+ progress: number;
+}): MotionOffset {
+ const definition = effectsRegistry.get(effectType);
+ if (!definition.motion) {
+ throw new Error(`${effectType} has no motion config`);
+ }
+ return definition.motion.evaluate({
+ effectParams: { intensity, easing: "linear" },
+ progress,
+ });
+}
+
+describe("shake definition", () => {
+ test("translate stays within the scale headroom across the whole cycle", () => {
+ for (let step = 0; step <= 100; step++) {
+ const offset = evaluateMotion({
+ effectType: "shake",
+ progress: step / 100,
+ });
+ const headroom = (offset.scale - 1) / 2;
+ expect(offset.scale).toBeCloseTo(1.08, 5);
+ expect(Math.abs(offset.translateX)).toBeLessThanOrEqual(0.025 + 1e-9);
+ expect(Math.abs(offset.translateY)).toBeLessThanOrEqual(0.02 + 1e-9);
+ expect(Math.abs(offset.translateX)).toBeLessThanOrEqual(headroom);
+ expect(Math.abs(offset.translateY)).toBeLessThanOrEqual(headroom);
+ }
+ });
+
+ test("intensity scales travel and headroom proportionally", () => {
+ const full = evaluateMotion({ effectType: "shake", progress: 0.25 });
+ const half = evaluateMotion({
+ effectType: "shake",
+ intensity: 50,
+ progress: 0.25,
+ });
+ expect(half.scale).toBeCloseTo(1.04, 5);
+ expect(half.translateX).toBeCloseTo(full.translateX / 2, 8);
+ expect(half.translateY).toBeCloseTo(full.translateY / 2, 8);
+ });
+});
+
+describe("pulse definition", () => {
+ test("scale oscillates around 1 within the pulse range", () => {
+ for (let step = 0; step <= 100; step++) {
+ const offset = evaluateMotion({
+ effectType: "pulse",
+ progress: step / 100,
+ });
+ expect(offset.scale).toBeGreaterThanOrEqual(1 - 0.12 - 1e-9);
+ expect(offset.scale).toBeLessThanOrEqual(1 + 0.12 + 1e-9);
+ expect(offset.translateX).toBe(0);
+ expect(offset.translateY).toBe(0);
+ }
+ });
+
+ test("completes three full cycles", () => {
+ expect(
+ evaluateMotion({ effectType: "pulse", progress: 0 }).scale,
+ ).toBeCloseTo(1, 5);
+ expect(
+ evaluateMotion({ effectType: "pulse", progress: 1 / 12 }).scale,
+ ).toBeCloseTo(1.12, 5);
+ expect(
+ evaluateMotion({ effectType: "pulse", progress: 1 }).scale,
+ ).toBeCloseTo(1, 5);
+ });
+});
+
+describe("spin definition", () => {
+ test("rotate spans zero to a full turn scaled by intensity", () => {
+ expect(
+ evaluateMotion({ effectType: "spin", progress: 0 }).rotate,
+ ).toBeCloseTo(0, 5);
+ expect(
+ evaluateMotion({ effectType: "spin", progress: 0.5 }).rotate,
+ ).toBeCloseTo(180, 5);
+ expect(
+ evaluateMotion({ effectType: "spin", progress: 1 }).rotate,
+ ).toBeCloseTo(360, 5);
+ expect(
+ evaluateMotion({ effectType: "spin", intensity: 50, progress: 1 })
+ .rotate,
+ ).toBeCloseTo(180, 5);
+ });
+
+ test("does not scale or translate", () => {
+ const offset = evaluateMotion({ effectType: "spin", progress: 0.7 });
+ expect(offset.scale).toBe(1);
+ expect(offset.translateX).toBe(0);
+ expect(offset.translateY).toBe(0);
+ });
+
+ test("applyMotionEffects adds rotation onto the base transform", () => {
+ const effect = buildDefaultEffectInstance({ effectType: "spin" });
+ effect.params.easing = "linear";
+ const resolved = applyMotionEffects({
+ baseTransform: { ...baseTransform, rotate: 15 },
+ effects: [effect],
+ animations: undefined,
+ localTime: 5,
+ duration: 10,
+ canvasWidth: 1920,
+ canvasHeight: 1080,
+ });
+ const easedProgress = sampleEasingBezier({
+ bezier:
+ BUILTIN_PRESETS.find((preset) => preset.id === "linear")?.value ?? [
+ 0.25, 0.1, 0.25, 1,
+ ],
+ x: 0.5,
+ });
+ expect(resolved.rotate).toBeCloseTo(15 + easedProgress * 360, 5);
+ expect(resolved.scaleX).toBeCloseTo(1, 5);
+ });
+});
+
+describe("standalone spin effect layer", () => {
+ const renderer = { width: 1920, height: 1080 } as CanvasRenderer;
+
+ function expectedEasedRotation({ linearProgress }: { linearProgress: number }) {
+ const progress = sampleEasingBezier({
+ bezier:
+ BUILTIN_PRESETS.find((preset) => preset.id === "linear")?.value ?? [
+ 0.25, 0.1, 0.25, 1,
+ ],
+ x: linearProgress,
+ });
+ return progress * 360;
+ }
+
+ test("frame descriptor rotates lower layer quads by the eased rotation", async () => {
+ const root = new RootNode({ duration: 10 });
+ root.add(new ColorNode({ color: "#ff0000" }));
+ root.add(
+ new EffectLayerNode({
+ effectType: "spin",
+ effectParams: { intensity: 100, easing: "linear" },
+ timeOffset: 0,
+ duration: 10,
+ }),
+ );
+
+ await resolveRenderTree({ node: root, renderer, time: 5 });
+ const { frame } = await buildFrameDescriptor({ node: root, renderer });
+
+ expect(frame.items).toHaveLength(1);
+ const layer = frame.items.at(0);
+ if (layer?.type !== "layer") {
+ throw new Error("expected a single layer item");
+ }
+ expect(layer.transform.rotationDegrees).toBeCloseTo(
+ expectedEasedRotation({ linearProgress: 0.5 }),
+ 5,
+ );
+ expect(layer.transform.centerX).toBeCloseTo(renderer.width / 2, 5);
+ expect(layer.transform.centerY).toBeCloseTo(renderer.height / 2, 5);
+ expect(layer.transform.width).toBeCloseTo(renderer.width, 5);
+ });
+
+ test("zero intensity leaves lower layer quads untouched", async () => {
+ const root = new RootNode({ duration: 10 });
+ root.add(new ColorNode({ color: "#ff0000" }));
+ root.add(
+ new EffectLayerNode({
+ effectType: "spin",
+ effectParams: { intensity: 0, easing: "linear" },
+ timeOffset: 0,
+ duration: 10,
+ }),
+ );
+
+ await resolveRenderTree({ node: root, renderer, time: 5 });
+ const { frame } = await buildFrameDescriptor({ node: root, renderer });
+
+ expect(frame.items).toHaveLength(1);
+ const layer = frame.items.at(0);
+ if (layer?.type !== "layer") {
+ throw new Error("expected a single layer item");
+ }
+ expect(layer.transform).toEqual({
+ centerX: renderer.width / 2,
+ centerY: renderer.height / 2,
+ width: renderer.width,
+ height: renderer.height,
+ rotationDegrees: 0,
+ flipX: false,
+ flipY: false,
+ });
+ });
+});
+
+describe("motion-blur definition", () => {
+ const renderer = { width: 1920, height: 1080 } as CanvasRenderer;
+
+ function buildPasses({ amount, angle }: { amount: number; angle?: number }) {
+ return resolveEffectPasses({
+ definition: effectsRegistry.get("motion-blur"),
+ effectParams: { amount, angle: angle ?? 0 },
+ width: renderer.width,
+ height: renderer.height,
+ });
+ }
+
+ test("zero amount produces no passes", () => {
+ expect(buildPasses({ amount: 0 })).toEqual([]);
+ });
+
+ test("positive amount produces exactly one gaussian pass", () => {
+ const passes = buildPasses({ amount: 40 });
+ expect(passes).toHaveLength(1);
+ expect(passes.at(0)?.shader).toBe("gaussian-blur");
+ });
+
+ test("direction is a unit vector along the angle", () => {
+ for (const angle of [0, 37, 90, -120, 180]) {
+ const pass = buildPasses({ amount: 40, angle }).at(0);
+ const direction = pass?.uniforms.u_direction;
+ if (!Array.isArray(direction)) {
+ throw new Error("expected u_direction to be a vec2");
+ }
+ const [dx, dy] = direction;
+ expect(dx * dx + dy * dy).toBeCloseTo(1, 8);
+ expect(Math.atan2(dy, dx)).toBeCloseTo((angle * Math.PI) / 180, 8);
+ }
+ expect(buildPasses({ amount: 40, angle: 0 }).at(0)?.uniforms.u_direction).toEqual([1, 0]);
+ });
+
+ test("sigma stays within single-pass limits and grows with amount", () => {
+ const low = buildPasses({ amount: 20 }).at(0);
+ const high = buildPasses({ amount: 80 }).at(0);
+ if (!low || !high) {
+ throw new Error("expected passes for positive amounts");
+ }
+ const [lowUniforms, highUniforms] = [low, high].map((pass) => {
+ const sigma = pass.uniforms.u_sigma;
+ const step = pass.uniforms.u_step;
+ if (typeof sigma !== "number" || typeof step !== "number") {
+ throw new Error("expected numeric sigma and step");
+ }
+ expect(sigma).toBeLessThanOrEqual(10);
+ expect(sigma).toBeGreaterThan(0);
+ expect(step).toBeGreaterThanOrEqual(1);
+ return { sigma, step };
+ });
+ expect(highUniforms.sigma * highUniforms.step).toBeGreaterThan(
+ lowUniforms.sigma * lowUniforms.step,
+ );
+ });
+});
+
+describe("custom-zoom definition", () => {
+ function evaluateCustomZoom({
+ startScale,
+ endScale,
+ progress,
+ }: {
+ startScale: number;
+ endScale: number;
+ progress: number;
+ }): MotionOffset {
+ const definition = effectsRegistry.get("custom-zoom");
+ if (!definition.motion) {
+ throw new Error("custom-zoom has no motion config");
+ }
+ return definition.motion.evaluate({
+ effectParams: { startScale, endScale, easing: "linear" },
+ progress,
+ });
+ }
+
+ test("lerps exactly between start and end scale", () => {
+ expect(
+ evaluateCustomZoom({ startScale: 100, endScale: 200, progress: 0 })
+ .scale,
+ ).toBeCloseTo(1, 5);
+ expect(
+ evaluateCustomZoom({ startScale: 100, endScale: 200, progress: 1 })
+ .scale,
+ ).toBeCloseTo(2, 5);
+ expect(
+ evaluateCustomZoom({ startScale: 100, endScale: 200, progress: 0.5 })
+ .scale,
+ ).toBeCloseTo(1.5, 5);
+ });
+
+ test("supports zoom-out by inverting start and end", () => {
+ expect(
+ evaluateCustomZoom({ startScale: 200, endScale: 100, progress: 0.25 })
+ .scale,
+ ).toBeCloseTo(1.75, 5);
+ });
+
+ test("falls back to 100 percent for missing params", () => {
+ const definition = effectsRegistry.get("custom-zoom");
+ if (!definition.motion) {
+ throw new Error("custom-zoom has no motion config");
+ }
+ const offset = definition.motion.evaluate({
+ effectParams: {},
+ progress: 0.5,
+ });
+ expect(offset.scale).toBeCloseTo(1, 5);
+ });
+});
+
+describe("registry", () => {
+ test("registers all lens effects", () => {
+ for (const type of [
+ "shake",
+ "pulse",
+ "spin",
+ "motion-blur",
+ "custom-zoom",
+ ]) {
+ expect(effectsRegistry.get(type).category).toBe("lens");
+ }
+ });
+});