summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/timeline
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-02-28 18:41:22 +0100
committerMaze Winther <mazewinther@gmail.com>2026-02-28 18:41:22 +0100
commit216e3e0c397be3719a94e1a22bc1c2ce2de3856c (patch)
tree883bdda1539f11ccf5d6fe50a994e4e6dbee0d75 /apps/web/src/components/editor/panels/timeline
parenta9e93471a74a3d1978278f09f35632d4f6588a76 (diff)
downloadopencut-216e3e0c397be3719a94e1a22bc1c2ce2de3856c.tar.gz
feat: introduce WebGL effects system and Blur effect
This implements the foundational architecture for video effects, starting with a multi-pass WebGL rendering pipeline and a customizable Gaussian Blur effect. Key changes: - WebGL Engine: Added `raw-loader` for `.glsl` shaders, multi-pass framebuffer rendering, and live offscreen canvas previews. - Node Architecture: Replaced hardcoded background blur with `CompositeEffectNode` and added `EffectLayerNode` to apply effects to specific visual elements. - Timeline & DND: Added a new `effect` track type. Upgraded drag-and-drop to support dropping effects directly onto the timeline. Consolidated track constants into a cleaner `TRACK_CONFIG`. - UI/UX: Added an Effects tab in the assets panel with live previews. Added an Effect Properties panel with sliders and inputs for fine-tuning parameters. - Data Model: Added `sourceDuration` to video and audio elements, and wrote a v8 storage migration to update existing projects to the new schema. - Docs: Added `CHANGELOG.md` tracking v0.1.0 and v0.2.0, plus `docs/effects-renderer.md` to document the new WebGL pipeline.
Diffstat (limited to 'apps/web/src/components/editor/panels/timeline')
-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
3 files changed, 181 insertions, 100 deletions
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}
/>
);
})