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 | |
| 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')
| -rw-r--r-- | apps/web/src/effects/__tests__/motion.test.ts | 182 | ||||
| -rw-r--r-- | apps/web/src/effects/components/assets-view.tsx | 106 | ||||
| -rw-r--r-- | apps/web/src/effects/definitions/index.ts | 3 | ||||
| -rw-r--r-- | apps/web/src/effects/definitions/motion.ts | 132 | ||||
| -rw-r--r-- | apps/web/src/effects/motion.ts | 131 | ||||
| -rw-r--r-- | apps/web/src/effects/types.ts | 15 |
6 files changed, 566 insertions, 3 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); + }); +}); diff --git a/apps/web/src/effects/components/assets-view.tsx b/apps/web/src/effects/components/assets-view.tsx index 000af7e6..8c2ec85b 100644 --- a/apps/web/src/effects/components/assets-view.tsx +++ b/apps/web/src/effects/components/assets-view.tsx @@ -53,6 +53,102 @@ function EffectPreviewCanvas({ effectType }: { effectType: string }) { return <canvas ref={canvasRef} className="size-full" />; } +const MOTION_ARROW_DIRECTIONS: Record<string, [number, number]> = { + "pan-left": [-1, 0], + "pan-right": [1, 0], + "pan-up": [0, -1], + "pan-down": [0, 1], +}; + +function drawArrow({ + context, + fromX, + fromY, + toX, + toY, +}: { + context: CanvasRenderingContext2D; + fromX: number; + fromY: number; + toX: number; + toY: number; +}) { + const headLength = 10; + const angle = Math.atan2(toY - fromY, toX - fromX); + context.beginPath(); + context.moveTo(fromX, fromY); + context.lineTo(toX, toY); + context.moveTo(toX, toY); + context.lineTo( + toX - headLength * Math.cos(angle - Math.PI / 6), + toY - headLength * Math.sin(angle - Math.PI / 6), + ); + context.moveTo(toX, toY); + context.lineTo( + toX - headLength * Math.cos(angle + Math.PI / 6), + toY - headLength * Math.sin(angle + Math.PI / 6), + ); + context.stroke(); +} + +function MotionPreviewCanvas({ effectType }: { effectType: string }) { + const canvasRef = useRef<HTMLCanvasElement>(null); + + useEffect(() => { + const canvas = canvasRef.current; + const context = canvas?.getContext("2d"); + if (!canvas || !context) return; + + const size = canvas.width; + const center = size / 2; + context.clearRect(0, 0, size, size); + context.fillStyle = "#17171c"; + context.fillRect(0, 0, size, size); + context.strokeStyle = "rgba(255, 255, 255, 0.35)"; + context.lineWidth = 2; + context.strokeRect(size * 0.2, size * 0.28, size * 0.6, size * 0.44); + context.strokeStyle = "#ffffff"; + context.lineWidth = 4; + context.lineCap = "round"; + + const panDirection = MOTION_ARROW_DIRECTIONS[effectType]; + if (panDirection) { + const [dx, dy] = panDirection; + drawArrow({ + context, + fromX: center - dx * size * 0.18, + fromY: center - dy * size * 0.18, + toX: center + dx * size * 0.18, + toY: center + dy * size * 0.18, + }); + 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 }); + } + }, [effectType]); + + return ( + <canvas + ref={canvasRef} + width={effectPreviewService.PREVIEW_SIZE} + height={effectPreviewService.PREVIEW_SIZE} + className="size-full" + /> + ); +} + function EffectItem({ effect }: { effect: EffectDefinition }) { const editor = useEditor(); @@ -69,7 +165,11 @@ function EffectItem({ effect }: { effect: EffectDefinition }) { }); }, [editor, effect.type]); - const preview = <EffectPreviewCanvas effectType={effect.type} />; + const preview = effect.motion ? ( + <MotionPreviewCanvas effectType={effect.type} /> + ) : ( + <EffectPreviewCanvas effectType={effect.type} /> + ); return ( <DraggableItem @@ -82,7 +182,9 @@ function EffectItem({ effect }: { effect: EffectDefinition }) { effectType: effect.type, targetElementTypes: EFFECT_TARGET_ELEMENT_TYPES, }} - onAddToTimeline={handleAddToTimeline} + onAddToTimeline={ + effect.supportsStandalone === false ? undefined : handleAddToTimeline + } aspectRatio={1} isRounded variant="card" diff --git a/apps/web/src/effects/definitions/index.ts b/apps/web/src/effects/definitions/index.ts index 511df042..e38e5710 100644 --- a/apps/web/src/effects/definitions/index.ts +++ b/apps/web/src/effects/definitions/index.ts @@ -1,7 +1,8 @@ import { effectsRegistry } from "../registry"; import { blurEffectDefinition } from "./blur"; +import { motionEffectDefinitions } from "./motion"; -const defaultEffects = [blurEffectDefinition]; +const defaultEffects = [blurEffectDefinition, ...motionEffectDefinitions]; export function registerDefaultEffects(): void { for (const definition of defaultEffects) { diff --git a/apps/web/src/effects/definitions/motion.ts b/apps/web/src/effects/definitions/motion.ts new file mode 100644 index 00000000..8c81d801 --- /dev/null +++ b/apps/web/src/effects/definitions/motion.ts @@ -0,0 +1,132 @@ +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; + +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<EffectDefinition, "type" | "name" | "keywords"> & { + evaluate: NonNullable<EffectDefinition["motion"]>["evaluate"]; +}): EffectDefinition { + return { + type, + name, + keywords, + params: motionParams(), + renderer: { passes: [] }, + motion: { evaluate }, + supportsStandalone: false, + }; +} + +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 }), + }), +]; diff --git a/apps/web/src/effects/motion.ts b/apps/web/src/effects/motion.ts new file mode 100644 index 00000000..ab51002a --- /dev/null +++ b/apps/web/src/effects/motion.ts @@ -0,0 +1,131 @@ +import type { ElementAnimations } from "@/animation/types"; +import type { NormalizedCubicBezier } from "@/animation/types"; +import { getBezierPoint } from "@/animation/bezier"; +import { resolveEffectParamsAtTime } from "@/animation/effect-param-channel"; +import type { ParamValues } from "@/params"; +import { clamp } from "@/utils/math"; +import type { Transform } from "@/rendering"; +import type { Effect } from "@/effects/types"; +import { effectsRegistry } from "@/effects/registry"; +import { BUILTIN_PRESETS } from "@/timeline/components/graph-editor/easing-presets"; + +const BEZIER_SOLVE_ITERATIONS = 20; + +export const MOTION_EASING_OPTIONS = BUILTIN_PRESETS.map((preset) => ({ + value: preset.id, + label: preset.label, +})); + +export function getMotionIntensity({ + effectParams, +}: { + effectParams: ParamValues; +}): number { + const raw = effectParams.intensity; + const intensity = typeof raw === "number" ? raw : Number.parseFloat(String(raw)); + if (!Number.isFinite(intensity)) return 1; + return clamp({ value: intensity / 100, min: 0, max: 1 }); +} + +export function sampleEasingBezier({ + bezier, + x, +}: { + bezier: NormalizedCubicBezier; + x: number; +}): number { + const [x1, y1, x2, y2] = bezier; + let lower = 0; + let upper = 1; + for (let iteration = 0; iteration < BEZIER_SOLVE_ITERATIONS; iteration++) { + const mid = (lower + upper) / 2; + const estimate = getBezierPoint({ + progress: mid, + p0: 0, + p1: x1, + p2: x2, + p3: 1, + }); + if (estimate < x) { + lower = mid; + } else { + upper = mid; + } + } + return getBezierPoint({ + progress: (lower + upper) / 2, + p0: 0, + p1: y1, + p2: y2, + p3: 1, + }); +} + +export function applyMotionEffects({ + baseTransform, + effects, + animations, + localTime, + duration, + canvasWidth, + canvasHeight, +}: { + baseTransform: Transform; + effects: Effect[] | undefined; + animations: ElementAnimations | undefined; + localTime: number; + duration: number; + canvasWidth: number; + canvasHeight: number; +}): Transform { + const motionEffects = (effects ?? []).filter( + (effect) => effect.enabled && effectsRegistry.get(effect.type).motion, + ); + if (motionEffects.length === 0 || duration <= 0) { + return baseTransform; + } + + const linearProgress = clamp({ + value: localTime / duration, + min: 0, + max: 1, + }); + + let scale = 1; + let translateX = 0; + let translateY = 0; + + for (const effect of motionEffects) { + const definition = effectsRegistry.get(effect.type); + if (!definition.motion) continue; + const resolvedParams = resolveEffectParamsAtTime({ + effectId: effect.id, + params: effect.params, + animations, + localTime, + }); + const progress = sampleEasingBezier({ + bezier: + BUILTIN_PRESETS.find((entry) => entry.id === resolvedParams.easing) + ?.value ?? [0.25, 0.1, 0.25, 1], + x: linearProgress, + }); + const offset = definition.motion.evaluate({ + effectParams: resolvedParams, + progress, + }); + scale *= offset.scale; + translateX += offset.translateX; + translateY += offset.translateY; + } + + return { + scaleX: baseTransform.scaleX * scale, + scaleY: baseTransform.scaleY * scale, + position: { + x: baseTransform.position.x + translateX * canvasWidth, + y: baseTransform.position.y + translateY * canvasHeight, + }, + rotate: baseTransform.rotate, + }; +} diff --git a/apps/web/src/effects/types.ts b/apps/web/src/effects/types.ts index 6b85e743..88c32207 100644 --- a/apps/web/src/effects/types.ts +++ b/apps/web/src/effects/types.ts @@ -23,6 +23,19 @@ export interface EffectPassTemplate { }): Record<string, EffectUniformValue>; } +export interface MotionOffset { + scale: number; + translateX: number; + translateY: number; +} + +export interface EffectMotionConfig { + evaluate(params: { + effectParams: ParamValues; + progress: number; + }): MotionOffset; +} + export interface EffectRendererConfig { passes: EffectPassTemplate[]; buildPasses?: (params: { @@ -38,4 +51,6 @@ export interface EffectDefinition { keywords: string[]; params: ParamDefinition[]; renderer: EffectRendererConfig; + motion?: EffectMotionConfig; + supportsStandalone?: boolean; } |
