From 6ee16f9df8aeabfef0ca533f9f5b13daab3a3758 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 22 Jun 2025 19:28:03 +0200 Subject: feat: so much stuff --- apps/web/src/stores/media-store.ts | 135 ++++++++++++++++++++++++++++++++++ apps/web/src/stores/timeline-store.ts | 78 +++++++++++++++++++- 2 files changed, 212 insertions(+), 1 deletion(-) (limited to 'apps/web/src/stores') 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) => 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 => { + 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 => { + 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((set, get) => ({ mediaItems: [], @@ -28,8 +138,33 @@ export const useMediaStore = create((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) => 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((set) => ({ @@ -36,6 +48,7 @@ export const useTimelineStore = create((set) => ({ set((state) => ({ tracks: [...state.tracks, newTrack], })); + return newTrack.id; }, removeTrack: (trackId) => { @@ -58,4 +71,67 @@ export const useTimelineStore = create((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 }; + }), + })); + }, })); -- cgit v1.3.1