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;