From 6ee16f9df8aeabfef0ca533f9f5b13daab3a3758 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 22 Jun 2025 19:28:03 +0200 Subject: [PATCH] feat: so much stuff --- .../web/src/components/editor/media-panel.tsx | 284 +++++++++--- .../src/components/editor/preview-panel.tsx | 106 ++++- .../components/editor/properties-panel.tsx | 97 +++++ apps/web/src/components/editor/timeline.tsx | 409 +++++++++++++++++- .../ui/image-timeline-treatment.tsx | 101 +++++ apps/web/src/hooks/use-drag-drop.ts | 35 +- apps/web/src/lib/media-processing.ts | 67 +++ apps/web/src/stores/media-store.ts | 135 ++++++ apps/web/src/stores/timeline-store.ts | 78 +++- 9 files changed, 1229 insertions(+), 83 deletions(-) create mode 100644 apps/web/src/components/ui/image-timeline-treatment.tsx create mode 100644 apps/web/src/lib/media-processing.ts diff --git a/apps/web/src/components/editor/media-panel.tsx b/apps/web/src/components/editor/media-panel.tsx index 565a4e7..17fe041 100644 --- a/apps/web/src/components/editor/media-panel.tsx +++ b/apps/web/src/components/editor/media-panel.tsx @@ -4,25 +4,76 @@ import { Button } from "../ui/button"; import { AspectRatio } from "../ui/aspect-ratio"; import { DragOverlay } from "../ui/drag-overlay"; import { useMediaStore } from "@/stores/media-store"; -import { Plus, Image, Video, Music } from "lucide-react"; +import { processMediaFiles } from "@/lib/media-processing"; +import { Plus, Image, Video, Music, Trash2, Upload } from "lucide-react"; import { useDragDrop } from "@/hooks/use-drag-drop"; +import { useRef, useState } from "react"; +import { toast } from "sonner"; export function MediaPanel() { - const { mediaItems, addMediaItem } = useMediaStore(); + const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore(); + const fileInputRef = useRef(null); + const [isProcessing, setIsProcessing] = useState(false); + + const processFiles = async (files: FileList | File[]) => { + setIsProcessing(true); + + try { + const processedItems = await processMediaFiles(files); + + for (const processedItem of processedItems) { + addMediaItem(processedItem); + toast.success(`Added ${processedItem.name} to project`); + } + } catch (error) { + console.error("Error processing files:", error); + toast.error("Failed to process files"); + } finally { + setIsProcessing(false); + } + }; const { isDragOver, dragProps } = useDragDrop({ onDrop: (files) => { - // TODO: Handle file drop functionality - console.log("Files dropped on media panel:", files); + processFiles(files); }, }); - const handleAddSampleMedia = () => { - // Just for testing - add a sample media item - addMediaItem({ - name: `Sample ${mediaItems.length + 1}`, - type: "image", - }); + const handleFileSelect = () => { + fileInputRef.current?.click(); + }; + + const handleFileInputChange = (e: React.ChangeEvent) => { + if (e.target.files && e.target.files.length > 0) { + processFiles(e.target.files); + // Reset the input so the same file can be selected again + e.target.value = ""; + } + }; + + const handleRemoveItem = (e: React.MouseEvent, itemId: string) => { + e.stopPropagation(); + removeMediaItem(itemId); + toast.success("Media removed from project"); + }; + + const handleDragStart = (e: React.DragEvent, item: any) => { + // Mark this as an internal app drag + e.dataTransfer.setData( + "application/x-media-item", + JSON.stringify({ + id: item.id, + type: item.type, + name: item.name, + }) + ); + e.dataTransfer.effectAllowed = "copy"; + }; + + const formatDuration = (duration: number) => { + const minutes = Math.floor(duration / 60); + const seconds = Math.floor(duration % 60); + return `${minutes}:${seconds.toString().padStart(2, "0")}`; }; const getMediaIcon = (type: string) => { @@ -36,63 +87,188 @@ export function MediaPanel() { } }; - return ( -
- - -
- {/* Media Grid */} - {mediaItems.length === 0 ? ( - + const renderMediaPreview = (item: any) => { + switch (item.type) { + case "image": + return ( + {item.name} handleDragStart(e, item)} + /> + ); + case "video": + return item.thumbnailUrl ? ( +
+ {item.name} handleDragStart(e, item)} + /> +
+
+ {item.duration && ( +
+ {formatDuration(item.duration)} +
+ )} +
) : ( -
- {mediaItems.map((item) => ( - + + {/* Remove button - positioned outside the button container */} +
e.preventDefault()} + onDrag={(e) => e.preventDefault()} + > +
- - - ))} -
- )} +
+ ))} +
+ )} + - + ); } -function EmptyMedia({ onAddSample }: { onAddSample: () => void }) { +function EmptyMedia({ + onFileSelect, + isProcessing, +}: { + onFileSelect: () => void; + isProcessing: boolean; +}) { return (
- + {isProcessing ? ( +
+ +
+ ) : ( + + )}
-

No media in project

-

- Drag files or click to add media +

+ {isProcessing ? "Processing files..." : "No media in project"}

- +

+ {isProcessing + ? "Please wait while files are being processed" + : "Drag files or click to add media"} +

+ {!isProcessing && ( + + )}
); } diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 4361e96..488e1fc 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -1,11 +1,111 @@ +"use client"; + +import { useTimelineStore } from "@/stores/timeline-store"; +import { useMediaStore } from "@/stores/media-store"; +import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment"; +import { Button } from "@/components/ui/button"; +import { Play, Pause } from "lucide-react"; +import { useState } from "react"; + export function PreviewPanel() { - return ( -
-
+ const { tracks } = useTimelineStore(); + const { mediaItems } = useMediaStore(); + const [isPlaying, setIsPlaying] = useState(false); + + // Get the first clip from the first track for preview (simplified for now) + const firstClip = tracks[0]?.clips[0]; + const firstMediaItem = firstClip + ? mediaItems.find((item) => item.id === firstClip.mediaId) + : null; + + const renderPreviewContent = () => { + if (!firstMediaItem) { + return (
Drop media here or click to import
+ ); + } + + if (firstMediaItem.type === "image") { + return ( + + ); + } + + if (firstMediaItem.type === "video") { + return firstMediaItem.thumbnailUrl ? ( + {firstMediaItem.name} + ) : ( +
+ Video Preview +
+ ); + } + + if (firstMediaItem.type === "audio") { + return ( +
+
+
🎵
+

{firstMediaItem.name}

+
+
+ ); + } + + return null; + }; + + return ( +
+
+ {renderPreviewContent()} + + {/* Playback Controls Overlay */} + {firstMediaItem && ( +
+
+ + + {firstClip?.name || "No clip selected"} + +
+
+ )}
+ + {/* Preview Info */} + {firstMediaItem && ( +
+

+ Preview: {firstMediaItem.name} + {firstMediaItem.type === "image" && + " (with CapCut-style treatment)"} +

+
+ )}
); } diff --git a/apps/web/src/components/editor/properties-panel.tsx b/apps/web/src/components/editor/properties-panel.tsx index 280b6da..af80f46 100644 --- a/apps/web/src/components/editor/properties-panel.tsx +++ b/apps/web/src/components/editor/properties-panel.tsx @@ -5,11 +5,108 @@ import { Label } from "../ui/label"; import { Slider } from "../ui/slider"; import { ScrollArea } from "../ui/scroll-area"; import { Separator } from "../ui/separator"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../ui/select"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { useMediaStore } from "@/stores/media-store"; +import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment"; +import { useState } from "react"; export function PropertiesPanel() { + const { tracks } = useTimelineStore(); + const { mediaItems } = useMediaStore(); + const [backgroundType, setBackgroundType] = useState< + "blur" | "mirror" | "color" + >("blur"); + const [backgroundColor, setBackgroundColor] = useState("#000000"); + + // Get the first image clip for preview (simplified) + const firstImageClip = tracks + .flatMap((track) => track.clips) + .find((clip) => { + const mediaItem = mediaItems.find((item) => item.id === clip.mediaId); + return mediaItem?.type === "image"; + }); + + const firstImageItem = firstImageClip + ? mediaItems.find((item) => item.id === firstImageClip.mediaId) + : null; + return (
+ {/* Image Treatment - only show if an image is selected */} + {firstImageItem && ( + <> +
+

Image Treatment

+
+ {/* Preview */} +
+ +
+ +
+
+ + {/* Background Type */} +
+ + +
+ + {/* Background Color - only show for color type */} + {backgroundType === "color" && ( +
+ +
+ setBackgroundColor(e.target.value)} + className="w-16 h-10 p-1" + /> + setBackgroundColor(e.target.value)} + placeholder="#000000" + className="flex-1" + /> +
+
+ )} +
+
+ + + + )} + {/* Transform */}

