From 294ba01abeb2d8c3d3a04cb7ecc54abf97a6d4d3 Mon Sep 17 00:00:00 2001 From: Hyteq Date: Mon, 23 Jun 2025 15:39:56 +0300 Subject: [PATCH] feat: initial state for composition and overlay editing on the video player --- apps/web/src/app/editor/page.tsx | 6 +- .../src/components/editor/preview-panel.tsx | 536 +++++++++++++++--- 2 files changed, 449 insertions(+), 93 deletions(-) diff --git a/apps/web/src/app/editor/page.tsx b/apps/web/src/app/editor/page.tsx index 2f825a2..64186a3 100644 --- a/apps/web/src/app/editor/page.tsx +++ b/apps/web/src/app/editor/page.tsx @@ -7,7 +7,7 @@ import { ResizableHandle, } from "../../components/ui/resizable"; import { MediaPanel } from "../../components/editor/media-panel"; -import { PropertiesPanel } from "../../components/editor/properties-panel"; +// import { PropertiesPanel } from "../../components/editor/properties-panel"; import { Timeline } from "../../components/editor/timeline"; import { PreviewPanel } from "../../components/editor/preview-panel"; import { EditorHeader } from "@/components/editor-header"; @@ -74,13 +74,13 @@ export default function Editor() { {/* Properties Panel */} - - + */} diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 132d6c0..5d03e94 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -3,110 +3,397 @@ import { useTimelineStore } from "@/stores/timeline-store"; import { useMediaStore } from "@/stores/media-store"; import { usePlaybackStore } from "@/stores/playback-store"; -import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment"; import { VideoPlayer } from "@/components/ui/video-player"; import { Button } from "@/components/ui/button"; -import { Play, Pause } from "lucide-react"; +import { Play, Pause, Move, RotateCw, Crop, ZoomIn, ZoomOut } from "lucide-react"; +import { useState, useRef, useEffect, useCallback } from "react"; + +interface ClipTransform { + x: number; + y: number; + scale: number; + rotation: number; + opacity: number; + width: number; + height: number; + blendMode: string; + cropTop: number; + cropBottom: number; + cropLeft: number; + cropRight: number; +} + +interface DragState { + isDragging: boolean; + dragType: 'move' | 'resize-nw' | 'resize-ne' | 'resize-sw' | 'resize-se' | 'rotate' | 'scale' | 'crop-n' | 'crop-s' | 'crop-e' | 'crop-w'; + startMouseX: number; + startMouseY: number; + startTransform: ClipTransform; + clipId: string; +} export function PreviewPanel() { const { tracks } = useTimelineStore(); const { mediaItems } = useMediaStore(); const { isPlaying, toggle, currentTime } = usePlaybackStore(); - // Find the active clip at the current playback time - const getActiveClip = () => { - for (const track of tracks) { - for (const clip of track.clips) { + const [clipTransforms, setClipTransforms] = useState>({}); + const [canvasSize, setCanvasSize] = useState({ width: 1920, height: 1080 }); // Default 16:9 + const [dragState, setDragState] = useState(null); + const previewRef = useRef(null); + + // Get all active clips at current time (for overlaying) + const getActiveClips = () => { + const activeClips: Array<{ + clip: any; + track: any; + mediaItem: any; + layer: number; + }> = []; + + tracks.forEach((track, trackIndex) => { + track.clips.forEach((clip) => { const clipStart = clip.startTime; const clipEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); if (currentTime >= clipStart && currentTime < clipEnd) { - return clip; + const mediaItem = clip.mediaId === "test" + ? { type: "test", name: clip.name, url: "", thumbnailUrl: "" } + : mediaItems.find((item) => item.id === clip.mediaId); + + if (mediaItem || clip.mediaId === "test") { + activeClips.push({ + clip, + track, + mediaItem, + layer: trackIndex, // Track index determines layer order + }); + } } + }); + }); + + // Sort by layer (track order) - higher index = on top + return activeClips.sort((a, b) => a.layer - b.layer); + }; + + const activeClips = getActiveClips(); + const aspectRatio = canvasSize.width / canvasSize.height; + + // Get or create transform for a clip + const getClipTransform = (clipId: string): ClipTransform => { + return clipTransforms[clipId] || { + x: 0, + y: 0, + scale: 1, + rotation: 0, + opacity: 1, + width: 100, // Percentage of canvas + height: 100, + blendMode: 'normal', + cropTop: 0, + cropBottom: 0, + cropLeft: 0, + cropRight: 0, + }; + }; + + // Update clip transform + const updateClipTransform = useCallback((clipId: string, updates: Partial) => { + setClipTransforms(prev => { + const currentTransform = prev[clipId] || { + x: 0, + y: 0, + scale: 1, + rotation: 0, + opacity: 1, + width: 100, + height: 100, + blendMode: 'normal', + cropTop: 0, + cropBottom: 0, + cropLeft: 0, + cropRight: 0, + }; + + return { + ...prev, + [clipId]: { ...currentTransform, ...updates } + }; + }); + }, []); + + // Mouse event handlers + const handleMouseDown = (e: React.MouseEvent, clipId: string, dragType: DragState['dragType']) => { + e.preventDefault(); + e.stopPropagation(); + + setDragState({ + isDragging: true, + dragType, + startMouseX: e.clientX, + startMouseY: e.clientY, + startTransform: getClipTransform(clipId), + clipId + }); + }; + + const handleMouseMove = useCallback((e: MouseEvent) => { + if (!dragState || !dragState.isDragging) return; + + const deltaX = e.clientX - dragState.startMouseX; + const deltaY = e.clientY - dragState.startMouseY; + const { startTransform, clipId, dragType } = dragState; + + switch (dragType) { + case 'move': + updateClipTransform(clipId, { + x: Math.max(-100, Math.min(100, startTransform.x + deltaX * 0.3)), + y: Math.max(-100, Math.min(100, startTransform.y + deltaY * 0.3)) + }); + break; + + case 'resize-nw': + updateClipTransform(clipId, { + width: Math.max(20, startTransform.width - deltaX * 0.5), + height: Math.max(20, startTransform.height - deltaY * 0.5) + }); + break; + + case 'resize-ne': + updateClipTransform(clipId, { + width: Math.max(20, startTransform.width + deltaX * 0.5), + height: Math.max(20, startTransform.height - deltaY * 0.5) + }); + break; + + case 'resize-sw': + updateClipTransform(clipId, { + width: Math.max(20, startTransform.width - deltaX * 0.5), + height: Math.max(20, startTransform.height + deltaY * 0.5) + }); + break; + + case 'resize-se': + updateClipTransform(clipId, { + width: Math.max(20, startTransform.width + deltaX * 0.5), + height: Math.max(20, startTransform.height + deltaY * 0.5) + }); + break; + + case 'rotate': + updateClipTransform(clipId, { + rotation: (startTransform.rotation + deltaX * 2) % 360 + }); + break; + + case 'scale': + updateClipTransform(clipId, { + scale: Math.max(0.1, Math.min(3, startTransform.scale + deltaX * 0.01)) + }); + break; + + case 'crop-n': + updateClipTransform(clipId, { + cropTop: Math.max(0, Math.min(40, startTransform.cropTop + deltaY * 0.2)) + }); + break; + + case 'crop-s': + updateClipTransform(clipId, { + cropBottom: Math.max(0, Math.min(40, startTransform.cropBottom - deltaY * 0.2)) + }); + break; + + case 'crop-e': + updateClipTransform(clipId, { + cropRight: Math.max(0, Math.min(40, startTransform.cropRight - deltaX * 0.2)) + }); + break; + + case 'crop-w': + updateClipTransform(clipId, { + cropLeft: Math.max(0, Math.min(40, startTransform.cropLeft + deltaX * 0.2)) + }); + break; + } + }, [dragState, updateClipTransform]); + + const handleMouseUp = useCallback(() => { + setDragState(null); + }, []); + + // Add global mouse event listeners + useEffect(() => { + if (dragState?.isDragging) { + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + } + }, [dragState, handleMouseMove, handleMouseUp]); + + // Initialize transforms for new clips + useEffect(() => { + const activeClips = getActiveClips(); + const newTransforms: Record = {}; + let hasNewClips = false; + + activeClips.forEach(({ clip }) => { + if (!clipTransforms[clip.id]) { + hasNewClips = true; + newTransforms[clip.id] = { + x: 0, + y: 0, + scale: 1, + rotation: 0, + opacity: 1, + width: 100, + height: 100, + blendMode: 'normal', + cropTop: 0, + cropBottom: 0, + cropLeft: 0, + cropRight: 0, + }; } + }); + + if (hasNewClips) { + setClipTransforms(prev => ({ ...prev, ...newTransforms })); } - return null; - }; + }, [tracks, currentTime]); // Re-run when tracks or time changes - const activeClip = getActiveClip(); - const activeMediaItem = activeClip - ? mediaItems.find((item) => item.id === activeClip.mediaId) - : null; - const aspectRatio = activeMediaItem?.aspectRatio || 16 / 9; - const renderContent = () => { - if (!activeClip) { + // Render a single clip layer + const renderClipLayer = (clipData: any, index: number) => { + const { clip, mediaItem } = clipData; + const transform = getClipTransform(clip.id); + + const layerStyle = { + position: 'absolute' as const, + left: '50%', + top: '50%', + width: `${transform.width}%`, + height: `${transform.height}%`, + transform: `translate(-50%, -50%) translate(${transform.x}%, ${transform.y}%) scale(${transform.scale}) rotate(${transform.rotation}deg)`, + opacity: transform.opacity, + mixBlendMode: transform.blendMode as any, + clipPath: `inset(${transform.cropTop}% ${transform.cropRight}% ${transform.cropBottom}% ${transform.cropLeft}%)`, + zIndex: index + 10, + cursor: dragState?.isDragging && dragState.clipId === clip.id ? 'grabbing' : 'grab', + userSelect: 'none' as const, + }; + + const handleClipMouseDown = (e: React.MouseEvent) => { + e.stopPropagation(); + handleMouseDown(e, clip.id, 'move'); + }; + + // Handle test clips + if (!mediaItem || clip.mediaId === "test") { return ( -
- {tracks.length === 0 ? "Drop media to start editing" : "No clip at current time"} -
- ); - } - - // Handle test clips without media items - if (!activeMediaItem && activeClip.mediaId === "test") { - return ( -
-
-
🎬
-

