summaryrefslogtreecommitdiff
path: root/apps/web/src/effects/__tests__/motion.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/effects/__tests__/motion.test.ts')
-rw-r--r--apps/web/src/effects/__tests__/motion.test.ts164
1 files changed, 164 insertions, 0 deletions
diff --git a/apps/web/src/effects/__tests__/motion.test.ts b/apps/web/src/effects/__tests__/motion.test.ts
index 907dd6a9..aeeaf6ab 100644
--- a/apps/web/src/effects/__tests__/motion.test.ts
+++ b/apps/web/src/effects/__tests__/motion.test.ts
@@ -1,9 +1,17 @@
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";
@@ -180,3 +188,159 @@ describe("renderer wiring", () => {
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,
+ });
+ });
+});