From 216e3e0c397be3719a94e1a22bc1c2ce2de3856c Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sat, 28 Feb 2026 18:41:22 +0100 Subject: [PATCH] feat: introduce WebGL effects system and Blur effect This implements the foundational architecture for video effects, starting with a multi-pass WebGL rendering pipeline and a customizable Gaussian Blur effect. Key changes: - WebGL Engine: Added `raw-loader` for `.glsl` shaders, multi-pass framebuffer rendering, and live offscreen canvas previews. - Node Architecture: Replaced hardcoded background blur with `CompositeEffectNode` and added `EffectLayerNode` to apply effects to specific visual elements. - Timeline & DND: Added a new `effect` track type. Upgraded drag-and-drop to support dropping effects directly onto the timeline. Consolidated track constants into a cleaner `TRACK_CONFIG`. - UI/UX: Added an Effects tab in the assets panel with live previews. Added an Effect Properties panel with sliders and inputs for fine-tuning parameters. - Data Model: Added `sourceDuration` to video and audio elements, and wrote a v8 storage migration to update existing projects to the new schema. - Docs: Added `CHANGELOG.md` tracking v0.1.0 and v0.2.0, plus `docs/effects-renderer.md` to document the new WebGL pipeline. --- CHANGELOG.md | 41 +++ apps/web/next.config.ts | 8 + apps/web/package.json | 211 +++++++------ apps/web/public/effects/preview.jpg | Bin 0 -> 48179 bytes .../components/editor/panels/assets/index.tsx | 7 +- .../editor/panels/assets/views/assets.tsx | 6 + .../editor/panels/assets/views/effects.tsx | 95 ++++++ .../panels/properties/effect-properties.tsx | 103 ++++++ .../editor/panels/properties/index.tsx | 65 ++-- .../editor/panels/timeline/index.tsx | 12 +- .../panels/timeline/timeline-element.tsx | 266 ++++++++++------ .../editor/panels/timeline/timeline-track.tsx | 3 + apps/web/src/components/ui/select.tsx | 2 +- apps/web/src/components/ui/slider.tsx | 4 +- apps/web/src/constants/timeline-constants.tsx | 82 +++-- apps/web/src/core/index.ts | 2 + .../timeline/element/use-element-resize.ts | 8 +- .../src/hooks/timeline/use-selection-box.ts | 18 +- .../hooks/timeline/use-timeline-drag-drop.ts | 69 +++- apps/web/src/hooks/use-effect-preview.ts | 47 +++ apps/web/src/lib/animation/interpolation.ts | 5 + .../timeline/element/insert-element.ts | 5 + .../element/keyframes/retime-keyframe.ts | 1 + .../lib/effects/definitions/blur.frag.glsl | 25 ++ apps/web/src/lib/effects/definitions/blur.ts | 50 +++ apps/web/src/lib/effects/definitions/index.ts | 13 + apps/web/src/lib/effects/effect.vert.glsl | 7 + apps/web/src/lib/effects/index.ts | 34 ++ apps/web/src/lib/effects/registry.ts | 31 ++ apps/web/src/lib/preview/element-bounds.ts | 2 +- apps/web/src/lib/timeline/drop-utils.ts | 85 ++++- apps/web/src/lib/timeline/element-utils.ts | 42 ++- apps/web/src/lib/timeline/track-utils.ts | 38 +-- .../web/src/services/renderer/canvas-utils.ts | 16 + .../src/services/renderer/effect-preview.ts | 194 ++++++++++++ .../renderer/nodes/blur-background-node.ts | 75 ----- .../renderer/nodes/composite-effect-node.ts | 77 +++++ .../renderer/nodes/effect-layer-node.ts | 82 +++++ .../src/services/renderer/nodes/image-node.ts | 2 +- .../services/renderer/nodes/sticker-node.ts | 2 +- .../src/services/renderer/nodes/video-node.ts | 4 +- .../services/renderer/nodes/visual-node.ts | 68 +++- .../src/services/renderer/scene-builder.ts | 256 +++++++++------ .../renderer/webgl-effect-renderer.ts | 73 +++++ apps/web/src/services/renderer/webgl-utils.ts | 298 ++++++++++++++++++ .../src/services/storage/migrations/index.ts | 4 +- .../migrations/transformers/v7-to-v8.ts | 96 ++++++ .../services/storage/migrations/v7-to-v8.ts | 16 + apps/web/src/types/drag.ts | 15 +- apps/web/src/types/effects.ts | 45 +++ apps/web/src/types/glsl.d.ts | 4 + apps/web/src/types/timeline.ts | 38 ++- bun.lock | 169 +++++++++- docs/effects-renderer.md | 64 ++++ package.json | 62 ++-- 55 files changed, 2510 insertions(+), 537 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 apps/web/public/effects/preview.jpg create mode 100644 apps/web/src/components/editor/panels/assets/views/effects.tsx create mode 100644 apps/web/src/components/editor/panels/properties/effect-properties.tsx create mode 100644 apps/web/src/hooks/use-effect-preview.ts create mode 100644 apps/web/src/lib/effects/definitions/blur.frag.glsl create mode 100644 apps/web/src/lib/effects/definitions/blur.ts create mode 100644 apps/web/src/lib/effects/definitions/index.ts create mode 100644 apps/web/src/lib/effects/effect.vert.glsl create mode 100644 apps/web/src/lib/effects/index.ts create mode 100644 apps/web/src/lib/effects/registry.ts create mode 100644 apps/web/src/services/renderer/canvas-utils.ts create mode 100644 apps/web/src/services/renderer/effect-preview.ts delete mode 100644 apps/web/src/services/renderer/nodes/blur-background-node.ts create mode 100644 apps/web/src/services/renderer/nodes/composite-effect-node.ts create mode 100644 apps/web/src/services/renderer/nodes/effect-layer-node.ts create mode 100644 apps/web/src/services/renderer/webgl-effect-renderer.ts create mode 100644 apps/web/src/services/renderer/webgl-utils.ts create mode 100644 apps/web/src/services/storage/migrations/transformers/v7-to-v8.ts create mode 100644 apps/web/src/services/storage/migrations/v7-to-v8.ts create mode 100644 apps/web/src/types/effects.ts create mode 100644 apps/web/src/types/glsl.d.ts create mode 100644 docs/effects-renderer.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..4ddf2a88 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,41 @@ +## **0.2.0**: + +This release adds the foundation for motion and effects. + +**Features**: + +- Keyframe animation system, starting with transform properties (position, scale, rotation). +- Effects system (with our first effect: Blur!) + +**Bug fixes**: + +- Fixed an issue where click-and-drag selection on one timeline track could accidentally select items from the track below + +**Improvements**: + +## **0.1.0**: + +This first release focuses on making editing faster, clearer, and more reliable for day-to-day use. + +**Features**: + +- Rebuilt the properties panel from scratch. Number inputs now support math expressions and click-drag scrubbing instead of just typing values in. +- The properties panel went from a flat list of basic text styles to organized sections: transform, blending, typography, spacing, and background controls. Text elements finally have position, scale, and rotation. +- New color picker with eyedropper, opacity control, and multiple color formats. +- Bookmarks now support notes, color labels, and optional duration to plan scenes more clearly. +- Move, scale, and rotate elements directly in the preview panel. +- Blend modes (Multiply, Screen, Overlay, etc). Only available on text elements for now. +- Paste images, videos, or audio from your clipboard straight into the editor. +- Hold `Shift` while moving or resizing to temporarily turn off snapping. + +**Improvements**: + +- Expanded font selection from 7 to over 1,000. +- Fonts load much faster. +- All asset panel tabs now share a consistent layout. +- Text rendering properly supports multiple lines, line height, and letter spacing. +- Better contrast in the dark theme. + +**Bug fixes**: + +- Fixed undo/redo for drag-and-drop so dropped items are reliably undoable. diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index fa1ac7f5..c3676f0f 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -2,6 +2,14 @@ import type { NextConfig } from "next"; import { withBotId } from "botid/next/config"; const nextConfig: NextConfig = { + turbopack: { + rules: { + "*.glsl": { + loaders: [require.resolve("raw-loader")], + as: "*.js", + }, + }, + }, compiler: { removeConsole: process.env.NODE_ENV === "production", }, diff --git a/apps/web/package.json b/apps/web/package.json index 9a57a379..90aa5d3b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,107 +1,108 @@ { - "name": "@opencut/web", - "version": "0.1.0", - "private": true, - "packageManager": "bun@1.2.18", - "scripts": { - "dev": "next dev --turbopack", - "build": "next build", - "start": "next start", - "lint": "biome check src/", - "lint:fix": "biome check src/ --write", - "format": "biome format src/ --write", - "db:generate": "drizzle-kit generate", - "db:migrate": "drizzle-kit migrate", - "db:push:local": "cross-env NODE_ENV=development drizzle-kit push", - "db:push:prod": "cross-env NODE_ENV=production drizzle-kit push" - }, - "dependencies": { - "@ffmpeg/core": "^0.12.10", - "@ffmpeg/ffmpeg": "^0.12.15", - "@ffmpeg/util": "^0.12.2", - "@hello-pangea/dnd": "^18.0.1", - "@hookform/resolvers": "^3.9.1", - "@hugeicons/core-free-icons": "^3.1.1", - "@hugeicons/react": "^1.1.4", - "@huggingface/transformers": "^3.8.1", - "@opencut/env": "workspace:*", - "@opencut/ui": "workspace:*", - "@radix-ui/react-accordion": "^1.2.12", - "@radix-ui/react-checkbox": "^1.3.3", - "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-dropdown-menu": "^2.1.16", - "@radix-ui/react-primitive": "^2.1.4", - "@radix-ui/react-select": "^2.2.6", - "@radix-ui/react-separator": "^1.1.8", - "@radix-ui/react-slot": "^1.2.4", - "@radix-ui/react-tooltip": "^1.2.8", - "@types/culori": "^4.0.1", - "@upstash/ratelimit": "^2.0.6", - "@upstash/redis": "^1.35.4", - "@vercel/analytics": "^1.4.1", - "aws4fetch": "^1.0.20", - "better-auth": "^1.2.7", - "botid": "^1.4.2", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "cmdk": "^1.0.0", - "culori": "^4.0.2", - "dayjs": "^1.11.13", - "drizzle-orm": "^0.44.2", - "embla-carousel-react": "^8.5.1", - "eventemitter3": "^5.0.1", - "feed": "^5.1.0", - "input-otp": "^1.4.1", - "lucide-react": "^0.562.0", - "mediabunny": "^1.29.1", - "motion": "^12.18.1", - "nanoid": "^5.1.5", - "next": "16.1.3", - "next-themes": "^0.4.4", - "pg": "^8.16.2", - "postgres": "^3.4.5", - "radix-ui": "^1.4.3", - "react": "^19.0.0", - "react-day-picker": "^8.10.1", - "react-dom": "^19.0.0", - "react-hook-form": "^7.54.0", - "react-icons": "^5.4.0", - "react-markdown": "^10.1.0", - "react-phone-number-input": "^3.4.11", - "react-resizable-panels": "^2.1.7", - "react-window": "^2.2.7", - "recharts": "^2.14.1", - "rehype-autolink-headings": "^7.1.0", - "rehype-parse": "^9.0.1", - "rehype-sanitize": "^6.0.0", - "rehype-slug": "^6.0.0", - "rehype-stringify": "^10.0.1", - "sonner": "^1.7.1", - "tailwind-merge": "^2.5.5", - "tailwindcss-animate": "^1.0.7", - "unified": "^11.0.5", - "use-deep-compare-effect": "^1.8.1", - "vaul": "^1.1.2", - "wavesurfer.js": "^7.9.8", - "zod": "^3.25.67", - "zustand": "^5.0.2" - }, - "devDependencies": { - "@napi-rs/canvas": "^0.1.92", - "@tailwindcss/postcss": "^4.1.11", - "@tailwindcss/typography": "^0.5.16", - "@types/bun": "latest", - "@types/node": "^24.2.1", - "@types/pg": "^8.15.4", - "@types/react": "^19", - "@types/react-dom": "^19", - "cross-env": "^7.0.3", - "dotenv": "^16.5.0", - "drizzle-kit": "^0.31.4", - "postcss": "^8", - "sharp": "^0.34.5", - "tailwindcss": "^4.1.11", - "tsx": "^4.7.1", - "typescript": "^5.8.3" - } + "name": "@opencut/web", + "version": "0.1.0", + "private": true, + "packageManager": "bun@1.2.18", + "scripts": { + "dev": "next dev --turbopack", + "build": "next build", + "start": "next start", + "lint": "biome check src/", + "lint:fix": "biome check src/ --write", + "format": "biome format src/ --write", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:push:local": "cross-env NODE_ENV=development drizzle-kit push", + "db:push:prod": "cross-env NODE_ENV=production drizzle-kit push" + }, + "dependencies": { + "@ffmpeg/core": "^0.12.10", + "@ffmpeg/ffmpeg": "^0.12.15", + "@ffmpeg/util": "^0.12.2", + "@hello-pangea/dnd": "^18.0.1", + "@hookform/resolvers": "^3.9.1", + "@hugeicons/core-free-icons": "^3.1.1", + "@hugeicons/react": "^1.1.4", + "@huggingface/transformers": "^3.8.1", + "@opencut/env": "workspace:*", + "@opencut/ui": "workspace:*", + "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-primitive": "^2.1.4", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tooltip": "^1.2.8", + "@types/culori": "^4.0.1", + "@upstash/ratelimit": "^2.0.6", + "@upstash/redis": "^1.35.4", + "@vercel/analytics": "^1.4.1", + "aws4fetch": "^1.0.20", + "better-auth": "^1.2.7", + "botid": "^1.4.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.0.0", + "culori": "^4.0.2", + "dayjs": "^1.11.13", + "drizzle-orm": "^0.44.2", + "embla-carousel-react": "^8.5.1", + "eventemitter3": "^5.0.1", + "feed": "^5.1.0", + "input-otp": "^1.4.1", + "lucide-react": "^0.562.0", + "mediabunny": "^1.29.1", + "motion": "^12.18.1", + "nanoid": "^5.1.5", + "next": "16.1.3", + "next-themes": "^0.4.4", + "pg": "^8.16.2", + "postgres": "^3.4.5", + "radix-ui": "^1.4.3", + "react": "^19.0.0", + "react-day-picker": "^8.10.1", + "react-dom": "^19.0.0", + "react-hook-form": "^7.54.0", + "react-icons": "^5.4.0", + "react-markdown": "^10.1.0", + "react-phone-number-input": "^3.4.11", + "react-resizable-panels": "^2.1.7", + "react-window": "^2.2.7", + "recharts": "^2.14.1", + "rehype-autolink-headings": "^7.1.0", + "rehype-parse": "^9.0.1", + "rehype-sanitize": "^6.0.0", + "rehype-slug": "^6.0.0", + "rehype-stringify": "^10.0.1", + "sonner": "^1.7.1", + "tailwind-merge": "^2.5.5", + "tailwindcss-animate": "^1.0.7", + "unified": "^11.0.5", + "use-deep-compare-effect": "^1.8.1", + "vaul": "^1.1.2", + "wavesurfer.js": "^7.9.8", + "zod": "^3.25.67", + "zustand": "^5.0.2" + }, + "devDependencies": { + "@napi-rs/canvas": "^0.1.92", + "@tailwindcss/postcss": "^4.1.11", + "@tailwindcss/typography": "^0.5.16", + "@types/bun": "latest", + "@types/node": "^24.2.1", + "@types/pg": "^8.15.4", + "@types/react": "^19", + "@types/react-dom": "^19", + "cross-env": "^7.0.3", + "dotenv": "^16.5.0", + "drizzle-kit": "^0.31.4", + "postcss": "^8", + "raw-loader": "^4.0.2", + "sharp": "^0.34.5", + "tailwindcss": "^4.1.11", + "tsx": "^4.7.1", + "typescript": "^5.8.3" + } } diff --git a/apps/web/public/effects/preview.jpg b/apps/web/public/effects/preview.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6519189c06f6199dca0d3cb1e41ca9aeb679ab66 GIT binary patch literal 48179 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°iWOpn2~Y%6zM#4)=*3yk??<2 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 z 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 literal 0 HcmV?d00001 diff --git a/apps/web/src/components/editor/panels/assets/index.tsx b/apps/web/src/components/editor/panels/assets/index.tsx index 3600d00b..fe7db329 100644 --- a/apps/web/src/components/editor/panels/assets/index.tsx +++ b/apps/web/src/components/editor/panels/assets/index.tsx @@ -9,6 +9,7 @@ import { SettingsView } from "./views/settings"; import { SoundsView } from "./views/sounds"; import { StickersView } from "./views/stickers"; import { TextView } from "./views/text"; +import { EffectsView } from "./views/effects"; export function AssetsPanel() { const { activeTab } = useAssetsPanelStore(); @@ -18,11 +19,7 @@ export function AssetsPanel() { sounds: , text: , stickers: , - effects: ( -
- Effects view coming soon... -
- ), + effects: , transitions: (
Transitions view coming soon... diff --git a/apps/web/src/components/editor/panels/assets/views/assets.tsx b/apps/web/src/components/editor/panels/assets/views/assets.tsx index 808961f1..4c6922ff 100644 --- a/apps/web/src/components/editor/panels/assets/views/assets.tsx +++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx @@ -426,6 +426,9 @@ function GridView({ type: "media", mediaType: item.type, name: item.name, + ...(item.type !== "audio" && { + targetElementTypes: ["video", "image"] as const, + }), }} shouldShowPlusOnDrag={false} onAddToTimeline={({ currentTime }) => @@ -476,6 +479,9 @@ function ListView({ type: "media", mediaType: item.type, name: item.name, + ...(item.type !== "audio" && { + targetElementTypes: ["video", "image"] as const, + }), }} shouldShowPlusOnDrag={false} onAddToTimeline={({ currentTime }) => diff --git a/apps/web/src/components/editor/panels/assets/views/effects.tsx b/apps/web/src/components/editor/panels/assets/views/effects.tsx new file mode 100644 index 00000000..c9b8b2dd --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/views/effects.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useEffect, useRef, useCallback } from "react"; +import { PanelView } from "@/components/editor/panels/assets/views/base-view"; +import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; +import { getAllEffects, EFFECT_TARGET_ELEMENT_TYPES } from "@/lib/effects"; +import { + effectPreviewService, + onPreviewImageReady, +} from "@/services/renderer/effect-preview"; +import { useEditor } from "@/hooks/use-editor"; +import { buildEffectElement } from "@/lib/timeline/element-utils"; +import type { EffectDefinition } from "@/types/effects"; + +export function EffectsView() { + const effects = getAllEffects(); + + return ( + + + + ); +} + +function EffectsGrid({ effects }: { effects: EffectDefinition[] }) { + return ( +
+ {effects.map((effect) => ( + + ))} +
+ ); +} + +function EffectPreviewCanvas({ effectType }: { effectType: string }) { + const canvasRef = useRef(null); + + useEffect(() => { + const render = () => { + if (canvasRef.current) { + effectPreviewService.renderPreview({ + effectType, + params: {}, + targetCanvas: canvasRef.current, + }); + } + }; + + render(); + return onPreviewImageReady({ callback: render }); + }, [effectType]); + + return ; +} + +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 = ; + + return ( + + ); +} diff --git a/apps/web/src/components/editor/panels/properties/effect-properties.tsx b/apps/web/src/components/editor/panels/properties/effect-properties.tsx new file mode 100644 index 00000000..bf137291 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/effect-properties.tsx @@ -0,0 +1,103 @@ +"use client"; + +import type { EffectElement } from "@/types/timeline"; +import type { EffectParamDefinition } from "@/types/effects"; +import { getEffect } from "@/lib/effects/registry"; +import { useEditor } from "@/hooks/use-editor"; +import { clamp } from "@/utils/math"; +import { Section, SectionContent, SectionHeader, SectionField, SectionFields } from "./section"; +import { Slider } from "@/components/ui/slider"; +import { NumberField } from "@/components/ui/number-field"; +import { usePropertyDraft } from "./hooks/use-property-draft"; + +function EffectParamField({ + param, + element, + trackId, +}: { + param: EffectParamDefinition; + element: EffectElement; + trackId: string; +}) { + const editor = useEditor(); + + const currentValue = Number(element.params[param.key] ?? param.default); + const min = param.min ?? 0; + const max = param.max ?? 100; + const step = param.step ?? 1; + + const updateParam = (value: number) => + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { params: { ...element.params, [param.key]: value } }, + }, + ], + }); + + const commitParam = () => editor.timeline.commitPreview(); + + const draft = usePropertyDraft({ + displayValue: String(currentValue), + parse: (input) => { + const parsed = parseFloat(input); + if (Number.isNaN(parsed)) return null; + return clamp({ value: parsed, min, max }); + }, + onPreview: updateParam, + onCommit: commitParam, + }); + + return ( + +
+ updateParam(value)} + onValueCommit={commitParam} + /> + +
+
+ ); +} + +export function EffectProperties({ + element, + trackId, +}: { + element: EffectElement; + trackId: string; +}) { + const definition = getEffect({ effectType: element.effectType }); + + return ( +
+ + + + {definition.params.map((param) => ( + + ))} + + +
+ ); +} diff --git a/apps/web/src/components/editor/panels/properties/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx index 4f285f84..75dd81a9 100644 --- a/apps/web/src/components/editor/panels/properties/index.tsx +++ b/apps/web/src/components/editor/panels/properties/index.tsx @@ -4,9 +4,37 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { AudioProperties } from "./audio-properties"; import { VideoProperties } from "./video-properties"; import { TextProperties } from "./text-properties"; +import { EffectProperties } from "./effect-properties"; import { EmptyView } from "./empty-view"; import { useEditor } from "@/hooks/use-editor"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; +import type { TimelineElement, TimelineTrack } from "@/types/timeline"; + +function ElementProperties({ + track, + element, +}: { + track: TimelineTrack; + element: TimelineElement; +}) { + if (element.type === "text") { + return ; + } + if (element.type === "audio") { + return ; + } + if ( + element.type === "video" || + element.type === "image" || + element.type === "sticker" + ) { + return ; + } + if (element.type === "effect") { + return ; + } + return null; +} export function PropertiesPanel() { const editor = useEditor(); @@ -16,34 +44,19 @@ export function PropertiesPanel() { elements: selectedElements, }); + const hasSelection = selectedElements.length > 0; + return ( -
- {selectedElements.length > 0 ? ( +
+ {hasSelection ? ( - {elementsWithTracks.map(({ track, element }) => { - if (element.type === "text") { - return ( -
- -
- ); - } - if (element.type === "audio") { - return ; - } - if ( - element.type === "video" || - element.type === "image" || - element.type === "sticker" - ) { - return ( -
- -
- ); - } - return null; - })} + {elementsWithTracks.map(({ track, element }) => ( + + ))}
) : ( diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx index 18f768a9..d89d270d 100644 --- a/apps/web/src/components/editor/panels/timeline/index.tsx +++ b/apps/web/src/components/editor/panels/timeline/index.tsx @@ -27,7 +27,7 @@ import type { SnapPoint } from "@/lib/timeline/snap-utils"; import type { TimelineTrack } from "@/types/timeline"; import { TIMELINE_CONSTANTS, - TRACK_ICONS, + TRACK_CONFIG, } from "@/constants/timeline-constants"; import { useElementInteraction } from "@/hooks/timeline/element/use-element-interaction"; import { @@ -161,6 +161,7 @@ export function Timeline() { shouldIgnoreClick, } = useSelectionBox({ containerRef: tracksContainerRef, + headerRef: timelineHeaderRef, onSelectionComplete: (elements) => { setElementSelection({ elements }); }, @@ -326,7 +327,7 @@ export function Timeline() {
@@ -523,7 +529,7 @@ export function Timeline() { } function TrackIcon({ track }: { track: TimelineTrack }) { - return <>{TRACK_ICONS[track.type]}; + return <>{TRACK_CONFIG[track.type].icon}; } function TrackToggleIcon({ diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx index 7ad63692..38cf953b 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx @@ -46,6 +46,7 @@ import { Search01Icon, Exchange01Icon, KeyframeIcon, + MagicWand05Icon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { uppercase } from "@/utils/string"; @@ -59,7 +60,6 @@ interface KeyframeIndicator { time: number; offsetPx: number; keyframes: SelectedKeyframeRef[]; - isSelected: boolean; } function buildKeyframeIndicator({ @@ -69,7 +69,6 @@ function buildKeyframeIndicator({ displayedStartTime, zoomLevel, elementLeft, - isKeyframeSelected, }: { keyframe: ElementKeyframe; trackId: string; @@ -77,16 +76,10 @@ function buildKeyframeIndicator({ displayedStartTime: number; zoomLevel: number; elementLeft: number; - isKeyframeSelected: ({ - keyframe, - }: { - keyframe: SelectedKeyframeRef; - }) => boolean; }): { time: number; offsetPx: number; keyframeRef: SelectedKeyframeRef; - isSelected: boolean; } { const keyframeRef = { trackId, @@ -102,7 +95,6 @@ function buildKeyframeIndicator({ time: keyframe.time, offsetPx: keyframeLeft - elementLeft, keyframeRef, - isSelected: isKeyframeSelected({ keyframe: keyframeRef }), }; } @@ -114,7 +106,6 @@ function getKeyframeIndicators({ zoomLevel, elementLeft, elementWidth, - isKeyframeSelected, }: { keyframes: ElementKeyframe[]; trackId: string; @@ -123,11 +114,6 @@ function getKeyframeIndicators({ zoomLevel: number; elementLeft: number; elementWidth: number; - isKeyframeSelected: ({ - keyframe, - }: { - keyframe: SelectedKeyframeRef; - }) => boolean; }): KeyframeIndicator[] { if (elementWidth < KEYFRAME_INDICATOR_MIN_WIDTH_PX) { return []; @@ -142,7 +128,6 @@ function getKeyframeIndicators({ displayedStartTime, zoomLevel, elementLeft, - isKeyframeSelected, }); const existingIndicator = keyframesByTime.get(indicator.time); if (!existingIndicator) { @@ -150,14 +135,11 @@ function getKeyframeIndicators({ time: indicator.time, offsetPx: indicator.offsetPx, keyframes: [indicator.keyframeRef], - isSelected: indicator.isSelected, }); continue; } existingIndicator.keyframes.push(indicator.keyframeRef); - existingIndicator.isSelected = - existingIndicator.isSelected || indicator.isSelected; } return [...keyframesByTime.values()].sort((a, b) => a.time - b.time); @@ -187,6 +169,7 @@ interface TimelineElementProps { ) => void; onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void; dragState: ElementDragState; + isDropTarget?: boolean; } export function TimelineElement({ @@ -199,10 +182,10 @@ export function TimelineElement({ onElementMouseDown, onElementClick, dragState, + isDropTarget = false, }: TimelineElementProps) { const editor = useEditor(); const { selectedElements } = useElementSelection(); - const { isKeyframeSelected } = useKeyframeSelection(); const { requestRevealMedia } = useAssetsPanelStore(); const mediaAssets = editor.media.getAssets(); @@ -248,16 +231,17 @@ export function TimelineElement({ time: displayedStartTime, zoomLevel, }); - const keyframeIndicators = getKeyframeIndicators({ - keyframes: getElementKeyframes({ animations: element.animations }), - trackId: track.id, - elementId: element.id, - displayedStartTime, - zoomLevel, - elementLeft, - elementWidth, - isKeyframeSelected, - }); + const keyframeIndicators = isSelected + ? getKeyframeIndicators({ + keyframes: getElementKeyframes({ animations: element.animations }), + trackId: track.id, + elementId: element.id, + displayedStartTime, + zoomLevel, + elementLeft, + elementWidth, + }) + : []; const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => { event.stopPropagation(); if (hasMediaId(element)) { @@ -291,8 +275,9 @@ export function TimelineElement({ onElementClick={onElementClick} onElementMouseDown={onElementMouseDown} handleResizeStart={handleResizeStart} + isDropTarget={isDropTarget} /> - + {isSelected && }
@@ -365,6 +350,7 @@ function ElementInner({ onElementClick, onElementMouseDown, handleResizeStart, + isDropTarget = false, }: { element: TimelineElementType; track: TimelineTrack; @@ -382,14 +368,20 @@ function ElementInner({ elementId: string; side: "left" | "right"; }) => void; + isDropTarget?: boolean; }) { + const opacityClass = + (canElementBeHidden(element) && element.hidden) || isDropTarget + ? "opacity-50" + : ""; + return (
- )); + return indicators.map((indicator) => { + const isIndicatorSelected = indicator.keyframes.some((keyframe) => + isKeyframeSelected({ keyframe }), + ); + + return ( + + ); + }); } -function ElementContent({ - element, - track, - isSelected, - mediaAssets, -}: { +interface ElementContentProps { element: TimelineElementType; track: TimelineTrack; isSelected: boolean; mediaAssets: MediaAsset[]; -}) { - if (element.type === "text") { +} + +type ElementContentRenderer = (props: ElementContentProps) => ReactNode; + +const ELEMENT_CONTENT_RENDERERS: Record< + TimelineElementType["type"], + ElementContentRenderer +> = { + text: ({ element }) => { + const textElement = element as Extract< + TimelineElementType, + { type: "text" } + >; return ( -
- {element.content} +
+ + {textElement.content} +
); - } - - if (element.type === "sticker") { + }, + effect: ({ element }) => ( +
+ + {element.name} +
+ ), + sticker: ({ element }) => { + const stickerElement = element as Extract< + TimelineElementType, + { type: "sticker" } + >; return (
{element.name} - {element.name} + + {stickerElement.name} +
); - } - - if (element.type === "audio") { + }, + audio: ({ element, mediaAssets }) => { + const audioElement = element as Extract< + TimelineElementType, + { type: "audio" } + >; const audioBuffer = - element.sourceType === "library" ? element.buffer : undefined; - + audioElement.sourceType === "library" ? audioElement.buffer : undefined; const audioUrl = - element.sourceType === "library" - ? element.sourceUrl - : mediaAssets.find((asset) => asset.id === element.mediaId)?.url; + audioElement.sourceType === "library" + ? audioElement.sourceUrl + : mediaAssets.find((asset) => asset.id === audioElement.mediaId)?.url; if (audioBuffer || audioUrl) { return ( @@ -593,28 +614,78 @@ function ElementContent({ return ( - {element.name} + {audioElement.name} ); - } + }, + video: ({ element, track, isSelected, mediaAssets }) => { + const videoElement = element as Extract< + TimelineElementType, + { type: "video" } + >; + const mediaAsset = mediaAssets.find( + (asset) => asset.id === videoElement.mediaId, + ); + + if (!mediaAsset) { + return ( + + {videoElement.name} + + ); + } + + if (mediaAsset.thumbnailUrl) { + const trackHeight = getTrackHeight({ type: track.type }); + const tileWidth = trackHeight * (16 / 9); + + return ( +
+
+
+
+
+ ); + } - const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId); - if (!mediaAsset) { return ( - {element.name} + {videoElement.name} ); - } + }, + image: ({ element, track, isSelected, mediaAssets }) => { + const imageElement = element as Extract< + TimelineElementType, + { type: "image" } + >; + const mediaAsset = mediaAssets.find( + (asset) => asset.id === imageElement.mediaId, + ); + + if (!mediaAsset?.url) { + return ( + + {imageElement.name} + + ); + } - if ( - mediaAsset.type === "image" || - (mediaAsset.type === "video" && mediaAsset.thumbnailUrl) - ) { const trackHeight = getTrackHeight({ type: track.type }); const tileWidth = trackHeight * (16 / 9); - const imageUrl = - mediaAsset.type === "image" ? mediaAsset.url : mediaAsset.thumbnailUrl; return (
@@ -624,7 +695,7 @@ function ElementContent({
); - } + }, +}; - return ( - {element.name} - ); +function ElementContent(props: ElementContentProps) { + const renderer = ELEMENT_CONTENT_RENDERERS[props.element.type]; + return <>{renderer(props)}; } function CopyMenuItem() { diff --git a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx index 42faadaf..fbc7cb3f 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx @@ -32,6 +32,7 @@ interface TimelineTrackContentProps { onTrackMouseDown?: (event: React.MouseEvent) => void; onTrackClick?: (event: React.MouseEvent) => void; shouldIgnoreClick?: () => boolean; + targetElementId?: string | null; } export function TimelineTrackContent({ @@ -48,6 +49,7 @@ export function TimelineTrackContent({ onTrackMouseDown, onTrackClick, shouldIgnoreClick, + targetElementId = null, }: TimelineTrackContentProps) { const editor = useEditor(); const { isElementSelected, clearElementSelection } = useElementSelection(); @@ -102,6 +104,7 @@ export function TimelineTrackContent({ onElementClick({ event, element, track }) } dragState={dragState} + isDropTarget={element.id === targetElementId} /> ); }) diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx index 311f6edb..1b57ea4d 100644 --- a/apps/web/src/components/ui/select.tsx +++ b/apps/web/src/components/ui/select.tsx @@ -40,7 +40,7 @@ const selectTriggerVariants = cva( }, size: { default: "", - sm: "", + sm: "rounded-sm", }, }, defaultVariants: { diff --git a/apps/web/src/components/ui/slider.tsx b/apps/web/src/components/ui/slider.tsx index 57ddbcf0..700be644 100644 --- a/apps/web/src/components/ui/slider.tsx +++ b/apps/web/src/components/ui/slider.tsx @@ -7,7 +7,9 @@ import { cn } from "@/utils/ui"; const Slider = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef + React.ComponentPropsWithoutRef & { + className?: string; + } >(({ className, ...props }, ref) => ( = { +export const TRACK_CONFIG: Record< + TrackType, + { + background: string; + height: number; + defaultName: string; + icon: React.ReactNode; + } +> = { video: { background: "transparent", + height: 60, + defaultName: "Video track", + icon: , }, text: { background: "bg-[#5DBAA0]", + height: 25, + defaultName: "Text track", + icon: ( + + ), }, audio: { - background: "bg-[#915DBE]", + background: "bg-[#8F5DBA]", + height: 50, + defaultName: "Audio track", + icon: ( + + ), }, sticker: { - background: "bg-amber-500", + background: "bg-[#BA5D7A]", + height: 50, + defaultName: "Sticker track", + icon: ( + + ), + }, + effect: { + background: "bg-[#5d93ba]", + height: 25, + defaultName: "Effect track", + icon: ( + + ), }, -} as const; - -export const TRACK_HEIGHTS: Record = { - video: 60, - text: 25, - audio: 50, - sticker: 50, } as const; export const TRACK_GAP = 4; @@ -60,25 +100,3 @@ export const DEFAULT_TIMELINE_VIEW_STATE: TTimelineViewState = { scrollLeft: 0, playheadTime: 0, }; - -export const TRACK_ICONS: Record = { - video: , - text: ( - - ), - audio: ( - - ), - sticker: ( - - ), -} as const; diff --git a/apps/web/src/core/index.ts b/apps/web/src/core/index.ts index 4a8813f8..42ab3b87 100644 --- a/apps/web/src/core/index.ts +++ b/apps/web/src/core/index.ts @@ -8,6 +8,7 @@ import { CommandManager } from "./managers/commands"; import { SaveManager } from "./managers/save-manager"; import { AudioManager } from "./managers/audio-manager"; import { SelectionManager } from "./managers/selection-manager"; +import { registerDefaultEffects } from "@/lib/effects"; export class EditorCore { private static instance: EditorCore | null = null; @@ -24,6 +25,7 @@ export class EditorCore { public readonly selection: SelectionManager; private constructor() { + registerDefaultEffects(); this.command = new CommandManager(); this.playback = new PlaybackManager(this); this.timeline = new TimelineManager(this); diff --git a/apps/web/src/hooks/timeline/element/use-element-resize.ts b/apps/web/src/hooks/timeline/element/use-element-resize.ts index 2b303ad9..2340c9b2 100644 --- a/apps/web/src/hooks/timeline/element/use-element-resize.ts +++ b/apps/web/src/hooks/timeline/element/use-element-resize.ts @@ -86,12 +86,8 @@ export function useTimelineElementResize({ }; const canExtendElementDuration = useCallback(() => { - if (element.type === "text" || element.type === "image") { - return true; - } - - return false; - }, [element.type]); + return element.sourceDuration == null; + }, [element.sourceDuration]); const updateTrimFromMouseMove = useCallback( ({ clientX }: { clientX: number }) => { diff --git a/apps/web/src/hooks/timeline/use-selection-box.ts b/apps/web/src/hooks/timeline/use-selection-box.ts index 12c6bf8a..c4f8d6ce 100644 --- a/apps/web/src/hooks/timeline/use-selection-box.ts +++ b/apps/web/src/hooks/timeline/use-selection-box.ts @@ -5,6 +5,7 @@ import { useEditor } from "../use-editor"; interface UseSelectionBoxProps { containerRef: React.RefObject; + headerRef: React.RefObject; onSelectionComplete: ( elements: { trackId: string; elementId: string }[], ) => void; @@ -88,6 +89,7 @@ function isRectangleIntersecting({ export function useSelectionBox({ containerRef, + headerRef, onSelectionComplete, isEnabled = true, tracksScrollRef, @@ -131,6 +133,8 @@ export function useSelectionBox({ endPos, }); const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const timelineHeaderHeight = + headerRef.current?.getBoundingClientRect().height ?? 0; const selectedElements: { trackId: string; elementId: string }[] = []; for (const [trackIndex, track] of tracks.entries()) { @@ -139,8 +143,9 @@ export function useSelectionBox({ trackIndex, }); const trackHeight = getTrackHeight({ type: track.type }); - const elementTop = trackTop; - const elementBottom = trackTop + trackHeight; + const elementTop = + timelineHeaderHeight + TIMELINE_CONSTANTS.PADDING_TOP_PX + trackTop; + const elementBottom = elementTop + trackHeight; for (const element of track.elements) { const elementLeft = element.startTime * pixelsPerSecond; @@ -168,7 +173,14 @@ export function useSelectionBox({ } onSelectionComplete(selectedElements); }, - [containerRef, onSelectionComplete, tracks, tracksScrollRef, zoomLevel], + [ + containerRef, + headerRef, + onSelectionComplete, + tracks, + tracksScrollRef, + zoomLevel, + ], ); useEffect(() => { diff --git a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts index 632d2c78..ee739298 100644 --- a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts +++ b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts @@ -8,6 +8,7 @@ import { buildTextElement, buildStickerElement, buildElementFromMedia, + buildEffectElement, } from "@/lib/timeline/element-utils"; import type { Command } from "@/lib/commands/base-command"; import { AddMediaAssetCommand } from "@/lib/commands/media"; @@ -16,7 +17,11 @@ import { BatchCommand } from "@/lib/commands"; import { computeDropTarget } from "@/lib/timeline/drop-utils"; import { getDragData, hasDragData } from "@/lib/drag-data"; import type { TrackType, DropTarget, ElementType } from "@/types/timeline"; -import type { MediaDragData, StickerDragData } from "@/types/drag"; +import type { + MediaDragData, + StickerDragData, + EffectDragData, +} from "@/types/drag"; interface UseTimelineDragDropProps { containerRef: RefObject; @@ -54,6 +59,7 @@ export function useTimelineDragDrop({ if (dragData.type === "text") return "text"; if (dragData.type === "sticker") return "sticker"; + if (dragData.type === "effect") return "effect"; if (dragData.type === "media") { return dragData.mediaType; } @@ -70,7 +76,11 @@ export function useTimelineDragDrop({ elementType: ElementType; mediaId?: string; }): number => { - if (elementType === "text" || elementType === "sticker") { + if ( + elementType === "text" || + elementType === "sticker" || + elementType === "effect" + ) { return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; } if (mediaId) { @@ -124,6 +134,13 @@ export function useTimelineDragDrop({ const mouseX = e.clientX - rect.left; const mouseY = Math.max(0, e.clientY - rect.top - headerHeight); + const targetElementTypes = + dragData?.type === "effect" + ? (dragData as EffectDragData).targetElementTypes + : dragData?.type === "media" + ? (dragData as MediaDragData).targetElementTypes + : undefined; + const target = computeDropTarget({ elementType, mouseX, @@ -134,6 +151,7 @@ export function useTimelineDragDrop({ elementDuration: duration, pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND, zoomLevel, + targetElementTypes, }); target.xPosition = getSnappedTime({ time: target.xPosition }); @@ -248,6 +266,11 @@ export function useTimelineDragDrop({ const executeMediaDrop = useCallback( ({ target, dragData }: { target: DropTarget; dragData: MediaDragData }) => { + if (target.targetElement) { + toast.info("Replace media source is coming soon!"); + return; + } + const mediaAsset = mediaAssets.find((m) => m.id === dragData.id); if (!mediaAsset) return; @@ -284,6 +307,42 @@ export function useTimelineDragDrop({ [editor.timeline, mediaAssets, tracks], ); + const executeEffectDrop = useCallback( + ({ target, dragData }: { target: DropTarget; dragData: EffectDragData }) => { + const effectTrack = tracks.find((t) => t.type === "effect"); + let trackId: string; + + if (effectTrack && !target.targetElement) { + trackId = effectTrack.id; + } else if (target.targetElement) { + trackId = effectTrack?.id ?? editor.timeline.addTrack({ + type: "effect", + index: 0, + }); + } else if (target.isNewTrack) { + trackId = editor.timeline.addTrack({ + type: "effect", + index: target.trackIndex, + }); + } else { + const track = tracks[target.trackIndex]; + if (!track || track.type !== "effect") return; + trackId = track.id; + } + + const element = buildEffectElement({ + effectType: dragData.effectType, + startTime: target.xPosition, + }); + + editor.timeline.insertElement({ + placement: { mode: "explicit", trackId }, + element, + }); + }, + [editor.timeline, tracks], + ); + const executeFileDrop = useCallback( async ({ files, @@ -384,6 +443,11 @@ export function useTimelineDragDrop({ executeTextDrop({ target: currentTarget, dragData }); } else if (dragData.type === "sticker") { executeStickerDrop({ target: currentTarget, dragData }); + } else if (dragData.type === "effect") { + executeEffectDrop({ + target: currentTarget, + dragData: dragData as EffectDragData, + }); } else { executeMediaDrop({ target: currentTarget, dragData }); } @@ -410,6 +474,7 @@ export function useTimelineDragDrop({ executeTextDrop, executeStickerDrop, executeMediaDrop, + executeEffectDrop, executeFileDrop, containerRef, headerRef, diff --git a/apps/web/src/hooks/use-effect-preview.ts b/apps/web/src/hooks/use-effect-preview.ts new file mode 100644 index 00000000..a84781e0 --- /dev/null +++ b/apps/web/src/hooks/use-effect-preview.ts @@ -0,0 +1,47 @@ +import { useEffect, useRef } from "react"; +import { effectPreviewService } from "@/services/renderer/effect-preview"; +import type { EffectParamValues } from "@/types/effects"; + +export function useEffectPreview({ + effectType, + params, + canvasRef, + isActive, +}: { + effectType: string; + params: EffectParamValues; + canvasRef: React.RefObject; + isActive: boolean; +}): void { + const requestRef = useRef(0); + + useEffect(() => { + if (!isActive) { + if (requestRef.current) { + cancelAnimationFrame(requestRef.current); + requestRef.current = 0; + } + return; + } + + const loop = (): void => { + const canvas = canvasRef.current; + if (canvas) { + effectPreviewService.renderPreview({ + effectType, + params, + targetCanvas: canvas, + }); + } + requestRef.current = requestAnimationFrame(loop); + }; + + requestRef.current = requestAnimationFrame(loop); + + return () => { + if (requestRef.current) { + cancelAnimationFrame(requestRef.current); + } + }; + }, [effectType, params, canvasRef, isActive]); +} diff --git a/apps/web/src/lib/animation/interpolation.ts b/apps/web/src/lib/animation/interpolation.ts index a8f60643..d3f68010 100644 --- a/apps/web/src/lib/animation/interpolation.ts +++ b/apps/web/src/lib/animation/interpolation.ts @@ -217,6 +217,11 @@ function evaluateChannelValueAtTime { const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration)); + if (!Number.isFinite(boundedTime)) return element; return { ...element, animations: retimeElementKeyframe({ diff --git a/apps/web/src/lib/effects/definitions/blur.frag.glsl b/apps/web/src/lib/effects/definitions/blur.frag.glsl new file mode 100644 index 00000000..2c015725 --- /dev/null +++ b/apps/web/src/lib/effects/definitions/blur.frag.glsl @@ -0,0 +1,25 @@ +precision mediump float; + +uniform sampler2D u_texture; +uniform vec2 u_resolution; +uniform float u_sigma; +uniform vec2 u_direction; + +varying vec2 v_texCoord; + +void main() { + vec2 texelSize = 1.0 / u_resolution; + + vec4 color = vec4(0.0); + float totalWeight = 0.0; + + // step=1 texel — scaling step size instead causes discrete ghosting artifacts + for (int i = -30; i <= 30; i++) { + float fi = float(i); + float weight = exp(-(fi * fi) / (2.0 * u_sigma * u_sigma)); + color += texture2D(u_texture, v_texCoord + texelSize * u_direction * fi) * weight; + totalWeight += weight; + } + + gl_FragColor = color / totalWeight; +} diff --git a/apps/web/src/lib/effects/definitions/blur.ts b/apps/web/src/lib/effects/definitions/blur.ts new file mode 100644 index 00000000..3a8f691a --- /dev/null +++ b/apps/web/src/lib/effects/definitions/blur.ts @@ -0,0 +1,50 @@ +import type { EffectDefinition } from "@/types/effects"; +import blurFragmentShader from "./blur.frag.glsl"; + +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: { + type: "webgl", + passes: [ + { + fragmentShader: blurFragmentShader, + uniforms: ({ effectParams }) => { + const intensity = + typeof effectParams.intensity === "number" + ? effectParams.intensity + : Number.parseFloat(String(effectParams.intensity)); + return { + u_sigma: Math.max(intensity / 5, 0.001), + u_direction: [1, 0], + }; + }, + }, + { + fragmentShader: blurFragmentShader, + uniforms: ({ effectParams }) => { + const intensity = + typeof effectParams.intensity === "number" + ? effectParams.intensity + : Number.parseFloat(String(effectParams.intensity)); + return { + u_sigma: Math.max(intensity / 5, 0.001), + u_direction: [0, 1], + }; + }, + }, + ], + }, +}; diff --git a/apps/web/src/lib/effects/definitions/index.ts b/apps/web/src/lib/effects/definitions/index.ts new file mode 100644 index 00000000..1742b1d7 --- /dev/null +++ b/apps/web/src/lib/effects/definitions/index.ts @@ -0,0 +1,13 @@ +import { hasEffect, registerEffect } from "../registry"; +import { blurEffectDefinition } from "./blur"; + +const defaultEffects = [blurEffectDefinition]; + +export function registerDefaultEffects(): void { + for (const definition of defaultEffects) { + if (hasEffect({ effectType: definition.type })) { + continue; + } + registerEffect({ definition }); + } +} diff --git a/apps/web/src/lib/effects/effect.vert.glsl b/apps/web/src/lib/effects/effect.vert.glsl new file mode 100644 index 00000000..5febc835 --- /dev/null +++ b/apps/web/src/lib/effects/effect.vert.glsl @@ -0,0 +1,7 @@ +attribute vec2 a_position; +varying vec2 v_texCoord; + +void main() { + v_texCoord = a_position * 0.5 + 0.5; + gl_Position = vec4(a_position, 0.0, 1.0); +} diff --git a/apps/web/src/lib/effects/index.ts b/apps/web/src/lib/effects/index.ts new file mode 100644 index 00000000..14d06d84 --- /dev/null +++ b/apps/web/src/lib/effects/index.ts @@ -0,0 +1,34 @@ +import { generateUUID } from "@/utils/id"; +import { getEffect } from "./registry"; +import type { Effect, EffectParamValues } from "@/types/effects"; +import type { VisualElement } from "@/types/timeline"; + +export { getEffect, getAllEffects, hasEffect, registerEffect } from "./registry"; +export { registerDefaultEffects } from "./definitions"; + +export const EFFECT_TARGET_ELEMENT_TYPES: VisualElement["type"][] = [ + "video", + "image", + "text", + "sticker", +]; + +export function buildDefaultEffectInstance({ + effectType, +}: { + effectType: string; +}): Effect { + const definition = getEffect({ effectType }); + + const params: EffectParamValues = {}; + for (const paramDef of definition.params) { + params[paramDef.key] = paramDef.default; + } + + return { + id: generateUUID(), + type: effectType, + params, + enabled: true, + }; +} diff --git a/apps/web/src/lib/effects/registry.ts b/apps/web/src/lib/effects/registry.ts new file mode 100644 index 00000000..568b8924 --- /dev/null +++ b/apps/web/src/lib/effects/registry.ts @@ -0,0 +1,31 @@ +import type { EffectDefinition } from "@/types/effects"; + +const effectDefinitions = new Map(); + +export function registerEffect({ + definition, +}: { + definition: EffectDefinition; +}): void { + effectDefinitions.set(definition.type, definition); +} + +export function hasEffect({ effectType }: { effectType: string }): boolean { + return effectDefinitions.has(effectType); +} + +export function getEffect({ + effectType, +}: { + effectType: string; +}): EffectDefinition { + const definition = effectDefinitions.get(effectType); + if (!definition) { + throw new Error(`Unknown effect type: ${effectType}`); + } + return definition; +} + +export function getAllEffects(): EffectDefinition[] { + return Array.from(effectDefinitions.values()); +} diff --git a/apps/web/src/lib/preview/element-bounds.ts b/apps/web/src/lib/preview/element-bounds.ts index cc63048f..5b1f301d 100644 --- a/apps/web/src/lib/preview/element-bounds.ts +++ b/apps/web/src/lib/preview/element-bounds.ts @@ -70,7 +70,7 @@ export function getElementBounds({ mediaAsset?: MediaAsset | null; localTime: number; }): ElementBounds | null { - if (element.type === "audio") return null; + if (element.type === "audio" || element.type === "effect") return null; if ("hidden" in element && element.hidden) return null; const { width: canvasWidth, height: canvasHeight } = canvasSize; diff --git a/apps/web/src/lib/timeline/drop-utils.ts b/apps/web/src/lib/timeline/drop-utils.ts index 9182892f..b502974d 100644 --- a/apps/web/src/lib/timeline/drop-utils.ts +++ b/apps/web/src/lib/timeline/drop-utils.ts @@ -1,9 +1,42 @@ -import type { TimelineTrack, ElementType } from "@/types/timeline"; -import { TRACK_HEIGHTS, TRACK_GAP } from "@/constants/timeline-constants"; +import type { + TimelineTrack, + ElementType, + TimelineElement, +} from "@/types/timeline"; +import { TRACK_CONFIG, TRACK_GAP } from "@/constants/timeline-constants"; import { wouldElementOverlap } from "./element-utils"; import type { ComputeDropTargetParams, DropTarget } from "@/types/timeline"; import { isMainTrack, enforceMainTrackStart } from "./track-utils"; +function findElementAtPosition({ + mouseX, + tracks, + trackIndex, + targetElementTypes, + pixelsPerSecond, + zoomLevel, +}: { + mouseX: number; + tracks: TimelineTrack[]; + trackIndex: number; + targetElementTypes: string[]; + pixelsPerSecond: number; + zoomLevel: number; +}): { elementId: string; trackId: string } | null { + const time = mouseX / (pixelsPerSecond * zoomLevel); + const track = tracks[trackIndex]; + if (!track || !("elements" in track)) return null; + + const hit = track.elements.find( + (element: TimelineElement) => + targetElementTypes.includes(element.type) && + element.startTime <= time && + time < element.startTime + element.duration, + ); + if (!hit) return null; + return { elementId: hit.id, trackId: track.id }; +} + function getTrackAtY({ mouseY, tracks, @@ -16,7 +49,7 @@ function getTrackAtY({ let cumulativeHeight = 0; for (let i = 0; i < tracks.length; i++) { - const trackHeight = TRACK_HEIGHTS[tracks[i].type]; + const trackHeight = TRACK_CONFIG[tracks[i].type].height; const trackTop = cumulativeHeight; const trackBottom = trackTop + trackHeight; @@ -55,6 +88,7 @@ function isCompatible({ if (elementType === "text") return trackType === "text"; if (elementType === "audio") return trackType === "audio"; if (elementType === "sticker") return trackType === "sticker"; + if (elementType === "effect") return trackType === "effect"; if (elementType === "video" || elementType === "image") { return trackType === "video"; } @@ -100,6 +134,8 @@ function findInsertIndex({ }; } +const EMPTY_TARGET_ELEMENT = null; + export function computeDropTarget({ elementType, mouseX, @@ -113,6 +149,7 @@ export function computeDropTarget({ verticalDragDirection, startTimeOverride, excludeElementId, + targetElementTypes, }: ComputeDropTargetParams): DropTarget { const xPosition = typeof startTimeOverride === "number" @@ -130,9 +167,16 @@ export function computeDropTarget({ isNewTrack: true, insertPosition: "below", xPosition, + targetElement: EMPTY_TARGET_ELEMENT, }; } - return { trackIndex: 0, isNewTrack: true, insertPosition: null, xPosition }; + return { + trackIndex: 0, + isNewTrack: true, + insertPosition: null, + xPosition, + targetElement: EMPTY_TARGET_ELEMENT, + }; } const trackAtMouse = getTrackAtY({ mouseY, tracks, verticalDragDirection }); @@ -146,6 +190,7 @@ export function computeDropTarget({ isNewTrack: true, insertPosition: "below", xPosition, + targetElement: EMPTY_TARGET_ELEMENT, }; } @@ -155,6 +200,7 @@ export function computeDropTarget({ isNewTrack: true, insertPosition: "above", xPosition, + targetElement: EMPTY_TARGET_ELEMENT, }; } @@ -163,12 +209,37 @@ export function computeDropTarget({ isNewTrack: true, insertPosition: "above", xPosition, + targetElement: EMPTY_TARGET_ELEMENT, }; } const { trackIndex, relativeY } = trackAtMouse; const track = tracks[trackIndex]; - const trackHeight = TRACK_HEIGHTS[track.type]; + + if ( + targetElementTypes && + targetElementTypes.length > 0 + ) { + const targetElement = findElementAtPosition({ + mouseX, + tracks, + trackIndex, + targetElementTypes, + pixelsPerSecond, + zoomLevel, + }); + if (targetElement) { + return { + trackIndex, + isNewTrack: false, + insertPosition: null, + xPosition, + targetElement, + }; + } + } + + const trackHeight = TRACK_CONFIG[track.type].height; const isInUpperHalf = relativeY < trackHeight / 2; const isTrackCompatible = isCompatible({ @@ -200,6 +271,7 @@ export function computeDropTarget({ isNewTrack: false, insertPosition: null, xPosition: adjustedXPosition, + targetElement: EMPTY_TARGET_ELEMENT, }; } @@ -220,6 +292,7 @@ export function computeDropTarget({ isNewTrack: true, insertPosition: position, xPosition, + targetElement: EMPTY_TARGET_ELEMENT, }; } @@ -237,7 +310,7 @@ export function getDropLineY({ let y = 0; for (let i = 0; i < safeTrackIndex; i++) { - y += TRACK_HEIGHTS[tracks[i].type] + TRACK_GAP; + y += TRACK_CONFIG[tracks[i].type].height + TRACK_GAP; } return y; diff --git a/apps/web/src/lib/timeline/element-utils.ts b/apps/web/src/lib/timeline/element-utils.ts index 9e191833..8c02c097 100644 --- a/apps/web/src/lib/timeline/element-utils.ts +++ b/apps/web/src/lib/timeline/element-utils.ts @@ -6,6 +6,7 @@ import { TIMELINE_CONSTANTS, } from "@/constants/timeline-constants"; import type { + CreateEffectElement, CreateTimelineElement, CreateVideoElement, CreateImageElement, @@ -22,6 +23,8 @@ import type { UploadAudioElement, } from "@/types/timeline"; import type { MediaType } from "@/types/assets"; +import { buildDefaultEffectInstance } from "@/lib/effects"; +import { capitalizeFirstLetter } from "@/utils/string"; export function canElementHaveAudio( element: TimelineElement, @@ -155,13 +158,13 @@ export function buildTextElement({ fontFamily: t.fontFamily ?? DEFAULT_TEXT_ELEMENT.fontFamily, color: t.color ?? DEFAULT_TEXT_ELEMENT.color, background: { - color: t.background?.color ?? DEFAULT_TEXT_ELEMENT.background.color, - cornerRadius: t.background?.cornerRadius, - paddingX: t.background?.paddingX, - paddingY: t.background?.paddingY, - offsetX: t.background?.offsetX, - offsetY: t.background?.offsetY, - }, + color: t.background?.color ?? DEFAULT_TEXT_ELEMENT.background.color, + cornerRadius: t.background?.cornerRadius, + paddingX: t.background?.paddingX, + paddingY: t.background?.paddingY, + offsetX: t.background?.offsetX, + offsetY: t.background?.offsetY, + }, textAlign: t.textAlign ?? DEFAULT_TEXT_ELEMENT.textAlign, fontWeight: t.fontWeight ?? DEFAULT_TEXT_ELEMENT.fontWeight, fontStyle: t.fontStyle ?? DEFAULT_TEXT_ELEMENT.fontStyle, @@ -174,6 +177,28 @@ export function buildTextElement({ }; } +export function buildEffectElement({ + effectType, + startTime, + duration, +}: { + effectType: string; + startTime: number; + duration?: number; +}): CreateEffectElement { + const instance = buildDefaultEffectInstance({ effectType }); + return { + type: "effect", + name: capitalizeFirstLetter({ string: instance.type }), + effectType, + params: instance.params, + duration: duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION, + startTime, + trimStart: 0, + trimEnd: 0, + }; +} + export function buildStickerElement({ stickerId, name, @@ -218,6 +243,7 @@ export function buildVideoElement({ startTime, trimStart: 0, trimEnd: 0, + sourceDuration: duration, muted: false, hidden: false, transform: { ...DEFAULT_TRANSFORM }, @@ -274,6 +300,7 @@ export function buildUploadAudioElement({ startTime, trimStart: 0, trimEnd: 0, + sourceDuration: duration, volume: 1, muted: false, }; @@ -336,6 +363,7 @@ export function buildLibraryAudioElement({ startTime, trimStart: 0, trimEnd: 0, + sourceDuration: duration, volume: 1, muted: false, }; diff --git a/apps/web/src/lib/timeline/track-utils.ts b/apps/web/src/lib/timeline/track-utils.ts index 415d0ace..94ff21ba 100644 --- a/apps/web/src/lib/timeline/track-utils.ts +++ b/apps/web/src/lib/timeline/track-utils.ts @@ -6,11 +6,11 @@ import type { AudioTrack, StickerTrack, TextTrack, + EffectTrack, TimelineElement, } from "@/types/timeline"; import { - TRACK_COLORS, - TRACK_HEIGHTS, + TRACK_CONFIG, TRACK_GAP, } from "@/constants/timeline-constants"; import { generateUUID } from "@/utils/id"; @@ -23,21 +23,20 @@ export function canTracktHaveAudio( export function canTrackBeHidden( track: TimelineTrack, -): track is VideoTrack | TextTrack | StickerTrack { +): track is VideoTrack | TextTrack | StickerTrack | EffectTrack { return track.type !== "audio"; } export function getTrackColor({ type }: { type: TrackType }) { - return TRACK_COLORS[type]; + return TRACK_CONFIG[type]; } export function getTrackClasses({ type }: { type: TrackType }) { - const colors = TRACK_COLORS[type]; - return `${colors.background}`.trim(); + return TRACK_CONFIG[type].background.trim(); } export function getTrackHeight({ type }: { type: TrackType }): number { - return TRACK_HEIGHTS[type]; + return TRACK_CONFIG[type].height; } export function getCumulativeHeightBefore({ @@ -77,17 +76,7 @@ export function buildEmptyTrack({ type: TrackType; name?: string; }): TimelineTrack { - const trackName = - name ?? - (type === "video" - ? "Video track" - : type === "text" - ? "Text track" - : type === "audio" - ? "Audio track" - : type === "sticker" - ? "Sticker track" - : "Track"); + const trackName = name ?? TRACK_CONFIG[type].defaultName; switch (type) { case "video": @@ -124,6 +113,14 @@ export function buildEmptyTrack({ elements: [], muted: false, }; + case "effect": + return { + id, + name: trackName, + type: "effect", + elements: [], + hidden: false, + }; default: throw new Error(`Unsupported track type: ${type}`); } @@ -140,6 +137,10 @@ export function getDefaultInsertIndexForTrack({ return tracks.length; } + if (trackType === "effect") { + return 0; + } + const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track)); if (mainTrackIndex >= 0) { return mainTrackIndex; @@ -216,6 +217,7 @@ export function canElementGoOnTrack({ if (elementType === "text") return trackType === "text"; if (elementType === "audio") return trackType === "audio"; if (elementType === "sticker") return trackType === "sticker"; + if (elementType === "effect") return trackType === "effect"; if (elementType === "video" || elementType === "image") { return trackType === "video"; } diff --git a/apps/web/src/services/renderer/canvas-utils.ts b/apps/web/src/services/renderer/canvas-utils.ts new file mode 100644 index 00000000..093168e6 --- /dev/null +++ b/apps/web/src/services/renderer/canvas-utils.ts @@ -0,0 +1,16 @@ +export function createOffscreenCanvas({ + width, + height, +}: { + width: number; + height: number; +}): OffscreenCanvas | HTMLCanvasElement { + try { + return new OffscreenCanvas(width, height); + } catch { + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + return canvas; + } +} diff --git a/apps/web/src/services/renderer/effect-preview.ts b/apps/web/src/services/renderer/effect-preview.ts new file mode 100644 index 00000000..2cebec5d --- /dev/null +++ b/apps/web/src/services/renderer/effect-preview.ts @@ -0,0 +1,194 @@ +import { createOffscreenCanvas } from "./canvas-utils"; +import { getEffect } from "@/lib/effects"; +import type { EffectParamValues } from "@/types/effects"; +import { applyMultiPassEffect } from "./webgl-utils"; +import type { EffectPassData } from "./webgl-utils"; + +const PREVIEW_SIZE = 160; +const PREVIEW_IMAGE_PATH = "/effects/preview.jpg"; + +let previewGl: WebGLRenderingContext | null = null; +let previewCanvas: OffscreenCanvas | HTMLCanvasElement | null = null; +let testSourceCanvas: OffscreenCanvas | HTMLCanvasElement | null = null; +let previewImageElement: HTMLImageElement | null = null; +const programCache = new Map(); +const onReadyCallbacks = new Set<() => void>(); + +export function onPreviewImageReady({ + callback, +}: { + callback: () => void; +}): () => void { + onReadyCallbacks.add(callback); + return () => onReadyCallbacks.delete(callback); +} + +function loadPreviewImage(): void { + if (typeof window === "undefined") return; + const image = new Image(); + image.onload = () => { + testSourceCanvas = null; + for (const callback of onReadyCallbacks) { + callback(); + } + }; + image.src = PREVIEW_IMAGE_PATH; + previewImageElement = image; +} + +loadPreviewImage(); + +function buildDefaultParams({ + effectType, +}: { + effectType: string; +}): EffectParamValues { + const definition = getEffect({ effectType }); + const params: EffectParamValues = {}; + for (const paramDef of definition.params) { + params[paramDef.key] = paramDef.default; + } + return params; +} + +function createTestSource({ + width, + height, +}: { + width: number; + height: number; +}): OffscreenCanvas | HTMLCanvasElement | null { + const isImageReady = + previewImageElement?.complete && + (previewImageElement.naturalWidth ?? 0) > 0; + if (!isImageReady || !previewImageElement) { + return null; + } + + const canvas = createOffscreenCanvas({ width, height }); + const ctx = canvas.getContext("2d") as + | CanvasRenderingContext2D + | OffscreenCanvasRenderingContext2D + | null; + if (!ctx) { + throw new Error("failed to get 2d context for test source"); + } + ctx.drawImage(previewImageElement, 0, 0, width, height); + return canvas; +} + +function getOrCreatePreviewContext({ + width, + height, +}: { + width: number; + height: number; +}): { canvas: OffscreenCanvas | HTMLCanvasElement; gl: WebGLRenderingContext } { + if (!previewCanvas || !previewGl) { + previewCanvas = createOffscreenCanvas({ width, height }); + previewGl = previewCanvas.getContext("webgl", { + premultipliedAlpha: false, + }) as WebGLRenderingContext | null; + if (!previewGl) { + throw new Error("WebGL not supported"); + } + } + if (previewCanvas.width !== width || previewCanvas.height !== height) { + previewCanvas.width = width; + previewCanvas.height = height; + } + return { canvas: previewCanvas, gl: previewGl }; +} + +function getTestSource({ + width, + height, +}: { + width: number; + height: number; +}): CanvasImageSource | null { + if ( + !testSourceCanvas || + testSourceCanvas.width !== width || + testSourceCanvas.height !== height + ) { + testSourceCanvas = createTestSource({ width, height }); + } + return testSourceCanvas; +} + +function applyWebGlEffect({ + source, + width, + height, + passes, +}: { + source: CanvasImageSource; + width: number; + height: number; + passes: EffectPassData[]; +}): OffscreenCanvas | HTMLCanvasElement { + const { canvas: glCanvas, gl } = getOrCreatePreviewContext({ width, height }); + + applyMultiPassEffect({ context: gl, source, width, height, passes, programCache }); + + const outputCanvas = createOffscreenCanvas({ width, height }); + const outputCtx = outputCanvas.getContext("2d") as + | CanvasRenderingContext2D + | OffscreenCanvasRenderingContext2D + | null; + if (outputCtx) { + outputCtx.drawImage(glCanvas, 0, 0, width, height); + } + return outputCanvas; +} + +export function renderPreview({ + effectType, + params, + targetCanvas, +}: { + effectType: string; + params: EffectParamValues; + targetCanvas: HTMLCanvasElement; +}): void { + const size = PREVIEW_SIZE; + const source = getTestSource({ width: size, height: size }); + if (!source) return; + + const definition = getEffect({ effectType }); + const resolvedParams = + Object.keys(params).length > 0 + ? params + : buildDefaultParams({ effectType }); + + const passes = definition.renderer.passes.map((pass) => ({ + fragmentShader: pass.fragmentShader, + uniforms: pass.uniforms({ + effectParams: resolvedParams, + width: size, + height: size, + }), + })); + const result = applyWebGlEffect({ + source, + width: size, + height: size, + passes, + }); + + const targetCtx = targetCanvas.getContext( + "2d", + ) as CanvasRenderingContext2D | null; + if (targetCtx) { + targetCanvas.width = size; + targetCanvas.height = size; + targetCtx.drawImage(result, 0, 0, size, size); + } +} + +export const effectPreviewService = { + renderPreview, + onPreviewImageReady, + PREVIEW_SIZE, +}; diff --git a/apps/web/src/services/renderer/nodes/blur-background-node.ts b/apps/web/src/services/renderer/nodes/blur-background-node.ts deleted file mode 100644 index 504161d9..00000000 --- a/apps/web/src/services/renderer/nodes/blur-background-node.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { CanvasRenderer } from "../canvas-renderer"; -import { BaseNode } from "./base-node"; - -export type BlurBackgroundNodeParams = { - blurIntensity: number; - contentNodes: BaseNode[]; -}; - -export class BlurBackgroundNode extends BaseNode { - private blurIntensity: number; - private contentNodes: BaseNode[]; - - constructor(params: BlurBackgroundNodeParams) { - super(params); - this.blurIntensity = params.blurIntensity; - this.contentNodes = params.contentNodes; - } - - async render({ - renderer, - time, - }: { - renderer: CanvasRenderer; - time: number; - }): Promise { - let offscreen: OffscreenCanvas | HTMLCanvasElement; - let offscreenCtx: - | OffscreenCanvasRenderingContext2D - | CanvasRenderingContext2D; - - try { - offscreen = new OffscreenCanvas(renderer.width, renderer.height); - const ctx = offscreen.getContext("2d"); - if (!ctx) { - throw new Error("failed to get offscreen canvas context"); - } - offscreenCtx = ctx; - } catch { - offscreen = document.createElement("canvas"); - offscreen.width = renderer.width; - offscreen.height = renderer.height; - const ctx = offscreen.getContext("2d"); - if (!ctx) { - throw new Error("failed to get canvas context"); - } - offscreenCtx = ctx; - } - - const originalContext = renderer.context; - renderer.context = offscreenCtx; - - for (const node of this.contentNodes) { - await node.render({ renderer, time }); - } - - renderer.context = originalContext; - - const zoomScale = 1.4; - const scaledWidth = renderer.width * zoomScale; - const scaledHeight = renderer.height * zoomScale; - const offsetX = (renderer.width - scaledWidth) / 2; - const offsetY = (renderer.height - scaledHeight) / 2; - - renderer.context.save(); - renderer.context.filter = `blur(${this.blurIntensity}px)`; - renderer.context.drawImage( - offscreen as CanvasImageSource, - offsetX, - offsetY, - scaledWidth, - scaledHeight, - ); - renderer.context.restore(); - } -} diff --git a/apps/web/src/services/renderer/nodes/composite-effect-node.ts b/apps/web/src/services/renderer/nodes/composite-effect-node.ts new file mode 100644 index 00000000..3fbfffc1 --- /dev/null +++ b/apps/web/src/services/renderer/nodes/composite-effect-node.ts @@ -0,0 +1,77 @@ +import type { CanvasRenderer } from "../canvas-renderer"; +import { createOffscreenCanvas } from "../canvas-utils"; +import { getEffect } from "@/lib/effects"; +import type { EffectParamValues } from "@/types/effects"; +import { BaseNode } from "./base-node"; +import { webglEffectRenderer } from "../webgl-effect-renderer"; + +export type CompositeEffectNodeParams = { + contentNodes: BaseNode[]; + effectType: string; + effectParams: EffectParamValues; + scale: number; +}; + +export class CompositeEffectNode extends BaseNode { + async render({ + renderer, + time, + }: { + renderer: CanvasRenderer; + time: number; + }): Promise { + const offscreen = createOffscreenCanvas({ + width: renderer.width, + height: renderer.height, + }); + const offscreenCtx = offscreen.getContext("2d") as OffscreenCanvasRenderingContext2D | null; + if (!offscreenCtx) { + throw new Error("failed to get offscreen canvas context"); + } + + const originalContext = renderer.context; + renderer.context = offscreenCtx; + + for (const node of this.params.contentNodes) { + await node.render({ renderer, time }); + } + + renderer.context = originalContext; + + const effectDefinition = getEffect({ effectType: this.params.effectType }); + const scale = this.params.scale; + const scaledWidth = renderer.width * scale; + const scaledHeight = renderer.height * scale; + const offsetX = (renderer.width - scaledWidth) / 2; + const offsetY = (renderer.height - scaledHeight) / 2; + + const passes = effectDefinition.renderer.passes.map((pass) => ({ + fragmentShader: pass.fragmentShader, + uniforms: pass.uniforms({ + effectParams: this.params.effectParams, + width: renderer.width, + height: renderer.height, + }), + })); + const effectResult = webglEffectRenderer.applyEffect({ + source: offscreen as CanvasImageSource, + width: renderer.width, + height: renderer.height, + passes, + }); + + renderer.context.save(); + renderer.context.drawImage( + effectResult, + 0, + 0, + renderer.width, + renderer.height, + offsetX, + offsetY, + scaledWidth, + scaledHeight, + ); + renderer.context.restore(); + } +} diff --git a/apps/web/src/services/renderer/nodes/effect-layer-node.ts b/apps/web/src/services/renderer/nodes/effect-layer-node.ts new file mode 100644 index 00000000..3d84be2b --- /dev/null +++ b/apps/web/src/services/renderer/nodes/effect-layer-node.ts @@ -0,0 +1,82 @@ +import type { CanvasRenderer } from "../canvas-renderer"; +import { getEffect } from "@/lib/effects"; +import type { EffectParamValues } from "@/types/effects"; +import { BaseNode } from "./base-node"; +import { webglEffectRenderer } from "../webgl-effect-renderer"; + +const TIME_EPSILON = 1e-6; + +export type EffectLayerNodeParams = { + effectType: string; + effectParams: EffectParamValues; + timeOffset: number; + duration: number; +}; + +function isInRange({ + time, + timeOffset, + duration, +}: { + time: number; + timeOffset: number; + duration: number; +}): boolean { + return ( + time >= timeOffset - TIME_EPSILON && + time < timeOffset + duration + TIME_EPSILON + ); +} + +// snapshots whatever is currently on the canvas, applies the effect, draws it back +export class EffectLayerNode extends BaseNode { + async render({ + renderer, + time, + }: { + renderer: CanvasRenderer; + time: number; + }): Promise { + if ( + !isInRange({ + time, + timeOffset: this.params.timeOffset, + duration: this.params.duration, + }) + ) { + return; + } + + const source = renderer.context.canvas as CanvasImageSource; + + const effectDefinition = getEffect({ + effectType: this.params.effectType, + }); + + const passes = effectDefinition.renderer.passes.map((pass) => ({ + fragmentShader: pass.fragmentShader, + uniforms: pass.uniforms({ + effectParams: this.params.effectParams, + width: renderer.width, + height: renderer.height, + }), + })); + const effectResult = webglEffectRenderer.applyEffect({ + source, + width: renderer.width, + height: renderer.height, + passes, + }); + + renderer.context.save(); + renderer.context.clearRect(0, 0, renderer.width, renderer.height); + renderer.context.drawImage( + effectResult, + 0, + 0, + renderer.width, + renderer.height, + ); + renderer.context.restore(); + } +} diff --git a/apps/web/src/services/renderer/nodes/image-node.ts b/apps/web/src/services/renderer/nodes/image-node.ts index 6f4f2bd8..6720a5e5 100644 --- a/apps/web/src/services/renderer/nodes/image-node.ts +++ b/apps/web/src/services/renderer/nodes/image-node.ts @@ -73,7 +73,7 @@ export class ImageNode extends VisualNode { async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) { await super.render({ renderer, time }); - if (!this.isInRange(time)) { + if (!this.isInRange({ time })) { return; } diff --git a/apps/web/src/services/renderer/nodes/sticker-node.ts b/apps/web/src/services/renderer/nodes/sticker-node.ts index 86f14fb4..da271685 100644 --- a/apps/web/src/services/renderer/nodes/sticker-node.ts +++ b/apps/web/src/services/renderer/nodes/sticker-node.ts @@ -51,7 +51,7 @@ export class StickerNode extends VisualNode { async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) { await super.render({ renderer, time }); - if (!this.isInRange(time)) { + if (!this.isInRange({ time })) { return; } diff --git a/apps/web/src/services/renderer/nodes/video-node.ts b/apps/web/src/services/renderer/nodes/video-node.ts index 9ae63661..503c9c03 100644 --- a/apps/web/src/services/renderer/nodes/video-node.ts +++ b/apps/web/src/services/renderer/nodes/video-node.ts @@ -12,11 +12,11 @@ export class VideoNode extends VisualNode { async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) { await super.render({ renderer, time }); - if (!this.isInRange(time)) { + if (!this.isInRange({ time })) { return; } - const videoTime = this.getSourceLocalTime(time); + const videoTime = this.getSourceLocalTime({ time }); const frame = await videoCache.getFrameAt({ mediaId: this.params.mediaId, file: this.params.file, diff --git a/apps/web/src/services/renderer/nodes/visual-node.ts b/apps/web/src/services/renderer/nodes/visual-node.ts index 50451b4a..15a7b081 100644 --- a/apps/web/src/services/renderer/nodes/visual-node.ts +++ b/apps/web/src/services/renderer/nodes/visual-node.ts @@ -1,5 +1,7 @@ import type { CanvasRenderer } from "../canvas-renderer"; +import { createOffscreenCanvas } from "../canvas-utils"; import { BaseNode } from "./base-node"; +import type { Effect } from "@/types/effects"; import type { BlendMode } from "@/types/rendering"; import type { Transform } from "@/types/timeline"; import type { ElementAnimations } from "@/types/animation"; @@ -9,6 +11,8 @@ import { resolveTransformAtTime, } from "@/lib/animation"; import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; +import { getEffect } from "@/lib/effects"; +import { webglEffectRenderer } from "../webgl-effect-renderer"; export interface VisualNodeParams { duration: number; @@ -19,16 +23,17 @@ export interface VisualNodeParams { animations?: ElementAnimations; opacity: number; blendMode?: BlendMode; + effects?: Effect[]; } export abstract class VisualNode< Params extends VisualNodeParams = VisualNodeParams, > extends BaseNode { - protected getSourceLocalTime(time: number): number { + protected getSourceLocalTime({ time }: { time: number }): number { return time - this.params.timeOffset + this.params.trimStart; } - protected getAnimationLocalTime(time: number): number { + protected getAnimationLocalTime({ time }: { time: number }): number { return getElementLocalTime({ timelineTime: time, elementStartTime: this.params.timeOffset, @@ -36,8 +41,8 @@ export abstract class VisualNode< }); } - protected isInRange(time: number): boolean { - const localTime = this.getSourceLocalTime(time); + protected isInRange({ time }: { time: number }): boolean { + const localTime = this.getSourceLocalTime({ time }); return ( localTime >= this.params.trimStart - TIME_EPSILON_SECONDS && localTime < this.params.trimStart + this.params.duration @@ -59,7 +64,7 @@ export abstract class VisualNode< }): void { renderer.context.save(); - const animationLocalTime = this.getAnimationLocalTime(timelineTime); + const animationLocalTime = this.getAnimationLocalTime({ time: timelineTime }); const transform = resolveTransformAtTime({ baseTransform: this.params.transform, animations: this.params.animations, @@ -94,7 +99,58 @@ export abstract class VisualNode< renderer.context.translate(-centerX, -centerY); } - renderer.context.drawImage(source, x, y, scaledWidth, scaledHeight); + const enabledEffects = + this.params.effects?.filter((effect) => effect.enabled) ?? []; + + if (enabledEffects.length === 0) { + renderer.context.drawImage(source, x, y, scaledWidth, scaledHeight); + renderer.context.restore(); + return; + } + + const elementCanvas = createOffscreenCanvas({ + width: Math.round(scaledWidth), + height: Math.round(scaledHeight), + }); + const elementCtx = elementCanvas.getContext("2d") as + | CanvasRenderingContext2D + | OffscreenCanvasRenderingContext2D + | null; + if (!elementCtx) { + renderer.context.drawImage(source, x, y, scaledWidth, scaledHeight); + renderer.context.restore(); + return; + } + + elementCtx.drawImage(source, 0, 0, scaledWidth, scaledHeight); + + let currentResult: CanvasImageSource = elementCanvas; + + for (const effect of enabledEffects) { + const definition = getEffect({ effectType: effect.type }); + const passes = definition.renderer.passes.map((pass) => ({ + fragmentShader: pass.fragmentShader, + uniforms: pass.uniforms({ + effectParams: effect.params, + width: scaledWidth, + height: scaledHeight, + }), + })); + currentResult = webglEffectRenderer.applyEffect({ + source: currentResult, + width: Math.round(scaledWidth), + height: Math.round(scaledHeight), + passes, + }); + } + + renderer.context.drawImage( + currentResult, + x, + y, + scaledWidth, + scaledHeight, + ); renderer.context.restore(); } } diff --git a/apps/web/src/services/renderer/scene-builder.ts b/apps/web/src/services/renderer/scene-builder.ts index 2bbcf25f..695e077f 100644 --- a/apps/web/src/services/renderer/scene-builder.ts +++ b/apps/web/src/services/renderer/scene-builder.ts @@ -6,12 +6,137 @@ import { ImageNode } from "./nodes/image-node"; import { TextNode } from "./nodes/text-node"; import { StickerNode } from "./nodes/sticker-node"; import { ColorNode } from "./nodes/color-node"; -import { BlurBackgroundNode } from "./nodes/blur-background-node"; +import { CompositeEffectNode } from "./nodes/composite-effect-node"; +import { EffectLayerNode } from "./nodes/effect-layer-node"; +import type { BaseNode } from "./nodes/base-node"; import type { TBackground, TCanvasSize } from "@/types/project"; import { DEFAULT_BLUR_INTENSITY } from "@/constants/project-constants"; import { isMainTrack } from "@/lib/timeline"; const PREVIEW_MAX_IMAGE_SIZE = 2048; +const BLUR_BACKGROUND_ZOOM_SCALE = 1.4; + +function getVisibleSortedElements({ + track, +}: { + track: TimelineTrack; +}) { + return track.elements + .filter((element) => !("hidden" in element && element.hidden)) + .slice() + .sort((a, b) => { + if (a.startTime !== b.startTime) return a.startTime - b.startTime; + return a.id.localeCompare(b.id); + }); +} + +function buildTrackNodes({ + tracks, + mediaMap, + canvasSize, + isPreview, +}: { + tracks: TimelineTrack[]; + mediaMap: Map; + canvasSize: TCanvasSize; + isPreview?: boolean; +}): BaseNode[] { + const nodes: BaseNode[] = []; + + for (const track of tracks) { + const elements = getVisibleSortedElements({ track }); + + for (const element of elements) { + if (element.type === "effect") { + nodes.push( + new EffectLayerNode({ + effectType: element.effectType, + effectParams: element.params, + timeOffset: element.startTime, + duration: element.duration, + }), + ); + continue; + } + + if (element.type === "video" || element.type === "image") { + const mediaAsset = mediaMap.get(element.mediaId); + if (!mediaAsset?.file || !mediaAsset?.url) { + continue; + } + + if (mediaAsset.type === "video") { + nodes.push( + new VideoNode({ + mediaId: mediaAsset.id, + url: mediaAsset.url, + file: mediaAsset.file, + duration: element.duration, + timeOffset: element.startTime, + trimStart: element.trimStart, + trimEnd: element.trimEnd, + transform: element.transform, + animations: element.animations, + opacity: element.opacity, + blendMode: element.blendMode, + effects: element.effects, + }), + ); + } + if (mediaAsset.type === "image") { + nodes.push( + new ImageNode({ + url: mediaAsset.url, + duration: element.duration, + timeOffset: element.startTime, + trimStart: element.trimStart, + trimEnd: element.trimEnd, + transform: element.transform, + animations: element.animations, + opacity: element.opacity, + blendMode: element.blendMode, + effects: element.effects, + ...(isPreview && { + maxSourceSize: PREVIEW_MAX_IMAGE_SIZE, + }), + }), + ); + } + } + + if (element.type === "text") { + nodes.push( + new TextNode({ + ...element, + canvasCenter: { x: canvasSize.width / 2, y: canvasSize.height / 2 }, + canvasHeight: canvasSize.height, + textBaseline: "middle", + effects: element.effects, + }), + ); + } + + if (element.type === "sticker") { + nodes.push( + new StickerNode({ + stickerId: element.stickerId, + duration: element.duration, + timeOffset: element.startTime, + trimStart: element.trimStart, + trimEnd: element.trimEnd, + transform: element.transform, + animations: element.animations, + opacity: element.opacity, + blendMode: element.blendMode, + effects: element.effects, + }), + ); + } + } + } + + return nodes; +} export type BuildSceneParams = { canvasSize: TCanvasSize; @@ -22,9 +147,14 @@ export type BuildSceneParams = { isPreview?: boolean; }; -export function buildScene(params: BuildSceneParams) { - const { tracks, mediaAssets, duration, canvasSize, background } = params; - +export function buildScene({ + canvasSize, + tracks, + mediaAssets, + duration, + background, + isPreview, +}: BuildSceneParams) { const rootNode = new RootNode({ duration }); const mediaMap = new Map(mediaAssets.map((m) => [m.id, m])); @@ -39,107 +169,33 @@ export function buildScene(params: BuildSceneParams) { const orderedTracksBottomToTop = orderedTracksTopToBottom.slice().reverse(); - const contentNodes = []; - - for (const track of orderedTracksBottomToTop) { - const elements = track.elements - .filter((element) => !("hidden" in element && element.hidden)) - .slice() - .sort((a, b) => { - if (a.startTime !== b.startTime) return a.startTime - b.startTime; - return a.id.localeCompare(b.id); - }); - - for (const element of elements) { - if (element.type === "video" || element.type === "image") { - const mediaAsset = mediaMap.get(element.mediaId); - if (!mediaAsset?.file || !mediaAsset?.url) { - continue; - } - - if (mediaAsset.type === "video") { - contentNodes.push( - new VideoNode({ - mediaId: mediaAsset.id, - url: mediaAsset.url, - file: mediaAsset.file, - duration: element.duration, - timeOffset: element.startTime, - trimStart: element.trimStart, - trimEnd: element.trimEnd, - transform: element.transform, - animations: element.animations, - opacity: element.opacity, - blendMode: element.blendMode, - }), - ); - } - if (mediaAsset.type === "image") { - contentNodes.push( - new ImageNode({ - url: mediaAsset.url, - duration: element.duration, - timeOffset: element.startTime, - trimStart: element.trimStart, - trimEnd: element.trimEnd, - transform: element.transform, - animations: element.animations, - opacity: element.opacity, - blendMode: element.blendMode, - ...(params.isPreview && { - maxSourceSize: PREVIEW_MAX_IMAGE_SIZE, - }), - }), - ); - } - } - - if (element.type === "text") { - contentNodes.push( - new TextNode({ - ...element, - canvasCenter: { x: canvasSize.width / 2, y: canvasSize.height / 2 }, - canvasHeight: canvasSize.height, - textBaseline: "middle", - }), - ); - } - - if (element.type === "sticker") { - contentNodes.push( - new StickerNode({ - stickerId: element.stickerId, - duration: element.duration, - timeOffset: element.startTime, - trimStart: element.trimStart, - trimEnd: element.trimEnd, - transform: element.transform, - animations: element.animations, - opacity: element.opacity, - blendMode: element.blendMode, - }), - ); - } - } - } + const allNodes = buildTrackNodes({ + tracks: orderedTracksBottomToTop, + mediaMap, + canvasSize, + isPreview, + }); if (background.type === "blur") { rootNode.add( - new BlurBackgroundNode({ - blurIntensity: background.blurIntensity ?? DEFAULT_BLUR_INTENSITY, - contentNodes, + new CompositeEffectNode({ + contentNodes: allNodes.filter( + (node) => !(node instanceof EffectLayerNode), + ), + effectType: "blur", + effectParams: { + intensity: + background.blurIntensity ?? DEFAULT_BLUR_INTENSITY, + }, + scale: BLUR_BACKGROUND_ZOOM_SCALE, }), ); - for (const node of contentNodes) { - rootNode.add(node); - } - } else { - if (background.type === "color" && background.color !== "transparent") { - rootNode.add(new ColorNode({ color: background.color })); - } - for (const node of contentNodes) { - rootNode.add(node); - } + } else if (background.type === "color" && background.color !== "transparent") { + rootNode.add(new ColorNode({ color: background.color })); + } + + for (const node of allNodes) { + rootNode.add(node); } return rootNode; diff --git a/apps/web/src/services/renderer/webgl-effect-renderer.ts b/apps/web/src/services/renderer/webgl-effect-renderer.ts new file mode 100644 index 00000000..ad5cd40e --- /dev/null +++ b/apps/web/src/services/renderer/webgl-effect-renderer.ts @@ -0,0 +1,73 @@ +import { createOffscreenCanvas } from "./canvas-utils"; +import { applyMultiPassEffect } from "./webgl-utils"; +import type { EffectPassData } from "./webgl-utils"; + +export interface ApplyEffectParams { + source: CanvasImageSource; + width: number; + height: number; + passes: EffectPassData[]; +} + +let gl: WebGLRenderingContext | null = null; +let canvas: OffscreenCanvas | HTMLCanvasElement | null = null; +const programCache = new Map(); + +function getOrCreateCanvas({ + width, + height, +}: { + width: number; + height: number; +}): OffscreenCanvas | HTMLCanvasElement { + if (!canvas) { + canvas = createOffscreenCanvas({ width, height }); + gl = canvas.getContext("webgl", { + premultipliedAlpha: false, + }) as WebGLRenderingContext | null; + if (!gl) { + throw new Error("WebGL not supported"); + } + } + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + } + return canvas; +} + +function applyEffect({ + source, + width, + height, + passes, +}: ApplyEffectParams): OffscreenCanvas | HTMLCanvasElement { + const targetCanvas = getOrCreateCanvas({ width, height }); + const context = gl; + if (!context) { + throw new Error("WebGL context not initialized"); + } + + applyMultiPassEffect({ + context, + source, + width, + height, + passes, + programCache, + }); + + const outputCanvas = createOffscreenCanvas({ width, height }); + const outputCtx = outputCanvas.getContext("2d") as + | CanvasRenderingContext2D + | OffscreenCanvasRenderingContext2D + | null; + if (outputCtx) { + outputCtx.drawImage(targetCanvas, 0, 0, width, height); + } + return outputCanvas; +} + +export const webglEffectRenderer = { + applyEffect, +}; diff --git a/apps/web/src/services/renderer/webgl-utils.ts b/apps/web/src/services/renderer/webgl-utils.ts new file mode 100644 index 00000000..834c652e --- /dev/null +++ b/apps/web/src/services/renderer/webgl-utils.ts @@ -0,0 +1,298 @@ +import VERTEX_SHADER_SOURCE from "@/lib/effects/effect.vert.glsl"; + +export interface EffectPassData { + fragmentShader: string; + uniforms: Record; +} + +export const QUAD_POSITIONS = new Float32Array([ + -1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1, +]); + +export function compileProgram({ + context, + fragmentShaderSource, + programCache, +}: { + context: WebGLRenderingContext; + fragmentShaderSource: string; + programCache: Map; +}): WebGLProgram { + const cached = programCache.get(fragmentShaderSource); + if (cached) { + return cached; + } + const vertexShader = compileShader({ + context, + source: VERTEX_SHADER_SOURCE, + type: context.VERTEX_SHADER, + }); + const fragmentShader = compileShader({ + context, + source: fragmentShaderSource, + type: context.FRAGMENT_SHADER, + }); + const program = context.createProgram(); + if (!program) { + throw new Error("Failed to create WebGL program"); + } + context.attachShader(program, vertexShader); + context.attachShader(program, fragmentShader); + context.linkProgram(program); + if (!context.getProgramParameter(program, context.LINK_STATUS)) { + const info = context.getProgramInfoLog(program); + context.deleteProgram(program); + throw new Error(`WebGL program link failed: ${info}`); + } + context.deleteShader(vertexShader); + context.deleteShader(fragmentShader); + programCache.set(fragmentShaderSource, program); + return program; +} + +export function compileShader({ + context, + source, + type, +}: { + context: WebGLRenderingContext; + source: string; + type: number; +}): WebGLShader { + const shader = context.createShader(type); + if (!shader) { + throw new Error("Failed to create WebGL shader"); + } + context.shaderSource(shader, source); + context.compileShader(shader); + if (!context.getShaderParameter(shader, context.COMPILE_STATUS)) { + const info = context.getShaderInfoLog(shader); + context.deleteShader(shader); + throw new Error(`WebGL shader compile failed: ${info}`); + } + return shader; +} + +export function createTexture({ + context, + source, +}: { + context: WebGLRenderingContext; + source: CanvasImageSource; +}): WebGLTexture { + const texture = context.createTexture(); + if (!texture) { + throw new Error("Failed to create WebGL texture"); + } + context.activeTexture(context.TEXTURE0); + context.bindTexture(context.TEXTURE_2D, texture); + context.pixelStorei(context.UNPACK_FLIP_Y_WEBGL, 1); + context.texParameteri( + context.TEXTURE_2D, + context.TEXTURE_WRAP_S, + context.CLAMP_TO_EDGE, + ); + context.texParameteri( + context.TEXTURE_2D, + context.TEXTURE_WRAP_T, + context.CLAMP_TO_EDGE, + ); + context.texParameteri( + context.TEXTURE_2D, + context.TEXTURE_MIN_FILTER, + context.LINEAR, + ); + context.texParameteri( + context.TEXTURE_2D, + context.TEXTURE_MAG_FILTER, + context.LINEAR, + ); + context.texImage2D( + context.TEXTURE_2D, + 0, + context.RGBA, + context.RGBA, + context.UNSIGNED_BYTE, + source as TexImageSource, + ); + return texture; +} + +export function setUniforms({ + context, + program, + uniforms, +}: { + context: WebGLRenderingContext; + program: WebGLProgram; + uniforms: Record; +}): void { + for (const [name, value] of Object.entries(uniforms)) { + const location = context.getUniformLocation(program, name); + if (location === null) continue; + + if (typeof value === "number") { + context.uniform1f(location, value); + } else if (Array.isArray(value)) { + if (value.length === 2) { + context.uniform2fv(location, new Float32Array(value)); + } else if (value.length === 3) { + context.uniform3fv(location, new Float32Array(value)); + } else if (value.length === 4) { + context.uniform4fv(location, new Float32Array(value)); + } + } + } +} + +export function drawFullscreenQuad({ + context, + program, + width, + height, +}: { + context: WebGLRenderingContext; + program: WebGLProgram; + width: number; + height: number; +}): void { + const positionLocation = context.getAttribLocation(program, "a_position"); + const buffer = context.createBuffer(); + context.bindBuffer(context.ARRAY_BUFFER, buffer); + context.bufferData(context.ARRAY_BUFFER, QUAD_POSITIONS, context.STATIC_DRAW); + context.enableVertexAttribArray(positionLocation); + context.vertexAttribPointer(positionLocation, 2, context.FLOAT, false, 0, 0); + + context.viewport(0, 0, width, height); + context.clearColor(0, 0, 0, 0); + context.clear(context.COLOR_BUFFER_BIT); + context.drawArrays(context.TRIANGLES, 0, 6); +} + +export function createFramebufferTexture({ + context, + width, + height, +}: { + context: WebGLRenderingContext; + width: number; + height: number; +}): { texture: WebGLTexture; framebuffer: WebGLFramebuffer } { + const texture = context.createTexture(); + if (!texture) throw new Error("Failed to create framebuffer texture"); + context.bindTexture(context.TEXTURE_2D, texture); + context.texImage2D( + context.TEXTURE_2D, + 0, + context.RGBA, + width, + height, + 0, + context.RGBA, + context.UNSIGNED_BYTE, + null, + ); + context.texParameteri( + context.TEXTURE_2D, + context.TEXTURE_WRAP_S, + context.CLAMP_TO_EDGE, + ); + context.texParameteri( + context.TEXTURE_2D, + context.TEXTURE_WRAP_T, + context.CLAMP_TO_EDGE, + ); + context.texParameteri( + context.TEXTURE_2D, + context.TEXTURE_MIN_FILTER, + context.LINEAR, + ); + context.texParameteri( + context.TEXTURE_2D, + context.TEXTURE_MAG_FILTER, + context.LINEAR, + ); + context.bindTexture(context.TEXTURE_2D, null); + + const framebuffer = context.createFramebuffer(); + if (!framebuffer) throw new Error("Failed to create framebuffer"); + context.bindFramebuffer(context.FRAMEBUFFER, framebuffer); + context.framebufferTexture2D( + context.FRAMEBUFFER, + context.COLOR_ATTACHMENT0, + context.TEXTURE_2D, + texture, + 0, + ); + context.bindFramebuffer(context.FRAMEBUFFER, null); + + return { texture, framebuffer }; +} + +export function applyMultiPassEffect({ + context, + source, + width, + height, + passes, + programCache, +}: { + context: WebGLRenderingContext; + source: CanvasImageSource; + width: number; + height: number; + passes: EffectPassData[]; + programCache: Map; +}): void { + const sourceTexture = createTexture({ context, source }); + let currentTexture: WebGLTexture = sourceTexture; + + const intermediates: Array<{ + texture: WebGLTexture; + framebuffer: WebGLFramebuffer; + }> = []; + for (let i = 0; i < passes.length - 1; i++) { + intermediates.push(createFramebufferTexture({ context, width, height })); + } + + for (let i = 0; i < passes.length; i++) { + const pass = passes[i]; + const program = compileProgram({ + context, + fragmentShaderSource: pass.fragmentShader, + programCache, + }); + const isLastPass = i === passes.length - 1; + const targetFramebuffer = isLastPass ? null : intermediates[i].framebuffer; + + context.bindFramebuffer(context.FRAMEBUFFER, targetFramebuffer); + // biome-ignore lint/correctness/useHookAtTopLevel: WebGL API method, not a React hook + context.useProgram(program); + context.activeTexture(context.TEXTURE0); + context.bindTexture(context.TEXTURE_2D, currentTexture); + + const uTextureLocation = context.getUniformLocation(program, "u_texture"); + if (uTextureLocation) { + context.uniform1i(uTextureLocation, 0); + } + + setUniforms({ + context, + program, + uniforms: { ...pass.uniforms, u_resolution: [width, height] }, + }); + drawFullscreenQuad({ context, program, width, height }); + + if (!isLastPass) { + currentTexture = intermediates[i].texture; + } + } + + context.deleteTexture(sourceTexture); + for (const intermediate of intermediates) { + context.deleteTexture(intermediate.texture); + context.deleteFramebuffer(intermediate.framebuffer); + } + context.bindTexture(context.TEXTURE_2D, null); + context.bindFramebuffer(context.FRAMEBUFFER, null); +} diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts index fd40f14c..86a202b2 100644 --- a/apps/web/src/services/storage/migrations/index.ts +++ b/apps/web/src/services/storage/migrations/index.ts @@ -6,10 +6,11 @@ import { V3toV4Migration } from "./v3-to-v4"; import { V4toV5Migration } from "./v4-to-v5"; import { V5toV6Migration } from "./v5-to-v6"; import { V6toV7Migration } from "./v6-to-v7"; +import { V7toV8Migration } from "./v7-to-v8"; export { runStorageMigrations } from "./runner"; export type { MigrationProgress } from "./runner"; -export const CURRENT_PROJECT_VERSION = 7; +export const CURRENT_PROJECT_VERSION = 8; export const migrations = [ new V0toV1Migration(), @@ -19,4 +20,5 @@ export const migrations = [ new V4toV5Migration(), new V5toV6Migration(), new V6toV7Migration(), + new V7toV8Migration(), ]; diff --git a/apps/web/src/services/storage/migrations/transformers/v7-to-v8.ts b/apps/web/src/services/storage/migrations/transformers/v7-to-v8.ts new file mode 100644 index 00000000..7393f776 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v7-to-v8.ts @@ -0,0 +1,96 @@ +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +export function transformProjectV7ToV8({ + project, +}: { + project: ProjectRecord; +}): MigrationResult { + const projectId = getProjectId({ project }); + if (!projectId) { + return { project, skipped: true, reason: "no project id" }; + } + + if (isV8Project({ project })) { + return { project, skipped: true, reason: "already v8" }; + } + + const migratedProject = migrateProjectElements({ project }); + + return { + project: { ...migratedProject, version: 8 }, + skipped: false, + }; +} + +function migrateProjectElements({ + project, +}: { + project: ProjectRecord; +}): ProjectRecord { + const scenesValue = project.scenes; + if (!Array.isArray(scenesValue)) return project; + + let hasChanges = false; + const migratedScenes = scenesValue.map((scene) => { + const migrated = migrateSceneElements({ scene }); + if (migrated !== scene) hasChanges = true; + return migrated; + }); + + if (!hasChanges) return project; + return { ...project, scenes: migratedScenes }; +} + +function migrateSceneElements({ scene }: { scene: unknown }): unknown { + if (!isRecord(scene)) return scene; + + const tracksValue = scene.tracks; + if (!Array.isArray(tracksValue)) return scene; + + let hasChanges = false; + const migratedTracks = tracksValue.map((track) => { + const migrated = migrateTrackElements({ track }); + if (migrated !== track) hasChanges = true; + return migrated; + }); + + if (!hasChanges) return scene; + return { ...scene, tracks: migratedTracks }; +} + +function migrateTrackElements({ track }: { track: unknown }): unknown { + if (!isRecord(track)) return track; + + const elementsValue = track.elements; + if (!Array.isArray(elementsValue)) return track; + + let hasChanges = false; + const migratedElements = elementsValue.map((element) => { + const migrated = migrateElement({ element }); + if (migrated !== element) hasChanges = true; + return migrated; + }); + + if (!hasChanges) return track; + return { ...track, elements: migratedElements }; +} + +function migrateElement({ element }: { element: unknown }): unknown { + if (!isRecord(element)) return element; + if (element.type !== "video" && element.type !== "audio") return element; + if (typeof element.sourceDuration === "number") return element; + + const trimStart = typeof element.trimStart === "number" ? element.trimStart : 0; + const duration = typeof element.duration === "number" ? element.duration : 0; + const trimEnd = typeof element.trimEnd === "number" ? element.trimEnd : 0; + + return { + ...element, + sourceDuration: trimStart + duration + trimEnd, + }; +} + +function isV8Project({ project }: { project: ProjectRecord }): boolean { + return typeof project.version === "number" && project.version >= 8; +} diff --git a/apps/web/src/services/storage/migrations/v7-to-v8.ts b/apps/web/src/services/storage/migrations/v7-to-v8.ts new file mode 100644 index 00000000..33372c0a --- /dev/null +++ b/apps/web/src/services/storage/migrations/v7-to-v8.ts @@ -0,0 +1,16 @@ +import { StorageMigration } from "./base"; +import type { ProjectRecord } from "./transformers/types"; +import { transformProjectV7ToV8 } from "./transformers/v7-to-v8"; + +export class V7toV8Migration extends StorageMigration { + from = 7; + to = 8; + + async transform(project: ProjectRecord): Promise<{ + project: ProjectRecord; + skipped: boolean; + reason?: string; + }> { + return transformProjectV7ToV8({ project }); + } +} diff --git a/apps/web/src/types/drag.ts b/apps/web/src/types/drag.ts index 61aba9b5..4e10d767 100644 --- a/apps/web/src/types/drag.ts +++ b/apps/web/src/types/drag.ts @@ -1,3 +1,5 @@ +import type { VisualElement } from "./timeline"; + interface BaseDragData { id: string; name: string; @@ -6,6 +8,7 @@ interface BaseDragData { export interface MediaDragData extends BaseDragData { type: "media"; mediaType: "image" | "video" | "audio"; + targetElementTypes?: ("video" | "image")[]; } export interface TextDragData extends BaseDragData { @@ -18,4 +21,14 @@ export interface StickerDragData extends BaseDragData { stickerId: string; } -export type TimelineDragData = MediaDragData | TextDragData | StickerDragData; +export interface EffectDragData extends BaseDragData { + type: "effect"; + effectType: string; + targetElementTypes: VisualElement["type"][]; +} + +export type TimelineDragData = + | MediaDragData + | TextDragData + | StickerDragData + | EffectDragData; diff --git a/apps/web/src/types/effects.ts b/apps/web/src/types/effects.ts new file mode 100644 index 00000000..cc369450 --- /dev/null +++ b/apps/web/src/types/effects.ts @@ -0,0 +1,45 @@ +export interface Effect { + id: string; + type: string; + params: EffectParamValues; + enabled: boolean; +} + +export type EffectParamType = "number" | "boolean" | "select" | "color"; + +export type EffectParamValues = Record; + +export interface EffectParamDefinition { + key: string; + label: string; + type: EffectParamType; + default: number | string | boolean; + min?: number; + max?: number; + step?: number; + options?: Array<{ value: string; label: string }>; +} + +export interface WebGLEffectPass { + fragmentShader: string; + uniforms(params: { + effectParams: EffectParamValues; + width: number; + height: number; + }): Record; +} + +export interface WebGLEffectRenderer { + type: "webgl"; + passes: WebGLEffectPass[]; +} + +export type EffectRenderer = WebGLEffectRenderer; + +export interface EffectDefinition { + type: string; + name: string; + keywords: string[]; + params: EffectParamDefinition[]; + renderer: EffectRenderer; +} diff --git a/apps/web/src/types/glsl.d.ts b/apps/web/src/types/glsl.d.ts new file mode 100644 index 00000000..4e93dbe5 --- /dev/null +++ b/apps/web/src/types/glsl.d.ts @@ -0,0 +1,4 @@ +declare module "*.glsl" { + const value: string; + export default value; +} diff --git a/apps/web/src/types/timeline.ts b/apps/web/src/types/timeline.ts index 26690458..0c138f56 100644 --- a/apps/web/src/types/timeline.ts +++ b/apps/web/src/types/timeline.ts @@ -1,5 +1,6 @@ -import type { BlendMode, Transform } from "./rendering"; import type { ElementAnimations } from "./animation"; +import type { Effect, EffectParamValues } from "./effects"; +import type { BlendMode, Transform } from "./rendering"; export interface Bookmark { time: number; @@ -18,7 +19,7 @@ export interface TScene { updatedAt: Date; } -export type TrackType = "video" | "text" | "audio" | "sticker"; +export type TrackType = "video" | "text" | "audio" | "sticker" | "effect"; interface BaseTrack { id: string; @@ -51,7 +52,18 @@ export interface StickerTrack extends BaseTrack { hidden: boolean; } -export type TimelineTrack = VideoTrack | TextTrack | AudioTrack | StickerTrack; +export interface EffectTrack extends BaseTrack { + type: "effect"; + elements: EffectElement[]; + hidden: boolean; +} + +export type TimelineTrack = + | VideoTrack + | TextTrack + | AudioTrack + | StickerTrack + | EffectTrack; export type { Transform } from "./rendering"; @@ -81,6 +93,7 @@ interface BaseTimelineElement { startTime: number; trimStart: number; trimEnd: number; + sourceDuration?: number; animations?: ElementAnimations; } @@ -92,6 +105,7 @@ export interface VideoElement extends BaseTimelineElement { transform: Transform; opacity: number; blendMode?: BlendMode; + effects?: Effect[]; } export interface ImageElement extends BaseTimelineElement { @@ -101,6 +115,7 @@ export interface ImageElement extends BaseTimelineElement { transform: Transform; opacity: number; blendMode?: BlendMode; + effects?: Effect[]; } export interface TextElement extends BaseTimelineElement { @@ -127,6 +142,7 @@ export interface TextElement extends BaseTimelineElement { transform: Transform; opacity: number; blendMode?: BlendMode; + effects?: Effect[]; } export interface StickerElement extends BaseTimelineElement { @@ -136,6 +152,13 @@ export interface StickerElement extends BaseTimelineElement { transform: Transform; opacity: number; blendMode?: BlendMode; + effects?: Effect[]; +} + +export interface EffectElement extends BaseTimelineElement { + type: "effect"; + effectType: string; + params: EffectParamValues; } export type VisualElement = @@ -154,7 +177,8 @@ export type TimelineElement = | VideoElement | ImageElement | TextElement - | StickerElement; + | StickerElement + | EffectElement; export type ElementType = TimelineElement["type"]; @@ -167,12 +191,14 @@ export type CreateVideoElement = Omit; export type CreateImageElement = Omit; export type CreateTextElement = Omit; export type CreateStickerElement = Omit; +export type CreateEffectElement = Omit; export type CreateTimelineElement = | CreateAudioElement | CreateVideoElement | CreateImageElement | CreateTextElement - | CreateStickerElement; + | CreateStickerElement + | CreateEffectElement; export interface ElementDragState { isDragging: boolean; @@ -191,6 +217,7 @@ export interface DropTarget { isNewTrack: boolean; insertPosition: "above" | "below" | null; xPosition: number; + targetElement: { elementId: string; trackId: string } | null; } export interface ComputeDropTargetParams { @@ -206,6 +233,7 @@ export interface ComputeDropTargetParams { verticalDragDirection?: "up" | "down" | null; startTimeOverride?: number; excludeElementId?: string; + targetElementTypes?: string[]; } export interface ClipboardItem { diff --git a/bun.lock b/bun.lock index 6c46fcb9..371fa28f 100644 --- a/bun.lock +++ b/bun.lock @@ -101,6 +101,7 @@ "dotenv": "^16.5.0", "drizzle-kit": "^0.31.4", "postcss": "^8", + "raw-loader": "^4.0.2", "sharp": "^0.34.5", "tailwindcss": "^4.1.11", "tsx": "^4.7.1", @@ -300,6 +301,8 @@ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + "@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.4", "", {}, "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="], @@ -560,12 +563,18 @@ "@types/dom-webcodecs": ["@types/dom-webcodecs@0.1.13", "", {}, "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ=="], + "@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="], + + "@types/eslint-scope": ["@types/eslint-scope@3.7.7", "", { "dependencies": { "@types/eslint": "*", "@types/estree": "*" } }, "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], @@ -592,6 +601,50 @@ "@vercel/analytics": ["@vercel/analytics@1.5.0", "", { "peerDependencies": { "@remix-run/react": "^2", "@sveltejs/kit": "^1 || ^2", "next": ">= 13", "react": "^18 || ^19 || ^19.0.0-rc", "svelte": ">= 4", "vue": "^3", "vue-router": "^4" }, "optionalPeers": ["@remix-run/react", "@sveltejs/kit", "next", "react", "svelte", "vue", "vue-router"] }, "sha512-MYsBzfPki4gthY5HnYN7jgInhAZ7Ac1cYDoRWFomwGHWEX7odTEzbtg9kf/QSo7XEsEAqlQugA6gJ2WS2DEa3g=="], + "@webassemblyjs/ast": ["@webassemblyjs/ast@1.14.1", "", { "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ=="], + + "@webassemblyjs/floating-point-hex-parser": ["@webassemblyjs/floating-point-hex-parser@1.13.2", "", {}, "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA=="], + + "@webassemblyjs/helper-api-error": ["@webassemblyjs/helper-api-error@1.13.2", "", {}, "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ=="], + + "@webassemblyjs/helper-buffer": ["@webassemblyjs/helper-buffer@1.14.1", "", {}, "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA=="], + + "@webassemblyjs/helper-numbers": ["@webassemblyjs/helper-numbers@1.13.2", "", { "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", "@xtuc/long": "4.2.2" } }, "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA=="], + + "@webassemblyjs/helper-wasm-bytecode": ["@webassemblyjs/helper-wasm-bytecode@1.13.2", "", {}, "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA=="], + + "@webassemblyjs/helper-wasm-section": ["@webassemblyjs/helper-wasm-section@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/wasm-gen": "1.14.1" } }, "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw=="], + + "@webassemblyjs/ieee754": ["@webassemblyjs/ieee754@1.13.2", "", { "dependencies": { "@xtuc/ieee754": "^1.2.0" } }, "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw=="], + + "@webassemblyjs/leb128": ["@webassemblyjs/leb128@1.13.2", "", { "dependencies": { "@xtuc/long": "4.2.2" } }, "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw=="], + + "@webassemblyjs/utf8": ["@webassemblyjs/utf8@1.13.2", "", {}, "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ=="], + + "@webassemblyjs/wasm-edit": ["@webassemblyjs/wasm-edit@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/helper-wasm-section": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-opt": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1", "@webassemblyjs/wast-printer": "1.14.1" } }, "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ=="], + + "@webassemblyjs/wasm-gen": ["@webassemblyjs/wasm-gen@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg=="], + + "@webassemblyjs/wasm-opt": ["@webassemblyjs/wasm-opt@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1" } }, "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw=="], + + "@webassemblyjs/wasm-parser": ["@webassemblyjs/wasm-parser@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ=="], + + "@webassemblyjs/wast-printer": ["@webassemblyjs/wast-printer@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" } }, "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw=="], + + "@xtuc/ieee754": ["@xtuc/ieee754@1.2.0", "", {}, "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA=="], + + "@xtuc/long": ["@xtuc/long@4.2.2", "", {}, "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="], + + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "acorn-import-phases": ["acorn-import-phases@1.0.4", "", { "peerDependencies": { "acorn": "^8.14.0" } }, "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ=="], + + "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], + + "ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], + + "ajv-keywords": ["ajv-keywords@3.5.2", "", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="], + "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], "aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="], @@ -604,10 +657,14 @@ "better-call": ["better-call@1.1.8", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw=="], + "big.js": ["big.js@5.2.2", "", {}, "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ=="], + "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], "botid": ["botid@1.4.2", "", { "peerDependencies": { "next": "*", "react": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["next"] }, "sha512-yiRWEdxXa5QhxzJW4lTk0lRZkbqPsVWdGrhnHLLihZf0xBEtsTUGtxLqK++IY80FX/Ye/rNMnGqBp2pl4yYU8w=="], + "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], @@ -626,6 +683,8 @@ "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + "chrome-trace-event": ["chrome-trace-event@1.0.4", "", {}, "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ=="], + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], "classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="], @@ -638,6 +697,8 @@ "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + "commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + "country-flag-icons": ["country-flag-icons@1.5.19", "", {}, "sha512-D/ZkRyj+ywJC6b2IrAN3/tpbReMUqmuRLlcKFoY/o0+EPQN9Ev/e8tV+D3+9scvu/tarxwLErNwS73C3yzxs/g=="], "cross-env": ["cross-env@7.0.3", "", { "dependencies": { "cross-spawn": "^7.0.1" }, "bin": { "cross-env": "src/bin/cross-env.js", "cross-env-shell": "src/bin/cross-env-shell.js" } }, "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw=="], @@ -708,12 +769,16 @@ "drizzle-orm": ["drizzle-orm@0.44.3", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-8nIiYQxOpgUicEL04YFojJmvC4DNO4KoyXsEIqN44+g6gNBr6hmVpWk3uyAt4CaTiRGDwoU+alfqNNeonLAFOQ=="], + "electron-to-chromium": ["electron-to-chromium@1.5.302", "", {}, "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg=="], + "embla-carousel": ["embla-carousel@8.6.0", "", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="], "embla-carousel-react": ["embla-carousel-react@8.6.0", "", { "dependencies": { "embla-carousel": "8.6.0", "embla-carousel-reactive-utils": "8.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA=="], "embla-carousel-reactive-utils": ["embla-carousel-reactive-utils@8.6.0", "", { "peerDependencies": { "embla-carousel": "8.6.0" } }, "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A=="], + "emojis-list": ["emojis-list@3.0.0", "", {}, "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q=="], + "enhanced-resolve": ["enhanced-resolve@5.18.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ=="], "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], @@ -722,22 +787,40 @@ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + "es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="], + "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], "esbuild": ["esbuild@0.25.8", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.8", "@esbuild/android-arm": "0.25.8", "@esbuild/android-arm64": "0.25.8", "@esbuild/android-x64": "0.25.8", "@esbuild/darwin-arm64": "0.25.8", "@esbuild/darwin-x64": "0.25.8", "@esbuild/freebsd-arm64": "0.25.8", "@esbuild/freebsd-x64": "0.25.8", "@esbuild/linux-arm": "0.25.8", "@esbuild/linux-arm64": "0.25.8", "@esbuild/linux-ia32": "0.25.8", "@esbuild/linux-loong64": "0.25.8", "@esbuild/linux-mips64el": "0.25.8", "@esbuild/linux-ppc64": "0.25.8", "@esbuild/linux-riscv64": "0.25.8", "@esbuild/linux-s390x": "0.25.8", "@esbuild/linux-x64": "0.25.8", "@esbuild/netbsd-arm64": "0.25.8", "@esbuild/netbsd-x64": "0.25.8", "@esbuild/openbsd-arm64": "0.25.8", "@esbuild/openbsd-x64": "0.25.8", "@esbuild/openharmony-arm64": "0.25.8", "@esbuild/sunos-x64": "0.25.8", "@esbuild/win32-arm64": "0.25.8", "@esbuild/win32-ia32": "0.25.8", "@esbuild/win32-x64": "0.25.8" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q=="], "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="], + "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-equals": ["fast-equals@5.2.2", "", {}, "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw=="], + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + "feed": ["feed@5.1.0", "", { "dependencies": { "xml-js": "^1.6.11" } }, "sha512-qGNhgYygnefSkAHHrNHqC7p3R8J0/xQDS/cYUud8er/qD9EFGWyCdUDfULHTJQN1d3H3WprzVwMc9MfB4J50Wg=="], "flatbuffers": ["flatbuffers@25.9.23", "", {}, "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ=="], @@ -752,6 +835,8 @@ "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], + "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="], + "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], @@ -762,6 +847,8 @@ "guid-typescript": ["guid-typescript@1.0.9", "", {}, "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], "hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="], @@ -810,14 +897,22 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "jest-worker": ["jest-worker@27.5.1", "", { "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg=="], + "jiti": ["jiti@2.5.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w=="], "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "kysely": ["kysely@0.28.9", "", {}, "sha512-3BeXMoiOhpOwu62CiVpO6lxfq4eS6KMYfQdMsN/2kUCRNuF2YiEr7u0HLHaQU+O4Xu8YXE3bHVkwaQ85i72EuA=="], "libphonenumber-js": ["libphonenumber-js@1.12.10", "", {}, "sha512-E91vHJD61jekHHR/RF/E83T/CMoaLXT7cwYA75T4gim4FZjnM6hbJjVIGg7chqlSqRsSvQ3izGmOjHy1SQzcGQ=="], @@ -844,6 +939,10 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="], + "loader-runner": ["loader-runner@4.3.1", "", {}, "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q=="], + + "loader-utils": ["loader-utils@2.0.4", "", { "dependencies": { "big.js": "^5.2.2", "emojis-list": "^3.0.0", "json5": "^2.1.2" } }, "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw=="], + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], "lodash.castarray": ["lodash.castarray@4.4.0", "", {}, "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q=="], @@ -882,6 +981,8 @@ "mediabunny": ["mediabunny@1.29.1", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-RrlKs69MxRGa/l9cMGeI4hzuTSgchOGFHk9lIAuu9EcbSdJ05gDbRsTY67dojAiyUP3ic4PExij5OqDxqSv82Q=="], + "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], + "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], @@ -924,6 +1025,10 @@ "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], "minizlib": ["minizlib@3.0.2", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA=="], @@ -942,10 +1047,14 @@ "nanostores": ["nanostores@1.1.0", "", {}, "sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA=="], + "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], + "next": ["next@16.1.3", "", { "dependencies": { "@next/env": "16.1.3", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.3", "@next/swc-darwin-x64": "16.1.3", "@next/swc-linux-arm64-gnu": "16.1.3", "@next/swc-linux-arm64-musl": "16.1.3", "@next/swc-linux-x64-gnu": "16.1.3", "@next/swc-linux-x64-musl": "16.1.3", "@next/swc-win32-arm64-msvc": "16.1.3", "@next/swc-win32-x64-msvc": "16.1.3", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-gthG3TRD+E3/mA0uDQb9lqBmx1zVosq5kIwxNN6+MRNd085GzD+9VXMPUs+GGZCbZ+GDZdODUq4Pm7CTXK6ipw=="], "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], + "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], @@ -1002,10 +1111,16 @@ "protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "radix-ui": ["radix-ui@1.4.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="], "raf-schd": ["raf-schd@4.0.3", "", {}, "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ=="], + "randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="], + + "raw-loader": ["raw-loader@4.0.2", "", { "dependencies": { "loader-utils": "^2.0.0", "schema-utils": "^3.0.0" }, "peerDependencies": { "webpack": "^4.0.0 || ^5.0.0" } }, "sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA=="], + "react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="], "react-day-picker": ["react-day-picker@8.10.1", "", { "peerDependencies": { "date-fns": "^2.28.0 || ^3.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA=="], @@ -1058,22 +1173,30 @@ "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "sax": ["sax@1.4.1", "", {}, "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "schema-utils": ["schema-utils@3.3.0", "", { "dependencies": { "@types/json-schema": "^7.0.8", "ajv": "^6.12.5", "ajv-keywords": "^3.5.2" } }, "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg=="], + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="], + "serialize-javascript": ["serialize-javascript@6.0.2", "", { "dependencies": { "randombytes": "^2.1.0" } }, "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g=="], + "set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="], "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], @@ -1104,16 +1227,22 @@ "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], + "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + "tailwind-merge": ["tailwind-merge@2.6.0", "", {}, "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA=="], "tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="], "tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="], - "tapable": ["tapable@2.2.2", "", {}, "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg=="], + "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], "tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="], + "terser": ["terser@5.46.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg=="], + + "terser-webpack-plugin": ["terser-webpack-plugin@5.3.16", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", "serialize-javascript": "^6.0.2", "terser": "^5.31.1" }, "peerDependencies": { "webpack": "^5.1.0" } }, "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q=="], + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], @@ -1158,6 +1287,10 @@ "unist-util-visit-parents": ["unist-util-visit-parents@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], "use-deep-compare-effect": ["use-deep-compare-effect@1.8.1", "", { "dependencies": { "@babel/runtime": "^7.12.5", "dequal": "^2.0.2" }, "peerDependencies": { "react": ">=16.13" } }, "sha512-kbeNVZ9Zkc0RFGpfMN3MNfaKNvcLNyxOAAd9O4CBZ+kCBXXscn9s/4I+8ytUER4RDpEYs5+O6Rs4PqiZ+rHr5Q=="], @@ -1178,10 +1311,16 @@ "victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="], + "watchpack": ["watchpack@2.5.1", "", { "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" } }, "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg=="], + "wavesurfer.js": ["wavesurfer.js@7.10.0", "", {}, "sha512-GiyAHdorqGtUYG5fe4BfTf5lmtSLhrXoHeNlMsR80JOiOZxOrIOv9QaIR8RnqlleJ6D8R9cqvZKR9lfJcWcapg=="], "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], + "webpack": ["webpack@5.105.3", "", { "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.16.0", "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.19.0", "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", "terser-webpack-plugin": "^5.3.16", "watchpack": "^2.5.1", "webpack-sources": "^3.3.4" }, "bin": { "webpack": "bin/webpack.js" } }, "sha512-LLBBA4oLmT7sZdHiYE/PeVuifOxYyE2uL/V+9VQP7YSYdJU7bSf7H8bZRRxW8kEPMkmVjnrXmoR3oejIdX0xbg=="], + + "webpack-sources": ["webpack-sources@3.3.4", "", {}, "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "xml-js": ["xml-js@1.6.11", "", { "dependencies": { "sax": "^1.2.4" }, "bin": { "xml-js": "./bin/cli.js" } }, "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g=="], @@ -1310,12 +1449,20 @@ "@upstash/core-analytics/@upstash/redis": ["@upstash/redis@1.35.1", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-sIMuAMU9IYbE2bkgDby8KLoQKRiBMXn0moXxqLvUmQ7VUu2CvulZLtK8O0x3WQZFvvZhU5sRC2/lOVZdGfudkA=="], + "ajv-formats/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "browserslist/caniuse-lite": ["caniuse-lite@1.0.30001774", "", {}, "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA=="], + "cmdk/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw=="], "cmdk/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], "dom-helpers/csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + "enhanced-resolve/tapable": ["tapable@2.2.2", "", {}, "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg=="], + + "esrecurse/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + "lightningcss/detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="], "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], @@ -1338,8 +1485,14 @@ "recharts/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + "terser-webpack-plugin/schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="], + "vaul/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw=="], + "webpack/enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="], + + "webpack/schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], @@ -1450,6 +1603,8 @@ "@types/pg/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "cmdk/@radix-ui/react-dialog/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], "cmdk/@radix-ui/react-dialog/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="], @@ -1464,6 +1619,10 @@ "next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "terser-webpack-plugin/schema-utils/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "terser-webpack-plugin/schema-utils/ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "ajv": "^8.8.2" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="], + "vaul/@radix-ui/react-dialog/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], "vaul/@radix-ui/react-dialog/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="], @@ -1475,5 +1634,13 @@ "vaul/@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], "vaul/@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "webpack/schema-utils/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "webpack/schema-utils/ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "ajv": "^8.8.2" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="], + + "terser-webpack-plugin/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "webpack/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], } } diff --git a/docs/effects-renderer.md b/docs/effects-renderer.md new file mode 100644 index 00000000..fd41282b --- /dev/null +++ b/docs/effects-renderer.md @@ -0,0 +1,64 @@ +# Effects & WebGL Renderer + +## How to add a new effect + +1. Create a new file in `apps/web/src/lib/effects/definitions/` (e.g. `brightness.ts`) +2. Export an `EffectDefinition` — see `blur.ts` as a reference +3. Register it in `apps/web/src/lib/effects/definitions/index.ts` + +An effect definition has: +- `type` — unique string identifier +- `name` — display name +- `keywords` — for search +- `params` — user-facing controls (sliders, toggles, etc.) +- `renderer` — always `webgl` + +All effects use WebGL. Even simple single-value effects like brightness or contrast are trivial shaders — there's no reason to leave the GPU pipeline for them. + +## Single-pass vs multi-pass + +The `webgl` renderer supports a `passes` array. Single-pass effects (e.g. color grading) just have one entry. Multi-pass is needed when an effect has to process its own output — blur (H then V), bloom (extract → blur → composite), glow, etc. + +```typescript +renderer: { + type: "webgl", + passes: [ + { fragmentShader: myShader, uniforms: ({ effectParams }) => ({ ... }) }, + ], +} +``` + +All WebGL rendering — both the main renderer and the effect preview — goes through `applyMultiPassEffect` in `apps/web/src/services/renderer/webgl-utils.ts`. Don't add a new rendering path somewhere else; update that function if needed. + +## Writing fragment shaders + +Shaders live in `apps/web/src/lib/effects/definitions/`. The shared vertex shader (`effect.vert.glsl`) maps clip space to UV coordinates — don't replace it unless you have a specific reason. + +Available uniforms (automatically injected, no need to pass them manually): +- `u_texture` — the input texture (sampler2D) +- `u_resolution` — canvas size in pixels (vec2) + +Any additional uniforms come from the `uniforms()` function in the pass definition. + +**Sampling density — the most common mistake** + +Always use a step of 1 texel when sampling neighbors. Do not scale the step size with the blur radius or intensity — it creates visible discrete artifacts (ghosting/glow look) because there are large gaps between samples that the GPU fills with linear interpolation instead of your intended curve. + +```glsl +// correct — step is always 1 texel, loop count controls radius +for (int i = -30; i <= 30; i++) { + color += texture2D(u_texture, v_texCoord + texelSize * u_direction * float(i)) * weight; +} + +// wrong — stepping 6 texels at a time looks ghosty at high intensity +vec2 offset = texelSize * u_direction * u_radius; +color += texture2D(u_texture, v_texCoord + offset * 2.0) * someWeight; +``` + +If you need a large radius with a fixed kernel size, increase the number of samples rather than the step. + +## Y-flip and coordinate systems + +Source textures (uploaded from canvas) are Y-flipped via `UNPACK_FLIP_Y_WEBGL`. Intermediate FBO textures (rendered by WebGL between passes) are not. In practice this cancels out correctly as long as you use the shared vertex shader — it maps clip space Y consistently so both texture types sample correctly. + +If you write a custom vertex shader or do manual coordinate math, be aware that canvas and WebGL have opposite Y origins (canvas: top-left, WebGL: bottom-left). Getting this wrong produces an upside-down result with no obvious error. diff --git a/package.json b/package.json index 213b6870..521b90b6 100644 --- a/package.json +++ b/package.json @@ -1,33 +1,33 @@ { - "name": "opencut", - "packageManager": "bun@1.2.18", - "workspaces": [ - "apps/*", - "packages/*" - ], - "scripts": { - "dev:web": "turbo run dev --filter=@opencut/web", - "build:web": "turbo run build --filter=@opencut/web", - "dev:tools": "turbo run dev --filter=@opencut/tools", - "build:tools": "turbo run build --filter=@opencut/tools", - "start:tools": "turbo run start --filter=@opencut/tools", - "lint:web": "biome lint apps/web/src --max-diagnostics=1000", - "lint:web:fix": "biome lint apps/web/src --write --max-diagnostics=1000", - "format:web": "biome format apps/web/src/services/renderer --write --max-diagnostics=1000", - "test": "bun test", - "generate:fonts": "npx tsx apps/web/scripts/generate-font-sprites.ts" - }, - "dependencies": { - "@types/react": "^19.2.10", - "@types/react-dom": "^19.2.3", - "better-auth": "^1.4.15", - "next": "^16.1.3" - }, - "devDependencies": { - "turbo": "^2.7.5", - "typescript": "5.8.3" - }, - "trustedDependencies": [ - "@tailwindcss/oxide" - ] + "name": "opencut", + "packageManager": "bun@1.2.18", + "workspaces": [ + "apps/*", + "packages/*" + ], + "scripts": { + "dev:web": "turbo run dev --filter=@opencut/web", + "build:web": "turbo run build --filter=@opencut/web", + "dev:tools": "turbo run dev --filter=@opencut/tools", + "build:tools": "turbo run build --filter=@opencut/tools", + "start:tools": "turbo run start --filter=@opencut/tools", + "lint:web": "biome lint apps/web/src --max-diagnostics=1000", + "lint:web:fix": "biome lint apps/web/src --write --max-diagnostics=1000", + "format:web": "biome format apps/web/src/services/renderer --write --max-diagnostics=1000", + "test": "bun test", + "generate:fonts": "npx tsx apps/web/scripts/generate-font-sprites.ts" + }, + "dependencies": { + "@types/react": "^19.2.10", + "@types/react-dom": "^19.2.3", + "better-auth": "^1.4.15", + "next": "^16.1.3" + }, + "devDependencies": { + "turbo": "^2.7.5", + "typescript": "5.8.3" + }, + "trustedDependencies": [ + "@tailwindcss/oxide" + ] }