summaryrefslogtreecommitdiff
path: root/apps/web/src/stores
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2025-06-22 19:28:03 +0200
committerMaze Winther <mazewinther@gmail.com>2025-06-22 19:28:03 +0200
commit6ee16f9df8aeabfef0ca533f9f5b13daab3a3758 (patch)
tree75a9db32a8c4e4a60c54e35c914444b00d1a76d9 /apps/web/src/stores
parente22aa6620c5cdf97c71947e96e894313ac3e895e (diff)
downloadopencut-6ee16f9df8aeabfef0ca533f9f5b13daab3a3758.tar.gz
feat: so much stuff
Diffstat (limited to 'apps/web/src/stores')
-rw-r--r--apps/web/src/stores/media-store.ts135
-rw-r--r--apps/web/src/stores/timeline-store.ts78
2 files changed, 212 insertions, 1 deletions
diff --git a/apps/web/src/stores/media-store.ts b/apps/web/src/stores/media-store.ts
index 25d11af8..bdb4d52a 100644
--- a/apps/web/src/stores/media-store.ts
+++ b/apps/web/src/stores/media-store.ts
@@ -4,6 +4,11 @@ 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 {
@@ -12,8 +17,113 @@ interface MediaStore {
// Actions
addMediaItem: (item: Omit<MediaItem, "id">) => void;
removeMediaItem: (id: string) => void;
+ clearAllMedia: () => 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: [],
@@ -28,8 +138,33 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
},
removeMediaItem: (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);
+ }
+ }
+
set((state) => ({
mediaItems: state.mediaItems.filter((item) => item.id !== id),
}));
},
+
+ clearAllMedia: () => {
+ const state = get();
+
+ // Cleanup all object URLs
+ state.mediaItems.forEach((item) => {
+ URL.revokeObjectURL(item.url);
+ if (item.thumbnailUrl) {
+ URL.revokeObjectURL(item.thumbnailUrl);
+ }
+ });
+
+ set({ mediaItems: [] });
+ },
}));
diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts
index 66fb085c..243ed220 100644
--- a/apps/web/src/stores/timeline-store.ts
+++ b/apps/web/src/stores/timeline-store.ts
@@ -18,9 +18,21 @@ interface TimelineStore {
tracks: TimelineTrack[];
// Actions
- addTrack: (type: "video" | "audio" | "effects") => void;
+ addTrack: (type: "video" | "audio" | "effects") => string;
removeTrack: (trackId: string) => void;
addClipToTrack: (trackId: string, clip: Omit<TimelineClip, "id">) => void;
+ removeClipFromTrack: (trackId: string, clipId: string) => void;
+ moveClipToTrack: (
+ fromTrackId: string,
+ toTrackId: string,
+ clipId: string,
+ insertIndex?: number
+ ) => void;
+ reorderClipInTrack: (
+ trackId: string,
+ clipId: string,
+ newIndex: number
+ ) => void;
}
export const useTimelineStore = create<TimelineStore>((set) => ({
@@ -36,6 +48,7 @@ export const useTimelineStore = create<TimelineStore>((set) => ({
set((state) => ({
tracks: [...state.tracks, newTrack],
}));
+ return newTrack.id;
},
removeTrack: (trackId) => {
@@ -58,4 +71,67 @@ export const useTimelineStore = create<TimelineStore>((set) => ({
),
}));
},
+
+ removeClipFromTrack: (trackId, clipId) => {
+ set((state) => ({
+ tracks: state.tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ clips: track.clips.filter((clip) => clip.id !== clipId),
+ }
+ : track
+ ),
+ }));
+ },
+
+ moveClipToTrack: (fromTrackId, toTrackId, clipId, insertIndex) => {
+ set((state) => {
+ // Find the clip to move
+ const fromTrack = state.tracks.find((track) => track.id === fromTrackId);
+ const clipToMove = fromTrack?.clips.find((clip) => clip.id === clipId);
+
+ if (!clipToMove) return state;
+
+ return {
+ tracks: state.tracks.map((track) => {
+ if (track.id === fromTrackId) {
+ // Remove clip from source track
+ return {
+ ...track,
+ clips: track.clips.filter((clip) => clip.id !== clipId),
+ };
+ } else if (track.id === toTrackId) {
+ // Add clip to destination track
+ const newClips = [...track.clips];
+ const index =
+ insertIndex !== undefined ? insertIndex : newClips.length;
+ newClips.splice(index, 0, clipToMove);
+ return {
+ ...track,
+ clips: newClips,
+ };
+ }
+ return track;
+ }),
+ };
+ });
+ },
+
+ reorderClipInTrack: (trackId, clipId, newIndex) => {
+ set((state) => ({
+ tracks: state.tracks.map((track) => {
+ if (track.id !== trackId) return track;
+
+ const clipIndex = track.clips.findIndex((clip) => clip.id === clipId);
+ if (clipIndex === -1) return track;
+
+ const newClips = [...track.clips];
+ const [movedClip] = newClips.splice(clipIndex, 1);
+ newClips.splice(newIndex, 0, movedClip);
+
+ return { ...track, clips: newClips };
+ }),
+ }));
+ },
}));