diff options
| author | cashmere <cashmere@autistici.org> | 2026-08-02 19:59:32 +0200 |
|---|---|---|
| committer | cashmere <cashmere@autistici.org> | 2026-08-08 10:40:12 +0200 |
| commit | 237248321a1a40b9b98e7e0a6df156d0aa247ef0 (patch) | |
| tree | 62f2f46474b08c3b7723e78a2f4dd9cf58d64494 /apps/web/src/effects/__tests__ | |
| parent | 465071d8232e74b1690280a38a86d435269fc35d (diff) | |
| download | opencut-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__')
| -rw-r--r-- | apps/web/src/effects/__tests__/motion.test.ts | 182 |
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); + }); +}); |
