refactor: update media processing to use width and height instead of aspect ratio

This commit is contained in:
Maze Winther
2025-07-01 01:13:14 +02:00
parent 9b37ce6610
commit 1a01871cfc
6 changed files with 335 additions and 310 deletions

View File

@ -5,7 +5,11 @@ import {
type TimelineClip, type TimelineClip,
type TimelineTrack, type TimelineTrack,
} from "@/stores/timeline-store"; } 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 { usePlaybackStore } from "@/stores/playback-store";
import { useEditorStore } from "@/stores/editor-store"; import { useEditorStore } from "@/stores/editor-store";
import { VideoPlayer } from "@/components/ui/video-player"; import { VideoPlayer } from "@/components/ui/video-player";
@ -262,7 +266,7 @@ function PreviewToolbar({ hasAnyClips }: { hasAnyClips: boolean }) {
mediaItem && mediaItem &&
(mediaItem.type === "video" || mediaItem.type === "image") (mediaItem.type === "video" || mediaItem.type === "image")
) { ) {
return mediaItem.aspectRatio || 16 / 9; // Default to 16:9 if aspectRatio not available return getMediaAspectRatio(mediaItem);
} }
} }
} }

View File

@ -1,81 +1,85 @@
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
getFileType, getFileType,
generateVideoThumbnail, generateVideoThumbnail,
getMediaDuration, getMediaDuration,
getImageAspectRatio, getImageDimensions,
type MediaItem, type MediaItem,
} from "@/stores/media-store"; } from "@/stores/media-store";
// import { generateThumbnail, getVideoInfo } from "./ffmpeg-utils"; // Temporarily disabled // import { generateThumbnail, getVideoInfo } from "./ffmpeg-utils"; // Temporarily disabled
export interface ProcessedMediaItem extends Omit<MediaItem, "id"> {} export interface ProcessedMediaItem extends Omit<MediaItem, "id"> {}
export async function processMediaFiles( export async function processMediaFiles(
files: FileList | File[], files: FileList | File[],
onProgress?: (progress: number) => void onProgress?: (progress: number) => void
): Promise<ProcessedMediaItem[]> { ): Promise<ProcessedMediaItem[]> {
const fileArray = Array.from(files); const fileArray = Array.from(files);
const processedItems: ProcessedMediaItem[] = []; const processedItems: ProcessedMediaItem[] = [];
const total = fileArray.length; const total = fileArray.length;
let completed = 0; let completed = 0;
for (const file of fileArray) { for (const file of fileArray) {
const fileType = getFileType(file); const fileType = getFileType(file);
if (!fileType) { if (!fileType) {
toast.error(`Unsupported file type: ${file.name}`); toast.error(`Unsupported file type: ${file.name}`);
continue; continue;
} }
const url = URL.createObjectURL(file); const url = URL.createObjectURL(file);
let thumbnailUrl: string | undefined; let thumbnailUrl: string | undefined;
let duration: number | undefined; let duration: number | undefined;
let aspectRatio: number = 16 / 9; // Default fallback let width: number | undefined;
let height: number | undefined;
try {
if (fileType === "image") { try {
// Get image aspect ratio if (fileType === "image") {
aspectRatio = await getImageAspectRatio(file); // Get image dimensions
} else if (fileType === "video") { const dimensions = await getImageDimensions(file);
// Use basic thumbnail generation for now width = dimensions.width;
const videoResult = await generateVideoThumbnail(file); height = dimensions.height;
thumbnailUrl = videoResult.thumbnailUrl; } else if (fileType === "video") {
aspectRatio = videoResult.aspectRatio; // Use basic thumbnail generation for now
} else if (fileType === "audio") { const videoResult = await generateVideoThumbnail(file);
// For audio, use a square aspect ratio thumbnailUrl = videoResult.thumbnailUrl;
aspectRatio = 1; width = videoResult.width;
} height = videoResult.height;
} else if (fileType === "audio") {
// Get duration for videos and audio (if not already set by FFmpeg) // For audio, we don't set width/height (they'll be undefined)
if ((fileType === "video" || fileType === "audio") && !duration) { }
duration = await getMediaDuration(file);
} // Get duration for videos and audio (if not already set by FFmpeg)
if ((fileType === "video" || fileType === "audio") && !duration) {
processedItems.push({ duration = await getMediaDuration(file);
name: file.name, }
type: fileType,
file, processedItems.push({
url, name: file.name,
thumbnailUrl, type: fileType,
duration, file,
aspectRatio, url,
}); thumbnailUrl,
duration,
// Yield back to the event loop to keep the UI responsive width,
await new Promise((resolve) => setTimeout(resolve, 0)); height,
});
completed += 1;
if (onProgress) { // Yield back to the event loop to keep the UI responsive
const percent = Math.round((completed / total) * 100); await new Promise((resolve) => setTimeout(resolve, 0));
onProgress(percent);
} completed += 1;
} catch (error) { if (onProgress) {
console.error("Error processing file:", file.name, error); const percent = Math.round((completed / total) * 100);
toast.error(`Failed to process ${file.name}`); onProgress(percent);
URL.revokeObjectURL(url); // Clean up on error }
} } catch (error) {
} console.error("Error processing file:", file.name, error);
toast.error(`Failed to process ${file.name}`);
return processedItems; URL.revokeObjectURL(url); // Clean up on error
} }
}
return processedItems;
}

