diff options
Diffstat (limited to 'apps/web/src/effects')
| -rw-r--r-- | apps/web/src/effects/__tests__/lens-effects.test.ts | 349 | ||||
| -rw-r--r-- | apps/web/src/effects/__tests__/motion.test.ts | 164 | ||||
| -rw-r--r-- | apps/web/src/effects/components/assets-view.tsx | 54 | ||||
| -rw-r--r-- | apps/web/src/effects/components/effects-tab.tsx | 7 | ||||
| -rw-r--r-- | apps/web/src/effects/definitions/blur.ts | 1 | ||||
| -rw-r--r-- | apps/web/src/effects/definitions/index.ts | 10 | ||||
| -rw-r--r-- | apps/web/src/effects/definitions/motion-blur.ts | 67 | ||||
| -rw-r--r-- | apps/web/src/effects/definitions/motion.ts | 115 | ||||
| -rw-r--r-- | apps/web/src/effects/motion.ts | 4 | ||||
| -rw-r--r-- | apps/web/src/effects/types.ts | 5 |
10 files changed, 750 insertions, 26 deletions
diff --git a/apps/web/src/effects/__tests__/lens-effects.test.ts b/apps/web/src/effects/__tests__/lens-effects.test.ts new file mode 100644 index 00000000..39578514 --- /dev/null +++ b/apps/web/src/effects/__tests__/lens-effects.test.ts @@ -0,0 +1,349 @@ +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"); + } + }); +}); 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, + }); + }); +}); diff --git a/apps/web/src/effects/components/assets-view.tsx b/apps/web/src/effects/components/assets-view.tsx index 8c2ec85b..54817f54 100644 --- a/apps/web/src/effects/components/assets-view.tsx +++ b/apps/web/src/effects/components/assets-view.tsx @@ -7,13 +7,16 @@ import { effectsRegistry, EFFECT_TARGET_ELEMENT_TYPES } from "@/effects"; import { effectPreviewService } from "@/services/renderer/effect-preview"; import { useEditor } from "@/editor/use-editor"; import { buildEffectElement } from "@/timeline/element-utils"; -import type { EffectDefinition } from "@/effects/types"; +import type { EffectCategory, EffectDefinition } from "@/effects/types"; -export function EffectsView() { - const effects = effectsRegistry.getAll(); +export function EffectsView({ category }: { category?: EffectCategory }) { + const all = effectsRegistry.getAll(); + const effects = category + ? all.filter((effect) => effect.category === category) + : all; return ( - <PanelView title="Effects"> + <PanelView title={category === "lens" ? "Lens" : "Effects"}> <EffectsGrid effects={effects} /> </PanelView> ); @@ -124,18 +127,33 @@ function MotionPreviewCanvas({ effectType }: { effectType: string }) { return; } - const inward = effectType === "zoom-out"; - const spread = size * 0.16; - const reach = size * 0.3; - for (const [dx, dy] of [ - [1, 1], - [-1, -1], - ] as const) { - const innerX = center + dx * (inward ? reach : spread); - const innerY = center + dy * (inward ? reach : spread) * 0.7; - const outerX = center + dx * (inward ? spread : reach); - const outerY = center + dy * (inward ? spread : reach) * 0.7; - drawArrow({ context, fromX: innerX, fromY: innerY, toX: outerX, toY: outerY }); + if (effectType === "zoom-in" || effectType === "zoom-out") { + const inward = effectType === "zoom-out"; + const spread = size * 0.16; + const reach = size * 0.3; + for (const [dx, dy] of [ + [1, 1], + [-1, -1], + ] as const) { + const innerX = center + dx * (inward ? reach : spread); + const innerY = center + dy * (inward ? reach : spread) * 0.7; + const outerX = center + dx * (inward ? spread : reach); + const outerY = center + dy * (inward ? spread : reach) * 0.7; + drawArrow({ + context, + fromX: innerX, + fromY: innerY, + toX: outerX, + toY: outerY, + }); + } + return; + } + + for (const radius of [size * 0.14, size * 0.26]) { + context.beginPath(); + context.arc(center, center, radius, 0, Math.PI * 2); + context.stroke(); } }, [effectType]); @@ -182,9 +200,7 @@ function EffectItem({ effect }: { effect: EffectDefinition }) { effectType: effect.type, targetElementTypes: EFFECT_TARGET_ELEMENT_TYPES, }} - onAddToTimeline={ - effect.supportsStandalone === false ? undefined : handleAddToTimeline - } + onAddToTimeline={handleAddToTimeline} aspectRatio={1} isRounded variant="card" diff --git a/apps/web/src/effects/components/effects-tab.tsx b/apps/web/src/effects/components/effects-tab.tsx index 102d818a..91ad1f42 100644 --- a/apps/web/src/effects/components/effects-tab.tsx +++ b/apps/web/src/effects/components/effects-tab.tsx @@ -25,7 +25,10 @@ import { } from "@hugeicons/core-free-icons"; import { cn } from "@/utils/ui"; import { Separator } from "@/components/ui/separator"; -import { useAssetsPanelStore } from "@/components/editor/panels/assets/assets-panel-store"; +import { + getDefaultEffectsTab, + useAssetsPanelStore, +} from "@/components/editor/panels/assets/assets-panel-store"; export function StandaloneEffectTab({ element, @@ -222,7 +225,7 @@ function EmptyView() { <Button variant="default" size="sm" - onClick={() => setActiveTab("effects")} + onClick={() => setActiveTab(getDefaultEffectsTab())} > Open effects </Button> diff --git a/apps/web/src/effects/definitions/blur.ts b/apps/web/src/effects/definitions/blur.ts index 194196e9..672e0cd4 100644 --- a/apps/web/src/effects/definitions/blur.ts +++ b/apps/web/src/effects/definitions/blur.ts @@ -69,6 +69,7 @@ export const blurEffectDefinition: EffectDefinition = { type: "blur", name: "Blur", keywords: ["blur", "soft", "defocus"], + category: "lens", params: [ { key: "intensity", diff --git a/apps/web/src/effects/definitions/index.ts b/apps/web/src/effects/definitions/index.ts index e38e5710..d2961c13 100644 --- a/apps/web/src/effects/definitions/index.ts +++ b/apps/web/src/effects/definitions/index.ts @@ -1,8 +1,14 @@ import { effectsRegistry } from "../registry"; import { blurEffectDefinition } from "./blur"; -import { motionEffectDefinitions } from "./motion"; +import { motionBlurEffectDefinition } from "./motion-blur"; +import { motionEffectDefinitions, customZoomEffectDefinition } from "./motion"; -const defaultEffects = [blurEffectDefinition, ...motionEffectDefinitions]; +const defaultEffects = [ + blurEffectDefinition, + motionBlurEffectDefinition, + customZoomEffectDefinition, + ...motionEffectDefinitions, +]; export function registerDefaultEffects(): void { for (const definition of defaultEffects) { diff --git a/apps/web/src/effects/definitions/motion-blur.ts b/apps/web/src/effects/definitions/motion-blur.ts new file mode 100644 index 00000000..b20e6c26 --- /dev/null +++ b/apps/web/src/effects/definitions/motion-blur.ts @@ -0,0 +1,67 @@ +import type { EffectDefinition } from "@/effects/types"; +import { GAUSSIAN_BLUR_SHADER, intensityToSigma } from "./blur"; + +const MAX_SINGLE_PASS_SIGMA = 10; +const MIN_SIGMA = 0.001; + +function parseNumericParam({ + effectParams, + key, +}: { + effectParams: Record<string, unknown>; + key: string; +}): number { + const raw = effectParams[key]; + return typeof raw === "number" ? raw : Number.parseFloat(String(raw)); +} + +export const motionBlurEffectDefinition: EffectDefinition = { + type: "motion-blur", + name: "Motion Blur", + keywords: ["motion blur", "smear", "directional", "speed", "streak"], + category: "lens", + params: [ + { + key: "amount", + label: "Amount", + type: "number", + default: 40, + min: 0, + max: 100, + step: 1, + }, + { + key: "angle", + label: "Angle", + type: "number", + default: 0, + min: -180, + max: 180, + step: 1, + }, + ], + renderer: { + passes: [], + buildPasses: ({ effectParams, width }) => { + const sigma = intensityToSigma({ + intensity: parseNumericParam({ effectParams, key: "amount" }), + resolution: width, + reference: 1920, + }); + if (sigma < MIN_SIGMA) return []; + + const angleRad = + (parseNumericParam({ effectParams, key: "angle" }) * Math.PI) / 180; + return [ + { + shader: GAUSSIAN_BLUR_SHADER, + uniforms: { + u_sigma: Math.min(sigma, MAX_SINGLE_PASS_SIGMA), + u_step: Math.max(1, sigma / MAX_SINGLE_PASS_SIGMA), + u_direction: [Math.cos(angleRad), Math.sin(angleRad)], + }, + }, + ]; + }, + }, +}; diff --git a/apps/web/src/effects/definitions/motion.ts b/apps/web/src/effects/definitions/motion.ts index 8c81d801..2e3292a2 100644 --- a/apps/web/src/effects/definitions/motion.ts +++ b/apps/web/src/effects/definitions/motion.ts @@ -5,6 +5,76 @@ import { MOTION_EASING_OPTIONS, getMotionIntensity } from "@/effects/motion"; const ZOOM_RANGE = 0.35; const PAN_ZOOM_HEADROOM = 0.15; const PAN_TRAVEL = 0.06; +const SHAKE_SCALE_HEADROOM = 0.08; +const SHAKE_TRAVEL_X = 0.025; +const SHAKE_TRAVEL_Y = 0.02; +const SHAKE_FREQUENCY = 6; +const SHAKE_Y_DETUNE = 1.31; +const PULSE_RANGE = 0.12; +const PULSE_FREQUENCY = 3; +const SPIN_FULL_TURN_DEGREES = 360; +const CUSTOM_ZOOM_MIN_SCALE = 25; +const CUSTOM_ZOOM_MAX_SCALE = 400; + +function readScaleParam({ + effectParams, + key, +}: { + effectParams: ParamValues; + key: string; +}): number { + const raw = effectParams[key]; + const value = typeof raw === "number" ? raw : Number.parseFloat(String(raw)); + return Number.isFinite(value) ? value : 100; +} + +export const customZoomEffectDefinition: EffectDefinition = { + type: "custom-zoom", + name: "Custom Zoom", + keywords: ["zoom", "ken burns", "custom", "scale", "start", "end"], + category: "lens", + params: [ + { + key: "startScale", + label: "Start scale", + type: "number", + default: 100, + min: CUSTOM_ZOOM_MIN_SCALE, + max: CUSTOM_ZOOM_MAX_SCALE, + step: 1, + keyframable: true, + }, + { + key: "endScale", + label: "End scale", + type: "number", + default: 135, + min: CUSTOM_ZOOM_MIN_SCALE, + max: CUSTOM_ZOOM_MAX_SCALE, + step: 1, + keyframable: true, + }, + { + key: "easing", + label: "Easing", + type: "select", + default: "smooth", + options: MOTION_EASING_OPTIONS, + }, + ], + renderer: { passes: [] }, + motion: { + evaluate: ({ effectParams, progress }) => { + const startScale = readScaleParam({ effectParams, key: "startScale" }); + const endScale = readScaleParam({ effectParams, key: "endScale" }); + return { + scale: (startScale + (endScale - startScale) * progress) / 100, + translateX: 0, + translateY: 0, + }; + }, + }, +}; function motionParams(): ParamDefinition[] { return [ @@ -79,10 +149,10 @@ function buildMotionDefinition({ type, name, keywords, + category: "lens", params: motionParams(), renderer: { passes: [] }, motion: { evaluate }, - supportsStandalone: false, }; } @@ -129,4 +199,47 @@ export const motionEffectDefinitions: EffectDefinition[] = [ evaluate: ({ effectParams, progress }) => panOffset({ effectParams, progress, axis: "y", direction: 1 }), }), + buildMotionDefinition({ + type: "shake", + name: "Shake", + keywords: ["shake", "camera", "handheld", "wobble", "jitter"], + evaluate: ({ effectParams, progress }) => { + const intensity = getMotionIntensity({ effectParams }); + const angle = progress * Math.PI * 2 * SHAKE_FREQUENCY; + return { + scale: 1 + SHAKE_SCALE_HEADROOM * intensity, + translateX: Math.sin(angle) * SHAKE_TRAVEL_X * intensity, + translateY: + Math.cos(angle * SHAKE_Y_DETUNE) * SHAKE_TRAVEL_Y * intensity, + }; + }, + }), + buildMotionDefinition({ + type: "pulse", + name: "Pulse", + keywords: ["pulse", "beat", "pump", "scale", "bounce"], + evaluate: ({ effectParams, progress }) => ({ + scale: + 1 + + Math.sin(progress * Math.PI * 2 * PULSE_FREQUENCY) * + PULSE_RANGE * + getMotionIntensity({ effectParams }), + translateX: 0, + translateY: 0, + }), + }), + buildMotionDefinition({ + type: "spin", + name: "Spin", + keywords: ["spin", "rotate", "turn", "twirl"], + evaluate: ({ effectParams, progress }) => ({ + scale: 1, + translateX: 0, + translateY: 0, + rotate: + progress * + SPIN_FULL_TURN_DEGREES * + getMotionIntensity({ effectParams }), + }), + }), ]; diff --git a/apps/web/src/effects/motion.ts b/apps/web/src/effects/motion.ts index ab51002a..8734d343 100644 --- a/apps/web/src/effects/motion.ts +++ b/apps/web/src/effects/motion.ts @@ -94,6 +94,7 @@ export function applyMotionEffects({ let scale = 1; let translateX = 0; let translateY = 0; + let rotate = 0; for (const effect of motionEffects) { const definition = effectsRegistry.get(effect.type); @@ -117,6 +118,7 @@ export function applyMotionEffects({ scale *= offset.scale; translateX += offset.translateX; translateY += offset.translateY; + rotate += offset.rotate ?? 0; } return { @@ -126,6 +128,6 @@ export function applyMotionEffects({ x: baseTransform.position.x + translateX * canvasWidth, y: baseTransform.position.y + translateY * canvasHeight, }, - rotate: baseTransform.rotate, + rotate: baseTransform.rotate + rotate, }; } diff --git a/apps/web/src/effects/types.ts b/apps/web/src/effects/types.ts index 88c32207..9b243e9b 100644 --- a/apps/web/src/effects/types.ts +++ b/apps/web/src/effects/types.ts @@ -27,6 +27,7 @@ export interface MotionOffset { scale: number; translateX: number; translateY: number; + rotate?: number; } export interface EffectMotionConfig { @@ -45,12 +46,14 @@ export interface EffectRendererConfig { }) => EffectPass[]; } +export type EffectCategory = "lens" | "effect"; + export interface EffectDefinition { type: string; name: string; keywords: string[]; + category: EffectCategory; params: ParamDefinition[]; renderer: EffectRendererConfig; motion?: EffectMotionConfig; - supportsStandalone?: boolean; } |
