summaryrefslogtreecommitdiff
path: root/apps/web/src/effects/__tests__/motion.test.ts
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-08-02 19:59:32 +0200
committercashmere <cashmere@autistici.org>2026-08-08 10:40:12 +0200
commit237248321a1a40b9b98e7e0a6df156d0aa247ef0 (patch)
tree62f2f46474b08c3b7723e78a2f4dd9cf58d64494 /apps/web/src/effects/__tests__/motion.test.ts
parent465071d8232e74b1690280a38a86d435269fc35d (diff)
downloadopencut-237248321a1a40b9b98e7e0a6df156d0aa247ef0.tar.gz
feat: pan & zoom motion presets
Ken Burns-style motion effects (zoom in/out, pan left/right/up/down) that drag onto clips like regular effects. Instead of shader passes they evaluate a per-frame transform offset in resolveVisualState, so they stack with keyframed transforms and work in preview and export. - intensity param (0-100%, keyframable) scales the whole effect - easing select maps progress through the graph editor's bezier presets - supportsStandalone: false hides them from effect-track creation - empty shader pass groups are filtered out so pass-less effects and zero-intensity blur no longer crash the compositor
Diffstat (limited to 'apps/web/src/effects/__tests__/motion.test.ts')
-rw-r--r--apps/web/src/effects/__tests__/motion.test.ts182
1 files changed, 182 insertions, 0 deletions
diff --git a/apps/web/src/effects/__tests__/motion.test.ts b/apps/web/src/effects/__tests__/motion.test.ts
new file mode 100644
index 00000000..907dd6a9
--- /dev/null
+++ b/apps/web/src/effects/__tests__/motion.test.ts
@@ -0,0 +1,182 @@
+import { describe, expect, test } from "bun:test";
+import { registerDefaultEffects } from "@/effects";
+import { buildDefaultEffectInstance } from "@/effects";
+import { applyMotionEffects, sampleEasingBezier } from "@/effects/motion";
+import type { Transform } from "@/rendering";
+import { GraphicNode } from "@/services/renderer/nodes/graphic-node";
+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);
+ });
+});