diff options
| author | Maze Winther <mazewinther@gmail.com> | 2025-07-01 01:13:14 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2025-07-01 01:13:14 +0200 |
| commit | 1a01871cfc7a6405ed1140c7ed415e170843e40d (patch) | |
| tree | be6251a9cddeacf8462e4daff0e05015ffda25a0 /apps/web/src | |
| parent | 9b37ce6610eeba26fc2b1b1d2589a7e9ba5e6c00 (diff) | |
| download | opencut-1a01871cfc7a6405ed1140c7ed415e170843e40d.tar.gz | |
refactor: update media processing to use width and height instead of aspect ratio
Diffstat (limited to 'apps/web/src')
| -rw-r--r-- | apps/web/src/components/editor/preview-panel.tsx | 8 | ||||
| -rw-r--r-- | apps/web/src/lib/media-processing.ts | 166 | ||||
| -rw-r--r-- | apps/web/src/lib/storage/storage-service.ts | 6 | ||||
| -rw-r--r-- | apps/web/src/lib/storage/types.ts | 5 | ||||
| -rw-r--r-- | apps/web/src/stores/media-store.ts | 453 | ||||
| -rw-r--r-- | apps/web/src/stores/timeline-store.ts | 7 |
6 files changed, 335 insertions, 310 deletions
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 72d58985..21d39d4d 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 daf22b34..4c1294ae 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<MediaItem, "id"> {}
-
-export async function processMediaFiles(
- files: FileList | File[],
- onProgress?: (progress: number) => void
-): Promise<ProcessedMediaItem[]> {
- 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<MediaItem, "id"> {} + +export async function processMediaFiles( + files: FileList | File[], + onProgress?: (progress: number) => void +): Promise<ProcessedMediaItem[]> { + 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 5582ae29..65b3c134 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 d56deebd..7ad18e19 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<FileSystemHandle>; 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 bf465811..48932db5 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<MediaItem, "id">) => Promise<void>;
- removeMediaItem: (id: string) => Promise<void>;
- loadAllMedia: () => Promise<void>;
- clearAllMedia: () => Promise<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<number> => {
- 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<number> => {
- 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<MediaStore>((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<MediaItem, "id">) => Promise<void>; + removeMediaItem: (id: string) => Promise<void>; + loadAllMedia: () => Promise<void>; + clearAllMedia: () => Promise<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 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<number> => { + 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<MediaStore>((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 9f98b320..01f4fea6 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<TimelineStore>((set, get) => ({ (mediaItem.type === "image" || mediaItem.type === "video") ) { const editorStore = useEditorStore.getState(); - editorStore.setCanvasSizeFromAspectRatio(mediaItem.aspectRatio); + editorStore.setCanvasSizeFromAspectRatio( + getMediaAspectRatio(mediaItem) + ); } } |