Transform

diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index dd31276..2e23bfb 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -19,17 +19,164 @@ import { } from "../ui/tooltip"; import { DragOverlay } from "../ui/drag-overlay"; import { useTimelineStore, type TimelineTrack } from "@/stores/timeline-store"; -import { useDragDrop } from "@/hooks/use-drag-drop"; +import { useMediaStore } from "@/stores/media-store"; +import { processMediaFiles } from "@/lib/media-processing"; +import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment"; +import { toast } from "sonner"; +import { useState, useRef } from "react"; export function Timeline() { - const { tracks, addTrack } = useTimelineStore(); + const { tracks, addTrack, addClipToTrack } = useTimelineStore(); + const { mediaItems, addMediaItem } = useMediaStore(); + const [isDragOver, setIsDragOver] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + const dragCounterRef = useRef(0); - const { isDragOver, dragProps } = useDragDrop({ - onDrop: (files) => { - // TODO: Handle file drop functionality for timeline - console.log("Files dropped on timeline:", files); - }, - }); + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault(); + + // Don't show overlay for timeline clips or other internal drags + if (e.dataTransfer.types.includes("application/x-timeline-clip")) { + return; + } + + dragCounterRef.current += 1; + if (!isDragOver) { + setIsDragOver(true); + } + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + + // Don't update state for timeline clips + if (e.dataTransfer.types.includes("application/x-timeline-clip")) { + return; + } + + dragCounterRef.current -= 1; + if (dragCounterRef.current === 0) { + setIsDragOver(false); + } + }; + + const handleDrop = async (e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + dragCounterRef.current = 0; + + // Check if this is a timeline clip drop - now we'll handle it! + const timelineClipData = e.dataTransfer.getData( + "application/x-timeline-clip" + ); + if (timelineClipData) { + // Timeline clips dropped on the main timeline area (not on a specific track) + // For now, we'll just ignore these - clips should be dropped on specific tracks + return; + } + + // Check if this is an internal media item drop + const mediaItemData = e.dataTransfer.getData("application/x-media-item"); + if (mediaItemData) { + try { + const { id, type, name } = JSON.parse(mediaItemData); + + // Find the full media item from the store + const mediaItem = mediaItems.find((item) => item.id === id); + if (!mediaItem) { + toast.error("Media item not found"); + return; + } + + // Determine track type based on media type + let trackType: "video" | "audio" | "effects"; + if (type === "video") { + trackType = "video"; + } else if (type === "audio") { + trackType = "audio"; + } else { + // For images, we'll put them on video tracks + trackType = "video"; + } + + // Create a new track and get its ID + const newTrackId = addTrack(trackType); + + // Add the clip to the new track + addClipToTrack(newTrackId, { + mediaId: mediaItem.id, + name: mediaItem.name, + duration: mediaItem.duration || 5, // Default 5 seconds for images + }); + + toast.success(`Added ${name} to ${trackType} track`); + } catch (error) { + console.error("Error parsing media item data:", error); + toast.error("Failed to add media to timeline"); + } + } else if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { + // Handle external file drops + setIsProcessing(true); + + try { + const processedItems = await processMediaFiles(e.dataTransfer.files); + + for (const processedItem of processedItems) { + // Add to media store first + addMediaItem(processedItem); + + // The media item now has an ID, let's get it from the latest state + // Since addMediaItem is synchronous, we can get the latest item + const currentMediaItems = useMediaStore.getState().mediaItems; + const addedItem = currentMediaItems.find( + (item) => + item.name === processedItem.name && item.url === processedItem.url + ); + + if (addedItem) { + // Determine track type based on media type + let trackType: "video" | "audio" | "effects"; + if (processedItem.type === "video") { + trackType = "video"; + } else if (processedItem.type === "audio") { + trackType = "audio"; + } else { + // For images, we'll put them on video tracks + trackType = "video"; + } + + // Create a new track and get its ID + const newTrackId = addTrack(trackType); + + // Add the clip to the new track + addClipToTrack(newTrackId, { + mediaId: addedItem.id, + name: addedItem.name, + duration: addedItem.duration || 5, // Default 5 seconds for images + }); + + toast.success(`Added ${processedItem.name} to timeline`); + } + } + } catch (error) { + console.error("Error processing external files:", error); + toast.error("Failed to process dropped files"); + } finally { + setIsProcessing(false); + } + } + }; + + const dragProps = { + onDragEnter: handleDragEnter, + onDragOver: handleDragOver, + onDragLeave: handleDragLeave, + onDrop: handleDrop, + }; return (
{/* Toolbar */} @@ -154,6 +305,149 @@ export function Timeline() { } function TimelineTrackComponent({ track }: { track: TimelineTrack }) { + const { mediaItems } = useMediaStore(); + const { moveClipToTrack, reorderClipInTrack } = useTimelineStore(); + const [isDropping, setIsDropping] = useState(false); + + const handleClipDragStart = (e: React.DragEvent, clip: any) => { + // Mark this as an timeline clip drag to differentiate from media items + const dragData = { + clipId: clip.id, + trackId: track.id, + name: clip.name, + }; + + e.dataTransfer.setData( + "application/x-timeline-clip", + JSON.stringify(dragData) + ); + e.dataTransfer.effectAllowed = "move"; + + // Use the entire clip container as the drag image instead of just the content + const target = e.currentTarget as HTMLElement; + e.dataTransfer.setDragImage( + target, + target.offsetWidth / 2, + target.offsetHeight / 2 + ); + }; + + const handleTrackDragOver = (e: React.DragEvent) => { + e.preventDefault(); + + // Only handle timeline clip drags + if (!e.dataTransfer.types.includes("application/x-timeline-clip")) { + return; + } + + e.dataTransfer.dropEffect = "move"; + }; + + const handleTrackDragEnter = (e: React.DragEvent) => { + e.preventDefault(); + + // Only handle timeline clip drags + if (!e.dataTransfer.types.includes("application/x-timeline-clip")) { + return; + } + + setIsDropping(true); + }; + + const handleTrackDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + + // Only handle timeline clip drags + if (!e.dataTransfer.types.includes("application/x-timeline-clip")) { + return; + } + + // Check if we're actually leaving the track area + const rect = e.currentTarget.getBoundingClientRect(); + const x = e.clientX; + const y = e.clientY; + + const isActuallyLeaving = + x < rect.left || x > rect.right || y < rect.top || y > rect.bottom; + + if (isActuallyLeaving) { + setIsDropping(false); + } + }; + + const handleTrackDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDropping(false); + + // Only handle timeline clip drags + if (!e.dataTransfer.types.includes("application/x-timeline-clip")) { + return; + } + + const timelineClipData = e.dataTransfer.getData( + "application/x-timeline-clip" + ); + + if (!timelineClipData) { + return; + } + + try { + const parsedData = JSON.parse(timelineClipData); + const { clipId, trackId: fromTrackId } = parsedData; + + // Calculate where to insert the clip based on mouse position + const trackContainer = e.currentTarget.querySelector( + ".track-clips-container" + ) as HTMLElement; + + if (!trackContainer) { + return; + } + + const rect = trackContainer.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + + // Calculate insertion index based on position + let insertIndex = 0; + const clipElements = trackContainer.querySelectorAll(".timeline-clip"); + + for (let i = 0; i < clipElements.length; i++) { + const clipRect = clipElements[i].getBoundingClientRect(); + const clipCenterX = clipRect.left + clipRect.width / 2 - rect.left; + + if (mouseX > clipCenterX) { + insertIndex = i + 1; + } else { + break; + } + } + + if (fromTrackId === track.id) { + // Moving within the same track - reorder + const currentIndex = track.clips.findIndex( + (clip) => clip.id === clipId + ); + + if (currentIndex !== -1 && currentIndex !== insertIndex) { + // Adjust index if we're moving to a position after the current one + const adjustedIndex = + insertIndex > currentIndex ? insertIndex - 1 : insertIndex; + + reorderClipInTrack(track.id, clipId, adjustedIndex); + toast.success("Clip reordered"); + } + } else { + // Moving between different tracks + moveClipToTrack(fromTrackId, track.id, clipId, insertIndex); + toast.success("Clip moved to different track"); + } + } catch (error) { + console.error("Error moving clip:", error); + toast.error("Failed to move clip"); + } + }; + const getTrackColor = (type: string) => { switch (type) { case "video": @@ -167,26 +461,93 @@ function TimelineTrackComponent({ track }: { track: TimelineTrack }) { } }; + const renderClipContent = (clip: any) => { + const mediaItem = mediaItems.find((item) => item.id === clip.mediaId); + + if (!mediaItem) { + return ( + {clip.name} + ); + } + + if (mediaItem.type === "image") { + return ( +
+
+ +
+ + {clip.name} + +
+ ); + } + + if (mediaItem.type === "video" && mediaItem.thumbnailUrl) { + return ( +
+
+ {mediaItem.name} +
+ + {clip.name} + +
+ ); + } + + // Fallback for audio or videos without thumbnails + return ( + {clip.name} + ); + }; + return (
{track.name}
-
- {track.clips.length === 0 ? ( -
- Drop media here -
- ) : ( -
- - {track.clips.length} clip{track.clips.length !== 1 ? "s" : ""} - -
- )} +
+
+ {track.clips.length === 0 ? ( +
+ Drop media here +
+ ) : ( + track.clips.map((clip, index) => ( +
handleClipDragStart(e, clip)} + > + {renderClipContent(clip)} +
+ )) + )} +
); diff --git a/apps/web/src/components/ui/image-timeline-treatment.tsx b/apps/web/src/components/ui/image-timeline-treatment.tsx new file mode 100644 index 0000000..600dd24 --- /dev/null +++ b/apps/web/src/components/ui/image-timeline-treatment.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useState } from "react"; +import { cn } from "@/lib/utils"; + +interface ImageTimelineTreatmentProps { + src: string; + alt: string; + targetAspectRatio?: number; // Default to 16:9 for video + className?: string; + backgroundType?: "blur" | "mirror" | "color"; + backgroundColor?: string; +} + +export function ImageTimelineTreatment({ + src, + alt, + targetAspectRatio = 16 / 9, + className, + backgroundType = "blur", + backgroundColor = "#000000", +}: ImageTimelineTreatmentProps) { + const [imageLoaded, setImageLoaded] = useState(false); + const [imageDimensions, setImageDimensions] = useState<{ + width: number; + height: number; + } | null>(null); + + const handleImageLoad = (e: React.SyntheticEvent) => { + const img = e.currentTarget; + setImageDimensions({ + width: img.naturalWidth, + height: img.naturalHeight, + }); + setImageLoaded(true); + }; + + const imageAspectRatio = imageDimensions + ? imageDimensions.width / imageDimensions.height + : 1; + + const needsAspectRatioTreatment = imageAspectRatio !== targetAspectRatio; + + return ( +
+ {/* Background Layer */} + {needsAspectRatioTreatment && imageLoaded && ( + <> + {backgroundType === "blur" && ( +
+ +
+
+ )} + + {backgroundType === "mirror" && ( +
+ +
+ )} + + {backgroundType === "color" && ( +
+ )} + + )} + + {/* Main Image Layer */} +
+ {alt} +
+ + {/* Loading state */} + {!imageLoaded && ( +
+
+ Loading... +
+
+ )} +
+ ); +} diff --git a/apps/web/src/hooks/use-drag-drop.ts b/apps/web/src/hooks/use-drag-drop.ts index 6b0c5a9..eaab89d 100644 --- a/apps/web/src/hooks/use-drag-drop.ts +++ b/apps/web/src/hooks/use-drag-drop.ts @@ -4,12 +4,29 @@ interface UseDragDropOptions { onDrop?: (files: FileList) => void; } +// Helper function to check if drag contains files from external sources (not internal app drags) +const containsFiles = (dataTransfer: DataTransfer): boolean => { + // Check if this is an internal app drag (media item) + if (dataTransfer.types.includes("application/x-media-item")) { + return false; + } + + // Only show overlay for external file drags + return dataTransfer.types.includes("Files"); +}; + export function useDragDrop(options: UseDragDropOptions = {}) { const [isDragOver, setIsDragOver] = useState(false); const dragCounterRef = useRef(0); const handleDragEnter = (e: React.DragEvent) => { e.preventDefault(); + + // Only handle external file drags, not internal app element drags + if (!containsFiles(e.dataTransfer)) { + return; + } + dragCounterRef.current += 1; if (!isDragOver) { setIsDragOver(true); @@ -18,10 +35,21 @@ export function useDragDrop(options: UseDragDropOptions = {}) { const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); + + // Only handle file drags + if (!containsFiles(e.dataTransfer)) { + return; + } }; const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); + + // Only handle file drags + if (!containsFiles(e.dataTransfer)) { + return; + } + dragCounterRef.current -= 1; if (dragCounterRef.current === 0) { setIsDragOver(false); @@ -33,7 +61,12 @@ export function useDragDrop(options: UseDragDropOptions = {}) { setIsDragOver(false); dragCounterRef.current = 0; - if (options.onDrop && e.dataTransfer.files) { + // Only handle file drops + if ( + options.onDrop && + e.dataTransfer.files && + containsFiles(e.dataTransfer) + ) { options.onDrop(e.dataTransfer.files); } }; diff --git a/apps/web/src/lib/media-processing.ts b/apps/web/src/lib/media-processing.ts new file mode 100644 index 0000000..c51dde5 --- /dev/null +++ b/apps/web/src/lib/media-processing.ts @@ -0,0 +1,67 @@ +import { toast } from "sonner"; +import { + getFileType, + generateVideoThumbnail, + getMediaDuration, + getImageAspectRatio, + type MediaItem, +} from "@/stores/media-store"; + +export interface ProcessedMediaItem extends Omit {} + +export async function processMediaFiles( + files: FileList | File[] +): Promise { + const fileArray = Array.from(files); + const processedItems: ProcessedMediaItem[] = []; + + for (const file of fileArray) { + const fileType = getFileType(file); + + if (!fileType) { + toast.error(`Unsupported file type: ${file.name}`); + continue; + } + + const url = URL.createObjectURL(file); + let thumbnailUrl: string | undefined; + let duration: number | undefined; + let aspectRatio: number = 16 / 9; // Default fallback + + try { + if (fileType === "image") { + // Get image aspect ratio + aspectRatio = await getImageAspectRatio(file); + } else if (fileType === "video") { + // Generate thumbnail and get aspect ratio for videos + const videoResult = await generateVideoThumbnail(file); + thumbnailUrl = videoResult.thumbnailUrl; + aspectRatio = videoResult.aspectRatio; + } else if (fileType === "audio") { + // For audio, use a square aspect ratio + aspectRatio = 1; + } + + // Get duration for videos and audio + if (fileType === "video" || fileType === "audio") { + duration = await getMediaDuration(file); + } + + processedItems.push({ + name: file.name, + type: fileType, + file, + url, + thumbnailUrl, + duration, + aspectRatio, + }); + } catch (error) { + console.error("Error processing file:", file.name, error); + toast.error(`Failed to process ${file.name}`); + URL.revokeObjectURL(url); // Clean up on error + } + } + + return processedItems; +} diff --git a/apps/web/src/stores/media-store.ts b/apps/web/src/stores/media-store.ts index 25d11af..bdb4d52 100644 --- a/apps/web/src/stores/media-store.ts +++ b/apps/web/src/stores/media-store.ts @@ -4,6 +4,11 @@ export interface MediaItem { id: string; name: string; type: "image" | "video" | "audio"; + file: File; + url: string; // Object URL for preview + thumbnailUrl?: string; // For video thumbnails + duration?: number; // For video/audio duration + aspectRatio: number; // width / height } interface MediaStore { @@ -12,8 +17,113 @@ interface MediaStore { // Actions addMediaItem: (item: Omit) => void; removeMediaItem: (id: string) => void; + clearAllMedia: () => void; } +// Helper function to determine file type +export const getFileType = (file: File): "image" | "video" | "audio" | null => { + const { type } = file; + + if (type.startsWith("image/")) { + return "image"; + } + if (type.startsWith("video/")) { + return "video"; + } + if (type.startsWith("audio/")) { + return "audio"; + } + + return null; +}; + +// Helper function to get image aspect ratio +export const getImageAspectRatio = (file: File): Promise => { + return new Promise((resolve, reject) => { + const img = new Image(); + + img.addEventListener("load", () => { + const aspectRatio = img.naturalWidth / img.naturalHeight; + resolve(aspectRatio); + img.remove(); + }); + + img.addEventListener("error", () => { + reject(new Error("Could not load image")); + img.remove(); + }); + + img.src = URL.createObjectURL(file); + }); +}; + +// Helper function to generate video thumbnail and get aspect ratio +export const generateVideoThumbnail = ( + file: File +): Promise<{ thumbnailUrl: string; aspectRatio: number }> => { + return new Promise((resolve, reject) => { + const video = document.createElement("video"); + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + + if (!ctx) { + reject(new Error("Could not get canvas context")); + return; + } + + video.addEventListener("loadedmetadata", () => { + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + + // Seek to 1 second or 10% of duration, whichever is smaller + video.currentTime = Math.min(1, video.duration * 0.1); + }); + + video.addEventListener("seeked", () => { + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + const thumbnailUrl = canvas.toDataURL("image/jpeg", 0.8); + const aspectRatio = video.videoWidth / video.videoHeight; + + resolve({ thumbnailUrl, aspectRatio }); + + // Cleanup + video.remove(); + canvas.remove(); + }); + + video.addEventListener("error", () => { + reject(new Error("Could not load video")); + video.remove(); + canvas.remove(); + }); + + video.src = URL.createObjectURL(file); + video.load(); + }); +}; + +// Helper function to get media duration +export const getMediaDuration = (file: File): Promise => { + return new Promise((resolve, reject) => { + const element = document.createElement( + file.type.startsWith("video/") ? "video" : "audio" + ) as HTMLVideoElement | HTMLAudioElement; + + element.addEventListener("loadedmetadata", () => { + resolve(element.duration); + element.remove(); + }); + + element.addEventListener("error", () => { + reject(new Error("Could not load media")); + element.remove(); + }); + + element.src = URL.createObjectURL(file); + element.load(); + }); +}; + export const useMediaStore = create((set, get) => ({ mediaItems: [], @@ -28,8 +138,33 @@ export const useMediaStore = create((set, get) => ({ }, removeMediaItem: (id) => { + const state = get(); + const item = state.mediaItems.find((item) => item.id === id); + + // Cleanup object URLs to prevent memory leaks + if (item) { + URL.revokeObjectURL(item.url); + if (item.thumbnailUrl) { + URL.revokeObjectURL(item.thumbnailUrl); + } + } + set((state) => ({ mediaItems: state.mediaItems.filter((item) => item.id !== id), })); }, + + clearAllMedia: () => { + const state = get(); + + // Cleanup all object URLs + state.mediaItems.forEach((item) => { + URL.revokeObjectURL(item.url); + if (item.thumbnailUrl) { + URL.revokeObjectURL(item.thumbnailUrl); + } + }); + + set({ mediaItems: [] }); + }, })); diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index 66fb085..243ed22 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -18,9 +18,21 @@ interface TimelineStore { tracks: TimelineTrack[]; // Actions - addTrack: (type: "video" | "audio" | "effects") => void; + addTrack: (type: "video" | "audio" | "effects") => string; removeTrack: (trackId: string) => void; addClipToTrack: (trackId: string, clip: Omit) => void; + removeClipFromTrack: (trackId: string, clipId: string) => void; + moveClipToTrack: ( + fromTrackId: string, + toTrackId: string, + clipId: string, + insertIndex?: number + ) => void; + reorderClipInTrack: ( + trackId: string, + clipId: string, + newIndex: number + ) => void; } export const useTimelineStore = create((set) => ({ @@ -36,6 +48,7 @@ export const useTimelineStore = create((set) => ({ set((state) => ({ tracks: [...state.tracks, newTrack], })); + return newTrack.id; }, removeTrack: (trackId) => { @@ -58,4 +71,67 @@ export const useTimelineStore = create((set) => ({ ), })); }, + + removeClipFromTrack: (trackId, clipId) => { + set((state) => ({ + tracks: state.tracks.map((track) => + track.id === trackId + ? { + ...track, + clips: track.clips.filter((clip) => clip.id !== clipId), + } + : track + ), + })); + }, + + moveClipToTrack: (fromTrackId, toTrackId, clipId, insertIndex) => { + set((state) => { + // Find the clip to move + const fromTrack = state.tracks.find((track) => track.id === fromTrackId); + const clipToMove = fromTrack?.clips.find((clip) => clip.id === clipId); + + if (!clipToMove) return state; + + return { + tracks: state.tracks.map((track) => { + if (track.id === fromTrackId) { + // Remove clip from source track + return { + ...track, + clips: track.clips.filter((clip) => clip.id !== clipId), + }; + } else if (track.id === toTrackId) { + // Add clip to destination track + const newClips = [...track.clips]; + const index = + insertIndex !== undefined ? insertIndex : newClips.length; + newClips.splice(index, 0, clipToMove); + return { + ...track, + clips: newClips, + }; + } + return track; + }), + }; + }); + }, + + reorderClipInTrack: (trackId, clipId, newIndex) => { + set((state) => ({ + tracks: state.tracks.map((track) => { + if (track.id !== trackId) return track; + + const clipIndex = track.clips.findIndex((clip) => clip.id === clipId); + if (clipIndex === -1) return track; + + const newClips = [...track.clips]; + const [movedClip] = newClips.splice(clipIndex, 1); + newClips.splice(newIndex, 0, movedClip); + + return { ...track, clips: newClips }; + }), + })); + }, }));