diff options
Diffstat (limited to 'apps/web/src/effects/__tests__/lens-effects.test.ts')
| -rw-r--r-- | apps/web/src/effects/__tests__/lens-effects.test.ts | 349 |
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"); + } + }); +}); |