{activeClip.name}

-

Test clip for playback

+
+
+
🎬
+

{clip.name}

+ + {/* Hover resize corners */} +
{ e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-nw'); }}>
+
{ e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-ne'); }}>
+
{ e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-sw'); }}>
+
{ e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-se'); }}>
); } - if (!activeMediaItem) { + // Render video + if (mediaItem.type === "video") { return ( -
- Media not found +
+ + + {/* Hover resize corners */} +
{ e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-nw'); }}>
+
{ e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-ne'); }}>
+
{ e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-sw'); }}>
+
{ e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-se'); }}>
); } - if (activeMediaItem.type === "video") { + // Render image + if (mediaItem.type === "image") { return ( - +
+ {mediaItem.name} + + {/* Hover resize corners */} +
{ e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-nw'); }}>
+
{ e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-ne'); }}>
+
{ e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-sw'); }}>
+
{ e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-se'); }}>
+
); } - if (activeMediaItem.type === "image") { + // Render audio (visual representation) + if (mediaItem.type === "audio") { return ( - - ); - } - - if (activeMediaItem.type === "audio") { - return ( -
-
-
🎵
-

{activeMediaItem.name}

- +
+
+
🎵
+

{mediaItem.name}

+ + {/* Hover resize corners */} +
{ e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-nw'); }}>
+
{ e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-ne'); }}>
+
{ e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-sw'); }}>
+
{ e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-se'); }}>
); } @@ -114,31 +401,100 @@ export function PreviewPanel() { return null; }; + // Canvas size presets + const canvasPresets = [ + { name: "16:9 HD", width: 1920, height: 1080 }, + { name: "16:9 4K", width: 3840, height: 2160 }, + { name: "9:16 Mobile", width: 1080, height: 1920 }, + { name: "1:1 Square", width: 1080, height: 1080 }, + { name: "4:3 Standard", width: 1440, height: 1080 }, + ]; + return ( -
-
1 ? "100%" : "auto", - height: aspectRatio <= 1 ? "100%" : "auto", - maxWidth: "100%", - maxHeight: "100%", - }} - > - {renderContent()} +
+ {/* Canvas Controls */} +
+ Canvas: + + +
- {activeMediaItem && ( -
-

- {activeMediaItem.name} -

-

- {aspectRatio.toFixed(2)} • {aspectRatio > 1 ? "Landscape" : aspectRatio < 1 ? "Portrait" : "Square"} -

+ {/* Preview Area - Full Width */} +
+
1 ? "100%" : "auto", + height: aspectRatio <= 1 ? "100%" : "auto", + maxWidth: "100%", + maxHeight: "100%", + background: '#000000', + border: '1px solid #374151' + }} + + > + + + {/* Render all active clips as layers */} + {activeClips.length === 0 ? ( +
+ {tracks.length === 0 ? "Drop media to start editing" : "No clips at current time"} +
+ ) : ( + activeClips.map((clipData, index) => renderClipLayer(clipData, index)) + )} + +
- )} +
+ + {/* Bottom Info Panel */} +
+ {/* Layer List */} +
+
Active Layers ({activeClips.length})
+
+ {activeClips.map((clipData, index) => ( +
+ + {index + 1} + + {clipData.clip.name} + {clipData.track.name} +
+ ))} +
+
+ + +
); }