import type { ParamDefinition, ParamValues } from "@/params"; import type { EffectDefinition, MotionOffset } from "@/effects/types"; 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 [ { key: "intensity", label: "Intensity", type: "number", default: 100, min: 0, max: 100, step: 1, keyframable: true, }, { key: "easing", label: "Easing", type: "select", default: "smooth", options: MOTION_EASING_OPTIONS, }, ]; } function zoomOffset({ effectParams, progress, direction, }: { effectParams: ParamValues; progress: number; direction: 1 | -1; }): MotionOffset { const intensity = getMotionIntensity({ effectParams }); const amount = direction === 1 ? progress * intensity : (1 - progress) * intensity; return { scale: 1 + ZOOM_RANGE * amount, translateX: 0, translateY: 0, }; } function panOffset({ effectParams, progress, axis, direction, }: { effectParams: ParamValues; progress: number; axis: "x" | "y"; direction: 1 | -1; }): MotionOffset { const intensity = getMotionIntensity({ effectParams }); const travel = (progress - 0.5) * 2 * PAN_TRAVEL * intensity * direction; return { scale: 1 + PAN_ZOOM_HEADROOM * intensity, translateX: axis === "x" ? travel : 0, translateY: axis === "y" ? travel : 0, }; } function buildMotionDefinition({ type, name, keywords, evaluate, }: Pick & { evaluate: NonNullable["evaluate"]; }): EffectDefinition { return { type, name, keywords, category: "lens", params: motionParams(), renderer: { passes: [] }, motion: { evaluate }, }; } export const motionEffectDefinitions: EffectDefinition[] = [ buildMotionDefinition({ type: "zoom-in", name: "Zoom In", keywords: ["zoom", "ken burns", "scale", "push in"], evaluate: ({ effectParams, progress }) => zoomOffset({ effectParams, progress, direction: 1 }), }), buildMotionDefinition({ type: "zoom-out", name: "Zoom Out", keywords: ["zoom", "ken burns", "scale", "pull out"], evaluate: ({ effectParams, progress }) => zoomOffset({ effectParams, progress, direction: -1 }), }), buildMotionDefinition({ type: "pan-left", name: "Pan Left", keywords: ["pan", "ken burns", "slide", "left"], evaluate: ({ effectParams, progress }) => panOffset({ effectParams, progress, axis: "x", direction: -1 }), }), buildMotionDefinition({ type: "pan-right", name: "Pan Right", keywords: ["pan", "ken burns", "slide", "right"], evaluate: ({ effectParams, progress }) => panOffset({ effectParams, progress, axis: "x", direction: 1 }), }), buildMotionDefinition({ type: "pan-up", name: "Pan Up", keywords: ["pan", "ken burns", "slide", "up", "tilt"], evaluate: ({ effectParams, progress }) => panOffset({ effectParams, progress, axis: "y", direction: -1 }), }), buildMotionDefinition({ type: "pan-down", name: "Pan Down", keywords: ["pan", "ken burns", "slide", "down", "tilt"], 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 }), }), }), ];