diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx index 1b53b6b5b..cd823202a 100644 --- a/apps/web/src/app/editor/[project_id]/page.tsx +++ b/apps/web/src/app/editor/[project_id]/page.tsx @@ -2,15 +2,16 @@ import { useEffect, useRef } from "react"; import { useParams, useRouter } from "next/navigation"; +import dynamic from "next/dynamic"; import { ResizablePanelGroup, ResizablePanel, ResizableHandle, } from "@/components/ui/resizable"; -import { MediaPanel } from "@/components/editor/media-panel"; -import { PropertiesPanel } from "@/components/editor/properties-panel"; -import { Timeline } from "@/components/editor/timeline"; -import { PreviewPanel } from "@/components/editor/preview-panel"; +// import { MediaPanel } from "@/components/editor/media-panel"; +// import { PropertiesPanel } from "@/components/editor/properties-panel"; +// import { Timeline } from "@/components/editor/timeline"; +// import { PreviewPanel } from "@/components/editor/preview-panel"; import { EditorHeader } from "@/components/editor/editor-header"; import { usePanelStore } from "@/stores/panel-store"; import { useProjectStore } from "@/stores/project-store"; @@ -18,6 +19,35 @@ import { EditorProvider } from "@/components/providers/editor-provider"; import { usePlaybackControls } from "@/hooks/use-playback-controls"; import { Onboarding } from "@/components/editor/onboarding"; +const Timeline = dynamic( + () => + import("@/components/editor/timeline").then((m) => ({ + default: m.Timeline, + })), + { ssr: false, loading: () =>
} +); +const PreviewPanel = dynamic( + () => + import("@/components/editor/preview-panel").then((m) => ({ + default: m.PreviewPanel, + })), + { ssr: false, loading: () =>
} +); +const MediaPanel = dynamic( + () => + import("@/components/editor/media-panel").then((m) => ({ + default: m.MediaPanel, + })), + { ssr: false, loading: () =>
} +); +const PropertiesPanel = dynamic( + () => + import("@/components/editor/properties-panel").then((m) => ({ + default: m.PropertiesPanel, + })), + { ssr: false, loading: () =>
} +); + export default function Editor() { const { toolsPanel, diff --git a/apps/web/src/lib/storage/storage-service.ts b/apps/web/src/lib/storage/storage-service.ts index 3aadf8831..a89b6436c 100644 --- a/apps/web/src/lib/storage/storage-service.ts +++ b/apps/web/src/lib/storage/storage-service.ts @@ -47,8 +47,11 @@ class StorageService { ); const mediaFilesAdapter = new OPFSAdapter(`media-files-${projectId}`); + const mediaThumbnailsAdapter = new OPFSAdapter( + `media-thumbnails-${projectId}` + ); - return { mediaMetadataAdapter, mediaFilesAdapter }; + return { mediaMetadataAdapter, mediaFilesAdapter, mediaThumbnailsAdapter }; } // Helper to get project-specific timeline adapter @@ -126,8 +129,11 @@ class StorageService { // Media operations async saveMediaFile(projectId: string, mediaItem: MediaFile): Promise { - const { mediaMetadataAdapter, mediaFilesAdapter } = - this.getProjectMediaAdapters(projectId); + const { + mediaMetadataAdapter, + mediaFilesAdapter, + mediaThumbnailsAdapter, + } = this.getProjectMediaAdapters(projectId); // Save file to project-specific OPFS await mediaFilesAdapter.set(mediaItem.id, mediaItem.file); @@ -143,17 +149,34 @@ class StorageService { height: mediaItem.height, duration: mediaItem.duration, ephemeral: mediaItem.ephemeral, + hasThumbnail: Boolean(mediaItem.thumbnailUrl), }; await mediaMetadataAdapter.set(mediaItem.id, metadata); + + if (mediaItem.thumbnailUrl) { + try { + const res = await fetch(mediaItem.thumbnailUrl); + const blob = await res.blob(); + const thumbFile = new File([blob], `${mediaItem.id}.jpg`, { + type: blob.type || "image/jpeg", + }); + await mediaThumbnailsAdapter.set(mediaItem.id, thumbFile); + } catch (e) { + console.error("Failed to persist media thumbnail:", e); + } + } } async loadMediaFile( projectId: string, id: string ): Promise { - const { mediaMetadataAdapter, mediaFilesAdapter } = - this.getProjectMediaAdapters(projectId); + const { + mediaMetadataAdapter, + mediaFilesAdapter, + mediaThumbnailsAdapter, + } = this.getProjectMediaAdapters(projectId); const [file, metadata] = await Promise.all([ mediaFilesAdapter.get(id), @@ -179,6 +202,18 @@ class StorageService { url = URL.createObjectURL(file); } + let storedThumbnailUrl: string | undefined; + if (metadata.hasThumbnail) { + try { + const thumbFile = await mediaThumbnailsAdapter.get(id); + if (thumbFile) { + storedThumbnailUrl = URL.createObjectURL(thumbFile); + } + } catch (e) { + console.warn("Failed to load stored thumbnail:", e); + } + } + return { id: metadata.id, name: metadata.name, @@ -189,6 +224,7 @@ class StorageService { height: metadata.height, duration: metadata.duration, ephemeral: metadata.ephemeral, + ...(storedThumbnailUrl ? { thumbnailUrl: storedThumbnailUrl } : {}), }; } @@ -209,25 +245,52 @@ class StorageService { } async deleteMediaFile(projectId: string, id: string): Promise { - const { mediaMetadataAdapter, mediaFilesAdapter } = - this.getProjectMediaAdapters(projectId); + const { + mediaMetadataAdapter, + mediaFilesAdapter, + mediaThumbnailsAdapter, + } = this.getProjectMediaAdapters(projectId); await Promise.all([ mediaFilesAdapter.remove(id), mediaMetadataAdapter.remove(id), + mediaThumbnailsAdapter.remove(id), ]); } async deleteProjectMedia(projectId: string): Promise { - const { mediaMetadataAdapter, mediaFilesAdapter } = - this.getProjectMediaAdapters(projectId); + const { + mediaMetadataAdapter, + mediaFilesAdapter, + mediaThumbnailsAdapter, + } = this.getProjectMediaAdapters(projectId); await Promise.all([ mediaMetadataAdapter.clear(), mediaFilesAdapter.clear(), + mediaThumbnailsAdapter.clear(), ]); } + async saveMediaThumbnail( + projectId: string, + id: string, + blob: Blob + ): Promise { + const { mediaMetadataAdapter, mediaThumbnailsAdapter } = + this.getProjectMediaAdapters(projectId); + + const file = new File([blob], `${id}.jpg`, { + type: blob.type || "image/jpeg", + }); + await mediaThumbnailsAdapter.set(id, file); + + const existing = await mediaMetadataAdapter.get(id); + if (existing) { + await mediaMetadataAdapter.set(id, { ...existing, hasThumbnail: true }); + } + } + // Timeline operations - now project-specific async saveTimeline( projectId: string, diff --git a/apps/web/src/lib/storage/types.ts b/apps/web/src/lib/storage/types.ts index 8f593867f..9a627d6f0 100644 --- a/apps/web/src/lib/storage/types.ts +++ b/apps/web/src/lib/storage/types.ts @@ -20,6 +20,7 @@ export interface MediaFileData { duration?: number; ephemeral?: boolean; sourceStickerIconName?: string; + hasThumbnail?: boolean; // File will be stored separately in OPFS } diff --git a/apps/web/src/stores/media-store.ts b/apps/web/src/stores/media-store.ts index 2b7cb62ec..0e2add91e 100644 --- a/apps/web/src/stores/media-store.ts +++ b/apps/web/src/stores/media-store.ts @@ -226,32 +226,38 @@ export const useMediaStore = create((set, get) => ({ try { const mediaItems = await storageService.loadAllMediaFiles(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; - } + // Fast path: immediately set items (use persisted thumbnails if available) + set({ mediaFiles: mediaItems }); + + // Background: generate thumbnails only for videos missing one, then persist + const missing = mediaItems.filter( + (item) => item.type === "video" && !item.thumbnailUrl && item.file + ); + + const tasks = missing.map((item) => + (async () => { + try { + const { thumbnailUrl } = await generateVideoThumbnail(item.file); + const res = await fetch(thumbnailUrl); + const blob = await res.blob(); + + await storageService.saveMediaThumbnail(projectId, item.id, blob); + + set((state) => ({ + mediaFiles: state.mediaFiles.map((m) => + m.id === item.id ? { ...m, thumbnailUrl } : m + ), + })); + } catch (error) { + console.error( + `Failed to generate thumbnail for video ${item.id}:`, + error + ); } - return item; - }) + })() ); - set({ mediaFiles: updatedMediaItems }); + void Promise.allSettled(tasks); } catch (error) { console.error("Failed to load media items:", error); } finally { diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index f0bae00c2..af31f12b3 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -1230,9 +1230,8 @@ export const useTimelineStore = create((set, get) => { getProjectThumbnail: async (projectId) => { try { const tracks = await storageService.loadTimeline(projectId); - const mediaItems = await storageService.loadAllMediaFiles(projectId); - if (!tracks || !mediaItems.length) return null; + if (!tracks) return null; const firstMediaElement = tracks .flatMap((track) => track.elements) @@ -1241,17 +1240,14 @@ export const useTimelineStore = create((set, get) => { if (!firstMediaElement) return null; - const mediaFile = mediaItems.find( - (item) => item.id === firstMediaElement.mediaId + const mediaFile = await storageService.loadMediaFile( + projectId, + firstMediaElement.mediaId ); if (!mediaFile) return null; - if (mediaFile.type === "video" && mediaFile.file) { - const { generateVideoThumbnail } = await import( - "@/stores/media-store" - ); - const { thumbnailUrl } = await generateVideoThumbnail(mediaFile.file); - return thumbnailUrl; + if (mediaFile.type === "video" && mediaFile.thumbnailUrl) { + return mediaFile.thumbnailUrl; } if (mediaFile.type === "image" && mediaFile.url) { return mediaFile.url;