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.
This commit is contained in:
Maze Winther 2026-02-28 18:41:22 +01:00
parent a9e93471a7
commit 216e3e0c39
55 changed files with 2510 additions and 537 deletions

41
CHANGELOG.md Normal file
View File

@ -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.

View File

@ -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",
},

View File

@ -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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

@ -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: <SoundsView />,
text: <TextView />,
stickers: <StickersView />,
effects: (
<div className="text-muted-foreground p-4">
Effects view coming soon...
</div>
),
effects: <EffectsView />,
transitions: (
<div className="text-muted-foreground p-4">
Transitions view coming soon...

View File

@ -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 }) =>

View File

@ -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 (
<PanelView title="Effects">
<EffectsGrid effects={effects} />
</PanelView>
);
}
function EffectsGrid({ effects }: { effects: EffectDefinition[] }) {
return (
<div
className="grid gap-2"
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(96px, 1fr))" }}
>
{effects.map((effect) => (
<EffectItem key={effect.type} effect={effect} />
))}
</div>
);
}
function EffectPreviewCanvas({ effectType }: { effectType: string }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const render = () => {
if (canvasRef.current) {
effectPreviewService.renderPreview({
effectType,
params: {},
targetCanvas: canvasRef.current,
});
}
};
render();
return onPreviewImageReady({ callback: render });
}, [effectType]);
return <canvas ref={canvasRef} className="size-full" />;
}
function EffectItem({ effect }: { effect: EffectDefinition }) {
const editor = useEditor();
const handleAddToTimeline = useCallback(() => {
const currentTime = editor.playback.getCurrentTime();
const element = buildEffectElement({
effectType: effect.type,
startTime: currentTime,
});
editor.timeline.insertElement({
placement: { mode: "auto", trackType: "effect" },
element,
});
}, [editor, effect.type]);
const preview = <EffectPreviewCanvas effectType={effect.type} />;
return (
<DraggableItem
name={effect.name}
preview={preview}
dragData={{
id: effect.type,
name: effect.name,
type: "effect",
effectType: effect.type,
targetElementTypes: EFFECT_TARGET_ELEMENT_TYPES,
}}
onAddToTimeline={handleAddToTimeline}
aspectRatio={1}
isRounded
variant="card"
containerClassName="w-full"
/>
);
}

View File

@ -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 (
<SectionField label={param.label}>
<div className="flex items-center gap-3">
<Slider
className="flex-1"
min={min}
max={max}
step={step}
value={[currentValue]}
onValueChange={([value]) => updateParam(value)}
onValueCommit={commitParam}
/>
<NumberField
className="w-16 shrink-0"
value={draft.displayValue}
onFocus={draft.onFocus}
onChange={draft.onChange}
onBlur={draft.onBlur}
/>
</div>
</SectionField>
);
}
export function EffectProperties({
element,
trackId,
}: {
element: EffectElement;
trackId: string;
}) {
const definition = getEffect({ effectType: element.effectType });
return (
<Section hasBorderTop={false}>
<SectionHeader title={definition.name} />
<SectionContent>
<SectionFields>
{definition.params.map((param) => (
<EffectParamField
key={param.key}
param={param}
element={element}
trackId={trackId}
/>
))}
</SectionFields>
</SectionContent>
</Section>
);
}

View File

@ -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 <TextProperties element={element} trackId={track.id} />;
}
if (element.type === "audio") {
return <AudioProperties _element={element} />;
}
if (
element.type === "video" ||
element.type === "image" ||
element.type === "sticker"
) {
return <VideoProperties element={element} trackId={track.id} />;
}
if (element.type === "effect") {
return <EffectProperties element={element} trackId={track.id} />;
}
return null;
}
export function PropertiesPanel() {
const editor = useEditor();
@ -16,34 +44,19 @@ export function PropertiesPanel() {
elements: selectedElements,
});
const hasSelection = selectedElements.length > 0;
return (
<div className="panel bg-background h-full rounded-sm border border-t-0 overflow-hidden">
{selectedElements.length > 0 ? (
<div className="panel bg-background h-full rounded-sm border overflow-hidden">
{hasSelection ? (
<ScrollArea className="h-full scrollbar-hidden">
{elementsWithTracks.map(({ track, element }) => {
if (element.type === "text") {
return (
<div key={element.id}>
<TextProperties element={element} trackId={track.id} />
</div>
);
}
if (element.type === "audio") {
return <AudioProperties key={element.id} _element={element} />;
}
if (
element.type === "video" ||
element.type === "image" ||
element.type === "sticker"
) {
return (
<div key={element.id}>
<VideoProperties element={element} trackId={track.id} />
</div>
);
}
return null;
})}
{elementsWithTracks.map(({ track, element }) => (
<ElementProperties
key={element.id}
track={track}
element={element}
/>
))}
</ScrollArea>
) : (
<EmptyView />

View File

@ -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() {
<DragLine
dropTarget={dropTarget}
tracks={timeline.getTracks()}
isVisible={isDragOver}
isVisible={isDragOver && !dropTarget?.targetElement}
headerHeight={timelineHeaderHeight}
/>
<DragLine
@ -453,6 +454,11 @@ export function Timeline() {
}}
onTrackClick={handleTracksClick}
shouldIgnoreClick={shouldIgnoreClick}
targetElementId={
isDragOver
? dropTarget?.targetElement?.elementId ?? null
: null
}
/>
</div>
</ContextMenuTrigger>
@ -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({

View File

@ -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}
/>
<KeyframeIndicators indicators={keyframeIndicators} />
{isSelected && <KeyframeIndicators indicators={keyframeIndicators} />}
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-64">
@ -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 (
<div
className={`relative h-full cursor-pointer overflow-hidden rounded-[0.5rem] ${getTrackClasses(
{
type: track.type,
},
)} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`}
)} ${opacityClass}`}
>
<button
type="button"
@ -473,9 +465,11 @@ function KeyframeIndicators({
}: {
indicators: KeyframeIndicator[];
}) {
const { toggleKeyframeSelection, selectKeyframeRange } =
const { isKeyframeSelected, toggleKeyframeSelection, selectKeyframeRange } =
useKeyframeSelection();
const orderedKeyframes = indicators.flatMap((indicator) => indicator.keyframes);
const orderedKeyframes = indicators.flatMap(
(indicator) => indicator.keyframes,
);
const handleKeyframeMouseDown = ({ event }: { event: React.MouseEvent }) => {
event.preventDefault();
@ -505,76 +499,103 @@ function KeyframeIndicators({
});
};
return indicators.map((indicator) => (
<button
key={indicator.time}
type="button"
className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-pointer"
style={{ left: indicator.offsetPx }}
onMouseDown={(event) => handleKeyframeMouseDown({ event })}
onClick={(event) =>
handleKeyframeClick({ event, keyframes: indicator.keyframes })
}
aria-label="Select keyframe"
>
<HugeiconsIcon
icon={KeyframeIcon}
className={cn(
"size-3.5 text-black",
indicator.isSelected ? "fill-primary" : "fill-white",
)}
strokeWidth={1.5}
/>
</button>
));
return indicators.map((indicator) => {
const isIndicatorSelected = indicator.keyframes.some((keyframe) =>
isKeyframeSelected({ keyframe }),
);
return (
<button
key={indicator.time}
type="button"
className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-pointer"
style={{ left: indicator.offsetPx }}
onMouseDown={(event) => handleKeyframeMouseDown({ event })}
onClick={(event) =>
handleKeyframeClick({ event, keyframes: indicator.keyframes })
}
aria-label="Select keyframe"
>
<HugeiconsIcon
icon={KeyframeIcon}
className={cn(
"size-3.5 text-black",
isIndicatorSelected ? "fill-primary" : "fill-white",
)}
strokeWidth={1.5}
/>
</button>
);
});
}
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 (
<div className="flex size-full items-center justify-start pl-2">
<span className="truncate text-xs text-white">{element.content}</span>
<div className="flex size-full items-center justify-start pl-3">
<span className="truncate text-xs text-white">
{textElement.content}
</span>
</div>
);
}
if (element.type === "sticker") {
},
effect: ({ element }) => (
<div className="flex size-full items-center justify-start gap-1 pl-2">
<HugeiconsIcon icon={MagicWand05Icon} className="size-4 shrink-0 text-white" />
<span className="truncate text-xs text-white">{element.name}</span>
</div>
),
sticker: ({ element }) => {
const stickerElement = element as Extract<
TimelineElementType,
{ type: "sticker" }
>;
return (
<div className="flex size-full items-center gap-2 pl-2">
<Image
src={resolveStickerId({
stickerId: element.stickerId,
stickerId: stickerElement.stickerId,
options: { width: 20, height: 20 },
})}
alt={element.name}
alt={stickerElement.name}
className="size-5 shrink-0"
width={20}
height={20}
unoptimized
/>
<span className="truncate text-xs text-white">{element.name}</span>
<span className="truncate text-xs text-white">
{stickerElement.name}
</span>
</div>
);
}
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 (
<span className="text-foreground/80 truncate text-xs">
{element.name}
{audioElement.name}
</span>
);
}
},
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 (
<span className="text-foreground/80 truncate text-xs">
{videoElement.name}
</span>
);
}
if (mediaAsset.thumbnailUrl) {
const trackHeight = getTrackHeight({ type: track.type });
const tileWidth = trackHeight * (16 / 9);
return (
<div className="flex size-full items-center justify-center">
<div
className={`relative size-full ${isSelected ? "bg-primary" : "bg-transparent"}`}
>
<div
className="absolute right-0 left-0"
style={{
backgroundImage: `url(${mediaAsset.thumbnailUrl})`,
backgroundRepeat: "repeat-x",
backgroundSize: `${tileWidth}px ${trackHeight}px`,
backgroundPosition: "left center",
pointerEvents: "none",
top: isSelected ? "0.25rem" : "0rem",
bottom: isSelected ? "0.25rem" : "0rem",
}}
/>
</div>
</div>
);
}
const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId);
if (!mediaAsset) {
return (
<span className="text-foreground/80 truncate text-xs">
{element.name}
{videoElement.name}
</span>
);
}
},
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 (
<span className="text-foreground/80 truncate text-xs">
{imageElement.name}
</span>
);
}
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 (
<div className="flex size-full items-center justify-center">
@ -624,7 +695,7 @@ function ElementContent({
<div
className="absolute right-0 left-0"
style={{
backgroundImage: imageUrl ? `url(${imageUrl})` : "none",
backgroundImage: `url(${mediaAsset.url})`,
backgroundRepeat: "repeat-x",
backgroundSize: `${tileWidth}px ${trackHeight}px`,
backgroundPosition: "left center",
@ -636,11 +707,12 @@ function ElementContent({
</div>
</div>
);
}
},
};
return (
<span className="text-foreground/80 truncate text-xs">{element.name}</span>
);
function ElementContent(props: ElementContentProps) {
const renderer = ELEMENT_CONTENT_RENDERERS[props.element.type];
return <>{renderer(props)}</>;
}
function CopyMenuItem() {

View File

@ -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}
/>
);
})

View File

@ -40,7 +40,7 @@ const selectTriggerVariants = cva(
},
size: {
default: "",
sm: "",
sm: "rounded-sm",
},
},
defaultVariants: {

View File

@ -7,7 +7,9 @@ import { cn } from "@/utils/ui";
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> & {
className?: string;
}
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}

View File

@ -3,6 +3,7 @@ import type { BlendMode } from "@/types/rendering";
import type { TrackType, Transform } from "@/types/timeline";
import {
Happy01Icon,
MagicWand05Icon,
MusicNote03Icon,
TextIcon,
} from "@hugeicons/core-free-icons";
@ -19,26 +20,65 @@ export const DEFAULT_OPACITY = 1;
export const DEFAULT_BLEND_MODE: BlendMode = "normal";
export const DEFAULT_BOOKMARK_COLOR = "#009dff";
export const TRACK_COLORS: Record<TrackType, { background: string }> = {
export const TRACK_CONFIG: Record<
TrackType,
{
background: string;
height: number;
defaultName: string;
icon: React.ReactNode;
}
> = {
video: {
background: "transparent",
height: 60,
defaultName: "Video track",
icon: <OcVideoIcon className="text-muted-foreground size-4 shrink-0" />,
},
text: {
background: "bg-[#5DBAA0]",
height: 25,
defaultName: "Text track",
icon: (
<HugeiconsIcon
icon={TextIcon}
className="text-muted-foreground size-4 shrink-0"
/>
),
},
audio: {
background: "bg-[#915DBE]",
background: "bg-[#8F5DBA]",
height: 50,
defaultName: "Audio track",
icon: (
<HugeiconsIcon
icon={MusicNote03Icon}
className="text-muted-foreground size-4 shrink-0"
/>
),
},
sticker: {
background: "bg-amber-500",
background: "bg-[#BA5D7A]",
height: 50,
defaultName: "Sticker track",
icon: (
<HugeiconsIcon
icon={Happy01Icon}
className="text-muted-foreground size-4 shrink-0"
/>
),
},
effect: {
background: "bg-[#5d93ba]",
height: 25,
defaultName: "Effect track",
icon: (
<HugeiconsIcon
icon={MagicWand05Icon}
className="text-muted-foreground size-4 shrink-0"
/>
),
},
} as const;
export const TRACK_HEIGHTS: Record<TrackType, number> = {
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<TrackType, React.ReactNode> = {
video: <OcVideoIcon className="text-muted-foreground size-4 shrink-0" />,
text: (
<HugeiconsIcon
icon={TextIcon}
className="text-muted-foreground size-4 shrink-0"
/>
),
audio: (
<HugeiconsIcon
icon={MusicNote03Icon}
className="text-muted-foreground size-4 shrink-0"
/>
),
sticker: (
<HugeiconsIcon
icon={Happy01Icon}
className="text-muted-foreground size-4 shrink-0"
/>
),
} as const;

View File

@ -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);

View File

@ -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 }) => {

View File

@ -5,6 +5,7 @@ import { useEditor } from "../use-editor";
interface UseSelectionBoxProps {
containerRef: React.RefObject<HTMLElement | null>;
headerRef: React.RefObject<HTMLElement | null>;
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(() => {

View File

@ -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<HTMLDivElement | null>;
@ -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,

View File

@ -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<HTMLCanvasElement | null>;
isActive: boolean;
}): void {
const requestRef = useRef<number>(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]);
}

View File

@ -217,6 +217,11 @@ function evaluateChannelValueAtTime<TKeyframe extends { time: number; value: TVa
for (let keyframeIndex = 0; keyframeIndex < keyframes.length - 1; keyframeIndex++) {
const leftKeyframe = keyframes[keyframeIndex];
const rightKeyframe = keyframes[keyframeIndex + 1];
if (Math.abs(time - rightKeyframe.time) <= TIME_EPSILON_SECONDS) {
return rightKeyframe.value;
}
const isBetweenPair = isWithinTimePair({
time,
leftTime: leftKeyframe.time,

View File

@ -173,6 +173,11 @@ export class InsertElementCommand extends Command {
return false;
}
if (element.type === "effect" && !element.effectType) {
console.error("Effect element must have effectType");
return false;
}
return true;
}

View File

@ -49,6 +49,7 @@ export class RetimeKeyframeCommand extends Command {
}),
update: (element) => {
const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration));
if (!Number.isFinite(boundedTime)) return element;
return {
...element,
animations: retimeElementKeyframe({

View File

@ -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;
}

View File

@ -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],
};
},
},
],
},
};

View File

@ -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 });
}
}

View File

@ -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);
}

View File

@ -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,
};
}

View File

@ -0,0 +1,31 @@
import type { EffectDefinition } from "@/types/effects";
const effectDefinitions = new Map<string, EffectDefinition>();
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());
}

View File

@ -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;

View File

@ -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;

View File

@ -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,
};

View File

@ -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";
}

View File

@ -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;
}
}

View File

@ -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<string, WebGLProgram>();
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,
};

View File

@ -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<BlurBackgroundNodeParams> {
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<void> {
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();
}
}

View File

@ -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<CompositeEffectNodeParams> {
async render({
renderer,
time,
}: {
renderer: CanvasRenderer;
time: number;
}): Promise<void> {
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();
}
}

View File

@ -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<EffectLayerNodeParams> {
async render({
renderer,
time,
}: {
renderer: CanvasRenderer;
time: number;
}): Promise<void> {
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();
}
}

View File

@ -73,7 +73,7 @@ export class ImageNode extends VisualNode<ImageNodeParams> {
async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
await super.render({ renderer, time });
if (!this.isInRange(time)) {
if (!this.isInRange({ time })) {
return;
}

View File

@ -51,7 +51,7 @@ export class StickerNode extends VisualNode<StickerNodeParams> {
async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
await super.render({ renderer, time });
if (!this.isInRange(time)) {
if (!this.isInRange({ time })) {
return;
}

View File

@ -12,11 +12,11 @@ export class VideoNode extends VisualNode<VideoNodeParams> {
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,

View File

@ -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<Params> {
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();
}
}

View File

@ -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<string, MediaAsset>;
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;

View File

@ -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<string, WebGLProgram>();
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,
};

View File

@ -0,0 +1,298 @@
import VERTEX_SHADER_SOURCE from "@/lib/effects/effect.vert.glsl";
export interface EffectPassData {
fragmentShader: string;
uniforms: Record<string, number | number[]>;
}
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<string, WebGLProgram>;
}): 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<string, number | number[]>;
}): 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<string, WebGLProgram>;
}): 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);
}

View File

@ -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(),
];

View File

@ -0,0 +1,96 @@
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
export function transformProjectV7ToV8({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
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;
}

View File

@ -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 });
}
}

View File

@ -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;

View File

@ -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<string, number | string | boolean>;
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<string, number | number[]>;
}
export interface WebGLEffectRenderer {
type: "webgl";
passes: WebGLEffectPass[];
}
export type EffectRenderer = WebGLEffectRenderer;
export interface EffectDefinition {
type: string;
name: string;
keywords: string[];
params: EffectParamDefinition[];
renderer: EffectRenderer;
}

4
apps/web/src/types/glsl.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
declare module "*.glsl" {
const value: string;
export default value;
}

View File

@ -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<VideoElement, "id">;
export type CreateImageElement = Omit<ImageElement, "id">;
export type CreateTextElement = Omit<TextElement, "id">;
export type CreateStickerElement = Omit<StickerElement, "id">;
export type CreateEffectElement = Omit<EffectElement, "id">;
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 {

169
bun.lock
View File

@ -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=="],
}
}

64
docs/effects-renderer.md Normal file
View File

@ -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.

View File

@ -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"
]
}