summaryrefslogtreecommitdiff
path: root/apps/web/src/stores/media-store.ts
diff options
context:
space:
mode:
authorSimon Orzel <simonorzel@gmail.com>2025-07-20 18:35:06 +0200
committerSimon Orzel <simonorzel@gmail.com>2025-07-20 18:35:06 +0200
commitb03287df8d6fa14153af264c6a3e3da5b2a06153 (patch)
tree100b2c2a729295419dce61655bca418e74195ec4 /apps/web/src/stores/media-store.ts
parent061624ab588afff18073ae62fbac425c1aa5cf3a (diff)
parentba3da43728bc051aa033dc7b45042b0f0f788e63 (diff)
downloadopencut-b03287df8d6fa14153af264c6a3e3da5b2a06153.tar.gz
Merge branch 'main' into feat/project-preview-images
Diffstat (limited to 'apps/web/src/stores/media-store.ts')
-rw-r--r--apps/web/src/stores/media-store.ts438
1 files changed, 217 insertions, 221 deletions
diff --git a/apps/web/src/stores/media-store.ts b/apps/web/src/stores/media-store.ts
index 305316c8..c4ee5f3e 100644
--- a/apps/web/src/stores/media-store.ts
+++ b/apps/web/src/stores/media-store.ts
@@ -6,288 +6,284 @@ 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;
+ mediaItems: MediaItem[];
+ isLoading: boolean;
- // Actions - now require projectId
- addMediaItem: (
- projectId: string,
- item: Omit<MediaItem, "id">,
- ) => Promise<void>;
- removeMediaItem: (projectId: string, id: string) => Promise<void>;
- loadProjectMedia: (projectId: string) => Promise<void>;
- clearProjectMedia: (projectId: string) => Promise<void>;
- clearAllMedia: () => void; // Clear local state only
+ // Actions - now require projectId
+ addMediaItem: (
+ projectId: string,
+ item: Omit<MediaItem, "id">
+ ) => Promise<void>;
+ removeMediaItem: (projectId: string, id: string) => Promise<void>;
+ loadProjectMedia: (projectId: string) => Promise<void>;
+ clearProjectMedia: (projectId: string) => Promise<void>;
+ clearAllMedia: () => void; // Clear local state only
}
// Helper function to determine file type
export const getFileType = (file: File): MediaType | null => {
- const { type } = file;
+ const { type } = file;
- if (type.startsWith("image/")) {
- return "image";
- }
- if (type.startsWith("video/")) {
- return "video";
- }
- if (type.startsWith("audio/")) {
- return "audio";
- }
+ if (type.startsWith("image/")) {
+ return "image";
+ }
+ if (type.startsWith("video/")) {
+ return "video";
+ }
+ if (type.startsWith("audio/")) {
+ return "audio";
+ }
- return null;
+ 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();
+ 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("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.addEventListener("error", () => {
+ reject(new Error("Could not load image"));
+ img.remove();
+ });
- img.src = URL.createObjectURL(file);
- });
+ 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");
+ 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;
- }
+ if (!ctx) {
+ reject(new Error("Could not get canvas context"));
+ return;
+ }
- video.addEventListener("loadedmetadata", () => {
- canvas.width = video.videoWidth;
- canvas.height = video.videoHeight;
+ 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);
- });
+ // 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;
+ 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 });
+ resolve({ thumbnailUrl, width, height });
- // Cleanup
- video.remove();
- canvas.remove();
- });
+ // Cleanup
+ video.remove();
+ canvas.remove();
+ });
- video.addEventListener("error", () => {
- reject(new Error("Could not load video"));
- 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();
- });
+ 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;
+ 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("loadedmetadata", () => {
+ resolve(element.duration);
+ element.remove();
+ });
- element.addEventListener("error", () => {
- reject(new Error("Could not load media"));
- element.remove();
- });
+ element.addEventListener("error", () => {
+ reject(new Error("Could not load media"));
+ element.remove();
+ });
- element.src = URL.createObjectURL(file);
- element.load();
- });
+ 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<MediaStore>((set, get) => ({
- mediaItems: [],
- isLoading: false,
+ mediaItems: [],
+ isLoading: false,
- addMediaItem: async (projectId, item) => {
- const newItem: MediaItem = {
- ...item,
- id: generateUUID(),
- };
+ addMediaItem: async (projectId, item) => {
+ const newItem: MediaItem = {
+ ...item,
+ id: generateUUID(),
+ };
- // Add to local state immediately for UI responsiveness
- set((state) => ({
- mediaItems: [...state.mediaItems, newItem],
- }));
+ // 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),
- }));
- }
- },
+ // 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);
+ 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);
- }
- }
+ // 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 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);
- }
- },
+ // 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 });
+ loadProjectMedia: async (projectId) => {
+ set({ isLoading: true });
- try {
- const mediaItems = await storageService.loadAllMediaItems(projectId);
+ 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;
- }),
- );
+ // 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 });
- }
- },
+ set({ mediaItems: updatedMediaItems });
+ } catch (error) {
+ console.error("Failed to load media items:", error);
+ } finally {
+ set({ isLoading: false });
+ }
+ },
- clearProjectMedia: async (projectId) => {
- const state = get();
+ 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);
- }
- });
+ // 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 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);
- }
- },
+ // 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();
+ 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);
- }
- });
+ // 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 local state
+ set({ mediaItems: [] });
+ },
}));