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:
parent
a9e93471a7
commit
216e3e0c39
|
|
@ -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.
|
||||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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...
|
||||
|
|
|
|||
|
|
@ -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 }) =>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ const selectTriggerVariants = cva(
|
|||
},
|
||||
size: {
|
||||
default: "",
|
||||
sm: "",
|
||||
sm: "rounded-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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],
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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(),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
declare module "*.glsl" {
|
||||
const value: string;
|
||||
export default value;
|
||||
}
|
||||
|
|
@ -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
169
bun.lock
|
|
@ -101,6 +101,7 @@
|
|||
"dotenv": "^16.5.0",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"postcss": "^8",
|
||||
"raw-loader": "^4.0.2",
|
||||
"sharp": "^0.34.5",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"tsx": "^4.7.1",
|
||||
|
|
@ -300,6 +301,8 @@
|
|||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.4", "", {}, "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="],
|
||||
|
|
@ -560,12 +563,18 @@
|
|||
|
||||
"@types/dom-webcodecs": ["@types/dom-webcodecs@0.1.13", "", {}, "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ=="],
|
||||
|
||||
"@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="],
|
||||
|
||||
"@types/eslint-scope": ["@types/eslint-scope@3.7.7", "", { "dependencies": { "@types/eslint": "*", "@types/estree": "*" } }, "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
|
||||
|
||||
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
|
||||
|
||||
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||
|
|
@ -592,6 +601,50 @@
|
|||
|
||||
"@vercel/analytics": ["@vercel/analytics@1.5.0", "", { "peerDependencies": { "@remix-run/react": "^2", "@sveltejs/kit": "^1 || ^2", "next": ">= 13", "react": "^18 || ^19 || ^19.0.0-rc", "svelte": ">= 4", "vue": "^3", "vue-router": "^4" }, "optionalPeers": ["@remix-run/react", "@sveltejs/kit", "next", "react", "svelte", "vue", "vue-router"] }, "sha512-MYsBzfPki4gthY5HnYN7jgInhAZ7Ac1cYDoRWFomwGHWEX7odTEzbtg9kf/QSo7XEsEAqlQugA6gJ2WS2DEa3g=="],
|
||||
|
||||
"@webassemblyjs/ast": ["@webassemblyjs/ast@1.14.1", "", { "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ=="],
|
||||
|
||||
"@webassemblyjs/floating-point-hex-parser": ["@webassemblyjs/floating-point-hex-parser@1.13.2", "", {}, "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA=="],
|
||||
|
||||
"@webassemblyjs/helper-api-error": ["@webassemblyjs/helper-api-error@1.13.2", "", {}, "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ=="],
|
||||
|
||||
"@webassemblyjs/helper-buffer": ["@webassemblyjs/helper-buffer@1.14.1", "", {}, "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA=="],
|
||||
|
||||
"@webassemblyjs/helper-numbers": ["@webassemblyjs/helper-numbers@1.13.2", "", { "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", "@xtuc/long": "4.2.2" } }, "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA=="],
|
||||
|
||||
"@webassemblyjs/helper-wasm-bytecode": ["@webassemblyjs/helper-wasm-bytecode@1.13.2", "", {}, "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA=="],
|
||||
|
||||
"@webassemblyjs/helper-wasm-section": ["@webassemblyjs/helper-wasm-section@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/wasm-gen": "1.14.1" } }, "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw=="],
|
||||
|
||||
"@webassemblyjs/ieee754": ["@webassemblyjs/ieee754@1.13.2", "", { "dependencies": { "@xtuc/ieee754": "^1.2.0" } }, "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw=="],
|
||||
|
||||
"@webassemblyjs/leb128": ["@webassemblyjs/leb128@1.13.2", "", { "dependencies": { "@xtuc/long": "4.2.2" } }, "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw=="],
|
||||
|
||||
"@webassemblyjs/utf8": ["@webassemblyjs/utf8@1.13.2", "", {}, "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ=="],
|
||||
|
||||
"@webassemblyjs/wasm-edit": ["@webassemblyjs/wasm-edit@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/helper-wasm-section": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-opt": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1", "@webassemblyjs/wast-printer": "1.14.1" } }, "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ=="],
|
||||
|
||||
"@webassemblyjs/wasm-gen": ["@webassemblyjs/wasm-gen@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg=="],
|
||||
|
||||
"@webassemblyjs/wasm-opt": ["@webassemblyjs/wasm-opt@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1" } }, "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw=="],
|
||||
|
||||
"@webassemblyjs/wasm-parser": ["@webassemblyjs/wasm-parser@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ=="],
|
||||
|
||||
"@webassemblyjs/wast-printer": ["@webassemblyjs/wast-printer@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" } }, "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw=="],
|
||||
|
||||
"@xtuc/ieee754": ["@xtuc/ieee754@1.2.0", "", {}, "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA=="],
|
||||
|
||||
"@xtuc/long": ["@xtuc/long@4.2.2", "", {}, "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"acorn-import-phases": ["acorn-import-phases@1.0.4", "", { "peerDependencies": { "acorn": "^8.14.0" } }, "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ=="],
|
||||
|
||||
"ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
|
||||
|
||||
"ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="],
|
||||
|
||||
"ajv-keywords": ["ajv-keywords@3.5.2", "", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="],
|
||||
|
||||
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||
|
||||
"aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
|
||||
|
|
@ -604,10 +657,14 @@
|
|||
|
||||
"better-call": ["better-call@1.1.8", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw=="],
|
||||
|
||||
"big.js": ["big.js@5.2.2", "", {}, "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ=="],
|
||||
|
||||
"boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
|
||||
|
||||
"botid": ["botid@1.4.2", "", { "peerDependencies": { "next": "*", "react": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["next"] }, "sha512-yiRWEdxXa5QhxzJW4lTk0lRZkbqPsVWdGrhnHLLihZf0xBEtsTUGtxLqK++IY80FX/Ye/rNMnGqBp2pl4yYU8w=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
|
||||
|
|
@ -626,6 +683,8 @@
|
|||
|
||||
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
||||
|
||||
"chrome-trace-event": ["chrome-trace-event@1.0.4", "", {}, "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ=="],
|
||||
|
||||
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||
|
||||
"classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="],
|
||||
|
|
@ -638,6 +697,8 @@
|
|||
|
||||
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
|
||||
|
||||
"commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
|
||||
|
||||
"country-flag-icons": ["country-flag-icons@1.5.19", "", {}, "sha512-D/ZkRyj+ywJC6b2IrAN3/tpbReMUqmuRLlcKFoY/o0+EPQN9Ev/e8tV+D3+9scvu/tarxwLErNwS73C3yzxs/g=="],
|
||||
|
||||
"cross-env": ["cross-env@7.0.3", "", { "dependencies": { "cross-spawn": "^7.0.1" }, "bin": { "cross-env": "src/bin/cross-env.js", "cross-env-shell": "src/bin/cross-env-shell.js" } }, "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw=="],
|
||||
|
|
@ -708,12 +769,16 @@
|
|||
|
||||
"drizzle-orm": ["drizzle-orm@0.44.3", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-8nIiYQxOpgUicEL04YFojJmvC4DNO4KoyXsEIqN44+g6gNBr6hmVpWk3uyAt4CaTiRGDwoU+alfqNNeonLAFOQ=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.302", "", {}, "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg=="],
|
||||
|
||||
"embla-carousel": ["embla-carousel@8.6.0", "", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="],
|
||||
|
||||
"embla-carousel-react": ["embla-carousel-react@8.6.0", "", { "dependencies": { "embla-carousel": "8.6.0", "embla-carousel-reactive-utils": "8.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA=="],
|
||||
|
||||
"embla-carousel-reactive-utils": ["embla-carousel-reactive-utils@8.6.0", "", { "peerDependencies": { "embla-carousel": "8.6.0" } }, "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A=="],
|
||||
|
||||
"emojis-list": ["emojis-list@3.0.0", "", {}, "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.18.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ=="],
|
||||
|
||||
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||
|
|
@ -722,22 +787,40 @@
|
|||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="],
|
||||
|
||||
"es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.8", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.8", "@esbuild/android-arm": "0.25.8", "@esbuild/android-arm64": "0.25.8", "@esbuild/android-x64": "0.25.8", "@esbuild/darwin-arm64": "0.25.8", "@esbuild/darwin-x64": "0.25.8", "@esbuild/freebsd-arm64": "0.25.8", "@esbuild/freebsd-x64": "0.25.8", "@esbuild/linux-arm": "0.25.8", "@esbuild/linux-arm64": "0.25.8", "@esbuild/linux-ia32": "0.25.8", "@esbuild/linux-loong64": "0.25.8", "@esbuild/linux-mips64el": "0.25.8", "@esbuild/linux-ppc64": "0.25.8", "@esbuild/linux-riscv64": "0.25.8", "@esbuild/linux-s390x": "0.25.8", "@esbuild/linux-x64": "0.25.8", "@esbuild/netbsd-arm64": "0.25.8", "@esbuild/netbsd-x64": "0.25.8", "@esbuild/openbsd-arm64": "0.25.8", "@esbuild/openbsd-x64": "0.25.8", "@esbuild/openharmony-arm64": "0.25.8", "@esbuild/sunos-x64": "0.25.8", "@esbuild/win32-arm64": "0.25.8", "@esbuild/win32-ia32": "0.25.8", "@esbuild/win32-x64": "0.25.8" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q=="],
|
||||
|
||||
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="],
|
||||
|
||||
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
|
||||
|
||||
"estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="],
|
||||
|
||||
"estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
|
||||
|
||||
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
|
||||
|
||||
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
|
||||
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-equals": ["fast-equals@5.2.2", "", {}, "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw=="],
|
||||
|
||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
|
||||
|
||||
"feed": ["feed@5.1.0", "", { "dependencies": { "xml-js": "^1.6.11" } }, "sha512-qGNhgYygnefSkAHHrNHqC7p3R8J0/xQDS/cYUud8er/qD9EFGWyCdUDfULHTJQN1d3H3WprzVwMc9MfB4J50Wg=="],
|
||||
|
||||
"flatbuffers": ["flatbuffers@25.9.23", "", {}, "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ=="],
|
||||
|
|
@ -752,6 +835,8 @@
|
|||
|
||||
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
|
||||
|
||||
"glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="],
|
||||
|
||||
"global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="],
|
||||
|
||||
"globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
|
||||
|
|
@ -762,6 +847,8 @@
|
|||
|
||||
"guid-typescript": ["guid-typescript@1.0.9", "", {}, "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
|
||||
|
||||
"hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="],
|
||||
|
|
@ -810,14 +897,22 @@
|
|||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jest-worker": ["jest-worker@27.5.1", "", { "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg=="],
|
||||
|
||||
"jiti": ["jiti@2.5.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w=="],
|
||||
|
||||
"jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
|
||||
"json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"kysely": ["kysely@0.28.9", "", {}, "sha512-3BeXMoiOhpOwu62CiVpO6lxfq4eS6KMYfQdMsN/2kUCRNuF2YiEr7u0HLHaQU+O4Xu8YXE3bHVkwaQ85i72EuA=="],
|
||||
|
||||
"libphonenumber-js": ["libphonenumber-js@1.12.10", "", {}, "sha512-E91vHJD61jekHHR/RF/E83T/CMoaLXT7cwYA75T4gim4FZjnM6hbJjVIGg7chqlSqRsSvQ3izGmOjHy1SQzcGQ=="],
|
||||
|
|
@ -844,6 +939,10 @@
|
|||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="],
|
||||
|
||||
"loader-runner": ["loader-runner@4.3.1", "", {}, "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q=="],
|
||||
|
||||
"loader-utils": ["loader-utils@2.0.4", "", { "dependencies": { "big.js": "^5.2.2", "emojis-list": "^3.0.0", "json5": "^2.1.2" } }, "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw=="],
|
||||
|
||||
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
||||
|
||||
"lodash.castarray": ["lodash.castarray@4.4.0", "", {}, "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q=="],
|
||||
|
|
@ -882,6 +981,8 @@
|
|||
|
||||
"mediabunny": ["mediabunny@1.29.1", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-RrlKs69MxRGa/l9cMGeI4hzuTSgchOGFHk9lIAuu9EcbSdJ05gDbRsTY67dojAiyUP3ic4PExij5OqDxqSv82Q=="],
|
||||
|
||||
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
|
||||
|
||||
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
|
||||
|
||||
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
|
||||
|
|
@ -924,6 +1025,10 @@
|
|||
|
||||
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
|
||||
|
||||
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
|
||||
|
||||
"minizlib": ["minizlib@3.0.2", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA=="],
|
||||
|
|
@ -942,10 +1047,14 @@
|
|||
|
||||
"nanostores": ["nanostores@1.1.0", "", {}, "sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA=="],
|
||||
|
||||
"neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="],
|
||||
|
||||
"next": ["next@16.1.3", "", { "dependencies": { "@next/env": "16.1.3", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.3", "@next/swc-darwin-x64": "16.1.3", "@next/swc-linux-arm64-gnu": "16.1.3", "@next/swc-linux-arm64-musl": "16.1.3", "@next/swc-linux-x64-gnu": "16.1.3", "@next/swc-linux-x64-musl": "16.1.3", "@next/swc-win32-arm64-msvc": "16.1.3", "@next/swc-win32-x64-msvc": "16.1.3", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-gthG3TRD+E3/mA0uDQb9lqBmx1zVosq5kIwxNN6+MRNd085GzD+9VXMPUs+GGZCbZ+GDZdODUq4Pm7CTXK6ipw=="],
|
||||
|
||||
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
|
||||
|
|
@ -1002,10 +1111,16 @@
|
|||
|
||||
"protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"radix-ui": ["radix-ui@1.4.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="],
|
||||
|
||||
"raf-schd": ["raf-schd@4.0.3", "", {}, "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ=="],
|
||||
|
||||
"randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="],
|
||||
|
||||
"raw-loader": ["raw-loader@4.0.2", "", { "dependencies": { "loader-utils": "^2.0.0", "schema-utils": "^3.0.0" }, "peerDependencies": { "webpack": "^4.0.0 || ^5.0.0" } }, "sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA=="],
|
||||
|
||||
"react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="],
|
||||
|
||||
"react-day-picker": ["react-day-picker@8.10.1", "", { "peerDependencies": { "date-fns": "^2.28.0 || ^3.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA=="],
|
||||
|
|
@ -1058,22 +1173,30 @@
|
|||
|
||||
"remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="],
|
||||
|
||||
"rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"sax": ["sax@1.4.1", "", {}, "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"schema-utils": ["schema-utils@3.3.0", "", { "dependencies": { "@types/json-schema": "^7.0.8", "ajv": "^6.12.5", "ajv-keywords": "^3.5.2" } }, "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg=="],
|
||||
|
||||
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="],
|
||||
|
||||
"serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="],
|
||||
|
||||
"serialize-javascript": ["serialize-javascript@6.0.2", "", { "dependencies": { "randombytes": "^2.1.0" } }, "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g=="],
|
||||
|
||||
"set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="],
|
||||
|
||||
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
||||
|
|
@ -1104,16 +1227,22 @@
|
|||
|
||||
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
|
||||
|
||||
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@2.6.0", "", {}, "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="],
|
||||
|
||||
"tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="],
|
||||
|
||||
"tapable": ["tapable@2.2.2", "", {}, "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg=="],
|
||||
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||
|
||||
"tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="],
|
||||
|
||||
"terser": ["terser@5.46.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg=="],
|
||||
|
||||
"terser-webpack-plugin": ["terser-webpack-plugin@5.3.16", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", "serialize-javascript": "^6.0.2", "terser": "^5.31.1" }, "peerDependencies": { "webpack": "^5.1.0" } }, "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q=="],
|
||||
|
||||
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||
|
||||
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
|
||||
|
|
@ -1158,6 +1287,10 @@
|
|||
|
||||
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
|
||||
|
||||
"use-deep-compare-effect": ["use-deep-compare-effect@1.8.1", "", { "dependencies": { "@babel/runtime": "^7.12.5", "dequal": "^2.0.2" }, "peerDependencies": { "react": ">=16.13" } }, "sha512-kbeNVZ9Zkc0RFGpfMN3MNfaKNvcLNyxOAAd9O4CBZ+kCBXXscn9s/4I+8ytUER4RDpEYs5+O6Rs4PqiZ+rHr5Q=="],
|
||||
|
|
@ -1178,10 +1311,16 @@
|
|||
|
||||
"victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="],
|
||||
|
||||
"watchpack": ["watchpack@2.5.1", "", { "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" } }, "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg=="],
|
||||
|
||||
"wavesurfer.js": ["wavesurfer.js@7.10.0", "", {}, "sha512-GiyAHdorqGtUYG5fe4BfTf5lmtSLhrXoHeNlMsR80JOiOZxOrIOv9QaIR8RnqlleJ6D8R9cqvZKR9lfJcWcapg=="],
|
||||
|
||||
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
|
||||
|
||||
"webpack": ["webpack@5.105.3", "", { "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.16.0", "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.19.0", "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", "terser-webpack-plugin": "^5.3.16", "watchpack": "^2.5.1", "webpack-sources": "^3.3.4" }, "bin": { "webpack": "bin/webpack.js" } }, "sha512-LLBBA4oLmT7sZdHiYE/PeVuifOxYyE2uL/V+9VQP7YSYdJU7bSf7H8bZRRxW8kEPMkmVjnrXmoR3oejIdX0xbg=="],
|
||||
|
||||
"webpack-sources": ["webpack-sources@3.3.4", "", {}, "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"xml-js": ["xml-js@1.6.11", "", { "dependencies": { "sax": "^1.2.4" }, "bin": { "xml-js": "./bin/cli.js" } }, "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g=="],
|
||||
|
|
@ -1310,12 +1449,20 @@
|
|||
|
||||
"@upstash/core-analytics/@upstash/redis": ["@upstash/redis@1.35.1", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-sIMuAMU9IYbE2bkgDby8KLoQKRiBMXn0moXxqLvUmQ7VUu2CvulZLtK8O0x3WQZFvvZhU5sRC2/lOVZdGfudkA=="],
|
||||
|
||||
"ajv-formats/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
|
||||
|
||||
"browserslist/caniuse-lite": ["caniuse-lite@1.0.30001774", "", {}, "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA=="],
|
||||
|
||||
"cmdk/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw=="],
|
||||
|
||||
"cmdk/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"dom-helpers/csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
|
||||
"enhanced-resolve/tapable": ["tapable@2.2.2", "", {}, "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg=="],
|
||||
|
||||
"esrecurse/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
|
||||
|
||||
"lightningcss/detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="],
|
||||
|
||||
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
|
|
@ -1338,8 +1485,14 @@
|
|||
|
||||
"recharts/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
|
||||
|
||||
"terser-webpack-plugin/schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="],
|
||||
|
||||
"vaul/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw=="],
|
||||
|
||||
"webpack/enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
|
||||
|
||||
"webpack/schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
|
||||
|
|
@ -1450,6 +1603,8 @@
|
|||
|
||||
"@types/pg/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"cmdk/@radix-ui/react-dialog/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
|
||||
|
||||
"cmdk/@radix-ui/react-dialog/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="],
|
||||
|
|
@ -1464,6 +1619,10 @@
|
|||
|
||||
"next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"terser-webpack-plugin/schema-utils/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
|
||||
|
||||
"terser-webpack-plugin/schema-utils/ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "ajv": "^8.8.2" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="],
|
||||
|
||||
"vaul/@radix-ui/react-dialog/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
|
||||
|
||||
"vaul/@radix-ui/react-dialog/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="],
|
||||
|
|
@ -1475,5 +1634,13 @@
|
|||
"vaul/@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"vaul/@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"webpack/schema-utils/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
|
||||
|
||||
"webpack/schema-utils/ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "ajv": "^8.8.2" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="],
|
||||
|
||||
"terser-webpack-plugin/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"webpack/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
62
package.json
62
package.json
|
|
@ -1,33 +1,33 @@
|
|||
{
|
||||
"name": "opencut",
|
||||
"packageManager": "bun@1.2.18",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev:web": "turbo run dev --filter=@opencut/web",
|
||||
"build:web": "turbo run build --filter=@opencut/web",
|
||||
"dev:tools": "turbo run dev --filter=@opencut/tools",
|
||||
"build:tools": "turbo run build --filter=@opencut/tools",
|
||||
"start:tools": "turbo run start --filter=@opencut/tools",
|
||||
"lint:web": "biome lint apps/web/src --max-diagnostics=1000",
|
||||
"lint:web:fix": "biome lint apps/web/src --write --max-diagnostics=1000",
|
||||
"format:web": "biome format apps/web/src/services/renderer --write --max-diagnostics=1000",
|
||||
"test": "bun test",
|
||||
"generate:fonts": "npx tsx apps/web/scripts/generate-font-sprites.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/react": "^19.2.10",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"better-auth": "^1.4.15",
|
||||
"next": "^16.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"turbo": "^2.7.5",
|
||||
"typescript": "5.8.3"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@tailwindcss/oxide"
|
||||
]
|
||||
"name": "opencut",
|
||||
"packageManager": "bun@1.2.18",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev:web": "turbo run dev --filter=@opencut/web",
|
||||
"build:web": "turbo run build --filter=@opencut/web",
|
||||
"dev:tools": "turbo run dev --filter=@opencut/tools",
|
||||
"build:tools": "turbo run build --filter=@opencut/tools",
|
||||
"start:tools": "turbo run start --filter=@opencut/tools",
|
||||
"lint:web": "biome lint apps/web/src --max-diagnostics=1000",
|
||||
"lint:web:fix": "biome lint apps/web/src --write --max-diagnostics=1000",
|
||||
"format:web": "biome format apps/web/src/services/renderer --write --max-diagnostics=1000",
|
||||
"test": "bun test",
|
||||
"generate:fonts": "npx tsx apps/web/scripts/generate-font-sprites.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/react": "^19.2.10",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"better-auth": "^1.4.15",
|
||||
"next": "^16.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"turbo": "^2.7.5",
|
||||
"typescript": "5.8.3"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@tailwindcss/oxide"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue