diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 72d5898..21d39d4 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -5,7 +5,11 @@ import { type TimelineClip, type TimelineTrack, } from "@/stores/timeline-store"; -import { useMediaStore, type MediaItem } from "@/stores/media-store"; +import { + useMediaStore, + type MediaItem, + getMediaAspectRatio, +} from "@/stores/media-store"; import { usePlaybackStore } from "@/stores/playback-store"; import { useEditorStore } from "@/stores/editor-store"; import { VideoPlayer } from "@/components/ui/video-player"; @@ -262,7 +266,7 @@ function PreviewToolbar({ hasAnyClips }: { hasAnyClips: boolean }) { mediaItem && (mediaItem.type === "video" || mediaItem.type === "image") ) { - return mediaItem.aspectRatio || 16 / 9; // Default to 16:9 if aspectRatio not available + return getMediaAspectRatio(mediaItem); } } } diff --git a/apps/web/src/lib/media-processing.ts b/apps/web/src/lib/media-processing.ts index daf22b3..4c1294a 100644 --- a/apps/web/src/lib/media-processing.ts +++ b/apps/web/src/lib/media-processing.ts @@ -1,81 +1,85 @@ -import { toast } from "sonner"; -import { - getFileType, - generateVideoThumbnail, - getMediaDuration, - getImageAspectRatio, - type MediaItem, -} from "@/stores/media-store"; -// import { generateThumbnail, getVideoInfo } from "./ffmpeg-utils"; // Temporarily disabled - -export interface ProcessedMediaItem extends Omit {} - -export async function processMediaFiles( - files: FileList | File[], - onProgress?: (progress: number) => void -): Promise { - const fileArray = Array.from(files); - const processedItems: ProcessedMediaItem[] = []; - - const total = fileArray.length; - let completed = 0; - - 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") { - // Use basic thumbnail generation for now - 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 not already set by FFmpeg) - if ((fileType === "video" || fileType === "audio") && !duration) { - duration = await getMediaDuration(file); - } - - processedItems.push({ - name: file.name, - type: fileType, - file, - url, - thumbnailUrl, - duration, - aspectRatio, - }); - - // Yield back to the event loop to keep the UI responsive - await new Promise((resolve) => setTimeout(resolve, 0)); - - completed += 1; - if (onProgress) { - const percent = Math.round((completed / total) * 100); - onProgress(percent); - } - } 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; -} +import { toast } from "sonner"; +import { + getFileType, + generateVideoThumbnail, + getMediaDuration, + getImageDimensions, + type MediaItem, +} from "@/stores/media-store"; +// import { generateThumbnail, getVideoInfo } from "./ffmpeg-utils"; // Temporarily disabled + +export interface ProcessedMediaItem extends Omit {} + +export async function processMediaFiles( + files: FileList | File[], + onProgress?: (progress: number) => void +): Promise { + const fileArray = Array.from(files); + const processedItems: ProcessedMediaItem[] = []; + + const total = fileArray.length; + let completed = 0; + + 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 width: number | undefined; + let height: number | undefined; + + try { + if (fileType === "image") { + // Get image dimensions + const dimensions = await getImageDimensions(file); + width = dimensions.width; + height = dimensions.height; + } else if (fileType === "video") { + // Use basic thumbnail generation for now + const videoResult = await generateVideoThumbnail(file); + thumbnailUrl = videoResult.thumbnailUrl; + width = videoResult.width; + height = videoResult.height; + } else if (fileType === "audio") { + // For audio, we don't set width/height (they'll be undefined) + } + + // Get duration for videos and audio (if not already set by FFmpeg) + if ((fileType === "video" || fileType === "audio") && !duration) { + duration = await getMediaDuration(file); + } + + processedItems.push({ + name: file.name, + type: fileType, + file, + url, + thumbnailUrl, + duration, + width, + height, + }); + + // Yield back to the event loop to keep the UI responsive + await new Promise((resolve) => setTimeout(resolve, 0)); + + completed += 1; + if (onProgress) { + const percent = Math.round((completed / total) * 100); + onProgress(percent); + } + } 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/lib/storage/storage-service.ts b/apps/web/src/lib/storage/storage-service.ts index 5582ae2..65b3c13 100644 --- a/apps/web/src/lib/storage/storage-service.ts +++ b/apps/web/src/lib/storage/storage-service.ts @@ -94,7 +94,8 @@ class StorageService { type: mediaItem.type, size: mediaItem.file.size, lastModified: mediaItem.file.lastModified, - aspectRatio: mediaItem.aspectRatio, + width: mediaItem.width, + height: mediaItem.height, duration: mediaItem.duration, }; @@ -118,7 +119,8 @@ class StorageService { type: metadata.type, file, url, - aspectRatio: metadata.aspectRatio, + width: metadata.width, + height: metadata.height, duration: metadata.duration, // thumbnailUrl would need to be regenerated or cached separately }; diff --git a/apps/web/src/lib/storage/types.ts b/apps/web/src/lib/storage/types.ts index d56deeb..7ad18e1 100644 --- a/apps/web/src/lib/storage/types.ts +++ b/apps/web/src/lib/storage/types.ts @@ -14,7 +14,8 @@ export interface MediaFileData { type: "image" | "video" | "audio"; size: number; lastModified: number; - aspectRatio: number; + width?: number; + height?: number; duration?: number; // File will be stored separately in OPFS } @@ -38,4 +39,4 @@ declare global { values(): AsyncIterableIterator; entries(): AsyncIterableIterator<[string, FileSystemHandle]>; } -} \ No newline at end of file +} diff --git a/apps/web/src/stores/media-store.ts b/apps/web/src/stores/media-store.ts index bf46581..48932db 100644 --- a/apps/web/src/stores/media-store.ts +++ b/apps/web/src/stores/media-store.ts @@ -1,220 +1,233 @@ -import { create } from "zustand"; -import { storageService } from "@/lib/storage/storage-service"; - -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 { - mediaItems: MediaItem[]; - isLoading: boolean; - - // Actions - addMediaItem: (item: Omit) => Promise; - removeMediaItem: (id: string) => Promise; - loadAllMedia: () => Promise; - clearAllMedia: () => Promise; -} - -// 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: [], - isLoading: false, - - addMediaItem: async (item) => { - const newItem: MediaItem = { - ...item, - id: crypto.randomUUID(), - }; - - // Add to local state immediately for UI responsiveness - set((state) => ({ - mediaItems: [...state.mediaItems, newItem], - })); - - // Save to persistent storage in background - try { - await storageService.saveMediaItem(newItem); - } catch (error) { - console.error("Failed to save media item:", error); - // Remove from local state if save failed - set((state) => ({ - mediaItems: state.mediaItems.filter((item) => item.id !== newItem.id), - })); - } - }, - - removeMediaItem: async (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); - } - } - - // Remove from local state immediately - set((state) => ({ - mediaItems: state.mediaItems.filter((item) => item.id !== id), - })); - - // Remove from persistent storage - try { - await storageService.deleteMediaItem(id); - } catch (error) { - console.error("Failed to delete media item:", error); - // Could re-add to local state here if needed - } - }, - - loadAllMedia: async () => { - set({ isLoading: true }); - - try { - const mediaItems = await storageService.loadAllMediaItems(); - set({ mediaItems }); - } catch (error) { - console.error("Failed to load media items:", error); - } finally { - set({ isLoading: false }); - } - }, - - clearAllMedia: async () => { - const state = get(); - - // Cleanup all object URLs - state.mediaItems.forEach((item) => { - 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(id)) - ); - } catch (error) { - console.error("Failed to clear media items from storage:", error); - } - }, -})); +import { create } from "zustand"; +import { storageService } from "@/lib/storage/storage-service"; + +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 + width?: number; // For video/image width + height?: number; // For video/image height +} + +interface MediaStore { + mediaItems: MediaItem[]; + isLoading: boolean; + + // Actions + addMediaItem: (item: Omit) => Promise; + removeMediaItem: (id: string) => Promise; + loadAllMedia: () => Promise; + clearAllMedia: () => Promise; +} + +// 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 dimensions +export const getImageDimensions = ( + file: File +): Promise<{ width: number; height: number }> => { + return new Promise((resolve, reject) => { + const img = new 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 +): Promise<{ thumbnailUrl: string; width: number; height: 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 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 | 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(); + }); +}; + +// 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 +}; + +export const useMediaStore = create((set, get) => ({ + mediaItems: [], + isLoading: false, + + addMediaItem: async (item) => { + const newItem: MediaItem = { + ...item, + id: crypto.randomUUID(), + }; + + // Add to local state immediately for UI responsiveness + set((state) => ({ + mediaItems: [...state.mediaItems, newItem], + })); + + // Save to persistent storage in background + try { + await storageService.saveMediaItem(newItem); + } catch (error) { + console.error("Failed to save media item:", error); + // Remove from local state if save failed + set((state) => ({ + mediaItems: state.mediaItems.filter((item) => item.id !== newItem.id), + })); + } + }, + + removeMediaItem: async (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); + } + } + + // Remove from local state immediately + set((state) => ({ + mediaItems: state.mediaItems.filter((item) => item.id !== id), + })); + + // Remove from persistent storage + try { + await storageService.deleteMediaItem(id); + } catch (error) { + console.error("Failed to delete media item:", error); + // Could re-add to local state here if needed + } + }, + + loadAllMedia: async () => { + set({ isLoading: true }); + + try { + const mediaItems = await storageService.loadAllMediaItems(); + set({ mediaItems }); + } catch (error) { + console.error("Failed to load media items:", error); + } finally { + set({ isLoading: false }); + } + }, + + clearAllMedia: async () => { + const state = get(); + + // Cleanup all object URLs + state.mediaItems.forEach((item) => { + 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(id)) + ); + } catch (error) { + console.error("Failed to clear media items from storage:", error); + } + }, +})); diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index 9f98b32..01f4fea 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -1,8 +1,7 @@ import { create } from "zustand"; import type { TrackType } from "@/types/timeline"; import { useEditorStore } from "./editor-store"; -import { useMediaStore } from "./media-store"; -import { toast } from "sonner"; +import { useMediaStore, getMediaAspectRatio } from "./media-store"; // Helper function to manage clip naming with suffixes const getClipNameWithSuffix = ( @@ -232,7 +231,9 @@ export const useTimelineStore = create((set, get) => ({ (mediaItem.type === "image" || mediaItem.type === "video") ) { const editorStore = useEditorStore.getState(); - editorStore.setCanvasSizeFromAspectRatio(mediaItem.aspectRatio); + editorStore.setCanvasSizeFromAspectRatio( + getMediaAspectRatio(mediaItem) + ); } }