import { describe, expect, test } from "bun:test"; import { registerDefaultEffects } from "@/effects"; import { buildDefaultEffectInstance } from "@/effects"; import { effectsRegistry } from "@/effects"; import { applyMotionEffects, sampleEasingBezier } from "@/effects/motion"; import type { MotionOffset } from "@/effects/types"; import type { ParamValues } from "@/params"; import type { Transform } from "@/rendering"; import { BUILTIN_PRESETS } from "@/timeline/components/graph-editor/easing-presets"; import { GraphicNode } from "@/services/renderer/nodes/graphic-node"; 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 applyZoomIn({ localTime, duration = 10, intensity = 100, easing = "linear", }: { localTime: number; duration?: number; intensity?: number; easing?: string; }) { const effect = buildDefaultEffectInstance({ effectType: "zoom-in" }); effect.params.intensity = intensity; effect.params.easing = easing; return applyMotionEffects({ baseTransform, effects: [effect], animations: undefined, localTime, duration, canvasWidth: 1920, canvasHeight: 1080, }); } describe("sampleEasingBezier", () => { test("linear easing is identity", () => { expect(sampleEasingBezier({ bezier: [0, 0, 1, 1], x: 0.37 })).toBeCloseTo( 0.37, 5, ); }); test("preserves endpoints", () => { expect(sampleEasingBezier({ bezier: [0.4, 0, 0.2, 1], x: 0 })).toBeCloseTo( 0, 5, ); expect(sampleEasingBezier({ bezier: [0.4, 0, 0.2, 1], x: 1 })).toBeCloseTo( 1, 5, ); }); test("ease-in lags behind linear progress mid-way", () => { const eased = sampleEasingBezier({ bezier: [0.8, 0, 1, 1], x: 0.5 }); expect(eased).toBeLessThan(0.5); }); }); describe("applyMotionEffects", () => { test("zoom-in ramps scale from 1 to full range over the clip", () => { expect(applyZoomIn({ localTime: 0 }).scaleX).toBeCloseTo(1, 5); expect(applyZoomIn({ localTime: 10 }).scaleX).toBeCloseTo(1.35, 5); }); test("intensity scales the effect proportionally", () => { const full = applyZoomIn({ localTime: 10, intensity: 100 }); const fifth = applyZoomIn({ localTime: 10, intensity: 20 }); expect(fifth.scaleX).toBeCloseTo(1.07, 5); expect((fifth.scaleX - 1) / (full.scaleX - 1)).toBeCloseTo(0.2, 5); }); test("eased zoom is non-linear at the midpoint", () => { const eased = applyZoomIn({ localTime: 5, easing: "ease-in" }); expect(eased.scaleX).toBeLessThan(1.175); expect(eased.scaleX).toBeGreaterThan(1); }); test("zoom-out starts zoomed and settles at 1", () => { const effect = buildDefaultEffectInstance({ effectType: "zoom-out" }); effect.params.easing = "linear"; const apply = (localTime: number) => applyMotionEffects({ baseTransform, effects: [effect], animations: undefined, localTime, duration: 10, canvasWidth: 1920, canvasHeight: 1080, }); expect(apply(0).scaleX).toBeCloseTo(1.35, 5); expect(apply(10).scaleX).toBeCloseTo(1, 5); }); test("pan-right drifts position across the canvas with zoom headroom", () => { const effect = buildDefaultEffectInstance({ effectType: "pan-right" }); effect.params.easing = "linear"; const apply = (localTime: number) => applyMotionEffects({ baseTransform, effects: [effect], animations: undefined, localTime, duration: 10, canvasWidth: 1920, canvasHeight: 1080, }); const start = apply(0); const end = apply(10); expect(start.position.x).toBeCloseTo(-0.06 * 1920, 3); expect(end.position.x).toBeCloseTo(0.06 * 1920, 3); expect(end.scaleX).toBeCloseTo(1.15, 5); expect(end.scaleX).toBe(end.scaleY); }); test("multiplies onto an existing base transform", () => { const scaledBase: Transform = { ...baseTransform, scaleX: 2, scaleY: 2 }; const effect = buildDefaultEffectInstance({ effectType: "zoom-in" }); effect.params.easing = "linear"; const resolved = applyMotionEffects({ baseTransform: scaledBase, effects: [effect], animations: undefined, localTime: 10, duration: 10, canvasWidth: 1920, canvasHeight: 1080, }); expect(resolved.scaleX).toBeCloseTo(2.7, 5); }); test("ignores disabled effects and effects without motion", () => { const motion = buildDefaultEffectInstance({ effectType: "zoom-in" }); motion.enabled = false; const blur = buildDefaultEffectInstance({ effectType: "blur" }); const resolved = applyMotionEffects({ baseTransform, effects: [motion, blur], animations: undefined, localTime: 5, duration: 10, canvasWidth: 1920, canvasHeight: 1080, }); expect(resolved).toEqual(baseTransform); }); }); describe("renderer wiring", () => { test("resolveRenderTree applies motion presets to the resolved transform", async () => { const effect = buildDefaultEffectInstance({ effectType: "zoom-in" }); effect.params.easing = "linear"; const node = new GraphicNode({ definitionId: "rectangle", params: {}, duration: 10, timeOffset: 0, trimStart: 0, trimEnd: 0, transform: baseTransform, opacity: 1, effects: [effect], }); const renderer = { width: 1920, height: 1080 } as CanvasRenderer; await resolveRenderTree({ node, renderer, time: 0 }); expect(node.resolved?.transform.scaleX).toBeCloseTo(1, 5); await resolveRenderTree({ node, renderer, time: 5 }); expect(node.resolved?.transform.scaleX).toBeCloseTo(1.175, 5); }); }); describe("standalone motion effect layers", () => { const renderer = { width: 1920, height: 1080 } as CanvasRenderer; function evaluateExpectedOffset({ effectType, effectParams, linearProgress, }: { effectType: string; effectParams: ParamValues; linearProgress: number; }): MotionOffset { const definition = effectsRegistry.get(effectType); if (!definition.motion) { throw new Error(`${effectType} has no motion config`); } const progress = sampleEasingBezier({ bezier: BUILTIN_PRESETS.find((preset) => preset.id === effectParams.easing) ?.value ?? [0.25, 0.1, 0.25, 1], x: linearProgress, }); return definition.motion.evaluate({ effectParams, progress }); } test("standalone zoom-in resolves motion state from definition math", async () => { const effectParams = { intensity: 100, easing: "linear" }; const node = new EffectLayerNode({ effectType: "zoom-in", effectParams, timeOffset: 0, duration: 10, }); await resolveRenderTree({ node, renderer, time: 5 }); const expected = evaluateExpectedOffset({ effectType: "zoom-in", effectParams, linearProgress: 0.5, }); expect(node.resolved?.passes).toEqual([]); expect(node.resolved?.motion?.scale).toBeCloseTo(expected.scale, 5); expect(node.resolved?.motion?.scale).toBeGreaterThan(1); expect(node.resolved?.motion?.translateX).toBeCloseTo(0, 5); expect(node.resolved?.motion?.translateY).toBeCloseTo(0, 5); }); test("frame descriptor transforms lower layer quads without a sceneEffect item", async () => { const effectParams = { intensity: 100, easing: "linear" }; const root = new RootNode({ duration: 10 }); root.add(new ColorNode({ color: "#ff0000" })); root.add( new EffectLayerNode({ effectType: "zoom-in", effectParams, 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"); } const expected = evaluateExpectedOffset({ effectType: "zoom-in", effectParams, linearProgress: 0.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 * expected.scale, 5, ); expect(layer.transform.height).toBeCloseTo( renderer.height * expected.scale, 5, ); }); test("frame descriptor translates lower layers for pan motion", async () => { const effectParams = { intensity: 100, easing: "linear" }; const root = new RootNode({ duration: 10 }); root.add(new ColorNode({ color: "#ff0000" })); root.add( new EffectLayerNode({ effectType: "pan-right", effectParams, timeOffset: 0, duration: 10, }), ); await resolveRenderTree({ node: root, renderer, time: 7.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"); } const expected = evaluateExpectedOffset({ effectType: "pan-right", effectParams, linearProgress: 0.75, }); expect(expected.translateX).toBeGreaterThan(0); expect(layer.transform.centerX).toBeCloseTo( renderer.width / 2 + expected.translateX * renderer.width, 5, ); expect(layer.transform.centerY).toBeCloseTo(renderer.height / 2, 5); expect(layer.transform.width).toBeCloseTo( renderer.width * expected.scale, 5, ); }); test("identity motion leaves lower layer quads untouched", async () => { const root = new RootNode({ duration: 10 }); root.add(new ColorNode({ color: "#ff0000" })); root.add( new EffectLayerNode({ effectType: "zoom-in", 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, }); }); });