mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: introduce WebGL effects system and Blur effect
This implements the foundational architecture for video effects, starting with a multi-pass WebGL rendering pipeline and a customizable Gaussian Blur effect. Key changes: - WebGL Engine: Added `raw-loader` for `.glsl` shaders, multi-pass framebuffer rendering, and live offscreen canvas previews. - Node Architecture: Replaced hardcoded background blur with `CompositeEffectNode` and added `EffectLayerNode` to apply effects to specific visual elements. - Timeline & DND: Added a new `effect` track type. Upgraded drag-and-drop to support dropping effects directly onto the timeline. Consolidated track constants into a cleaner `TRACK_CONFIG`. - UI/UX: Added an Effects tab in the assets panel with live previews. Added an Effect Properties panel with sliders and inputs for fine-tuning parameters. - Data Model: Added `sourceDuration` to video and audio elements, and wrote a v8 storage migration to update existing projects to the new schema. - Docs: Added `CHANGELOG.md` tracking v0.1.0 and v0.2.0, plus `docs/effects-renderer.md` to document the new WebGL pipeline.
This commit is contained in:
@@ -86,12 +86,8 @@ export function useTimelineElementResize({
|
||||
};
|
||||
|
||||
const canExtendElementDuration = useCallback(() => {
|
||||
if (element.type === "text" || element.type === "image") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [element.type]);
|
||||
return element.sourceDuration == null;
|
||||
}, [element.sourceDuration]);
|
||||
|
||||
const updateTrimFromMouseMove = useCallback(
|
||||
({ clientX }: { clientX: number }) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEditor } from "../use-editor";
|
||||
|
||||
interface UseSelectionBoxProps {
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
headerRef: React.RefObject<HTMLElement | null>;
|
||||
onSelectionComplete: (
|
||||
elements: { trackId: string; elementId: string }[],
|
||||
) => void;
|
||||
@@ -88,6 +89,7 @@ function isRectangleIntersecting({
|
||||
|
||||
export function useSelectionBox({
|
||||
containerRef,
|
||||
headerRef,
|
||||
onSelectionComplete,
|
||||
isEnabled = true,
|
||||
tracksScrollRef,
|
||||
@@ -131,6 +133,8 @@ export function useSelectionBox({
|
||||
endPos,
|
||||
});
|
||||
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const timelineHeaderHeight =
|
||||
headerRef.current?.getBoundingClientRect().height ?? 0;
|
||||
const selectedElements: { trackId: string; elementId: string }[] = [];
|
||||
|
||||
for (const [trackIndex, track] of tracks.entries()) {
|
||||
@@ -139,8 +143,9 @@ export function useSelectionBox({
|
||||
trackIndex,
|
||||
});
|
||||
const trackHeight = getTrackHeight({ type: track.type });
|
||||
const elementTop = trackTop;
|
||||
const elementBottom = trackTop + trackHeight;
|
||||
const elementTop =
|
||||
timelineHeaderHeight + TIMELINE_CONSTANTS.PADDING_TOP_PX + trackTop;
|
||||
const elementBottom = elementTop + trackHeight;
|
||||
|
||||
for (const element of track.elements) {
|
||||
const elementLeft = element.startTime * pixelsPerSecond;
|
||||
@@ -168,7 +173,14 @@ export function useSelectionBox({
|
||||
}
|
||||
onSelectionComplete(selectedElements);
|
||||
},
|
||||
[containerRef, onSelectionComplete, tracks, tracksScrollRef, zoomLevel],
|
||||
[
|
||||
containerRef,
|
||||
headerRef,
|
||||
onSelectionComplete,
|
||||
tracks,
|
||||
tracksScrollRef,
|
||||
zoomLevel,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
buildTextElement,
|
||||
buildStickerElement,
|
||||
buildElementFromMedia,
|
||||
buildEffectElement,
|
||||
} from "@/lib/timeline/element-utils";
|
||||
import type { Command } from "@/lib/commands/base-command";
|
||||
import { AddMediaAssetCommand } from "@/lib/commands/media";
|
||||
@@ -16,7 +17,11 @@ import { BatchCommand } from "@/lib/commands";
|
||||
import { computeDropTarget } from "@/lib/timeline/drop-utils";
|
||||
import { getDragData, hasDragData } from "@/lib/drag-data";
|
||||
import type { TrackType, DropTarget, ElementType } from "@/types/timeline";
|
||||
import type { MediaDragData, StickerDragData } from "@/types/drag";
|
||||
import type {
|
||||
MediaDragData,
|
||||
StickerDragData,
|
||||
EffectDragData,
|
||||
} from "@/types/drag";
|
||||
|
||||
interface UseTimelineDragDropProps {
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
@@ -54,6 +59,7 @@ export function useTimelineDragDrop({
|
||||
|
||||
if (dragData.type === "text") return "text";
|
||||
if (dragData.type === "sticker") return "sticker";
|
||||
if (dragData.type === "effect") return "effect";
|
||||
if (dragData.type === "media") {
|
||||
return dragData.mediaType;
|
||||
}
|
||||
@@ -70,7 +76,11 @@ export function useTimelineDragDrop({
|
||||
elementType: ElementType;
|
||||
mediaId?: string;
|
||||
}): number => {
|
||||
if (elementType === "text" || elementType === "sticker") {
|
||||
if (
|
||||
elementType === "text" ||
|
||||
elementType === "sticker" ||
|
||||
elementType === "effect"
|
||||
) {
|
||||
return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
}
|
||||
if (mediaId) {
|
||||
@@ -124,6 +134,13 @@ export function useTimelineDragDrop({
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = Math.max(0, e.clientY - rect.top - headerHeight);
|
||||
|
||||
const targetElementTypes =
|
||||
dragData?.type === "effect"
|
||||
? (dragData as EffectDragData).targetElementTypes
|
||||
: dragData?.type === "media"
|
||||
? (dragData as MediaDragData).targetElementTypes
|
||||
: undefined;
|
||||
|
||||
const target = computeDropTarget({
|
||||
elementType,
|
||||
mouseX,
|
||||
@@ -134,6 +151,7 @@ export function useTimelineDragDrop({
|
||||
elementDuration: duration,
|
||||
pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
|
||||
zoomLevel,
|
||||
targetElementTypes,
|
||||
});
|
||||
|
||||
target.xPosition = getSnappedTime({ time: target.xPosition });
|
||||
@@ -248,6 +266,11 @@ export function useTimelineDragDrop({
|
||||
|
||||
const executeMediaDrop = useCallback(
|
||||
({ target, dragData }: { target: DropTarget; dragData: MediaDragData }) => {
|
||||
if (target.targetElement) {
|
||||
toast.info("Replace media source is coming soon!");
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaAsset = mediaAssets.find((m) => m.id === dragData.id);
|
||||
if (!mediaAsset) return;
|
||||
|
||||
@@ -284,6 +307,42 @@ export function useTimelineDragDrop({
|
||||
[editor.timeline, mediaAssets, tracks],
|
||||
);
|
||||
|
||||
const executeEffectDrop = useCallback(
|
||||
({ target, dragData }: { target: DropTarget; dragData: EffectDragData }) => {
|
||||
const effectTrack = tracks.find((t) => t.type === "effect");
|
||||
let trackId: string;
|
||||
|
||||
if (effectTrack && !target.targetElement) {
|
||||
trackId = effectTrack.id;
|
||||
} else if (target.targetElement) {
|
||||
trackId = effectTrack?.id ?? editor.timeline.addTrack({
|
||||
type: "effect",
|
||||
index: 0,
|
||||
});
|
||||
} else if (target.isNewTrack) {
|
||||
trackId = editor.timeline.addTrack({
|
||||
type: "effect",
|
||||
index: target.trackIndex,
|
||||
});
|
||||
} else {
|
||||
const track = tracks[target.trackIndex];
|
||||
if (!track || track.type !== "effect") return;
|
||||
trackId = track.id;
|
||||
}
|
||||
|
||||
const element = buildEffectElement({
|
||||
effectType: dragData.effectType,
|
||||
startTime: target.xPosition,
|
||||
});
|
||||
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
element,
|
||||
});
|
||||
},
|
||||
[editor.timeline, tracks],
|
||||
);
|
||||
|
||||
const executeFileDrop = useCallback(
|
||||
async ({
|
||||
files,
|
||||
@@ -384,6 +443,11 @@ export function useTimelineDragDrop({
|
||||
executeTextDrop({ target: currentTarget, dragData });
|
||||
} else if (dragData.type === "sticker") {
|
||||
executeStickerDrop({ target: currentTarget, dragData });
|
||||
} else if (dragData.type === "effect") {
|
||||
executeEffectDrop({
|
||||
target: currentTarget,
|
||||
dragData: dragData as EffectDragData,
|
||||
});
|
||||
} else {
|
||||
executeMediaDrop({ target: currentTarget, dragData });
|
||||
}
|
||||
@@ -410,6 +474,7 @@ export function useTimelineDragDrop({
|
||||
executeTextDrop,
|
||||
executeStickerDrop,
|
||||
executeMediaDrop,
|
||||
executeEffectDrop,
|
||||
executeFileDrop,
|
||||
containerRef,
|
||||
headerRef,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { effectPreviewService } from "@/services/renderer/effect-preview";
|
||||
import type { EffectParamValues } from "@/types/effects";
|
||||
|
||||
export function useEffectPreview({
|
||||
effectType,
|
||||
params,
|
||||
canvasRef,
|
||||
isActive,
|
||||
}: {
|
||||
effectType: string;
|
||||
params: EffectParamValues;
|
||||
canvasRef: React.RefObject<HTMLCanvasElement | null>;
|
||||
isActive: boolean;
|
||||
}): void {
|
||||
const requestRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
if (requestRef.current) {
|
||||
cancelAnimationFrame(requestRef.current);
|
||||
requestRef.current = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const loop = (): void => {
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
effectPreviewService.renderPreview({
|
||||
effectType,
|
||||
params,
|
||||
targetCanvas: canvas,
|
||||
});
|
||||
}
|
||||
requestRef.current = requestAnimationFrame(loop);
|
||||
};
|
||||
|
||||
requestRef.current = requestAnimationFrame(loop);
|
||||
|
||||
return () => {
|
||||
if (requestRef.current) {
|
||||
cancelAnimationFrame(requestRef.current);
|
||||
}
|
||||
};
|
||||
}, [effectType, params, canvasRef, isActive]);
|
||||
}
|
||||
Reference in New Issue
Block a user