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"); } }); });