feat: introduce WebGL effects system and Blur effect

This implements the foundational architecture for video effects, starting with a multi-pass WebGL rendering pipeline and a customizable Gaussian Blur effect.

Key changes:
- WebGL Engine: Added `raw-loader` for `.glsl` shaders, multi-pass framebuffer rendering, and live offscreen canvas previews.
- Node Architecture: Replaced hardcoded background blur with `CompositeEffectNode` and added `EffectLayerNode` to apply effects to specific visual elements.
- Timeline & DND: Added a new `effect` track type. Upgraded drag-and-drop to support dropping effects directly onto the timeline. Consolidated track constants into a cleaner `TRACK_CONFIG`.
- UI/UX: Added an Effects tab in the assets panel with live previews. Added an Effect Properties panel with sliders and inputs for fine-tuning parameters.
- Data Model: Added `sourceDuration` to video and audio elements, and wrote a v8 storage migration to update existing projects to the new schema.
- Docs: Added `CHANGELOG.md` tracking v0.1.0 and v0.2.0, plus `docs/effects-renderer.md` to document the new WebGL pipeline.
This commit is contained in:
Maze Winther
2026-02-28 18:41:22 +01:00
parent a9e93471a7
commit 216e3e0c39
55 changed files with 2510 additions and 537 deletions
+8
View File
@@ -2,6 +2,14 @@ import type { NextConfig } from "next";
import { withBotId } from "botid/next/config";
const nextConfig: NextConfig = {
turbopack: {
rules: {
"*.glsl": {
loaders: [require.resolve("raw-loader")],
as: "*.js",
},
},
},
compiler: {
removeConsole: process.env.NODE_ENV === "production",
},
+106 -105
View File
@@ -1,107 +1,108 @@
{
"name": "@opencut/web",
"version": "0.1.0",
"private": true,
"packageManager": "bun@1.2.18",
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "biome check src/",
"lint:fix": "biome check src/ --write",
"format": "biome format src/ --write",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push:local": "cross-env NODE_ENV=development drizzle-kit push",
"db:push:prod": "cross-env NODE_ENV=production drizzle-kit push"
},
"dependencies": {
"@ffmpeg/core": "^0.12.10",
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
"@hello-pangea/dnd": "^18.0.1",
"@hookform/resolvers": "^3.9.1",
"@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react": "^1.1.4",
"@huggingface/transformers": "^3.8.1",
"@opencut/env": "workspace:*",
"@opencut/ui": "workspace:*",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-primitive": "^2.1.4",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@types/culori": "^4.0.1",
"@upstash/ratelimit": "^2.0.6",
"@upstash/redis": "^1.35.4",
"@vercel/analytics": "^1.4.1",
"aws4fetch": "^1.0.20",
"better-auth": "^1.2.7",
"botid": "^1.4.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"culori": "^4.0.2",
"dayjs": "^1.11.13",
"drizzle-orm": "^0.44.2",
"embla-carousel-react": "^8.5.1",
"eventemitter3": "^5.0.1",
"feed": "^5.1.0",
"input-otp": "^1.4.1",
"lucide-react": "^0.562.0",
"mediabunny": "^1.29.1",
"motion": "^12.18.1",
"nanoid": "^5.1.5",
"next": "16.1.3",
"next-themes": "^0.4.4",
"pg": "^8.16.2",
"postgres": "^3.4.5",
"radix-ui": "^1.4.3",
"react": "^19.0.0",
"react-day-picker": "^8.10.1",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.0",
"react-icons": "^5.4.0",
"react-markdown": "^10.1.0",
"react-phone-number-input": "^3.4.11",
"react-resizable-panels": "^2.1.7",
"react-window": "^2.2.7",
"recharts": "^2.14.1",
"rehype-autolink-headings": "^7.1.0",
"rehype-parse": "^9.0.1",
"rehype-sanitize": "^6.0.0",
"rehype-slug": "^6.0.0",
"rehype-stringify": "^10.0.1",
"sonner": "^1.7.1",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"unified": "^11.0.5",
"use-deep-compare-effect": "^1.8.1",
"vaul": "^1.1.2",
"wavesurfer.js": "^7.9.8",
"zod": "^3.25.67",
"zustand": "^5.0.2"
},
"devDependencies": {
"@napi-rs/canvas": "^0.1.92",
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/typography": "^0.5.16",
"@types/bun": "latest",
"@types/node": "^24.2.1",
"@types/pg": "^8.15.4",
"@types/react": "^19",
"@types/react-dom": "^19",
"cross-env": "^7.0.3",
"dotenv": "^16.5.0",
"drizzle-kit": "^0.31.4",
"postcss": "^8",
"sharp": "^0.34.5",
"tailwindcss": "^4.1.11",
"tsx": "^4.7.1",
"typescript": "^5.8.3"
}
"name": "@opencut/web",
"version": "0.1.0",
"private": true,
"packageManager": "bun@1.2.18",
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "biome check src/",
"lint:fix": "biome check src/ --write",
"format": "biome format src/ --write",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push:local": "cross-env NODE_ENV=development drizzle-kit push",
"db:push:prod": "cross-env NODE_ENV=production drizzle-kit push"
},
"dependencies": {
"@ffmpeg/core": "^0.12.10",
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
"@hello-pangea/dnd": "^18.0.1",
"@hookform/resolvers": "^3.9.1",
"@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react": "^1.1.4",
"@huggingface/transformers": "^3.8.1",
"@opencut/env": "workspace:*",
"@opencut/ui": "workspace:*",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-primitive": "^2.1.4",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@types/culori": "^4.0.1",
"@upstash/ratelimit": "^2.0.6",
"@upstash/redis": "^1.35.4",
"@vercel/analytics": "^1.4.1",
"aws4fetch": "^1.0.20",
"better-auth": "^1.2.7",
"botid": "^1.4.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"culori": "^4.0.2",
"dayjs": "^1.11.13",
"drizzle-orm": "^0.44.2",
"embla-carousel-react": "^8.5.1",
"eventemitter3": "^5.0.1",
"feed": "^5.1.0",
"input-otp": "^1.4.1",
"lucide-react": "^0.562.0",
"mediabunny": "^1.29.1",
"motion": "^12.18.1",
"nanoid": "^5.1.5",
"next": "16.1.3",
"next-themes": "^0.4.4",
"pg": "^8.16.2",
"postgres": "^3.4.5",
"radix-ui": "^1.4.3",
"react": "^19.0.0",
"react-day-picker": "^8.10.1",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.0",
"react-icons": "^5.4.0",
"react-markdown": "^10.1.0",
"react-phone-number-input": "^3.4.11",
"react-resizable-panels": "^2.1.7",
"react-window": "^2.2.7",
"recharts": "^2.14.1",
"rehype-autolink-headings": "^7.1.0",
"rehype-parse": "^9.0.1",
"rehype-sanitize": "^6.0.0",
"rehype-slug": "^6.0.0",
"rehype-stringify": "^10.0.1",
"sonner": "^1.7.1",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"unified": "^11.0.5",
"use-deep-compare-effect": "^1.8.1",
"vaul": "^1.1.2",
"wavesurfer.js": "^7.9.8",
"zod": "^3.25.67",
"zustand": "^5.0.2"
},
"devDependencies": {
"@napi-rs/canvas": "^0.1.92",
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/typography": "^0.5.16",
"@types/bun": "latest",
"@types/node": "^24.2.1",
"@types/pg": "^8.15.4",
"@types/react": "^19",
"@types/react-dom": "^19",
"cross-env": "^7.0.3",
"dotenv": "^16.5.0",
"drizzle-kit": "^0.31.4",
"postcss": "^8",
"raw-loader": "^4.0.2",
"sharp": "^0.34.5",
"tailwindcss": "^4.1.11",
"tsx": "^4.7.1",
"typescript": "^5.8.3"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

@@ -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}
/>
);
})
+1 -1
View File
@@ -40,7 +40,7 @@ const selectTriggerVariants = cva(
},
size: {
default: "",
sm: "",
sm: "rounded-sm",
},
},
defaultVariants: {
+3 -1
View File
@@ -7,7 +7,9 @@ import { cn } from "@/utils/ui";
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> & {
className?: string;
}
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
+50 -32
View File
@@ -3,6 +3,7 @@ import type { BlendMode } from "@/types/rendering";
import type { TrackType, Transform } from "@/types/timeline";
import {
Happy01Icon,
MagicWand05Icon,
MusicNote03Icon,
TextIcon,
} from "@hugeicons/core-free-icons";
@@ -19,26 +20,65 @@ export const DEFAULT_OPACITY = 1;
export const DEFAULT_BLEND_MODE: BlendMode = "normal";
export const DEFAULT_BOOKMARK_COLOR = "#009dff";
export const TRACK_COLORS: Record<TrackType, { background: string }> = {
export const TRACK_CONFIG: Record<
TrackType,
{
background: string;
height: number;
defaultName: string;
icon: React.ReactNode;
}
> = {
video: {
background: "transparent",
height: 60,
defaultName: "Video track",
icon: <OcVideoIcon className="text-muted-foreground size-4 shrink-0" />,
},
text: {
background: "bg-[#5DBAA0]",
height: 25,
defaultName: "Text track",
icon: (
<HugeiconsIcon
icon={TextIcon}
className="text-muted-foreground size-4 shrink-0"
/>
),
},
audio: {
background: "bg-[#915DBE]",
background: "bg-[#8F5DBA]",
height: 50,
defaultName: "Audio track",
icon: (
<HugeiconsIcon
icon={MusicNote03Icon}
className="text-muted-foreground size-4 shrink-0"
/>
),
},
sticker: {
background: "bg-amber-500",
background: "bg-[#BA5D7A]",
height: 50,
defaultName: "Sticker track",
icon: (
<HugeiconsIcon
icon={Happy01Icon}
className="text-muted-foreground size-4 shrink-0"
/>
),
},
effect: {
background: "bg-[#5d93ba]",
height: 25,
defaultName: "Effect track",
icon: (
<HugeiconsIcon
icon={MagicWand05Icon}
className="text-muted-foreground size-4 shrink-0"
/>
),
},
} as const;
export const TRACK_HEIGHTS: Record<TrackType, number> = {
video: 60,
text: 25,
audio: 50,
sticker: 50,
} as const;
export const TRACK_GAP = 4;
@@ -60,25 +100,3 @@ export const DEFAULT_TIMELINE_VIEW_STATE: TTimelineViewState = {
scrollLeft: 0,
playheadTime: 0,
};
export const TRACK_ICONS: Record<TrackType, React.ReactNode> = {
video: <OcVideoIcon className="text-muted-foreground size-4 shrink-0" />,
text: (
<HugeiconsIcon
icon={TextIcon}
className="text-muted-foreground size-4 shrink-0"
/>
),
audio: (
<HugeiconsIcon
icon={MusicNote03Icon}
className="text-muted-foreground size-4 shrink-0"
/>
),
sticker: (
<HugeiconsIcon
icon={Happy01Icon}
className="text-muted-foreground size-4 shrink-0"
/>
),
} as const;
+2
View File
@@ -8,6 +8,7 @@ import { CommandManager } from "./managers/commands";
import { SaveManager } from "./managers/save-manager";
import { AudioManager } from "./managers/audio-manager";
import { SelectionManager } from "./managers/selection-manager";
import { registerDefaultEffects } from "@/lib/effects";
export class EditorCore {
private static instance: EditorCore | null = null;
@@ -24,6 +25,7 @@ export class EditorCore {
public readonly selection: SelectionManager;
private constructor() {
registerDefaultEffects();
this.command = new CommandManager();
this.playback = new PlaybackManager(this);
this.timeline = new TimelineManager(this);
@@ -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,
+47
View File
@@ -0,0 +1,47 @@
import { useEffect, useRef } from "react";
import { effectPreviewService } from "@/services/renderer/effect-preview";
import type { EffectParamValues } from "@/types/effects";
export function useEffectPreview({
effectType,
params,
canvasRef,
isActive,
}: {
effectType: string;
params: EffectParamValues;
canvasRef: React.RefObject<HTMLCanvasElement | null>;
isActive: boolean;
}): void {
const requestRef = useRef<number>(0);
useEffect(() => {
if (!isActive) {
if (requestRef.current) {
cancelAnimationFrame(requestRef.current);
requestRef.current = 0;
}
return;
}
const loop = (): void => {
const canvas = canvasRef.current;
if (canvas) {
effectPreviewService.renderPreview({
effectType,
params,
targetCanvas: canvas,
});
}
requestRef.current = requestAnimationFrame(loop);
};
requestRef.current = requestAnimationFrame(loop);
return () => {
if (requestRef.current) {
cancelAnimationFrame(requestRef.current);
}
};
}, [effectType, params, canvasRef, isActive]);
}
@@ -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);
}
+34
View File
@@ -0,0 +1,34 @@
import { generateUUID } from "@/utils/id";
import { getEffect } from "./registry";
import type { Effect, EffectParamValues } from "@/types/effects";
import type { VisualElement } from "@/types/timeline";
export { getEffect, getAllEffects, hasEffect, registerEffect } from "./registry";
export { registerDefaultEffects } from "./definitions";
export const EFFECT_TARGET_ELEMENT_TYPES: VisualElement["type"][] = [
"video",
"image",
"text",
"sticker",
];
export function buildDefaultEffectInstance({
effectType,
}: {
effectType: string;
}): Effect {
const definition = getEffect({ effectType });
const params: EffectParamValues = {};
for (const paramDef of definition.params) {
params[paramDef.key] = paramDef.default;
}
return {
id: generateUUID(),
type: effectType,
params,
enabled: true,
};
}
+31
View File
@@ -0,0 +1,31 @@
import type { EffectDefinition } from "@/types/effects";
const effectDefinitions = new Map<string, EffectDefinition>();
export function registerEffect({
definition,
}: {
definition: EffectDefinition;
}): void {
effectDefinitions.set(definition.type, definition);
}
export function hasEffect({ effectType }: { effectType: string }): boolean {
return effectDefinitions.has(effectType);
}
export function getEffect({
effectType,
}: {
effectType: string;
}): EffectDefinition {
const definition = effectDefinitions.get(effectType);
if (!definition) {
throw new Error(`Unknown effect type: ${effectType}`);
}
return definition;
}
export function getAllEffects(): EffectDefinition[] {
return Array.from(effectDefinitions.values());
}
+1 -1
View File
@@ -70,7 +70,7 @@ export function getElementBounds({
mediaAsset?: MediaAsset | null;
localTime: number;
}): ElementBounds | null {
if (element.type === "audio") return null;
if (element.type === "audio" || element.type === "effect") return null;
if ("hidden" in element && element.hidden) return null;
const { width: canvasWidth, height: canvasHeight } = canvasSize;
+79 -6
View File
@@ -1,9 +1,42 @@
import type { TimelineTrack, ElementType } from "@/types/timeline";
import { TRACK_HEIGHTS, TRACK_GAP } from "@/constants/timeline-constants";
import type {
TimelineTrack,
ElementType,
TimelineElement,
} from "@/types/timeline";
import { TRACK_CONFIG, TRACK_GAP } from "@/constants/timeline-constants";
import { wouldElementOverlap } from "./element-utils";
import type { ComputeDropTargetParams, DropTarget } from "@/types/timeline";
import { isMainTrack, enforceMainTrackStart } from "./track-utils";
function findElementAtPosition({
mouseX,
tracks,
trackIndex,
targetElementTypes,
pixelsPerSecond,
zoomLevel,
}: {
mouseX: number;
tracks: TimelineTrack[];
trackIndex: number;
targetElementTypes: string[];
pixelsPerSecond: number;
zoomLevel: number;
}): { elementId: string; trackId: string } | null {
const time = mouseX / (pixelsPerSecond * zoomLevel);
const track = tracks[trackIndex];
if (!track || !("elements" in track)) return null;
const hit = track.elements.find(
(element: TimelineElement) =>
targetElementTypes.includes(element.type) &&
element.startTime <= time &&
time < element.startTime + element.duration,
);
if (!hit) return null;
return { elementId: hit.id, trackId: track.id };
}
function getTrackAtY({
mouseY,
tracks,
@@ -16,7 +49,7 @@ function getTrackAtY({
let cumulativeHeight = 0;
for (let i = 0; i < tracks.length; i++) {
const trackHeight = TRACK_HEIGHTS[tracks[i].type];
const trackHeight = TRACK_CONFIG[tracks[i].type].height;
const trackTop = cumulativeHeight;
const trackBottom = trackTop + trackHeight;
@@ -55,6 +88,7 @@ function isCompatible({
if (elementType === "text") return trackType === "text";
if (elementType === "audio") return trackType === "audio";
if (elementType === "sticker") return trackType === "sticker";
if (elementType === "effect") return trackType === "effect";
if (elementType === "video" || elementType === "image") {
return trackType === "video";
}
@@ -100,6 +134,8 @@ function findInsertIndex({
};
}
const EMPTY_TARGET_ELEMENT = null;
export function computeDropTarget({
elementType,
mouseX,
@@ -113,6 +149,7 @@ export function computeDropTarget({
verticalDragDirection,
startTimeOverride,
excludeElementId,
targetElementTypes,
}: ComputeDropTargetParams): DropTarget {
const xPosition =
typeof startTimeOverride === "number"
@@ -130,9 +167,16 @@ export function computeDropTarget({
isNewTrack: true,
insertPosition: "below",
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
}
return { trackIndex: 0, isNewTrack: true, insertPosition: null, xPosition };
return {
trackIndex: 0,
isNewTrack: true,
insertPosition: null,
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
}
const trackAtMouse = getTrackAtY({ mouseY, tracks, verticalDragDirection });
@@ -146,6 +190,7 @@ export function computeDropTarget({
isNewTrack: true,
insertPosition: "below",
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
}
@@ -155,6 +200,7 @@ export function computeDropTarget({
isNewTrack: true,
insertPosition: "above",
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
}
@@ -163,12 +209,37 @@ export function computeDropTarget({
isNewTrack: true,
insertPosition: "above",
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
}
const { trackIndex, relativeY } = trackAtMouse;
const track = tracks[trackIndex];
const trackHeight = TRACK_HEIGHTS[track.type];
if (
targetElementTypes &&
targetElementTypes.length > 0
) {
const targetElement = findElementAtPosition({
mouseX,
tracks,
trackIndex,
targetElementTypes,
pixelsPerSecond,
zoomLevel,
});
if (targetElement) {
return {
trackIndex,
isNewTrack: false,
insertPosition: null,
xPosition,
targetElement,
};
}
}
const trackHeight = TRACK_CONFIG[track.type].height;
const isInUpperHalf = relativeY < trackHeight / 2;
const isTrackCompatible = isCompatible({
@@ -200,6 +271,7 @@ export function computeDropTarget({
isNewTrack: false,
insertPosition: null,
xPosition: adjustedXPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
}
@@ -220,6 +292,7 @@ export function computeDropTarget({
isNewTrack: true,
insertPosition: position,
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
}
@@ -237,7 +310,7 @@ export function getDropLineY({
let y = 0;
for (let i = 0; i < safeTrackIndex; i++) {
y += TRACK_HEIGHTS[tracks[i].type] + TRACK_GAP;
y += TRACK_CONFIG[tracks[i].type].height + TRACK_GAP;
}
return y;
+35 -7
View File
@@ -6,6 +6,7 @@ import {
TIMELINE_CONSTANTS,
} from "@/constants/timeline-constants";
import type {
CreateEffectElement,
CreateTimelineElement,
CreateVideoElement,
CreateImageElement,
@@ -22,6 +23,8 @@ import type {
UploadAudioElement,
} from "@/types/timeline";
import type { MediaType } from "@/types/assets";
import { buildDefaultEffectInstance } from "@/lib/effects";
import { capitalizeFirstLetter } from "@/utils/string";
export function canElementHaveAudio(
element: TimelineElement,
@@ -155,13 +158,13 @@ export function buildTextElement({
fontFamily: t.fontFamily ?? DEFAULT_TEXT_ELEMENT.fontFamily,
color: t.color ?? DEFAULT_TEXT_ELEMENT.color,
background: {
color: t.background?.color ?? DEFAULT_TEXT_ELEMENT.background.color,
cornerRadius: t.background?.cornerRadius,
paddingX: t.background?.paddingX,
paddingY: t.background?.paddingY,
offsetX: t.background?.offsetX,
offsetY: t.background?.offsetY,
},
color: t.background?.color ?? DEFAULT_TEXT_ELEMENT.background.color,
cornerRadius: t.background?.cornerRadius,
paddingX: t.background?.paddingX,
paddingY: t.background?.paddingY,
offsetX: t.background?.offsetX,
offsetY: t.background?.offsetY,
},
textAlign: t.textAlign ?? DEFAULT_TEXT_ELEMENT.textAlign,
fontWeight: t.fontWeight ?? DEFAULT_TEXT_ELEMENT.fontWeight,
fontStyle: t.fontStyle ?? DEFAULT_TEXT_ELEMENT.fontStyle,
@@ -174,6 +177,28 @@ export function buildTextElement({
};
}
export function buildEffectElement({
effectType,
startTime,
duration,
}: {
effectType: string;
startTime: number;
duration?: number;
}): CreateEffectElement {
const instance = buildDefaultEffectInstance({ effectType });
return {
type: "effect",
name: capitalizeFirstLetter({ string: instance.type }),
effectType,
params: instance.params,
duration: duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
startTime,
trimStart: 0,
trimEnd: 0,
};
}
export function buildStickerElement({
stickerId,
name,
@@ -218,6 +243,7 @@ export function buildVideoElement({
startTime,
trimStart: 0,
trimEnd: 0,
sourceDuration: duration,
muted: false,
hidden: false,
transform: { ...DEFAULT_TRANSFORM },
@@ -274,6 +300,7 @@ export function buildUploadAudioElement({
startTime,
trimStart: 0,
trimEnd: 0,
sourceDuration: duration,
volume: 1,
muted: false,
};
@@ -336,6 +363,7 @@ export function buildLibraryAudioElement({
startTime,
trimStart: 0,
trimEnd: 0,
sourceDuration: duration,
volume: 1,
muted: false,
};
+20 -18
View File
@@ -6,11 +6,11 @@ import type {
AudioTrack,
StickerTrack,
TextTrack,
EffectTrack,
TimelineElement,
} from "@/types/timeline";
import {
TRACK_COLORS,
TRACK_HEIGHTS,
TRACK_CONFIG,
TRACK_GAP,
} from "@/constants/timeline-constants";
import { generateUUID } from "@/utils/id";
@@ -23,21 +23,20 @@ export function canTracktHaveAudio(
export function canTrackBeHidden(
track: TimelineTrack,
): track is VideoTrack | TextTrack | StickerTrack {
): track is VideoTrack | TextTrack | StickerTrack | EffectTrack {
return track.type !== "audio";
}
export function getTrackColor({ type }: { type: TrackType }) {
return TRACK_COLORS[type];
return TRACK_CONFIG[type];
}
export function getTrackClasses({ type }: { type: TrackType }) {
const colors = TRACK_COLORS[type];
return `${colors.background}`.trim();
return TRACK_CONFIG[type].background.trim();
}
export function getTrackHeight({ type }: { type: TrackType }): number {
return TRACK_HEIGHTS[type];
return TRACK_CONFIG[type].height;
}
export function getCumulativeHeightBefore({
@@ -77,17 +76,7 @@ export function buildEmptyTrack({
type: TrackType;
name?: string;
}): TimelineTrack {
const trackName =
name ??
(type === "video"
? "Video track"
: type === "text"
? "Text track"
: type === "audio"
? "Audio track"
: type === "sticker"
? "Sticker track"
: "Track");
const trackName = name ?? TRACK_CONFIG[type].defaultName;
switch (type) {
case "video":
@@ -124,6 +113,14 @@ export function buildEmptyTrack({
elements: [],
muted: false,
};
case "effect":
return {
id,
name: trackName,
type: "effect",
elements: [],
hidden: false,
};
default:
throw new Error(`Unsupported track type: ${type}`);
}
@@ -140,6 +137,10 @@ export function getDefaultInsertIndexForTrack({
return tracks.length;
}
if (trackType === "effect") {
return 0;
}
const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
if (mainTrackIndex >= 0) {
return mainTrackIndex;
@@ -216,6 +217,7 @@ export function canElementGoOnTrack({
if (elementType === "text") return trackType === "text";
if (elementType === "audio") return trackType === "audio";
if (elementType === "sticker") return trackType === "sticker";
if (elementType === "effect") return trackType === "effect";
if (elementType === "video" || elementType === "image") {
return trackType === "video";
}
@@ -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();
}
}
+156 -100
View File
@@ -6,12 +6,137 @@ import { ImageNode } from "./nodes/image-node";
import { TextNode } from "./nodes/text-node";
import { StickerNode } from "./nodes/sticker-node";
import { ColorNode } from "./nodes/color-node";
import { BlurBackgroundNode } from "./nodes/blur-background-node";
import { CompositeEffectNode } from "./nodes/composite-effect-node";
import { EffectLayerNode } from "./nodes/effect-layer-node";
import type { BaseNode } from "./nodes/base-node";
import type { TBackground, TCanvasSize } from "@/types/project";
import { DEFAULT_BLUR_INTENSITY } from "@/constants/project-constants";
import { isMainTrack } from "@/lib/timeline";
const PREVIEW_MAX_IMAGE_SIZE = 2048;
const BLUR_BACKGROUND_ZOOM_SCALE = 1.4;
function getVisibleSortedElements({
track,
}: {
track: TimelineTrack;
}) {
return track.elements
.filter((element) => !("hidden" in element && element.hidden))
.slice()
.sort((a, b) => {
if (a.startTime !== b.startTime) return a.startTime - b.startTime;
return a.id.localeCompare(b.id);
});
}
function buildTrackNodes({
tracks,
mediaMap,
canvasSize,
isPreview,
}: {
tracks: TimelineTrack[];
mediaMap: Map<string, MediaAsset>;
canvasSize: TCanvasSize;
isPreview?: boolean;
}): BaseNode[] {
const nodes: BaseNode[] = [];
for (const track of tracks) {
const elements = getVisibleSortedElements({ track });
for (const element of elements) {
if (element.type === "effect") {
nodes.push(
new EffectLayerNode({
effectType: element.effectType,
effectParams: element.params,
timeOffset: element.startTime,
duration: element.duration,
}),
);
continue;
}
if (element.type === "video" || element.type === "image") {
const mediaAsset = mediaMap.get(element.mediaId);
if (!mediaAsset?.file || !mediaAsset?.url) {
continue;
}
if (mediaAsset.type === "video") {
nodes.push(
new VideoNode({
mediaId: mediaAsset.id,
url: mediaAsset.url,
file: mediaAsset.file,
duration: element.duration,
timeOffset: element.startTime,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
transform: element.transform,
animations: element.animations,
opacity: element.opacity,
blendMode: element.blendMode,
effects: element.effects,
}),
);
}
if (mediaAsset.type === "image") {
nodes.push(
new ImageNode({
url: mediaAsset.url,
duration: element.duration,
timeOffset: element.startTime,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
transform: element.transform,
animations: element.animations,
opacity: element.opacity,
blendMode: element.blendMode,
effects: element.effects,
...(isPreview && {
maxSourceSize: PREVIEW_MAX_IMAGE_SIZE,
}),
}),
);
}
}
if (element.type === "text") {
nodes.push(
new TextNode({
...element,
canvasCenter: { x: canvasSize.width / 2, y: canvasSize.height / 2 },
canvasHeight: canvasSize.height,
textBaseline: "middle",
effects: element.effects,
}),
);
}
if (element.type === "sticker") {
nodes.push(
new StickerNode({
stickerId: element.stickerId,
duration: element.duration,
timeOffset: element.startTime,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
transform: element.transform,
animations: element.animations,
opacity: element.opacity,
blendMode: element.blendMode,
effects: element.effects,
}),
);
}
}
}
return nodes;
}
export type BuildSceneParams = {
canvasSize: TCanvasSize;
@@ -22,9 +147,14 @@ export type BuildSceneParams = {
isPreview?: boolean;
};
export function buildScene(params: BuildSceneParams) {
const { tracks, mediaAssets, duration, canvasSize, background } = params;
export function buildScene({
canvasSize,
tracks,
mediaAssets,
duration,
background,
isPreview,
}: BuildSceneParams) {
const rootNode = new RootNode({ duration });
const mediaMap = new Map(mediaAssets.map((m) => [m.id, m]));
@@ -39,107 +169,33 @@ export function buildScene(params: BuildSceneParams) {
const orderedTracksBottomToTop = orderedTracksTopToBottom.slice().reverse();
const contentNodes = [];
for (const track of orderedTracksBottomToTop) {
const elements = track.elements
.filter((element) => !("hidden" in element && element.hidden))
.slice()
.sort((a, b) => {
if (a.startTime !== b.startTime) return a.startTime - b.startTime;
return a.id.localeCompare(b.id);
});
for (const element of elements) {
if (element.type === "video" || element.type === "image") {
const mediaAsset = mediaMap.get(element.mediaId);
if (!mediaAsset?.file || !mediaAsset?.url) {
continue;
}
if (mediaAsset.type === "video") {
contentNodes.push(
new VideoNode({
mediaId: mediaAsset.id,
url: mediaAsset.url,
file: mediaAsset.file,
duration: element.duration,
timeOffset: element.startTime,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
transform: element.transform,
animations: element.animations,
opacity: element.opacity,
blendMode: element.blendMode,
}),
);
}
if (mediaAsset.type === "image") {
contentNodes.push(
new ImageNode({
url: mediaAsset.url,
duration: element.duration,
timeOffset: element.startTime,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
transform: element.transform,
animations: element.animations,
opacity: element.opacity,
blendMode: element.blendMode,
...(params.isPreview && {
maxSourceSize: PREVIEW_MAX_IMAGE_SIZE,
}),
}),
);
}
}
if (element.type === "text") {
contentNodes.push(
new TextNode({
...element,
canvasCenter: { x: canvasSize.width / 2, y: canvasSize.height / 2 },
canvasHeight: canvasSize.height,
textBaseline: "middle",
}),
);
}
if (element.type === "sticker") {
contentNodes.push(
new StickerNode({
stickerId: element.stickerId,
duration: element.duration,
timeOffset: element.startTime,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
transform: element.transform,
animations: element.animations,
opacity: element.opacity,
blendMode: element.blendMode,
}),
);
}
}
}
const allNodes = buildTrackNodes({
tracks: orderedTracksBottomToTop,
mediaMap,
canvasSize,
isPreview,
});
if (background.type === "blur") {
rootNode.add(
new BlurBackgroundNode({
blurIntensity: background.blurIntensity ?? DEFAULT_BLUR_INTENSITY,
contentNodes,
new CompositeEffectNode({
contentNodes: allNodes.filter(
(node) => !(node instanceof EffectLayerNode),
),
effectType: "blur",
effectParams: {
intensity:
background.blurIntensity ?? DEFAULT_BLUR_INTENSITY,
},
scale: BLUR_BACKGROUND_ZOOM_SCALE,
}),
);
for (const node of contentNodes) {
rootNode.add(node);
}
} else {
if (background.type === "color" && background.color !== "transparent") {
rootNode.add(new ColorNode({ color: background.color }));
}
for (const node of contentNodes) {
rootNode.add(node);
}
} else if (background.type === "color" && background.color !== "transparent") {
rootNode.add(new ColorNode({ color: background.color }));
}
for (const node of allNodes) {
rootNode.add(node);
}
return rootNode;
@@ -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 });
}
}
+14 -1
View File
@@ -1,3 +1,5 @@
import type { VisualElement } from "./timeline";
interface BaseDragData {
id: string;
name: string;
@@ -6,6 +8,7 @@ interface BaseDragData {
export interface MediaDragData extends BaseDragData {
type: "media";
mediaType: "image" | "video" | "audio";
targetElementTypes?: ("video" | "image")[];
}
export interface TextDragData extends BaseDragData {
@@ -18,4 +21,14 @@ export interface StickerDragData extends BaseDragData {
stickerId: string;
}
export type TimelineDragData = MediaDragData | TextDragData | StickerDragData;
export interface EffectDragData extends BaseDragData {
type: "effect";
effectType: string;
targetElementTypes: VisualElement["type"][];
}
export type TimelineDragData =
| MediaDragData
| TextDragData
| StickerDragData
| EffectDragData;
+45
View File
@@ -0,0 +1,45 @@
export interface Effect {
id: string;
type: string;
params: EffectParamValues;
enabled: boolean;
}
export type EffectParamType = "number" | "boolean" | "select" | "color";
export type EffectParamValues = Record<string, number | string | boolean>;
export interface EffectParamDefinition {
key: string;
label: string;
type: EffectParamType;
default: number | string | boolean;
min?: number;
max?: number;
step?: number;
options?: Array<{ value: string; label: string }>;
}
export interface WebGLEffectPass {
fragmentShader: string;
uniforms(params: {
effectParams: EffectParamValues;
width: number;
height: number;
}): Record<string, number | number[]>;
}
export interface WebGLEffectRenderer {
type: "webgl";
passes: WebGLEffectPass[];
}
export type EffectRenderer = WebGLEffectRenderer;
export interface EffectDefinition {
type: string;
name: string;
keywords: string[];
params: EffectParamDefinition[];
renderer: EffectRenderer;
}
+4
View File
@@ -0,0 +1,4 @@
declare module "*.glsl" {
const value: string;
export default value;
}
+33 -5
View File
@@ -1,5 +1,6 @@
import type { BlendMode, Transform } from "./rendering";
import type { ElementAnimations } from "./animation";
import type { Effect, EffectParamValues } from "./effects";
import type { BlendMode, Transform } from "./rendering";
export interface Bookmark {
time: number;
@@ -18,7 +19,7 @@ export interface TScene {
updatedAt: Date;
}
export type TrackType = "video" | "text" | "audio" | "sticker";
export type TrackType = "video" | "text" | "audio" | "sticker" | "effect";
interface BaseTrack {
id: string;
@@ -51,7 +52,18 @@ export interface StickerTrack extends BaseTrack {
hidden: boolean;
}
export type TimelineTrack = VideoTrack | TextTrack | AudioTrack | StickerTrack;
export interface EffectTrack extends BaseTrack {
type: "effect";
elements: EffectElement[];
hidden: boolean;
}
export type TimelineTrack =
| VideoTrack
| TextTrack
| AudioTrack
| StickerTrack
| EffectTrack;
export type { Transform } from "./rendering";
@@ -81,6 +93,7 @@ interface BaseTimelineElement {
startTime: number;
trimStart: number;
trimEnd: number;
sourceDuration?: number;
animations?: ElementAnimations;
}
@@ -92,6 +105,7 @@ export interface VideoElement extends BaseTimelineElement {
transform: Transform;
opacity: number;
blendMode?: BlendMode;
effects?: Effect[];
}
export interface ImageElement extends BaseTimelineElement {
@@ -101,6 +115,7 @@ export interface ImageElement extends BaseTimelineElement {
transform: Transform;
opacity: number;
blendMode?: BlendMode;
effects?: Effect[];
}
export interface TextElement extends BaseTimelineElement {
@@ -127,6 +142,7 @@ export interface TextElement extends BaseTimelineElement {
transform: Transform;
opacity: number;
blendMode?: BlendMode;
effects?: Effect[];
}
export interface StickerElement extends BaseTimelineElement {
@@ -136,6 +152,13 @@ export interface StickerElement extends BaseTimelineElement {
transform: Transform;
opacity: number;
blendMode?: BlendMode;
effects?: Effect[];
}
export interface EffectElement extends BaseTimelineElement {
type: "effect";
effectType: string;
params: EffectParamValues;
}
export type VisualElement =
@@ -154,7 +177,8 @@ export type TimelineElement =
| VideoElement
| ImageElement
| TextElement
| StickerElement;
| StickerElement
| EffectElement;
export type ElementType = TimelineElement["type"];
@@ -167,12 +191,14 @@ export type CreateVideoElement = Omit<VideoElement, "id">;
export type CreateImageElement = Omit<ImageElement, "id">;
export type CreateTextElement = Omit<TextElement, "id">;
export type CreateStickerElement = Omit<StickerElement, "id">;
export type CreateEffectElement = Omit<EffectElement, "id">;
export type CreateTimelineElement =
| CreateAudioElement
| CreateVideoElement
| CreateImageElement
| CreateTextElement
| CreateStickerElement;
| CreateStickerElement
| CreateEffectElement;
export interface ElementDragState {
isDragging: boolean;
@@ -191,6 +217,7 @@ export interface DropTarget {
isNewTrack: boolean;
insertPosition: "above" | "below" | null;
xPosition: number;
targetElement: { elementId: string; trackId: string } | null;
}
export interface ComputeDropTargetParams {
@@ -206,6 +233,7 @@ export interface ComputeDropTargetParams {
verticalDragDirection?: "up" | "down" | null;
startTimeOverride?: number;
excludeElementId?: string;
targetElementTypes?: string[];
}
export interface ClipboardItem {