diff --git a/apps/web/src/components/editor/media-panel/views/media.tsx b/apps/web/src/components/editor/media-panel/views/media.tsx index 712dd9223..99a51ae44 100644 --- a/apps/web/src/components/editor/media-panel/views/media.tsx +++ b/apps/web/src/components/editor/media-panel/views/media.tsx @@ -9,290 +9,330 @@ import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { MediaDragOverlay } from "@/components/editor/media-panel/drag-overlay"; import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger, + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, } from "@/components/ui/context-menu"; import { Input } from "@/components/ui/input"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, } from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; import { DraggableMediaItem } from "@/components/ui/draggable-item"; import { useProjectStore } from "@/stores/project-store"; import { useTimelineStore } from "@/stores/timeline-store"; export function MediaView() { - const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore(); - const { activeProject } = useProjectStore(); - const fileInputRef = useRef(null); - const [isProcessing, setIsProcessing] = useState(false); - const [progress, setProgress] = useState(0); - const [searchQuery, setSearchQuery] = useState(""); - const [mediaFilter, setMediaFilter] = useState("all"); + const { + mediaItems, + addMediaItem, + removeMediaItem, + isLoading, + initialMediaCount, + } = useMediaStore(); + const { activeProject } = useProjectStore(); + const fileInputRef = useRef(null); + const [isProcessing, setIsProcessing] = useState(false); + const [progress, setProgress] = useState(0); + const [searchQuery, setSearchQuery] = useState(""); + const [mediaFilter, setMediaFilter] = useState("all"); - const processFiles = async (files: FileList | File[]) => { - if (!files || files.length === 0) return; - if (!activeProject) { - toast.error("No active project"); - return; - } + const processFiles = async (files: FileList | File[]) => { + if (!files || files.length === 0) return; + if (!activeProject) { + toast.error("No active project"); + return; + } - setIsProcessing(true); - setProgress(0); - try { - // Process files (extract metadata, generate thumbnails, etc.) - const processedItems = await processMediaFiles(files, (p) => - setProgress(p), - ); - // Add each processed media item to the store - for (const item of processedItems) { - await addMediaItem(activeProject.id, item); - } - } catch (error) { - // Show error toast if processing fails - console.error("Error processing files:", error); - toast.error("Failed to process files"); - } finally { - setIsProcessing(false); - setProgress(0); - } - }; + setIsProcessing(true); + setProgress(0); + try { + // Process files (extract metadata, generate thumbnails, etc.) + const processedItems = await processMediaFiles(files, (p) => + setProgress(p), + ); + // Add each processed media item to the store + for (const item of processedItems) { + await addMediaItem(activeProject.id, item); + } + } catch (error) { + // Show error toast if processing fails + console.error("Error processing files:", error); + toast.error("Failed to process files"); + } finally { + setIsProcessing(false); + setProgress(0); + } + }; - const { isDragOver, dragProps } = useDragDrop({ - // When files are dropped, process them - onDrop: processFiles, - }); + const { isDragOver, dragProps } = useDragDrop({ + // When files are dropped, process them + onDrop: processFiles, + }); - const handleFileSelect = () => fileInputRef.current?.click(); // Open file picker + const handleFileSelect = () => fileInputRef.current?.click(); // Open file picker - const handleFileChange = (e: React.ChangeEvent) => { - // When files are selected via file picker, process them - if (e.target.files) processFiles(e.target.files); - e.target.value = ""; // Reset input - }; + const handleFileChange = (e: React.ChangeEvent) => { + // When files are selected via file picker, process them + if (e.target.files) processFiles(e.target.files); + e.target.value = ""; // Reset input + }; - const handleRemove = async (e: React.MouseEvent, id: string) => { - // Remove a media item from the store - e.stopPropagation(); + const handleRemove = async (e: React.MouseEvent, id: string) => { + // Remove a media item from the store + e.stopPropagation(); - if (!activeProject) { - toast.error("No active project"); - return; - } + if (!activeProject) { + toast.error("No active project"); + return; + } - // Media store now handles cascade deletion automatically - await removeMediaItem(activeProject.id, id); - }; + // Media store now handles cascade deletion automatically + await removeMediaItem(activeProject.id, id); + }; - const formatDuration = (duration: number) => { - // Format seconds as mm:ss - const min = Math.floor(duration / 60); - const sec = Math.floor(duration % 60); - return `${min}:${sec.toString().padStart(2, "0")}`; - }; + const formatDuration = (duration: number) => { + // Format seconds as mm:ss + const min = Math.floor(duration / 60); + const sec = Math.floor(duration % 60); + return `${min}:${sec.toString().padStart(2, "0")}`; + }; - const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems); + const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems); - useEffect(() => { - const filtered = mediaItems.filter((item) => { - if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) { - return false; - } + useEffect(() => { + const filtered = mediaItems.filter((item) => { + if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) { + return false; + } - if ( - searchQuery && - !item.name.toLowerCase().includes(searchQuery.toLowerCase()) - ) { - return false; - } + if ( + searchQuery && + !item.name.toLowerCase().includes(searchQuery.toLowerCase()) + ) { + return false; + } - return true; - }); + return true; + }); - setFilteredMediaItems(filtered); - }, [mediaItems, mediaFilter, searchQuery]); + setFilteredMediaItems(filtered); + }, [mediaItems, mediaFilter, searchQuery]); - const renderPreview = (item: MediaItem) => { - // Render a preview for each media type (image, video, audio, unknown) - if (item.type === "image") { - return ( -
- {item.name} -
- ); - } + const renderPreview = (item: MediaItem) => { + // Render a preview for each media type (image, video, audio, unknown) + if (item.type === "image") { + return ( +
+ {item.name} +
+ ); + } - if (item.type === "video") { - if (item.thumbnailUrl) { - return ( -
- {item.name} -
-
- {item.duration && ( -
- {formatDuration(item.duration)} -
- )} -
- ); - } - return ( -
-
- ); - } + if (item.type === "video") { + if (item.thumbnailUrl) { + return ( +
+ {item.name} +
+
+ {item.duration && ( +
+ {formatDuration(item.duration)} +
+ )} +
+ ); + } + return ( +
+
+ ); + } - if (item.type === "audio") { - return ( -
- - Audio - {item.duration && ( - - {formatDuration(item.duration)} - - )} -
- ); - } + if (item.type === "audio") { + return ( +
+ + Audio + {item.duration && ( + + {formatDuration(item.duration)} + + )} +
+ ); + } - return ( -
- - Unknown -
- ); - }; + return ( +
+ + Unknown +
+ ); + }; - return ( - <> - {/* Hidden file input for uploading media */} - + // render skeletons while loading media item thumbnails + if (isLoading) { + return ( +
+ {/* Search and filter controls skeleton */} +
+
+ {/* Filter dropdown */} + {/* Search input */} +
+
-
-
- {/* Search and filter controls */} -
- - setSearchQuery(e.target.value)} - /> - -
-
+ {/* Media grid skeleton */} +
+
+ {/* thumbnail skeletons */} + {Array.from({ length: initialMediaCount }).map((_, i) => ( +
+ + +
+ ))} +
+
+
+ ); + } -
- {isDragOver || filteredMediaItems.length === 0 ? ( - - ) : ( -
- {/* Render each media item as a draggable button */} - {filteredMediaItems.map((item) => ( - - - - useTimelineStore - .getState() - .addMediaAtTime(item, currentTime) - } - rounded={false} - /> - - - Export clips - handleRemove(e, item.id)} - > - Delete - - - - ))} -
- )} -
-
- - ); + return ( + <> + {/* Hidden file input for uploading media */} + + +
+
+ {/* Search and filter controls */} +
+ + setSearchQuery(e.target.value)} + /> + +
+
+ +
+ {isDragOver || filteredMediaItems.length === 0 ? ( + + ) : ( +
+ {/* Render each media item as a draggable button */} + {filteredMediaItems.map((item) => ( + + + + useTimelineStore + .getState() + .addMediaAtTime(item, currentTime) + } + rounded={false} + /> + + + Export clips + handleRemove(e, item.id)} + > + Delete + + + + ))} +
+ )} +
+
+ + ); } diff --git a/apps/web/src/stores/media-store.ts b/apps/web/src/stores/media-store.ts index 305316c88..4394caa85 100644 --- a/apps/web/src/stores/media-store.ts +++ b/apps/web/src/stores/media-store.ts @@ -6,288 +6,290 @@ import { generateUUID } from "@/lib/utils"; export type MediaType = "image" | "video" | "audio"; export interface MediaItem { - id: string; - name: string; - type: MediaType; - file: File; - url?: string; // Object URL for preview - thumbnailUrl?: string; // For video thumbnails - duration?: number; // For video/audio duration - width?: number; // For video/image width - height?: number; // For video/image height - fps?: number; // For video frame rate - // Text-specific properties - content?: string; // Text content - fontSize?: number; // Font size - fontFamily?: string; // Font family - color?: string; // Text color - backgroundColor?: string; // Background color - textAlign?: "left" | "center" | "right"; // Text alignment + id: string; + name: string; + type: MediaType; + file: File; + url?: string; // Object URL for preview + thumbnailUrl?: string; // For video thumbnails + duration?: number; // For video/audio duration + width?: number; // For video/image width + height?: number; // For video/image height + fps?: number; // For video frame rate + // Text-specific properties + content?: string; // Text content + fontSize?: number; // Font size + fontFamily?: string; // Font family + color?: string; // Text color + backgroundColor?: string; // Background color + textAlign?: "left" | "center" | "right"; // Text alignment } interface MediaStore { - mediaItems: MediaItem[]; - isLoading: boolean; - - // Actions - now require projectId - addMediaItem: ( - projectId: string, - item: Omit, - ) => Promise; - removeMediaItem: (projectId: string, id: string) => Promise; - loadProjectMedia: (projectId: string) => Promise; - clearProjectMedia: (projectId: string) => Promise; - clearAllMedia: () => void; // Clear local state only + mediaItems: MediaItem[]; + isLoading: boolean; + initialMediaCount: number; // keep track of initial media count for loading state + + // Actions - now require projectId + addMediaItem: ( + projectId: string, + item: Omit, + ) => Promise; + removeMediaItem: (projectId: string, id: string) => Promise; + loadProjectMedia: (projectId: string) => Promise; + clearProjectMedia: (projectId: string) => Promise; + clearAllMedia: () => void; // Clear local state only } // Helper function to determine file type export const getFileType = (file: File): MediaType | null => { - const { type } = file; - - if (type.startsWith("image/")) { - return "image"; - } - if (type.startsWith("video/")) { - return "video"; - } - if (type.startsWith("audio/")) { - return "audio"; - } - - return 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 dimensions export const getImageDimensions = ( - file: File, + file: File, ): Promise<{ width: number; height: number }> => { - return new Promise((resolve, reject) => { - const img = new window.Image(); - - img.addEventListener("load", () => { - const width = img.naturalWidth; - const height = img.naturalHeight; - resolve({ width, height }); - img.remove(); - }); - - img.addEventListener("error", () => { - reject(new Error("Could not load image")); - img.remove(); - }); - - img.src = URL.createObjectURL(file); - }); + return new Promise((resolve, reject) => { + const img = new window.Image(); + + img.addEventListener("load", () => { + const width = img.naturalWidth; + const height = img.naturalHeight; + resolve({ width, height }); + 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 dimensions export const generateVideoThumbnail = ( - file: File, + file: File, ): Promise<{ thumbnailUrl: string; width: number; height: number }> => { - return new Promise((resolve, reject) => { - const video = document.createElement("video") as HTMLVideoElement; - const canvas = document.createElement("canvas") as HTMLCanvasElement; - 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 width = video.videoWidth; - const height = video.videoHeight; - - resolve({ thumbnailUrl, width, height }); - - // 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(); - }); + return new Promise((resolve, reject) => { + const video = document.createElement("video") as HTMLVideoElement; + const canvas = document.createElement("canvas") as HTMLCanvasElement; + 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 width = video.videoWidth; + const height = video.videoHeight; + + resolve({ thumbnailUrl, width, height }); + + // 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; - - 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(); - }); + return new Promise((resolve, reject) => { + const element = document.createElement( + file.type.startsWith("video/") ? "video" : "audio", + ) as HTMLVideoElement; + + 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(); + }); }; // Helper to get aspect ratio from MediaItem export const getMediaAspectRatio = (item: MediaItem): number => { - if (item.width && item.height) { - return item.width / item.height; - } - return 16 / 9; // Default aspect ratio + if (item.width && item.height) { + return item.width / item.height; + } + return 16 / 9; // Default aspect ratio }; export const useMediaStore = create((set, get) => ({ - mediaItems: [], - isLoading: false, - - addMediaItem: async (projectId, item) => { - const newItem: MediaItem = { - ...item, - id: generateUUID(), - }; - - // Add to local state immediately for UI responsiveness - set((state) => ({ - mediaItems: [...state.mediaItems, newItem], - })); - - // Save to persistent storage in background - try { - await storageService.saveMediaItem(projectId, newItem); - } catch (error) { - console.error("Failed to save media item:", error); - // Remove from local state if save failed - set((state) => ({ - mediaItems: state.mediaItems.filter((media) => media.id !== newItem.id), - })); - } - }, - - removeMediaItem: async (projectId, id: string) => { - const state = get(); - const item = state.mediaItems.find((media) => media.id === id); - - // Cleanup object URLs to prevent memory leaks - if (item && item.url) { - URL.revokeObjectURL(item.url); - if (item.thumbnailUrl) { - URL.revokeObjectURL(item.thumbnailUrl); - } - } - - // Remove from local state immediately - set((state) => ({ - mediaItems: state.mediaItems.filter((media) => media.id !== id), - })); - - // Remove from persistent storage - try { - await storageService.deleteMediaItem(projectId, id); - } catch (error) { - console.error("Failed to delete media item:", error); - } - }, - - loadProjectMedia: async (projectId) => { - set({ isLoading: true }); - - try { - const mediaItems = await storageService.loadAllMediaItems(projectId); - - // Regenerate thumbnails for video items - const updatedMediaItems = await Promise.all( - mediaItems.map(async (item) => { - if (item.type === "video" && item.file) { - try { - const { thumbnailUrl, width, height } = - await generateVideoThumbnail(item.file); - return { - ...item, - thumbnailUrl, - width: width || item.width, - height: height || item.height, - }; - } catch (error) { - console.error( - `Failed to regenerate thumbnail for video ${item.id}:`, - error, - ); - return item; - } - } - return item; - }), - ); - - set({ mediaItems: updatedMediaItems }); - } catch (error) { - console.error("Failed to load media items:", error); - } finally { - set({ isLoading: false }); - } - }, - - clearProjectMedia: async (projectId) => { - const state = get(); - - // Cleanup all object URLs - state.mediaItems.forEach((item) => { - if (item.url) { - URL.revokeObjectURL(item.url); - } - if (item.thumbnailUrl) { - URL.revokeObjectURL(item.thumbnailUrl); - } - }); - - // Clear local state - set({ mediaItems: [] }); - - // Clear persistent storage - try { - const mediaIds = state.mediaItems.map((item) => item.id); - await Promise.all( - mediaIds.map((id) => storageService.deleteMediaItem(projectId, id)), - ); - } catch (error) { - console.error("Failed to clear media items from storage:", error); - } - }, - - clearAllMedia: () => { - const state = get(); - - // Cleanup all object URLs - state.mediaItems.forEach((item) => { - if (item.url) { - URL.revokeObjectURL(item.url); - } - if (item.thumbnailUrl) { - URL.revokeObjectURL(item.thumbnailUrl); - } - }); - - // Clear local state - set({ mediaItems: [] }); - }, + mediaItems: [], + isLoading: false, + initialMediaCount: 0, + + addMediaItem: async (projectId, item) => { + const newItem: MediaItem = { + ...item, + id: generateUUID(), + }; + + // Add to local state immediately for UI responsiveness + set((state) => ({ + mediaItems: [...state.mediaItems, newItem], + })); + + // Save to persistent storage in background + try { + await storageService.saveMediaItem(projectId, newItem); + } catch (error) { + console.error("Failed to save media item:", error); + // Remove from local state if save failed + set((state) => ({ + mediaItems: state.mediaItems.filter((media) => media.id !== newItem.id), + })); + } + }, + + removeMediaItem: async (projectId, id: string) => { + const state = get(); + const item = state.mediaItems.find((media) => media.id === id); + + // Cleanup object URLs to prevent memory leaks + if (item && item.url) { + URL.revokeObjectURL(item.url); + if (item.thumbnailUrl) { + URL.revokeObjectURL(item.thumbnailUrl); + } + } + + // Remove from local state immediately + set((state) => ({ + mediaItems: state.mediaItems.filter((media) => media.id !== id), + })); + + // Remove from persistent storage + try { + await storageService.deleteMediaItem(projectId, id); + } catch (error) { + console.error("Failed to delete media item:", error); + } + }, + + loadProjectMedia: async (projectId) => { + set({ isLoading: true }); + + try { + const mediaItems = await storageService.loadAllMediaItems(projectId); + + // Regenerate thumbnails for video items + const updatedMediaItems = await Promise.all( + mediaItems.map(async (item) => { + if (item.type === "video" && item.file) { + try { + const { thumbnailUrl, width, height } = + await generateVideoThumbnail(item.file); + return { + ...item, + thumbnailUrl, + width: width || item.width, + height: height || item.height, + }; + } catch (error) { + console.error( + `Failed to regenerate thumbnail for video ${item.id}:`, + error, + ); + return item; + } + } + return item; + }), + ); + + set({ mediaItems: updatedMediaItems }); + } catch (error) { + console.error("Failed to load media items:", error); + } finally { + set({ isLoading: false }); + } + }, + + clearProjectMedia: async (projectId) => { + const state = get(); + + // Cleanup all object URLs + state.mediaItems.forEach((item) => { + if (item.url) { + URL.revokeObjectURL(item.url); + } + if (item.thumbnailUrl) { + URL.revokeObjectURL(item.thumbnailUrl); + } + }); + + // Clear local state + set({ mediaItems: [] }); + + // Clear persistent storage + try { + const mediaIds = state.mediaItems.map((item) => item.id); + await Promise.all( + mediaIds.map((id) => storageService.deleteMediaItem(projectId, id)), + ); + } catch (error) { + console.error("Failed to clear media items from storage:", error); + } + }, + + clearAllMedia: () => { + const state = get(); + + // Cleanup all object URLs + state.mediaItems.forEach((item) => { + if (item.url) { + URL.revokeObjectURL(item.url); + } + if (item.thumbnailUrl) { + URL.revokeObjectURL(item.thumbnailUrl); + } + }); + + // Clear local state + set({ mediaItems: [] }); + }, })); diff --git a/docker-compose.yaml b/docker-compose.yaml index 40e22339c..fe3a2b1ca 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -9,7 +9,7 @@ services: volumes: - postgres_data:/var/lib/postgresql/data ports: - - "5432:5432" + - "5433:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U opencut"] interval: 30s @@ -55,9 +55,9 @@ services: - "3100:3000" # app is running on 3000 so we run this at 3100 environment: - NODE_ENV=production - - DATABASE_URL=postgresql://opencut:opencutthegoat@db:5432/opencut + - DATABASE_URL=postgresql://opencut:opencutthegoat@db:5433/opencut - BETTER_AUTH_URL=http://localhost:3000 - - BETTER_AUTH_SECRET=your-production-secret-key-here + - BETTER_AUTH_SECRET=my7s9A8YfUwyBL4ye3VlwZl4fwKisVhIZZMq1Ga5Jq4= - UPSTASH_REDIS_REST_URL=http://serverless-redis-http:80 - UPSTASH_REDIS_REST_TOKEN=example_token depends_on: