summaryrefslogtreecommitdiff
path: root/apps
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2025-07-01 01:13:14 +0200
committerMaze Winther <mazewinther@gmail.com>2025-07-01 01:13:14 +0200
commit1a01871cfc7a6405ed1140c7ed415e170843e40d (patch)
treebe6251a9cddeacf8462e4daff0e05015ffda25a0 /apps
parent9b37ce6610eeba26fc2b1b1d2589a7e9ba5e6c00 (diff)
downloadopencut-1a01871cfc7a6405ed1140c7ed415e170843e40d.tar.gz
refactor: update media processing to use width and height instead of aspect ratio
Diffstat (limited to 'apps')
-rw-r--r--apps/web/src/components/editor/preview-panel.tsx8
-rw-r--r--apps/web/src/lib/media-processing.ts166
-rw-r--r--apps/web/src/lib/storage/storage-service.ts6
-rw-r--r--apps/web/src/lib/storage/types.ts5
-rw-r--r--apps/web/src/stores/media-store.ts453
-rw-r--r--apps/web/src/stores/timeline-store.ts7
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)
+ );
}
}