summaryrefslogtreecommitdiff
path: root/apps/web/src/effects
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
commit3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch)
tree57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/effects
parent729d10592f4e802f91c555c309ff3ba4b119f843 (diff)
downloadopencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/effects')
-rw-r--r--apps/web/src/effects/components/assets-view.tsx92
-rw-r--r--apps/web/src/effects/components/effects-tab.tsx309
-rw-r--r--apps/web/src/effects/definitions/blur.ts110
-rw-r--r--apps/web/src/effects/definitions/index.ts13
-rw-r--r--apps/web/src/effects/index.ts47
-rw-r--r--apps/web/src/effects/registry.ts10
-rw-r--r--apps/web/src/effects/types.ts41
7 files changed, 622 insertions, 0 deletions
diff --git a/apps/web/src/effects/components/assets-view.tsx b/apps/web/src/effects/components/assets-view.tsx
new file mode 100644
index 00000000..000af7e6
--- /dev/null
+++ b/apps/web/src/effects/components/assets-view.tsx
@@ -0,0 +1,92 @@
+"use client";
+
+import { useEffect, useRef, useCallback } from "react";
+import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
+import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
+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";
+
+export function EffectsView() {
+ const effects = effectsRegistry.getAll();
+
+ return (
+ <PanelView title="Effects">
+ <EffectsGrid effects={effects} />
+ </PanelView>
+ );
+}
+
+function EffectsGrid({ effects }: { effects: EffectDefinition[] }) {
+ return (
+ <div
+ className="grid gap-2"
+ style={{ gridTemplateColumns: "repeat(auto-fill, minmax(96px, 1fr))" }}
+ >
+ {effects.map((effect) => (
+ <EffectItem key={effect.type} effect={effect} />
+ ))}
+ </div>
+ );
+}
+
+function EffectPreviewCanvas({ effectType }: { effectType: string }) {
+ const canvasRef = useRef<HTMLCanvasElement>(null);
+
+ useEffect(() => {
+ const render = () => {
+ if (canvasRef.current) {
+ effectPreviewService.renderPreview({
+ effectType,
+ params: {},
+ targetCanvas: canvasRef.current,
+ });
+ }
+ };
+
+ render();
+ return effectPreviewService.onPreviewImageReady({ callback: render });
+ }, [effectType]);
+
+ return <canvas ref={canvasRef} className="size-full" />;
+}
+
+function EffectItem({ effect }: { effect: EffectDefinition }) {
+ const editor = useEditor();
+
+ const handleAddToTimeline = useCallback(() => {
+ const currentTime = editor.playback.getCurrentTime();
+ const element = buildEffectElement({
+ effectType: effect.type,
+ startTime: currentTime,
+ });
+
+ editor.timeline.insertElement({
+ placement: { mode: "auto", trackType: "effect" },
+ element,
+ });
+ }, [editor, effect.type]);
+
+ const preview = <EffectPreviewCanvas effectType={effect.type} />;
+
+ return (
+ <DraggableItem
+ name={effect.name}
+ preview={preview}
+ dragData={{
+ id: effect.type,
+ name: effect.name,
+ type: "effect",
+ effectType: effect.type,
+ targetElementTypes: EFFECT_TARGET_ELEMENT_TYPES,
+ }}
+ onAddToTimeline={handleAddToTimeline}
+ aspectRatio={1}
+ isRounded
+ variant="card"
+ containerClassName="w-full"
+ />
+ );
+}
diff --git a/apps/web/src/effects/components/effects-tab.tsx b/apps/web/src/effects/components/effects-tab.tsx
new file mode 100644
index 00000000..102d818a
--- /dev/null
+++ b/apps/web/src/effects/components/effects-tab.tsx
@@ -0,0 +1,309 @@
+"use client";
+
+import { useState } from "react";
+import type { ParamValues } from "@/params";
+import type { Effect } from "@/effects/types";
+import type { EffectElement, VisualElement } from "@/timeline";
+import { effectsRegistry } from "@/effects";
+import { useEditor } from "@/editor/use-editor";
+import { useElementPreview } from "@/timeline/hooks/use-element-preview";
+import {
+ Section,
+ SectionContent,
+ SectionHeader,
+ SectionTitle,
+ SectionFields,
+} from "@/components/section";
+import { PropertyParamField } from "@/components/editor/panels/properties/components/property-param-field";
+import { Button } from "@/components/ui/button";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ Delete02Icon,
+ ViewIcon,
+ ViewOffSlashIcon,
+ MagicWand05Icon,
+} 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";
+
+export function StandaloneEffectTab({
+ element,
+ trackId,
+}: {
+ element: EffectElement;
+ trackId: string;
+}) {
+ const { renderElement, previewUpdates, commit } = useElementPreview({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+
+ const effect: Effect = {
+ id: element.id,
+ type: element.effectType,
+ params: element.params,
+ enabled: true,
+ };
+
+ const previewParam = (key: string) => (value: number | string | boolean) => {
+ previewUpdates({
+ params: { ...(renderElement as EffectElement).params, [key]: value },
+ });
+ };
+
+ return (
+ <div className="flex flex-col h-full">
+ <div className="border-b px-3.5 h-11 shrink-0 flex items-center">
+ <SectionTitle>Effect</SectionTitle>
+ </div>
+ <EffectSection
+ effect={effect}
+ renderParams={(renderElement as EffectElement).params}
+ previewParam={previewParam}
+ onCommit={commit}
+ />
+ </div>
+ );
+}
+
+export function ClipEffectsTab({
+ element,
+ trackId,
+}: {
+ element: VisualElement;
+ trackId: string;
+}) {
+ const [dragIndex, setDragIndex] = useState<number | null>(null);
+ const [dropIndex, setDropIndex] = useState<number | null>(null);
+ const editor = useEditor();
+ const { renderElement, previewUpdates, commit } = useElementPreview({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+
+ const effects: Effect[] = element.effects ?? [];
+
+ const getRenderParams = ({ effectId }: { effectId: string }): ParamValues => {
+ return (
+ (renderElement as VisualElement).effects?.find((ef) => ef.id === effectId)
+ ?.params ??
+ effects.find((ef) => ef.id === effectId)?.params ??
+ {}
+ );
+ };
+
+ const buildPreviewParam =
+ (effectId: string) =>
+ (key: string) =>
+ (value: number | string | boolean) => {
+ const updatedEffects = (
+ (renderElement as VisualElement).effects ?? []
+ ).map((existing) =>
+ existing.id !== effectId
+ ? existing
+ : { ...existing, params: { ...existing.params, [key]: value } },
+ );
+ previewUpdates({ effects: updatedEffects });
+ };
+
+ const handleDragStart = ({ index }: { index: number }) => setDragIndex(index);
+
+ const handleDragOver = ({
+ event,
+ index,
+ }: {
+ event: React.DragEvent;
+ index: number;
+ }) => {
+ event.preventDefault();
+ if (index !== dropIndex) setDropIndex(index);
+ };
+
+ const handleDrop = ({ toIndex }: { toIndex: number }) => {
+ if (dragIndex !== null && dragIndex !== toIndex) {
+ editor.timeline.reorderClipEffects({
+ trackId,
+ elementId: element.id,
+ fromIndex: dragIndex,
+ toIndex,
+ });
+ }
+ setDragIndex(null);
+ setDropIndex(null);
+ };
+
+ const handleDragEnd = () => {
+ setDragIndex(null);
+ setDropIndex(null);
+ };
+
+ return (
+ <div className="flex flex-col h-full">
+ <div className="border-b px-3.5 h-11 shrink-0 flex items-center">
+ <SectionTitle>Effects</SectionTitle>
+ </div>
+ {effects.length === 0 ? (
+ <EmptyView />
+ ) : (
+ <ul className="flex flex-col">
+ {effects.map((effect, index) => {
+ const resolvedDragIndex = dragIndex ?? -1;
+ const isDragging = dragIndex === index;
+ const isDropTarget =
+ dropIndex === index && dragIndex !== null && dragIndex !== index;
+ const showTopDropIndicator =
+ isDropTarget && index < resolvedDragIndex;
+ const showBottomDropIndicator =
+ isDropTarget && index > resolvedDragIndex;
+
+ return (
+ <li
+ key={effect.id}
+ draggable
+ onDragStart={() => handleDragStart({ index })}
+ onDragOver={(event) => handleDragOver({ event, index })}
+ onDrop={() => handleDrop({ toIndex: index })}
+ onDragEnd={handleDragEnd}
+ className={cn(
+ "group list-none",
+ isDragging && "opacity-40",
+ showTopDropIndicator && "border-t-2 border-primary",
+ showBottomDropIndicator && "border-b-2 border-primary",
+ )}
+ >
+ <EffectSection
+ effect={effect}
+ renderParams={getRenderParams({ effectId: effect.id })}
+ previewParam={buildPreviewParam(effect.id)}
+ onCommit={commit}
+ onToggle={() =>
+ editor.timeline.toggleClipEffect({
+ trackId,
+ elementId: element.id,
+ effectId: effect.id,
+ })
+ }
+ onRemove={() =>
+ editor.timeline.removeClipEffect({
+ trackId,
+ elementId: element.id,
+ effectId: effect.id,
+ })
+ }
+ />
+ </li>
+ );
+ })}
+ </ul>
+ )}
+ </div>
+ );
+}
+
+function EmptyView() {
+ const setActiveTab = useAssetsPanelStore((s) => s.setActiveTab);
+
+ return (
+ <div className="flex flex-col h-full items-center justify-center gap-4 text-center">
+ <HugeiconsIcon
+ icon={MagicWand05Icon}
+ className="size-10 text-muted-foreground"
+ strokeWidth={1}
+ />
+ <div className="flex flex-col gap-2">
+ <h3 className="font-medium text-foreground">No effects</h3>
+ <p className="text-muted-foreground text-sm text-balance max-w-44">
+ Add effects to this layer from the Assets panel.
+ </p>
+ </div>
+ <Button
+ variant="default"
+ size="sm"
+ onClick={() => setActiveTab("effects")}
+ >
+ Open effects
+ </Button>
+ </div>
+ );
+}
+
+function EffectSection({
+ effect,
+ renderParams,
+ previewParam,
+ onCommit,
+ onToggle,
+ onRemove,
+}: {
+ effect: Effect;
+ renderParams: ParamValues;
+ previewParam: (key: string) => (value: number | string | boolean) => void;
+ onCommit: () => void;
+ onToggle?: () => void;
+ onRemove?: () => void;
+}) {
+ const definition = effectsRegistry.get(effect.type);
+
+ return (
+ <Section
+ sectionKey={onToggle ? `clip-effect:${effect.id}` : undefined}
+ showTopBorder={false}
+ >
+ <SectionHeader
+ className={cn(onToggle && "cursor-move")}
+ trailing={
+ onToggle && (
+ <div className="flex items-center gap-1">
+ <Button
+ variant={effect.enabled ? "secondary" : "ghost"}
+ size="icon"
+ aria-label={`Toggle ${definition.name}`}
+ onClick={onToggle}
+ >
+ <HugeiconsIcon
+ icon={effect.enabled ? ViewIcon : ViewOffSlashIcon}
+ />
+ </Button>
+ <Button
+ variant="ghost"
+ size="icon"
+ aria-label={`Remove ${definition.name}`}
+ onClick={onRemove}
+ >
+ <HugeiconsIcon icon={Delete02Icon} />
+ </Button>
+ </div>
+ )
+ }
+ >
+ <SectionTitle
+ className={cn(onToggle && !effect.enabled && "text-muted-foreground")}
+ >
+ {definition.name}
+ </SectionTitle>
+ </SectionHeader>
+ <SectionContent
+ className={cn("p-0", onToggle && !effect.enabled && "opacity-50")}
+ >
+ <SectionFields>
+ {definition.params.map((param) => (
+ <div key={param.key} className="flex flex-col gap-3.5">
+ <div className="px-4">
+ <PropertyParamField
+ param={param}
+ value={renderParams[param.key] ?? param.default}
+ onPreview={previewParam(param.key)}
+ onCommit={onCommit}
+ />
+ </div>
+ <Separator />
+ </div>
+ ))}
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
diff --git a/apps/web/src/effects/definitions/blur.ts b/apps/web/src/effects/definitions/blur.ts
new file mode 100644
index 00000000..194196e9
--- /dev/null
+++ b/apps/web/src/effects/definitions/blur.ts
@@ -0,0 +1,110 @@
+import type { EffectDefinition, EffectPass } from "@/effects/types";
+
+export const GAUSSIAN_BLUR_SHADER = "gaussian-blur";
+
+const MAX_SINGLE_PASS_SIGMA = 10;
+const MAX_STEP = 4;
+const MAX_EFFECTIVE_SIGMA = MAX_SINGLE_PASS_SIGMA * MAX_STEP;
+const MAX_ITERATIONS = 8;
+
+export function buildGaussianBlurPasses({
+ sigmaX,
+ sigmaY,
+}: {
+ sigmaX: number;
+ sigmaY: number;
+}): EffectPass[] {
+ const maxSigma = Math.max(sigmaX, sigmaY);
+ if (maxSigma < 0.001) return [];
+
+ const iterations = Math.min(
+ MAX_ITERATIONS,
+ Math.max(
+ 1,
+ Math.ceil(
+ (maxSigma * maxSigma) /
+ (MAX_EFFECTIVE_SIGMA * MAX_EFFECTIVE_SIGMA),
+ ),
+ ),
+ );
+ const perPassSigmaX = sigmaX / Math.sqrt(iterations);
+ const perPassSigmaY = sigmaY / Math.sqrt(iterations);
+ const stepX = Math.max(1, perPassSigmaX / MAX_SINGLE_PASS_SIGMA);
+ const stepY = Math.max(1, perPassSigmaY / MAX_SINGLE_PASS_SIGMA);
+
+ const passes: EffectPass[] = [];
+ for (let i = 0; i < iterations; i++) {
+ passes.push({
+ shader: GAUSSIAN_BLUR_SHADER,
+ uniforms: {
+ u_sigma: perPassSigmaX,
+ u_step: stepX,
+ u_direction: [1, 0],
+ },
+ });
+ passes.push({
+ shader: GAUSSIAN_BLUR_SHADER,
+ uniforms: {
+ u_sigma: perPassSigmaY,
+ u_step: stepY,
+ u_direction: [0, 1],
+ },
+ });
+ }
+ return passes;
+}
+
+export const INTENSITY_TO_SIGMA_DIVISOR = 5;
+
+export function intensityToSigma({ intensity, resolution, reference }: { intensity: number; resolution: number; reference: number }): number {
+ return (intensity / INTENSITY_TO_SIGMA_DIVISOR) * (resolution / reference);
+}
+
+function parseIntensity(effectParams: Record<string, unknown>): number {
+ const raw = effectParams.intensity;
+ return typeof raw === "number" ? raw : Number.parseFloat(String(raw));
+}
+
+export const blurEffectDefinition: EffectDefinition = {
+ type: "blur",
+ name: "Blur",
+ keywords: ["blur", "soft", "defocus"],
+ params: [
+ {
+ key: "intensity",
+ label: "Intensity",
+ type: "number",
+ default: 15,
+ min: 0,
+ max: 100,
+ step: 1,
+ },
+ ],
+ renderer: {
+ passes: [
+ {
+ shader: GAUSSIAN_BLUR_SHADER,
+ uniforms: ({ effectParams, width }) => ({
+ u_sigma: Math.max(intensityToSigma({ intensity: parseIntensity(effectParams), resolution: width, reference: 1920 }), 0.001),
+ u_step: 1,
+ u_direction: [1, 0],
+ }),
+ },
+ {
+ shader: GAUSSIAN_BLUR_SHADER,
+ uniforms: ({ effectParams, height }) => ({
+ u_sigma: Math.max(intensityToSigma({ intensity: parseIntensity(effectParams), resolution: height, reference: 1080 }), 0.001),
+ u_step: 1,
+ u_direction: [0, 1],
+ }),
+ },
+ ],
+ buildPasses: ({ effectParams, width, height }) => {
+ const intensity = parseIntensity(effectParams);
+ return buildGaussianBlurPasses({
+ sigmaX: intensityToSigma({ intensity, resolution: width, reference: 1920 }),
+ sigmaY: intensityToSigma({ intensity, resolution: height, reference: 1080 }),
+ });
+ },
+ },
+};
diff --git a/apps/web/src/effects/definitions/index.ts b/apps/web/src/effects/definitions/index.ts
new file mode 100644
index 00000000..edc8c5e2
--- /dev/null
+++ b/apps/web/src/effects/definitions/index.ts
@@ -0,0 +1,13 @@
+import { effectsRegistry } from "../registry";
+import { blurEffectDefinition } from "./blur";
+
+const defaultEffects = [blurEffectDefinition];
+
+export function registerDefaultEffects(): void {
+ for (const definition of defaultEffects) {
+ if (effectsRegistry.has(definition.type)) {
+ continue;
+ }
+ effectsRegistry.register(definition.type, definition);
+ }
+}
diff --git a/apps/web/src/effects/index.ts b/apps/web/src/effects/index.ts
new file mode 100644
index 00000000..eebb8cbc
--- /dev/null
+++ b/apps/web/src/effects/index.ts
@@ -0,0 +1,47 @@
+import { generateUUID } from "@/utils/id";
+import { buildDefaultParamValues } from "@/params/registry";
+import { effectsRegistry } from "./registry";
+import type { ParamValues } from "@/params";
+import type { Effect, EffectDefinition, EffectPass } from "@/effects/types";
+import { VISUAL_ELEMENT_TYPES } from "@/timeline";
+
+export { effectsRegistry } from "./registry";
+export { registerDefaultEffects } from "./definitions";
+
+export function resolveEffectPasses({
+ definition,
+ effectParams,
+ width,
+ height,
+}: {
+ definition: EffectDefinition;
+ effectParams: ParamValues;
+ width: number;
+ height: number;
+}): EffectPass[] {
+ if (definition.renderer.buildPasses) {
+ return definition.renderer.buildPasses({ effectParams, width, height });
+ }
+ return definition.renderer.passes.map((pass) => ({
+ shader: pass.shader,
+ uniforms: pass.uniforms({ effectParams, width, height }),
+ }));
+}
+
+export const EFFECT_TARGET_ELEMENT_TYPES = VISUAL_ELEMENT_TYPES;
+
+export function buildDefaultEffectInstance({
+ effectType,
+}: {
+ effectType: string;
+}): Effect {
+ const definition = effectsRegistry.get(effectType);
+ const params: ParamValues = buildDefaultParamValues(definition.params);
+
+ return {
+ id: generateUUID(),
+ type: effectType,
+ params,
+ enabled: true,
+ };
+}
diff --git a/apps/web/src/effects/registry.ts b/apps/web/src/effects/registry.ts
new file mode 100644
index 00000000..610bca02
--- /dev/null
+++ b/apps/web/src/effects/registry.ts
@@ -0,0 +1,10 @@
+import { DefinitionRegistry } from "@/params/registry";
+import type { EffectDefinition } from "@/effects/types";
+
+export class EffectsRegistry extends DefinitionRegistry<string, EffectDefinition> {
+ constructor() {
+ super("effect");
+ }
+}
+
+export const effectsRegistry = new EffectsRegistry();
diff --git a/apps/web/src/effects/types.ts b/apps/web/src/effects/types.ts
new file mode 100644
index 00000000..6b85e743
--- /dev/null
+++ b/apps/web/src/effects/types.ts
@@ -0,0 +1,41 @@
+import type { ParamDefinition, ParamValues } from "@/params";
+
+export interface Effect {
+ id: string;
+ type: string;
+ params: ParamValues;
+ enabled: boolean;
+}
+
+export type EffectUniformValue = number | number[];
+
+export interface EffectPass {
+ shader: string;
+ uniforms: Record<string, EffectUniformValue>;
+}
+
+export interface EffectPassTemplate {
+ shader: string;
+ uniforms(params: {
+ effectParams: ParamValues;
+ width: number;
+ height: number;
+ }): Record<string, EffectUniformValue>;
+}
+
+export interface EffectRendererConfig {
+ passes: EffectPassTemplate[];
+ buildPasses?: (params: {
+ effectParams: ParamValues;
+ width: number;
+ height: number;
+ }) => EffectPass[];
+}
+
+export interface EffectDefinition {
+ type: string;
+ name: string;
+ keywords: string[];
+ params: ParamDefinition[];
+ renderer: EffectRendererConfig;
+}