summaryrefslogtreecommitdiff
path: root/apps/web/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src')
-rw-r--r--apps/web/src/components/editor/panels/assets/index.tsx7
-rw-r--r--apps/web/src/components/editor/panels/assets/views/assets.tsx6
-rw-r--r--apps/web/src/components/editor/panels/assets/views/effects.tsx95
-rw-r--r--apps/web/src/components/editor/panels/properties/effect-properties.tsx103
-rw-r--r--apps/web/src/components/editor/panels/properties/index.tsx65
-rw-r--r--apps/web/src/components/editor/panels/timeline/index.tsx12
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-element.tsx266
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-track.tsx3
-rw-r--r--apps/web/src/components/ui/select.tsx2
-rw-r--r--apps/web/src/components/ui/slider.tsx4
-rw-r--r--apps/web/src/constants/timeline-constants.tsx82
-rw-r--r--apps/web/src/core/index.ts2
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-resize.ts8
-rw-r--r--apps/web/src/hooks/timeline/use-selection-box.ts18
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-drag-drop.ts69
-rw-r--r--apps/web/src/hooks/use-effect-preview.ts47
-rw-r--r--apps/web/src/lib/animation/interpolation.ts5
-rw-r--r--apps/web/src/lib/commands/timeline/element/insert-element.ts5
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts1
-rw-r--r--apps/web/src/lib/effects/definitions/blur.frag.glsl25
-rw-r--r--apps/web/src/lib/effects/definitions/blur.ts50
-rw-r--r--apps/web/src/lib/effects/definitions/index.ts13
-rw-r--r--apps/web/src/lib/effects/effect.vert.glsl7
-rw-r--r--apps/web/src/lib/effects/index.ts34
-rw-r--r--apps/web/src/lib/effects/registry.ts31
-rw-r--r--apps/web/src/lib/preview/element-bounds.ts2
-rw-r--r--apps/web/src/lib/timeline/drop-utils.ts85
-rw-r--r--apps/web/src/lib/timeline/element-utils.ts42
-rw-r--r--apps/web/src/lib/timeline/track-utils.ts38
-rw-r--r--apps/web/src/services/renderer/canvas-utils.ts16
-rw-r--r--apps/web/src/services/renderer/effect-preview.ts194
-rw-r--r--apps/web/src/services/renderer/nodes/blur-background-node.ts75
-rw-r--r--apps/web/src/services/renderer/nodes/composite-effect-node.ts77
-rw-r--r--apps/web/src/services/renderer/nodes/effect-layer-node.ts82
-rw-r--r--apps/web/src/services/renderer/nodes/image-node.ts2
-rw-r--r--apps/web/src/services/renderer/nodes/sticker-node.ts2
-rw-r--r--apps/web/src/services/renderer/nodes/video-node.ts4
-rw-r--r--apps/web/src/services/renderer/nodes/visual-node.ts68
-rw-r--r--apps/web/src/services/renderer/scene-builder.ts160
-rw-r--r--apps/web/src/services/renderer/webgl-effect-renderer.ts73
-rw-r--r--apps/web/src/services/renderer/webgl-utils.ts298
-rw-r--r--apps/web/src/services/storage/migrations/index.ts4
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v7-to-v8.ts96
-rw-r--r--apps/web/src/services/storage/migrations/v7-to-v8.ts16
-rw-r--r--apps/web/src/types/drag.ts15
-rw-r--r--apps/web/src/types/effects.ts45
-rw-r--r--apps/web/src/types/glsl.d.ts4
-rw-r--r--apps/web/src/types/timeline.ts38
48 files changed, 2044 insertions, 352 deletions
diff --git a/apps/web/src/components/editor/panels/assets/index.tsx b/apps/web/src/components/editor/panels/assets/index.tsx
index 3600d00b..fe7db329 100644
--- a/apps/web/src/components/editor/panels/assets/index.tsx
+++ b/apps/web/src/components/editor/panels/assets/index.tsx
@@ -9,6 +9,7 @@ import { SettingsView } from "./views/settings";
import { SoundsView } from "./views/sounds";
import { StickersView } from "./views/stickers";
import { TextView } from "./views/text";
+import { EffectsView } from "./views/effects";
export function AssetsPanel() {
const { activeTab } = useAssetsPanelStore();
@@ -18,11 +19,7 @@ export function AssetsPanel() {
sounds: <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...
diff --git a/apps/web/src/components/editor/panels/assets/views/assets.tsx b/apps/web/src/components/editor/panels/assets/views/assets.tsx
index 808961f1..4c6922ff 100644
--- a/apps/web/src/components/editor/panels/assets/views/assets.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx
@@ -426,6 +426,9 @@ function GridView({
type: "media",
mediaType: item.type,
name: item.name,
+ ...(item.type !== "audio" && {
+ targetElementTypes: ["video", "image"] as const,
+ }),
}}
shouldShowPlusOnDrag={false}
onAddToTimeline={({ currentTime }) =>
@@ -476,6 +479,9 @@ function ListView({
type: "media",
mediaType: item.type,
name: item.name,
+ ...(item.type !== "audio" && {
+ targetElementTypes: ["video", "image"] as const,
+ }),
}}
shouldShowPlusOnDrag={false}
onAddToTimeline={({ currentTime }) =>
diff --git a/apps/web/src/components/editor/panels/assets/views/effects.tsx b/apps/web/src/components/editor/panels/assets/views/effects.tsx
new file mode 100644
index 00000000..c9b8b2dd
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/views/effects.tsx
@@ -0,0 +1,95 @@
+"use client";
+
+import { useEffect, useRef, useCallback } from "react";
+import { PanelView } from "@/components/editor/panels/assets/views/base-view";
+import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
+import { getAllEffects, EFFECT_TARGET_ELEMENT_TYPES } from "@/lib/effects";
+import {
+ effectPreviewService,
+ onPreviewImageReady,
+} from "@/services/renderer/effect-preview";
+import { useEditor } from "@/hooks/use-editor";
+import { buildEffectElement } from "@/lib/timeline/element-utils";
+import type { EffectDefinition } from "@/types/effects";
+
+export function EffectsView() {
+ const effects = getAllEffects();
+
+ return (
+ <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"
+ />
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/effect-properties.tsx b/apps/web/src/components/editor/panels/properties/effect-properties.tsx
new file mode 100644
index 00000000..bf137291
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/effect-properties.tsx
@@ -0,0 +1,103 @@
+"use client";
+
+import type { EffectElement } from "@/types/timeline";
+import type { EffectParamDefinition } from "@/types/effects";
+import { getEffect } from "@/lib/effects/registry";
+import { useEditor } from "@/hooks/use-editor";
+import { clamp } from "@/utils/math";
+import { Section, SectionContent, SectionHeader, SectionField, SectionFields } from "./section";
+import { Slider } from "@/components/ui/slider";
+import { NumberField } from "@/components/ui/number-field";
+import { usePropertyDraft } from "./hooks/use-property-draft";
+
+function EffectParamField({
+ param,
+ element,
+ trackId,
+}: {
+ param: EffectParamDefinition;
+ element: EffectElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+
+ const currentValue = Number(element.params[param.key] ?? param.default);
+ const min = param.min ?? 0;
+ const max = param.max ?? 100;
+ const step = param.step ?? 1;
+
+ const updateParam = (value: number) =>
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { params: { ...element.params, [param.key]: value } },
+ },
+ ],
+ });
+
+ const commitParam = () => editor.timeline.commitPreview();
+
+ const draft = usePropertyDraft({
+ displayValue: String(currentValue),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) return null;
+ return clamp({ value: parsed, min, max });
+ },
+ onPreview: updateParam,
+ onCommit: commitParam,
+ });
+
+ return (
+ <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>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx
index 4f285f84..75dd81a9 100644
--- a/apps/web/src/components/editor/panels/properties/index.tsx
+++ b/apps/web/src/components/editor/panels/properties/index.tsx
@@ -4,9 +4,37 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { AudioProperties } from "./audio-properties";
import { VideoProperties } from "./video-properties";
import { TextProperties } from "./text-properties";
+import { EffectProperties } from "./effect-properties";
import { EmptyView } from "./empty-view";
import { useEditor } from "@/hooks/use-editor";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
+import type { TimelineElement, TimelineTrack } from "@/types/timeline";
+
+function ElementProperties({
+ track,
+ element,
+}: {
+ track: TimelineTrack;
+ element: TimelineElement;
+}) {
+ if (element.type === "text") {
+ return <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 />
diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx
index 18f768a9..d89d270d 100644
--- a/apps/web/src/components/editor/panels/timeline/index.tsx
+++ b/apps/web/src/components/editor/panels/timeline/index.tsx
@@ -27,7 +27,7 @@ import type { SnapPoint } from "@/lib/timeline/snap-utils";
import type { TimelineTrack } from "@/types/timeline";
import {
TIMELINE_CONSTANTS,
- TRACK_ICONS,
+ TRACK_CONFIG,
} from "@/constants/timeline-constants";
import { useElementInteraction } from "@/hooks/timeline/element/use-element-interaction";
import {
@@ -161,6 +161,7 @@ export function Timeline() {
shouldIgnoreClick,
} = useSelectionBox({
containerRef: tracksContainerRef,
+ headerRef: timelineHeaderRef,
onSelectionComplete: (elements) => {
setElementSelection({ elements });
},
@@ -326,7 +327,7 @@ export function Timeline() {
<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({
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
index 7ad63692..38cf953b 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
@@ -46,6 +46,7 @@ import {
Search01Icon,
Exchange01Icon,
KeyframeIcon,
+ MagicWand05Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { uppercase } from "@/utils/string";
@@ -59,7 +60,6 @@ interface KeyframeIndicator {
time: number;
offsetPx: number;
keyframes: SelectedKeyframeRef[];
- isSelected: boolean;
}
function buildKeyframeIndicator({
@@ -69,7 +69,6 @@ function buildKeyframeIndicator({
displayedStartTime,
zoomLevel,
elementLeft,
- isKeyframeSelected,
}: {
keyframe: ElementKeyframe;
trackId: string;
@@ -77,16 +76,10 @@ function buildKeyframeIndicator({
displayedStartTime: number;
zoomLevel: number;
elementLeft: number;
- isKeyframeSelected: ({
- keyframe,
- }: {
- keyframe: SelectedKeyframeRef;
- }) => boolean;
}): {
time: number;
offsetPx: number;
keyframeRef: SelectedKeyframeRef;
- isSelected: boolean;
} {
const keyframeRef = {
trackId,
@@ -102,7 +95,6 @@ function buildKeyframeIndicator({
time: keyframe.time,
offsetPx: keyframeLeft - elementLeft,
keyframeRef,
- isSelected: isKeyframeSelected({ keyframe: keyframeRef }),
};
}
@@ -114,7 +106,6 @@ function getKeyframeIndicators({
zoomLevel,
elementLeft,
elementWidth,
- isKeyframeSelected,
}: {
keyframes: ElementKeyframe[];
trackId: string;
@@ -123,11 +114,6 @@ function getKeyframeIndicators({
zoomLevel: number;
elementLeft: number;
elementWidth: number;
- isKeyframeSelected: ({
- keyframe,
- }: {
- keyframe: SelectedKeyframeRef;
- }) => boolean;
}): KeyframeIndicator[] {
if (elementWidth < KEYFRAME_INDICATOR_MIN_WIDTH_PX) {
return [];
@@ -142,7 +128,6 @@ function getKeyframeIndicators({
displayedStartTime,
zoomLevel,
elementLeft,
- isKeyframeSelected,
});
const existingIndicator = keyframesByTime.get(indicator.time);
if (!existingIndicator) {
@@ -150,14 +135,11 @@ function getKeyframeIndicators({
time: indicator.time,
offsetPx: indicator.offsetPx,
keyframes: [indicator.keyframeRef],
- isSelected: indicator.isSelected,
});
continue;
}
existingIndicator.keyframes.push(indicator.keyframeRef);
- existingIndicator.isSelected =
- existingIndicator.isSelected || indicator.isSelected;
}
return [...keyframesByTime.values()].sort((a, b) => a.time - b.time);
@@ -187,6 +169,7 @@ interface TimelineElementProps {
) => void;
onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void;
dragState: ElementDragState;
+ isDropTarget?: boolean;
}
export function TimelineElement({
@@ -199,10 +182,10 @@ export function TimelineElement({
onElementMouseDown,
onElementClick,
dragState,
+ isDropTarget = false,
}: TimelineElementProps) {
const editor = useEditor();
const { selectedElements } = useElementSelection();
- const { isKeyframeSelected } = useKeyframeSelection();
const { requestRevealMedia } = useAssetsPanelStore();
const mediaAssets = editor.media.getAssets();
@@ -248,16 +231,17 @@ export function TimelineElement({
time: displayedStartTime,
zoomLevel,
});
- const keyframeIndicators = getKeyframeIndicators({
- keyframes: getElementKeyframes({ animations: element.animations }),
- trackId: track.id,
- elementId: element.id,
- displayedStartTime,
- zoomLevel,
- elementLeft,
- elementWidth,
- isKeyframeSelected,
- });
+ const keyframeIndicators = isSelected
+ ? getKeyframeIndicators({
+ keyframes: getElementKeyframes({ animations: element.animations }),
+ trackId: track.id,
+ elementId: element.id,
+ displayedStartTime,
+ zoomLevel,
+ elementLeft,
+ elementWidth,
+ })
+ : [];
const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => {
event.stopPropagation();
if (hasMediaId(element)) {
@@ -291,8 +275,9 @@ export function TimelineElement({
onElementClick={onElementClick}
onElementMouseDown={onElementMouseDown}
handleResizeStart={handleResizeStart}
+ isDropTarget={isDropTarget}
/>
- <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() {
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx
index 42faadaf..fbc7cb3f 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx
@@ -32,6 +32,7 @@ interface TimelineTrackContentProps {
onTrackMouseDown?: (event: React.MouseEvent) => void;
onTrackClick?: (event: React.MouseEvent) => void;
shouldIgnoreClick?: () => boolean;
+ targetElementId?: string | null;
}
export function TimelineTrackContent({
@@ -48,6 +49,7 @@ export function TimelineTrackContent({
onTrackMouseDown,
onTrackClick,
shouldIgnoreClick,
+ targetElementId = null,
}: TimelineTrackContentProps) {
const editor = useEditor();
const { isElementSelected, clearElementSelection } = useElementSelection();
@@ -102,6 +104,7 @@ export function TimelineTrackContent({
onElementClick({ event, element, track })
}
dragState={dragState}
+ isDropTarget={element.id === targetElementId}
/>
);
})
diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx
index 311f6edb..1b57ea4d 100644
--- a/apps/web/src/components/ui/select.tsx
+++ b/apps/web/src/components/ui/select.tsx
@@ -40,7 +40,7 @@ const selectTriggerVariants = cva(
},
size: {
default: "",
- sm: "",
+ sm: "rounded-sm",
},
},
defaultVariants: {
diff --git a/apps/web/src/components/ui/slider.tsx b/apps/web/src/components/ui/slider.tsx
index 57ddbcf0..700be644 100644
--- a/apps/web/src/components/ui/slider.tsx
+++ b/apps/web/src/components/ui/slider.tsx
@@ -7,7 +7,9 @@ import { cn } from "@/utils/ui";
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
- React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
+ React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> & {
+ className?: string;
+ }
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
diff --git a/apps/web/src/constants/timeline-constants.tsx b/apps/web/src/constants/timeline-constants.tsx
index 5ac0913a..5bc938bb 100644
--- a/apps/web/src/constants/timeline-constants.tsx
+++ b/apps/web/src/constants/timeline-constants.tsx
@@ -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;
diff --git a/apps/web/src/core/index.ts b/apps/web/src/core/index.ts
index 4a8813f8..42ab3b87 100644
--- a/apps/web/src/core/index.ts
+++ b/apps/web/src/core/index.ts
@@ -8,6 +8,7 @@ import { CommandManager } from "./managers/commands";
import { SaveManager } from "./managers/save-manager";
import { AudioManager } from "./managers/audio-manager";
import { SelectionManager } from "./managers/selection-manager";
+import { registerDefaultEffects } from "@/lib/effects";
export class EditorCore {
private static instance: EditorCore | null = null;
@@ -24,6 +25,7 @@ export class EditorCore {
public readonly selection: SelectionManager;
private constructor() {
+ registerDefaultEffects();
this.command = new CommandManager();
this.playback = new PlaybackManager(this);
this.timeline = new TimelineManager(this);
diff --git a/apps/web/src/hooks/timeline/element/use-element-resize.ts b/apps/web/src/hooks/timeline/element/use-element-resize.ts
index 2b303ad9..2340c9b2 100644
--- a/apps/web/src/hooks/timeline/element/use-element-resize.ts
+++ b/apps/web/src/hooks/timeline/element/use-element-resize.ts
@@ -86,12 +86,8 @@ export function useTimelineElementResize({
};
const canExtendElementDuration = useCallback(() => {
- if (element.type === "text" || element.type === "image") {
- return true;
- }
-
- return false;
- }, [element.type]);
+ return element.sourceDuration == null;
+ }, [element.sourceDuration]);
const updateTrimFromMouseMove = useCallback(
({ clientX }: { clientX: number }) => {
diff --git a/apps/web/src/hooks/timeline/use-selection-box.ts b/apps/web/src/hooks/timeline/use-selection-box.ts
index 12c6bf8a..c4f8d6ce 100644
--- a/apps/web/src/hooks/timeline/use-selection-box.ts
+++ b/apps/web/src/hooks/timeline/use-selection-box.ts
@@ -5,6 +5,7 @@ import { useEditor } from "../use-editor";
interface UseSelectionBoxProps {
containerRef: React.RefObject<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(() => {
diff --git a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
index 632d2c78..ee739298 100644
--- a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
+++ b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
@@ -8,6 +8,7 @@ import {
buildTextElement,
buildStickerElement,
buildElementFromMedia,
+ buildEffectElement,
} from "@/lib/timeline/element-utils";
import type { Command } from "@/lib/commands/base-command";
import { AddMediaAssetCommand } from "@/lib/commands/media";
@@ -16,7 +17,11 @@ import { BatchCommand } from "@/lib/commands";
import { computeDropTarget } from "@/lib/timeline/drop-utils";
import { getDragData, hasDragData } from "@/lib/drag-data";
import type { TrackType, DropTarget, ElementType } from "@/types/timeline";
-import type { MediaDragData, StickerDragData } from "@/types/drag";
+import type {
+ MediaDragData,
+ StickerDragData,
+ EffectDragData,
+} from "@/types/drag";
interface UseTimelineDragDropProps {
containerRef: RefObject<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,
diff --git a/apps/web/src/hooks/use-effect-preview.ts b/apps/web/src/hooks/use-effect-preview.ts
new file mode 100644
index 00000000..a84781e0
--- /dev/null
+++ b/apps/web/src/hooks/use-effect-preview.ts
@@ -0,0 +1,47 @@
+import { useEffect, useRef } from "react";
+import { effectPreviewService } from "@/services/renderer/effect-preview";
+import type { EffectParamValues } from "@/types/effects";
+
+export function useEffectPreview({
+ effectType,
+ params,
+ canvasRef,
+ isActive,
+}: {
+ effectType: string;
+ params: EffectParamValues;
+ canvasRef: React.RefObject<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]);
+}
diff --git a/apps/web/src/lib/animation/interpolation.ts b/apps/web/src/lib/animation/interpolation.ts
index a8f60643..d3f68010 100644
--- a/apps/web/src/lib/animation/interpolation.ts
+++ b/apps/web/src/lib/animation/interpolation.ts
@@ -217,6 +217,11 @@ function evaluateChannelValueAtTime<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,
diff --git a/apps/web/src/lib/commands/timeline/element/insert-element.ts b/apps/web/src/lib/commands/timeline/element/insert-element.ts
index e1551471..68fc9841 100644
--- a/apps/web/src/lib/commands/timeline/element/insert-element.ts
+++ b/apps/web/src/lib/commands/timeline/element/insert-element.ts
@@ -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;
}
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts
index 29d95918..5328419f 100644
--- a/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts
@@ -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({
diff --git a/apps/web/src/lib/effects/definitions/blur.frag.glsl b/apps/web/src/lib/effects/definitions/blur.frag.glsl
new file mode 100644
index 00000000..2c015725
--- /dev/null
+++ b/apps/web/src/lib/effects/definitions/blur.frag.glsl
@@ -0,0 +1,25 @@
+precision mediump float;
+
+uniform sampler2D u_texture;
+uniform vec2 u_resolution;
+uniform float u_sigma;
+uniform vec2 u_direction;
+
+varying vec2 v_texCoord;
+
+void main() {
+ vec2 texelSize = 1.0 / u_resolution;
+
+ vec4 color = vec4(0.0);
+ float totalWeight = 0.0;
+
+ // step=1 texel — scaling step size instead causes discrete ghosting artifacts
+ for (int i = -30; i <= 30; i++) {
+ float fi = float(i);
+ float weight = exp(-(fi * fi) / (2.0 * u_sigma * u_sigma));
+ color += texture2D(u_texture, v_texCoord + texelSize * u_direction * fi) * weight;
+ totalWeight += weight;
+ }
+
+ gl_FragColor = color / totalWeight;
+}
diff --git a/apps/web/src/lib/effects/definitions/blur.ts b/apps/web/src/lib/effects/definitions/blur.ts
new file mode 100644
index 00000000..3a8f691a
--- /dev/null
+++ b/apps/web/src/lib/effects/definitions/blur.ts
@@ -0,0 +1,50 @@
+import type { EffectDefinition } from "@/types/effects";
+import blurFragmentShader from "./blur.frag.glsl";
+
+export const blurEffectDefinition: EffectDefinition = {
+ type: "blur",
+ name: "Blur",
+ keywords: ["blur", "soft", "defocus"],
+ params: [
+ {
+ key: "intensity",
+ label: "Intensity",
+ type: "number",
+ default: 15,
+ min: 0,
+ max: 100,
+ step: 1,
+ },
+ ],
+ renderer: {
+ type: "webgl",
+ passes: [
+ {
+ fragmentShader: blurFragmentShader,
+ uniforms: ({ effectParams }) => {
+ const intensity =
+ typeof effectParams.intensity === "number"
+ ? effectParams.intensity
+ : Number.parseFloat(String(effectParams.intensity));
+ return {
+ u_sigma: Math.max(intensity / 5, 0.001),
+ u_direction: [1, 0],
+ };
+ },
+ },
+ {
+ fragmentShader: blurFragmentShader,
+ uniforms: ({ effectParams }) => {
+ const intensity =
+ typeof effectParams.intensity === "number"
+ ? effectParams.intensity
+ : Number.parseFloat(String(effectParams.intensity));
+ return {
+ u_sigma: Math.max(intensity / 5, 0.001),
+ u_direction: [0, 1],
+ };
+ },
+ },
+ ],
+ },
+};
diff --git a/apps/web/src/lib/effects/definitions/index.ts b/apps/web/src/lib/effects/definitions/index.ts
new file mode 100644
index 00000000..1742b1d7
--- /dev/null
+++ b/apps/web/src/lib/effects/definitions/index.ts
@@ -0,0 +1,13 @@
+import { hasEffect, registerEffect } from "../registry";
+import { blurEffectDefinition } from "./blur";
+
+const defaultEffects = [blurEffectDefinition];
+
+export function registerDefaultEffects(): void {
+ for (const definition of defaultEffects) {
+ if (hasEffect({ effectType: definition.type })) {
+ continue;
+ }
+ registerEffect({ definition });
+ }
+}
diff --git a/apps/web/src/lib/effects/effect.vert.glsl b/apps/web/src/lib/effects/effect.vert.glsl
new file mode 100644
index 00000000..5febc835
--- /dev/null
+++ b/apps/web/src/lib/effects/effect.vert.glsl
@@ -0,0 +1,7 @@
+attribute vec2 a_position;
+varying vec2 v_texCoord;
+
+void main() {
+ v_texCoord = a_position * 0.5 + 0.5;
+ gl_Position = vec4(a_position, 0.0, 1.0);
+}
diff --git a/apps/web/src/lib/effects/index.ts b/apps/web/src/lib/effects/index.ts
new file mode 100644
index 00000000..14d06d84
--- /dev/null
+++ b/apps/web/src/lib/effects/index.ts
@@ -0,0 +1,34 @@
+import { generateUUID } from "@/utils/id";
+import { getEffect } from "./registry";
+import type { Effect, EffectParamValues } from "@/types/effects";
+import type { VisualElement } from "@/types/timeline";
+
+export { getEffect, getAllEffects, hasEffect, registerEffect } from "./registry";
+export { registerDefaultEffects } from "./definitions";
+
+export const EFFECT_TARGET_ELEMENT_TYPES: VisualElement["type"][] = [
+ "video",
+ "image",
+ "text",
+ "sticker",
+];
+
+export function buildDefaultEffectInstance({
+ effectType,
+}: {
+ effectType: string;
+}): Effect {
+ const definition = getEffect({ effectType });
+
+ const params: EffectParamValues = {};
+ for (const paramDef of definition.params) {
+ params[paramDef.key] = paramDef.default;
+ }
+
+ return {
+ id: generateUUID(),
+ type: effectType,
+ params,
+ enabled: true,
+ };
+}
diff --git a/apps/web/src/lib/effects/registry.ts b/apps/web/src/lib/effects/registry.ts
new file mode 100644
index 00000000..568b8924
--- /dev/null
+++ b/apps/web/src/lib/effects/registry.ts
@@ -0,0 +1,31 @@
+import type { EffectDefinition } from "@/types/effects";
+
+const effectDefinitions = new Map<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());
+}
diff --git a/apps/web/src/lib/preview/element-bounds.ts b/apps/web/src/lib/preview/element-bounds.ts
index cc63048f..5b1f301d 100644
--- a/apps/web/src/lib/preview/element-bounds.ts
+++ b/apps/web/src/lib/preview/element-bounds.ts
@@ -70,7 +70,7 @@ export function getElementBounds({
mediaAsset?: MediaAsset | null;
localTime: number;
}): ElementBounds | null {
- if (element.type === "audio") return null;
+ if (element.type === "audio" || element.type === "effect") return null;
if ("hidden" in element && element.hidden) return null;
const { width: canvasWidth, height: canvasHeight } = canvasSize;
diff --git a/apps/web/src/lib/timeline/drop-utils.ts b/apps/web/src/lib/timeline/drop-utils.ts
index 9182892f..b502974d 100644
--- a/apps/web/src/lib/timeline/drop-utils.ts
+++ b/apps/web/src/lib/timeline/drop-utils.ts
@@ -1,9 +1,42 @@
-import type { TimelineTrack, ElementType } from "@/types/timeline";
-import { TRACK_HEIGHTS, TRACK_GAP } from "@/constants/timeline-constants";
+import type {
+ TimelineTrack,
+ ElementType,
+ TimelineElement,
+} from "@/types/timeline";
+import { TRACK_CONFIG, TRACK_GAP } from "@/constants/timeline-constants";
import { wouldElementOverlap } from "./element-utils";
import type { ComputeDropTargetParams, DropTarget } from "@/types/timeline";
import { isMainTrack, enforceMainTrackStart } from "./track-utils";
+function findElementAtPosition({
+ mouseX,
+ tracks,
+ trackIndex,
+ targetElementTypes,
+ pixelsPerSecond,
+ zoomLevel,
+}: {
+ mouseX: number;
+ tracks: TimelineTrack[];
+ trackIndex: number;
+ targetElementTypes: string[];
+ pixelsPerSecond: number;
+ zoomLevel: number;
+}): { elementId: string; trackId: string } | null {
+ const time = mouseX / (pixelsPerSecond * zoomLevel);
+ const track = tracks[trackIndex];
+ if (!track || !("elements" in track)) return null;
+
+ const hit = track.elements.find(
+ (element: TimelineElement) =>
+ targetElementTypes.includes(element.type) &&
+ element.startTime <= time &&
+ time < element.startTime + element.duration,
+ );
+ if (!hit) return null;
+ return { elementId: hit.id, trackId: track.id };
+}
+
function getTrackAtY({
mouseY,
tracks,
@@ -16,7 +49,7 @@ function getTrackAtY({
let cumulativeHeight = 0;
for (let i = 0; i < tracks.length; i++) {
- const trackHeight = TRACK_HEIGHTS[tracks[i].type];
+ const trackHeight = TRACK_CONFIG[tracks[i].type].height;
const trackTop = cumulativeHeight;
const trackBottom = trackTop + trackHeight;
@@ -55,6 +88,7 @@ function isCompatible({
if (elementType === "text") return trackType === "text";
if (elementType === "audio") return trackType === "audio";
if (elementType === "sticker") return trackType === "sticker";
+ if (elementType === "effect") return trackType === "effect";
if (elementType === "video" || elementType === "image") {
return trackType === "video";
}
@@ -100,6 +134,8 @@ function findInsertIndex({
};
}
+const EMPTY_TARGET_ELEMENT = null;
+
export function computeDropTarget({
elementType,
mouseX,
@@ -113,6 +149,7 @@ export function computeDropTarget({
verticalDragDirection,
startTimeOverride,
excludeElementId,
+ targetElementTypes,
}: ComputeDropTargetParams): DropTarget {
const xPosition =
typeof startTimeOverride === "number"
@@ -130,9 +167,16 @@ export function computeDropTarget({
isNewTrack: true,
insertPosition: "below",
xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
};
}
- return { trackIndex: 0, isNewTrack: true, insertPosition: null, xPosition };
+ return {
+ trackIndex: 0,
+ isNewTrack: true,
+ insertPosition: null,
+ xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
+ };
}
const trackAtMouse = getTrackAtY({ mouseY, tracks, verticalDragDirection });
@@ -146,6 +190,7 @@ export function computeDropTarget({
isNewTrack: true,
insertPosition: "below",
xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
};
}
@@ -155,6 +200,7 @@ export function computeDropTarget({
isNewTrack: true,
insertPosition: "above",
xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
};
}
@@ -163,12 +209,37 @@ export function computeDropTarget({
isNewTrack: true,
insertPosition: "above",
xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
};
}
const { trackIndex, relativeY } = trackAtMouse;
const track = tracks[trackIndex];
- const trackHeight = TRACK_HEIGHTS[track.type];
+
+ if (
+ targetElementTypes &&
+ targetElementTypes.length > 0
+ ) {
+ const targetElement = findElementAtPosition({
+ mouseX,
+ tracks,
+ trackIndex,
+ targetElementTypes,
+ pixelsPerSecond,
+ zoomLevel,
+ });
+ if (targetElement) {
+ return {
+ trackIndex,
+ isNewTrack: false,
+ insertPosition: null,
+ xPosition,
+ targetElement,
+ };
+ }
+ }
+
+ const trackHeight = TRACK_CONFIG[track.type].height;
const isInUpperHalf = relativeY < trackHeight / 2;
const isTrackCompatible = isCompatible({
@@ -200,6 +271,7 @@ export function computeDropTarget({
isNewTrack: false,
insertPosition: null,
xPosition: adjustedXPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
};
}
@@ -220,6 +292,7 @@ export function computeDropTarget({
isNewTrack: true,
insertPosition: position,
xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
};
}
@@ -237,7 +310,7 @@ export function getDropLineY({
let y = 0;
for (let i = 0; i < safeTrackIndex; i++) {
- y += TRACK_HEIGHTS[tracks[i].type] + TRACK_GAP;
+ y += TRACK_CONFIG[tracks[i].type].height + TRACK_GAP;
}
return y;
diff --git a/apps/web/src/lib/timeline/element-utils.ts b/apps/web/src/lib/timeline/element-utils.ts
index 9e191833..8c02c097 100644
--- a/apps/web/src/lib/timeline/element-utils.ts
+++ b/apps/web/src/lib/timeline/element-utils.ts
@@ -6,6 +6,7 @@ import {
TIMELINE_CONSTANTS,
} from "@/constants/timeline-constants";
import type {
+ CreateEffectElement,
CreateTimelineElement,
CreateVideoElement,
CreateImageElement,
@@ -22,6 +23,8 @@ import type {
UploadAudioElement,
} from "@/types/timeline";
import type { MediaType } from "@/types/assets";
+import { buildDefaultEffectInstance } from "@/lib/effects";
+import { capitalizeFirstLetter } from "@/utils/string";
export function canElementHaveAudio(
element: TimelineElement,
@@ -155,13 +158,13 @@ export function buildTextElement({
fontFamily: t.fontFamily ?? DEFAULT_TEXT_ELEMENT.fontFamily,
color: t.color ?? DEFAULT_TEXT_ELEMENT.color,
background: {
- color: t.background?.color ?? DEFAULT_TEXT_ELEMENT.background.color,
- cornerRadius: t.background?.cornerRadius,
- paddingX: t.background?.paddingX,
- paddingY: t.background?.paddingY,
- offsetX: t.background?.offsetX,
- offsetY: t.background?.offsetY,
- },
+ color: t.background?.color ?? DEFAULT_TEXT_ELEMENT.background.color,
+ cornerRadius: t.background?.cornerRadius,
+ paddingX: t.background?.paddingX,
+ paddingY: t.background?.paddingY,
+ offsetX: t.background?.offsetX,
+ offsetY: t.background?.offsetY,
+ },
textAlign: t.textAlign ?? DEFAULT_TEXT_ELEMENT.textAlign,
fontWeight: t.fontWeight ?? DEFAULT_TEXT_ELEMENT.fontWeight,
fontStyle: t.fontStyle ?? DEFAULT_TEXT_ELEMENT.fontStyle,
@@ -174,6 +177,28 @@ export function buildTextElement({
};
}
+export function buildEffectElement({
+ effectType,
+ startTime,
+ duration,
+}: {
+ effectType: string;
+ startTime: number;
+ duration?: number;
+}): CreateEffectElement {
+ const instance = buildDefaultEffectInstance({ effectType });
+ return {
+ type: "effect",
+ name: capitalizeFirstLetter({ string: instance.type }),
+ effectType,
+ params: instance.params,
+ duration: duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ };
+}
+
export function buildStickerElement({
stickerId,
name,
@@ -218,6 +243,7 @@ export function buildVideoElement({
startTime,
trimStart: 0,
trimEnd: 0,
+ sourceDuration: duration,
muted: false,
hidden: false,
transform: { ...DEFAULT_TRANSFORM },
@@ -274,6 +300,7 @@ export function buildUploadAudioElement({
startTime,
trimStart: 0,
trimEnd: 0,
+ sourceDuration: duration,
volume: 1,
muted: false,
};
@@ -336,6 +363,7 @@ export function buildLibraryAudioElement({
startTime,
trimStart: 0,
trimEnd: 0,
+ sourceDuration: duration,
volume: 1,
muted: false,
};
diff --git a/apps/web/src/lib/timeline/track-utils.ts b/apps/web/src/lib/timeline/track-utils.ts
index 415d0ace..94ff21ba 100644
--- a/apps/web/src/lib/timeline/track-utils.ts
+++ b/apps/web/src/lib/timeline/track-utils.ts
@@ -6,11 +6,11 @@ import type {
AudioTrack,
StickerTrack,
TextTrack,
+ EffectTrack,
TimelineElement,
} from "@/types/timeline";
import {
- TRACK_COLORS,
- TRACK_HEIGHTS,
+ TRACK_CONFIG,
TRACK_GAP,
} from "@/constants/timeline-constants";
import { generateUUID } from "@/utils/id";
@@ -23,21 +23,20 @@ export function canTracktHaveAudio(
export function canTrackBeHidden(
track: TimelineTrack,
-): track is VideoTrack | TextTrack | StickerTrack {
+): track is VideoTrack | TextTrack | StickerTrack | EffectTrack {
return track.type !== "audio";
}
export function getTrackColor({ type }: { type: TrackType }) {
- return TRACK_COLORS[type];
+ return TRACK_CONFIG[type];
}
export function getTrackClasses({ type }: { type: TrackType }) {
- const colors = TRACK_COLORS[type];
- return `${colors.background}`.trim();
+ return TRACK_CONFIG[type].background.trim();
}
export function getTrackHeight({ type }: { type: TrackType }): number {
- return TRACK_HEIGHTS[type];
+ return TRACK_CONFIG[type].height;
}
export function getCumulativeHeightBefore({
@@ -77,17 +76,7 @@ export function buildEmptyTrack({
type: TrackType;
name?: string;
}): TimelineTrack {
- const trackName =
- name ??
- (type === "video"
- ? "Video track"
- : type === "text"
- ? "Text track"
- : type === "audio"
- ? "Audio track"
- : type === "sticker"
- ? "Sticker track"
- : "Track");
+ const trackName = name ?? TRACK_CONFIG[type].defaultName;
switch (type) {
case "video":
@@ -124,6 +113,14 @@ export function buildEmptyTrack({
elements: [],
muted: false,
};
+ case "effect":
+ return {
+ id,
+ name: trackName,
+ type: "effect",
+ elements: [],
+ hidden: false,
+ };
default:
throw new Error(`Unsupported track type: ${type}`);
}
@@ -140,6 +137,10 @@ export function getDefaultInsertIndexForTrack({
return tracks.length;
}
+ if (trackType === "effect") {
+ return 0;
+ }
+
const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
if (mainTrackIndex >= 0) {
return mainTrackIndex;
@@ -216,6 +217,7 @@ export function canElementGoOnTrack({
if (elementType === "text") return trackType === "text";
if (elementType === "audio") return trackType === "audio";
if (elementType === "sticker") return trackType === "sticker";
+ if (elementType === "effect") return trackType === "effect";
if (elementType === "video" || elementType === "image") {
return trackType === "video";
}
diff --git a/apps/web/src/services/renderer/canvas-utils.ts b/apps/web/src/services/renderer/canvas-utils.ts
new file mode 100644
index 00000000..093168e6
--- /dev/null
+++ b/apps/web/src/services/renderer/canvas-utils.ts
@@ -0,0 +1,16 @@
+export function createOffscreenCanvas({
+ width,
+ height,
+}: {
+ width: number;
+ height: number;
+}): OffscreenCanvas | HTMLCanvasElement {
+ try {
+ return new OffscreenCanvas(width, height);
+ } catch {
+ const canvas = document.createElement("canvas");
+ canvas.width = width;
+ canvas.height = height;
+ return canvas;
+ }
+}
diff --git a/apps/web/src/services/renderer/effect-preview.ts b/apps/web/src/services/renderer/effect-preview.ts
new file mode 100644
index 00000000..2cebec5d
--- /dev/null
+++ b/apps/web/src/services/renderer/effect-preview.ts
@@ -0,0 +1,194 @@
+import { createOffscreenCanvas } from "./canvas-utils";
+import { getEffect } from "@/lib/effects";
+import type { EffectParamValues } from "@/types/effects";
+import { applyMultiPassEffect } from "./webgl-utils";
+import type { EffectPassData } from "./webgl-utils";
+
+const PREVIEW_SIZE = 160;
+const PREVIEW_IMAGE_PATH = "/effects/preview.jpg";
+
+let previewGl: WebGLRenderingContext | null = null;
+let previewCanvas: OffscreenCanvas | HTMLCanvasElement | null = null;
+let testSourceCanvas: OffscreenCanvas | HTMLCanvasElement | null = null;
+let previewImageElement: HTMLImageElement | null = null;
+const programCache = new Map<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,
+};
diff --git a/apps/web/src/services/renderer/nodes/blur-background-node.ts b/apps/web/src/services/renderer/nodes/blur-background-node.ts
deleted file mode 100644
index 504161d9..00000000
--- a/apps/web/src/services/renderer/nodes/blur-background-node.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import type { CanvasRenderer } from "../canvas-renderer";
-import { BaseNode } from "./base-node";
-
-export type BlurBackgroundNodeParams = {
- blurIntensity: number;
- contentNodes: BaseNode[];
-};
-
-export class BlurBackgroundNode extends BaseNode<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();
- }
-}
diff --git a/apps/web/src/services/renderer/nodes/composite-effect-node.ts b/apps/web/src/services/renderer/nodes/composite-effect-node.ts
new file mode 100644
index 00000000..3fbfffc1
--- /dev/null
+++ b/apps/web/src/services/renderer/nodes/composite-effect-node.ts
@@ -0,0 +1,77 @@
+import type { CanvasRenderer } from "../canvas-renderer";
+import { createOffscreenCanvas } from "../canvas-utils";
+import { getEffect } from "@/lib/effects";
+import type { EffectParamValues } from "@/types/effects";
+import { BaseNode } from "./base-node";
+import { webglEffectRenderer } from "../webgl-effect-renderer";
+
+export type CompositeEffectNodeParams = {
+ contentNodes: BaseNode[];
+ effectType: string;
+ effectParams: EffectParamValues;
+ scale: number;
+};
+
+export class CompositeEffectNode extends BaseNode<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();
+ }
+}
diff --git a/apps/web/src/services/renderer/nodes/effect-layer-node.ts b/apps/web/src/services/renderer/nodes/effect-layer-node.ts
new file mode 100644
index 00000000..3d84be2b
--- /dev/null
+++ b/apps/web/src/services/renderer/nodes/effect-layer-node.ts
@@ -0,0 +1,82 @@
+import type { CanvasRenderer } from "../canvas-renderer";
+import { getEffect } from "@/lib/effects";
+import type { EffectParamValues } from "@/types/effects";
+import { BaseNode } from "./base-node";
+import { webglEffectRenderer } from "../webgl-effect-renderer";
+
+const TIME_EPSILON = 1e-6;
+
+export type EffectLayerNodeParams = {
+ effectType: string;
+ effectParams: EffectParamValues;
+ timeOffset: number;
+ duration: number;
+};
+
+function isInRange({
+ time,
+ timeOffset,
+ duration,
+}: {
+ time: number;
+ timeOffset: number;
+ duration: number;
+}): boolean {
+ return (
+ time >= timeOffset - TIME_EPSILON &&
+ time < timeOffset + duration + TIME_EPSILON
+ );
+}
+
+// snapshots whatever is currently on the canvas, applies the effect, draws it back
+export class EffectLayerNode extends BaseNode<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();
+ }
+}
diff --git a/apps/web/src/services/renderer/nodes/image-node.ts b/apps/web/src/services/renderer/nodes/image-node.ts
index 6f4f2bd8..6720a5e5 100644
--- a/apps/web/src/services/renderer/nodes/image-node.ts
+++ b/apps/web/src/services/renderer/nodes/image-node.ts
@@ -73,7 +73,7 @@ export class ImageNode extends VisualNode<ImageNodeParams> {
async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
await super.render({ renderer, time });
- if (!this.isInRange(time)) {
+ if (!this.isInRange({ time })) {
return;
}
diff --git a/apps/web/src/services/renderer/nodes/sticker-node.ts b/apps/web/src/services/renderer/nodes/sticker-node.ts
index 86f14fb4..da271685 100644
--- a/apps/web/src/services/renderer/nodes/sticker-node.ts
+++ b/apps/web/src/services/renderer/nodes/sticker-node.ts
@@ -51,7 +51,7 @@ export class StickerNode extends VisualNode<StickerNodeParams> {
async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
await super.render({ renderer, time });
- if (!this.isInRange(time)) {
+ if (!this.isInRange({ time })) {
return;
}
diff --git a/apps/web/src/services/renderer/nodes/video-node.ts b/apps/web/src/services/renderer/nodes/video-node.ts
index 9ae63661..503c9c03 100644
--- a/apps/web/src/services/renderer/nodes/video-node.ts
+++ b/apps/web/src/services/renderer/nodes/video-node.ts
@@ -12,11 +12,11 @@ export class VideoNode extends VisualNode<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,
diff --git a/apps/web/src/services/renderer/nodes/visual-node.ts b/apps/web/src/services/renderer/nodes/visual-node.ts
index 50451b4a..15a7b081 100644
--- a/apps/web/src/services/renderer/nodes/visual-node.ts
+++ b/apps/web/src/services/renderer/nodes/visual-node.ts
@@ -1,5 +1,7 @@
import type { CanvasRenderer } from "../canvas-renderer";
+import { createOffscreenCanvas } from "../canvas-utils";
import { BaseNode } from "./base-node";
+import type { Effect } from "@/types/effects";
import type { BlendMode } from "@/types/rendering";
import type { Transform } from "@/types/timeline";
import type { ElementAnimations } from "@/types/animation";
@@ -9,6 +11,8 @@ import {
resolveTransformAtTime,
} from "@/lib/animation";
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
+import { getEffect } from "@/lib/effects";
+import { webglEffectRenderer } from "../webgl-effect-renderer";
export interface VisualNodeParams {
duration: number;
@@ -19,16 +23,17 @@ export interface VisualNodeParams {
animations?: ElementAnimations;
opacity: number;
blendMode?: BlendMode;
+ effects?: Effect[];
}
export abstract class VisualNode<
Params extends VisualNodeParams = VisualNodeParams,
> extends BaseNode<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();
}
}
diff --git a/apps/web/src/services/renderer/scene-builder.ts b/apps/web/src/services/renderer/scene-builder.ts
index 2bbcf25f..695e077f 100644
--- a/apps/web/src/services/renderer/scene-builder.ts
+++ b/apps/web/src/services/renderer/scene-builder.ts
@@ -6,51 +6,59 @@ 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;
-export type BuildSceneParams = {
- canvasSize: TCanvasSize;
+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[];
- mediaAssets: MediaAsset[];
- duration: number;
- background: TBackground;
+ mediaMap: Map<string, MediaAsset>;
+ canvasSize: TCanvasSize;
isPreview?: boolean;
-};
-
-export function buildScene(params: BuildSceneParams) {
- const { tracks, mediaAssets, duration, canvasSize, background } = params;
-
- const rootNode = new RootNode({ duration });
- const mediaMap = new Map(mediaAssets.map((m) => [m.id, m]));
+}): BaseNode[] {
+ const nodes: BaseNode[] = [];
- const visibleTracks = tracks.filter(
- (track) => !("hidden" in track && track.hidden),
- );
-
- const orderedTracksTopToBottom = [
- ...visibleTracks.filter((track) => !isMainTrack(track)),
- ...visibleTracks.filter((track) => isMainTrack(track)),
- ];
-
- 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 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) {
@@ -58,7 +66,7 @@ export function buildScene(params: BuildSceneParams) {
}
if (mediaAsset.type === "video") {
- contentNodes.push(
+ nodes.push(
new VideoNode({
mediaId: mediaAsset.id,
url: mediaAsset.url,
@@ -71,11 +79,12 @@ export function buildScene(params: BuildSceneParams) {
animations: element.animations,
opacity: element.opacity,
blendMode: element.blendMode,
+ effects: element.effects,
}),
);
}
if (mediaAsset.type === "image") {
- contentNodes.push(
+ nodes.push(
new ImageNode({
url: mediaAsset.url,
duration: element.duration,
@@ -86,7 +95,8 @@ export function buildScene(params: BuildSceneParams) {
animations: element.animations,
opacity: element.opacity,
blendMode: element.blendMode,
- ...(params.isPreview && {
+ effects: element.effects,
+ ...(isPreview && {
maxSourceSize: PREVIEW_MAX_IMAGE_SIZE,
}),
}),
@@ -95,18 +105,19 @@ export function buildScene(params: BuildSceneParams) {
}
if (element.type === "text") {
- contentNodes.push(
+ 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") {
- contentNodes.push(
+ nodes.push(
new StickerNode({
stickerId: element.stickerId,
duration: element.duration,
@@ -117,29 +128,74 @@ export function buildScene(params: BuildSceneParams) {
animations: element.animations,
opacity: element.opacity,
blendMode: element.blendMode,
+ effects: element.effects,
}),
);
}
}
}
+ return nodes;
+}
+
+export type BuildSceneParams = {
+ canvasSize: TCanvasSize;
+ tracks: TimelineTrack[];
+ mediaAssets: MediaAsset[];
+ duration: number;
+ background: TBackground;
+ isPreview?: boolean;
+};
+
+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]));
+
+ const visibleTracks = tracks.filter(
+ (track) => !("hidden" in track && track.hidden),
+ );
+
+ const orderedTracksTopToBottom = [
+ ...visibleTracks.filter((track) => !isMainTrack(track)),
+ ...visibleTracks.filter((track) => isMainTrack(track)),
+ ];
+
+ const orderedTracksBottomToTop = orderedTracksTopToBottom.slice().reverse();
+
+ const allNodes = buildTrackNodes({
+ tracks: orderedTracksBottomToTop,
+ mediaMap,
+ canvasSize,
+ isPreview,
+ });
+
if (background.type === "blur") {
rootNode.add(
- new BlurBackgroundNode({
- blurIntensity: background.blurIntensity ?? DEFAULT_BLUR_INTENSITY,
- contentNodes,
+ new CompositeEffectNode({
+ contentNodes: allNodes.filter(
+ (node) => !(node instanceof EffectLayerNode),
+ ),
+ effectType: "blur",
+ effectParams: {
+ intensity:
+ background.blurIntensity ?? DEFAULT_BLUR_INTENSITY,
+ },
+ scale: BLUR_BACKGROUND_ZOOM_SCALE,
}),
);
- for (const node of contentNodes) {
- rootNode.add(node);
- }
- } else {
- if (background.type === "color" && background.color !== "transparent") {
- rootNode.add(new ColorNode({ color: background.color }));
- }
- for (const node of contentNodes) {
- rootNode.add(node);
- }
+ } else if (background.type === "color" && background.color !== "transparent") {
+ rootNode.add(new ColorNode({ color: background.color }));
+ }
+
+ for (const node of allNodes) {
+ rootNode.add(node);
}
return rootNode;
diff --git a/apps/web/src/services/renderer/webgl-effect-renderer.ts b/apps/web/src/services/renderer/webgl-effect-renderer.ts
new file mode 100644
index 00000000..ad5cd40e
--- /dev/null
+++ b/apps/web/src/services/renderer/webgl-effect-renderer.ts
@@ -0,0 +1,73 @@
+import { createOffscreenCanvas } from "./canvas-utils";
+import { applyMultiPassEffect } from "./webgl-utils";
+import type { EffectPassData } from "./webgl-utils";
+
+export interface ApplyEffectParams {
+ source: CanvasImageSource;
+ width: number;
+ height: number;
+ passes: EffectPassData[];
+}
+
+let gl: WebGLRenderingContext | null = null;
+let canvas: OffscreenCanvas | HTMLCanvasElement | null = null;
+const programCache = new Map<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,
+};
diff --git a/apps/web/src/services/renderer/webgl-utils.ts b/apps/web/src/services/renderer/webgl-utils.ts
new file mode 100644
index 00000000..834c652e
--- /dev/null
+++ b/apps/web/src/services/renderer/webgl-utils.ts
@@ -0,0 +1,298 @@
+import VERTEX_SHADER_SOURCE from "@/lib/effects/effect.vert.glsl";
+
+export interface EffectPassData {
+ fragmentShader: string;
+ uniforms: Record<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);
+}
diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts
index fd40f14c..86a202b2 100644
--- a/apps/web/src/services/storage/migrations/index.ts
+++ b/apps/web/src/services/storage/migrations/index.ts
@@ -6,10 +6,11 @@ import { V3toV4Migration } from "./v3-to-v4";
import { V4toV5Migration } from "./v4-to-v5";
import { V5toV6Migration } from "./v5-to-v6";
import { V6toV7Migration } from "./v6-to-v7";
+import { V7toV8Migration } from "./v7-to-v8";
export { runStorageMigrations } from "./runner";
export type { MigrationProgress } from "./runner";
-export const CURRENT_PROJECT_VERSION = 7;
+export const CURRENT_PROJECT_VERSION = 8;
export const migrations = [
new V0toV1Migration(),
@@ -19,4 +20,5 @@ export const migrations = [
new V4toV5Migration(),
new V5toV6Migration(),
new V6toV7Migration(),
+ new V7toV8Migration(),
];
diff --git a/apps/web/src/services/storage/migrations/transformers/v7-to-v8.ts b/apps/web/src/services/storage/migrations/transformers/v7-to-v8.ts
new file mode 100644
index 00000000..7393f776
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/transformers/v7-to-v8.ts
@@ -0,0 +1,96 @@
+import type { MigrationResult, ProjectRecord } from "./types";
+import { getProjectId, isRecord } from "./utils";
+
+export function transformProjectV7ToV8({
+ project,
+}: {
+ project: ProjectRecord;
+}): MigrationResult<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;
+}
diff --git a/apps/web/src/services/storage/migrations/v7-to-v8.ts b/apps/web/src/services/storage/migrations/v7-to-v8.ts
new file mode 100644
index 00000000..33372c0a
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/v7-to-v8.ts
@@ -0,0 +1,16 @@
+import { StorageMigration } from "./base";
+import type { ProjectRecord } from "./transformers/types";
+import { transformProjectV7ToV8 } from "./transformers/v7-to-v8";
+
+export class V7toV8Migration extends StorageMigration {
+ from = 7;
+ to = 8;
+
+ async transform(project: ProjectRecord): Promise<{
+ project: ProjectRecord;
+ skipped: boolean;
+ reason?: string;
+ }> {
+ return transformProjectV7ToV8({ project });
+ }
+}
diff --git a/apps/web/src/types/drag.ts b/apps/web/src/types/drag.ts
index 61aba9b5..4e10d767 100644
--- a/apps/web/src/types/drag.ts
+++ b/apps/web/src/types/drag.ts
@@ -1,3 +1,5 @@
+import type { VisualElement } from "./timeline";
+
interface BaseDragData {
id: string;
name: string;
@@ -6,6 +8,7 @@ interface BaseDragData {
export interface MediaDragData extends BaseDragData {
type: "media";
mediaType: "image" | "video" | "audio";
+ targetElementTypes?: ("video" | "image")[];
}
export interface TextDragData extends BaseDragData {
@@ -18,4 +21,14 @@ export interface StickerDragData extends BaseDragData {
stickerId: string;
}
-export type TimelineDragData = MediaDragData | TextDragData | StickerDragData;
+export interface EffectDragData extends BaseDragData {
+ type: "effect";
+ effectType: string;
+ targetElementTypes: VisualElement["type"][];
+}
+
+export type TimelineDragData =
+ | MediaDragData
+ | TextDragData
+ | StickerDragData
+ | EffectDragData;
diff --git a/apps/web/src/types/effects.ts b/apps/web/src/types/effects.ts
new file mode 100644
index 00000000..cc369450
--- /dev/null
+++ b/apps/web/src/types/effects.ts
@@ -0,0 +1,45 @@
+export interface Effect {
+ id: string;
+ type: string;
+ params: EffectParamValues;
+ enabled: boolean;
+}
+
+export type EffectParamType = "number" | "boolean" | "select" | "color";
+
+export type EffectParamValues = Record<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;
+}
diff --git a/apps/web/src/types/glsl.d.ts b/apps/web/src/types/glsl.d.ts
new file mode 100644
index 00000000..4e93dbe5
--- /dev/null
+++ b/apps/web/src/types/glsl.d.ts
@@ -0,0 +1,4 @@
+declare module "*.glsl" {
+ const value: string;
+ export default value;
+}
diff --git a/apps/web/src/types/timeline.ts b/apps/web/src/types/timeline.ts
index 26690458..0c138f56 100644
--- a/apps/web/src/types/timeline.ts
+++ b/apps/web/src/types/timeline.ts
@@ -1,5 +1,6 @@
-import type { BlendMode, Transform } from "./rendering";
import type { ElementAnimations } from "./animation";
+import type { Effect, EffectParamValues } from "./effects";
+import type { BlendMode, Transform } from "./rendering";
export interface Bookmark {
time: number;
@@ -18,7 +19,7 @@ export interface TScene {
updatedAt: Date;
}
-export type TrackType = "video" | "text" | "audio" | "sticker";
+export type TrackType = "video" | "text" | "audio" | "sticker" | "effect";
interface BaseTrack {
id: string;
@@ -51,7 +52,18 @@ export interface StickerTrack extends BaseTrack {
hidden: boolean;
}
-export type TimelineTrack = VideoTrack | TextTrack | AudioTrack | StickerTrack;
+export interface EffectTrack extends BaseTrack {
+ type: "effect";
+ elements: EffectElement[];
+ hidden: boolean;
+}
+
+export type TimelineTrack =
+ | VideoTrack
+ | TextTrack
+ | AudioTrack
+ | StickerTrack
+ | EffectTrack;
export type { Transform } from "./rendering";
@@ -81,6 +93,7 @@ interface BaseTimelineElement {
startTime: number;
trimStart: number;
trimEnd: number;
+ sourceDuration?: number;
animations?: ElementAnimations;
}
@@ -92,6 +105,7 @@ export interface VideoElement extends BaseTimelineElement {
transform: Transform;
opacity: number;
blendMode?: BlendMode;
+ effects?: Effect[];
}
export interface ImageElement extends BaseTimelineElement {
@@ -101,6 +115,7 @@ export interface ImageElement extends BaseTimelineElement {
transform: Transform;
opacity: number;
blendMode?: BlendMode;
+ effects?: Effect[];
}
export interface TextElement extends BaseTimelineElement {
@@ -127,6 +142,7 @@ export interface TextElement extends BaseTimelineElement {
transform: Transform;
opacity: number;
blendMode?: BlendMode;
+ effects?: Effect[];
}
export interface StickerElement extends BaseTimelineElement {
@@ -136,6 +152,13 @@ export interface StickerElement extends BaseTimelineElement {
transform: Transform;
opacity: number;
blendMode?: BlendMode;
+ effects?: Effect[];
+}
+
+export interface EffectElement extends BaseTimelineElement {
+ type: "effect";
+ effectType: string;
+ params: EffectParamValues;
}
export type VisualElement =
@@ -154,7 +177,8 @@ export type TimelineElement =
| VideoElement
| ImageElement
| TextElement
- | StickerElement;
+ | StickerElement
+ | EffectElement;
export type ElementType = TimelineElement["type"];
@@ -167,12 +191,14 @@ export type CreateVideoElement = Omit<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 {