View File

@ -94,7 +94,8 @@ class StorageService {
type: mediaItem.type, type: mediaItem.type,
size: mediaItem.file.size, size: mediaItem.file.size,
lastModified: mediaItem.file.lastModified, lastModified: mediaItem.file.lastModified,
aspectRatio: mediaItem.aspectRatio, width: mediaItem.width,
height: mediaItem.height,
duration: mediaItem.duration, duration: mediaItem.duration,
}; };
@ -118,7 +119,8 @@ class StorageService {
type: metadata.type, type: metadata.type,
file, file,
url, url,
aspectRatio: metadata.aspectRatio, width: metadata.width,
height: metadata.height,
duration: metadata.duration, duration: metadata.duration,
// thumbnailUrl would need to be regenerated or cached separately // thumbnailUrl would need to be regenerated or cached separately
}; };

View File

@ -14,7 +14,8 @@ export interface MediaFileData {
type: "image" | "video" | "audio"; type: "image" | "video" | "audio";
size: number; size: number;
lastModified: number; lastModified: number;
aspectRatio: number; width?: number;
height?: number;
duration?: number; duration?: number;
// File will be stored separately in OPFS // File will be stored separately in OPFS
} }
@ -38,4 +39,4 @@ declare global {
values(): AsyncIterableIterator<FileSystemHandle>; values(): AsyncIterableIterator<FileSystemHandle>;
entries(): AsyncIterableIterator<[string, FileSystemHandle]>; entries(): AsyncIterableIterator<[string, FileSystemHandle]>;
} }
} }

View File

