summaryrefslogtreecommitdiff
path: root/apps/web/src/effects
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/effects')
-rw-r--r--apps/web/src/effects/__tests__/lens-effects.test.ts349
-rw-r--r--apps/web/src/effects/__tests__/motion.test.ts164
-rw-r--r--apps/web/src/effects/components/assets-view.tsx54
-rw-r--r--apps/web/src/effects/components/effects-tab.tsx7
-rw-r--r--apps/web/src/effects/definitions/blur.ts1
-rw-r--r--apps/web/src/effects/definitions/index.ts10
-rw-r--r--apps/web/src/effects/definitions/motion-blur.ts67
-rw-r--r--apps/web/src/effects/definitions/motion.ts115
-rw-r--r--apps/web/src/effects/motion.ts4
-rw-r--r--apps/web/src/effects/types.ts5
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;
}