mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
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:
@@ -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",
|
||||
},
|
||||
|
||||
+106
-105
@@ -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;
|
||||
}
|
||||
Vendored
+4
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user