@ -1,220 +1,233 @@
import { create } from "zustand"; import { create } from "zustand";
import { storageService } from "@/lib/storage/storage-service"; import { storageService } from "@/lib/storage/storage-service";
export interface MediaItem { export interface MediaItem {
id: string; id: string;
name: string; name: string;
type: "image" | "video" | "audio"; type: "image" | "video" | "audio";
file: File; file: File;
url: string; // Object URL for preview url: string; // Object URL for preview
thumbnailUrl?: string; // For video thumbnails thumbnailUrl?: string; // For video thumbnails
duration?: number; // For video/audio duration duration?: number; // For video/audio duration
aspectRatio: number; // width / height width?: number; // For video/image width
} height?: number; // For video/image height
}
interface MediaStore {
mediaItems: MediaItem[]; interface MediaStore {
isLoading: boolean; mediaItems: MediaItem[];
isLoading: boolean;
// Actions
addMediaItem: (item: Omit<MediaItem, "id">) => Promise<void>; // Actions
removeMediaItem: (id: string) => Promise<void>; addMediaItem: (item: Omit<MediaItem, "id">) => Promise<void>;
loadAllMedia: () => Promise<void>; removeMediaItem: (id: string) => Promise<void>;
clearAllMedia: () => Promise<void>; loadAllMedia: () => Promise<void>;
} clearAllMedia: () => Promise<void>;
}
// Helper function to determine file type
export const getFileType = (file: File): "image" | "video" | "audio" | null => { // Helper function to determine file type
const { type } = file; export const getFileType = (file: File): "image" | "video" | "audio" | null => {
const { type } = file;
if (type.startsWith("image/")) {
return "image"; if (type.startsWith("image/")) {
} return "image";
if (type.startsWith("video/")) { }
return "video"; if (type.startsWith("video/")) {
} return "video";
if (type.startsWith("audio/")) { }
return "audio"; if (type.startsWith("audio/")) {
} return "audio";
}
return null;
}; return null;
};
// Helper function to get image aspect ratio
export const getImageAspectRatio = (file: File): Promise<number> => { // Helper function to get image dimensions
return new Promise((resolve, reject) => { export const getImageDimensions = (
const img = new Image(); file: File
): Promise<{ width: number; height: number }> => {
img.addEventListener("load", () => { return new Promise((resolve, reject) => {
const aspectRatio = img.naturalWidth / img.naturalHeight; const img = new Image();
resolve(aspectRatio);
img.remove(); img.addEventListener("load", () => {
}); const width = img.naturalWidth;
const height = img.naturalHeight;
img.addEventListener("error", () => { resolve({ width, height });
reject(new Error("Could not load image")); img.remove();
img.remove(); });
});
img.addEventListener("error", () => {
img.src = URL.createObjectURL(file); reject(new Error("Could not load image"));
}); img.remove();
}; });
// Helper function to generate video thumbnail and get aspect ratio img.src = URL.createObjectURL(file);
export const generateVideoThumbnail = ( });
file: File };
): Promise<{ thumbnailUrl: string; aspectRatio: number }> => {
return new Promise((resolve, reject) => { // Helper function to generate video thumbnail and get dimensions
const video = document.createElement("video"); export const generateVideoThumbnail = (
const canvas = document.createElement("canvas"); file: File
const ctx = canvas.getContext("2d"); ): Promise<{ thumbnailUrl: string; width: number; height: number }> => {
return new Promise((resolve, reject) => {
if (!ctx) { const video = document.createElement("video");
reject(new Error("Could not get canvas context")); const canvas = document.createElement("canvas");
return; const ctx = canvas.getContext("2d");
}
if (!ctx) {
video.addEventListener("loadedmetadata", () => { reject(new Error("Could not get canvas context"));
canvas.width = video.videoWidth; return;
canvas.height = video.videoHeight; }
// Seek to 1 second or 10% of duration, whichever is smaller video.addEventListener("loadedmetadata", () => {
video.currentTime = Math.min(1, video.duration * 0.1); canvas.width = video.videoWidth;
}); canvas.height = video.videoHeight;
video.addEventListener("seeked", () => { // Seek to 1 second or 10% of duration, whichever is smaller
ctx.drawImage(video, 0, 0, canvas.width, canvas.height); video.currentTime = Math.min(1, video.duration * 0.1);
const thumbnailUrl = canvas.toDataURL("image/jpeg", 0.8); });
const aspectRatio = video.videoWidth / video.videoHeight;
video.addEventListener("seeked", () => {
resolve({ thumbnailUrl, aspectRatio }); ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const thumbnailUrl = canvas.toDataURL("image/jpeg", 0.8);
// Cleanup const width = video.videoWidth;
video.remove(); const height = video.videoHeight;
canvas.remove();
}); resolve({ thumbnailUrl, width, height });
video.addEventListener("error", () => { // Cleanup
reject(new Error("Could not load video")); video.remove();
video.remove(); canvas.remove();
canvas.remove(); });
});
video.addEventListener("error", () => {
video.src = URL.createObjectURL(file); reject(new Error("Could not load video"));
video.load(); video.remove();
}); canvas.remove();
}; });
// Helper function to get media duration video.src = URL.createObjectURL(file);
export const getMediaDuration = (file: File): Promise<number> => { video.load();
return new Promise((resolve, reject) => { });
const element = document.createElement( };
file.type.startsWith("video/") ? "video" : "audio"
) as HTMLVideoElement | HTMLAudioElement; // Helper function to get media duration
export const getMediaDuration = (file: File): Promise<number> => {
element.addEventListener("loadedmetadata", () => { return new Promise((resolve, reject) => {
resolve(element.duration); const element = document.createElement(
element.remove(); file.type.startsWith("video/") ? "video" : "audio"
}); ) as HTMLVideoElement | HTMLAudioElement;
element.addEventListener("error", () => { element.addEventListener("loadedmetadata", () => {
reject(new Error("Could not load media")); resolve(element.duration);
element.remove(); element.remove();
}); });
element.src = URL.createObjectURL(file); element.addEventListener("error", () => {
element.load(); reject(new Error("Could not load media"));
}); element.remove();
}; });
export const useMediaStore = create<MediaStore>((set, get) => ({ element.src = URL.createObjectURL(file);
mediaItems: [], element.load();
isLoading: false, });
};
addMediaItem: async (item) => {
const newItem: MediaItem = { // Helper to get aspect ratio from MediaItem
...item, export const getMediaAspectRatio = (item: MediaItem): number => {
id: crypto.randomUUID(), if (item.width && item.height) {
}; return item.width / item.height;
}
// Add to local state immediately for UI responsiveness return 16 / 9; // Default aspect ratio
set((state) => ({ };
mediaItems: [...state.mediaItems, newItem],
})); export const useMediaStore = create<MediaStore>((set, get) => ({
mediaItems: [],
// Save to persistent storage in background isLoading: false,
try {
await storageService.saveMediaItem(newItem); addMediaItem: async (item) => {
} catch (error) { const newItem: MediaItem = {
console.error("Failed to save media item:", error); ...item,
// Remove from local state if save failed id: crypto.randomUUID(),
set((state) => ({ };
mediaItems: state.mediaItems.filter((item) => item.id !== newItem.id),
})); // Add to local state immediately for UI responsiveness
} set((state) => ({
}, mediaItems: [...state.mediaItems, newItem],
}));
removeMediaItem: async (id) => {
const state = get(); // Save to persistent storage in background
const item = state.mediaItems.find((item) => item.id === id); try {
await storageService.saveMediaItem(newItem);
// Cleanup object URLs to prevent memory leaks } catch (error) {
if (item) { console.error("Failed to save media item:", error);
URL.revokeObjectURL(item.url); // Remove from local state if save failed
if (item.thumbnailUrl) { set((state) => ({
URL.revokeObjectURL(item.thumbnailUrl); mediaItems: state.mediaItems.filter((item) => item.id !== newItem.id),
} }));
} }
},
// Remove from local state immediately
set((state) => ({ removeMediaItem: async (id) => {
mediaItems: state.mediaItems.filter((item) => item.id !== id), const state = get();
})); const item = state.mediaItems.find((item) => item.id === id);
// Remove from persistent storage // Cleanup object URLs to prevent memory leaks
try { if (item) {
await storageService.deleteMediaItem(id); URL.revokeObjectURL(item.url);
} catch (error) { if (item.thumbnailUrl) {
console.error("Failed to delete media item:", error); URL.revokeObjectURL(item.thumbnailUrl);
// Could re-add to local state here if needed }
} }
},
// Remove from local state immediately
loadAllMedia: async () => { set((state) => ({
set({ isLoading: true }); mediaItems: state.mediaItems.filter((item) => item.id !== id),
}));
try {
const mediaItems = await storageService.loadAllMediaItems(); // Remove from persistent storage
set({ mediaItems }); try {
} catch (error) { await storageService.deleteMediaItem(id);
console.error("Failed to load media items:", error); } catch (error) {
} finally { console.error("Failed to delete media item:", error);
set({ isLoading: false }); // Could re-add to local state here if needed
} }
}, },
clearAllMedia: async () => { loadAllMedia: async () => {
const state = get(); set({ isLoading: true });
// Cleanup all object URLs try {
state.mediaItems.forEach((item) => { const mediaItems = await storageService.loadAllMediaItems();
URL.revokeObjectURL(item.url); set({ mediaItems });
if (item.thumbnailUrl) { } catch (error) {
URL.revokeObjectURL(item.thumbnailUrl); console.error("Failed to load media items:", error);
} } finally {
}); set({ isLoading: false });
}
// Clear local state },
set({ mediaItems: [] });
clearAllMedia: async () => {
// Clear persistent storage const state = get();
try {
const mediaIds = state.mediaItems.map((item) => item.id); // Cleanup all object URLs
await Promise.all( state.mediaItems.forEach((item) => {
mediaIds.map((id) => storageService.deleteMediaItem(id)) URL.revokeObjectURL(item.url);
); if (item.thumbnailUrl) {
} catch (error) { URL.revokeObjectURL(item.thumbnailUrl);
console.error("Failed to clear media items from storage:", error); }
} });
},
})); // 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);
}
},
}));

View File

@ -1,8 +1,7 @@
import { create } from "zustand"; import { create } from "zustand";
import type { TrackType } from "@/types/timeline"; import type { TrackType } from "@/types/timeline";
import { useEditorStore } from "./editor-store"; import { useEditorStore } from "./editor-store";
import { useMediaStore } from "./media-store"; import { useMediaStore, getMediaAspectRatio } from "./media-store";
import { toast } from "sonner";
// Helper function to manage clip naming with suffixes // Helper function to manage clip naming with suffixes
const getClipNameWithSuffix = ( const getClipNameWithSuffix = (
@ -232,7 +231,9 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
(mediaItem.type === "image" || mediaItem.type === "video") (mediaItem.type === "image" || mediaItem.type === "video")
) { ) {
const editorStore = useEditorStore.getState(); const editorStore = useEditorStore.getState();
editorStore.setCanvasSizeFromAspectRatio(mediaItem.aspectRatio); editorStore.setCanvasSizeFromAspectRatio(
getMediaAspectRatio(mediaItem)
);
} }
} }