Skip to content
Open

Main #585

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 34 additions & 4 deletions apps/web/src/app/editor/[project_id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,52 @@

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";
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: () => <div className="w-full h-full bg-panel" /> }
);
const PreviewPanel = dynamic(
() =>
import("@/components/editor/preview-panel").then((m) => ({
default: m.PreviewPanel,
})),
{ ssr: false, loading: () => <div className="w-full h-full bg-panel" /> }
);
const MediaPanel = dynamic(
() =>
import("@/components/editor/media-panel").then((m) => ({
default: m.MediaPanel,
})),
{ ssr: false, loading: () => <div className="w-full h-full bg-panel" /> }
);
const PropertiesPanel = dynamic(
() =>
import("@/components/editor/properties-panel").then((m) => ({
default: m.PropertiesPanel,
})),
{ ssr: false, loading: () => <div className="w-full h-full bg-panel" /> }
);

export default function Editor() {
const {
toolsPanel,
Expand Down
81 changes: 72 additions & 9 deletions apps/web/src/lib/storage/storage-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -126,8 +129,11 @@ class StorageService {

// Media operations
async saveMediaFile(projectId: string, mediaItem: MediaFile): Promise<void> {
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);
Expand All @@ -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<MediaFile | null> {
const { mediaMetadataAdapter, mediaFilesAdapter } =
this.getProjectMediaAdapters(projectId);
const {
mediaMetadataAdapter,
mediaFilesAdapter,
mediaThumbnailsAdapter,
} = this.getProjectMediaAdapters(projectId);

const [file, metadata] = await Promise.all([
mediaFilesAdapter.get(id),
Expand All @@ -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,
Expand All @@ -189,6 +224,7 @@ class StorageService {
height: metadata.height,
duration: metadata.duration,
ephemeral: metadata.ephemeral,
...(storedThumbnailUrl ? { thumbnailUrl: storedThumbnailUrl } : {}),
};
}

Expand All @@ -209,25 +245,52 @@ class StorageService {
}

async deleteMediaFile(projectId: string, id: string): Promise<void> {
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<void> {
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<void> {
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,
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/lib/storage/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface MediaFileData {
duration?: number;
ephemeral?: boolean;
sourceStickerIconName?: string;
hasThumbnail?: boolean;
// File will be stored separately in OPFS
}

Expand Down
52 changes: 29 additions & 23 deletions apps/web/src/stores/media-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,32 +226,38 @@ export const useMediaStore = create<MediaStore>((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)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Memory leak in generateVideoThumbnail function - the object URL created for the video element is never revoked, causing memory to accumulate with each thumbnail generation.

View Details
📝 Patch Details
diff --git a/apps/web/src/stores/media-store.ts b/apps/web/src/stores/media-store.ts
index 0e2add9..0244aa0 100644
--- a/apps/web/src/stores/media-store.ts
+++ b/apps/web/src/stores/media-store.ts
@@ -47,11 +47,13 @@ export const getImageDimensions = (
       const width = img.naturalWidth;
       const height = img.naturalHeight;
       resolve({ width, height });
+      URL.revokeObjectURL(img.src);
       img.remove();
     });
 
     img.addEventListener("error", () => {
       reject(new Error("Could not load image"));
+      URL.revokeObjectURL(img.src);
       img.remove();
     });
 
@@ -90,12 +92,14 @@ export const generateVideoThumbnail = (
       resolve({ thumbnailUrl, width, height });
 
       // Cleanup
+      URL.revokeObjectURL(video.src);
       video.remove();
       canvas.remove();
     });
 
     video.addEventListener("error", () => {
       reject(new Error("Could not load video"));
+      URL.revokeObjectURL(video.src);
       video.remove();
       canvas.remove();
     });
@@ -114,11 +118,13 @@ export const getMediaDuration = (file: File): Promise<number> => {
 
     element.addEventListener("loadedmetadata", () => {
       resolve(element.duration);
+      URL.revokeObjectURL(element.src);
       element.remove();
     });
 
     element.addEventListener("error", () => {
       reject(new Error("Could not load media"));
+      URL.revokeObjectURL(element.src);
       element.remove();
     });
 

Analysis

The generateVideoThumbnail function creates an object URL on line 103 using URL.createObjectURL(file) to load the video for thumbnail generation, but this URL is never revoked with URL.revokeObjectURL().

Object URLs consume memory until they are explicitly revoked or the document is closed. Since this function is called in background tasks for each video file that needs a thumbnail generated (line 240), and potentially multiple times during a user session, this will cause memory to accumulate over time.

The cleanup code on lines 93-94 and 99-100 only removes the DOM elements but doesn't handle the object URL cleanup. The fix is to add URL.revokeObjectURL(video.src) before removing the video element in both the success and error cleanup paths.

This memory leak will be particularly problematic in long-running editor sessions where users work with multiple video files, as each thumbnail generation will leave behind an unreleased object URL.

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;
})
})()
);
Comment on lines +233 to 258
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Don’t gate in-memory thumbnail update on persistence.

If OPFS isn’t available (Safari/older browsers) saveMediaThumbnail will fail and you’ll never display the generated thumbnail. Update state regardless; keep persistence best-effort.

-      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
-            );
-          }
-        })()
-      );
+      const tasks = missing.map((item) =>
+        (async () => {
+          const { thumbnailUrl } = await generateVideoThumbnail(item.file);
+          try {
+            const res = await fetch(thumbnailUrl);
+            const blob = await res.blob();
+            await storageService.saveMediaThumbnail(projectId, item.id, blob);
+          } catch (error) {
+            console.error(
+              `Failed to generate thumbnail for video ${item.id}:`,
+              error
+            );
+          } finally {
+            set((state) => ({
+              mediaFiles: state.mediaFiles.map((m) =>
+                m.id === item.id ? { ...m, thumbnailUrl } : m
+              ),
+            }));
+          }
+        })()
+      );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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;
})
})()
);
const missing = mediaItems.filter(
(item) => item.type === "video" && !item.thumbnailUrl && item.file
);
const tasks = missing.map((item) =>
(async () => {
const { thumbnailUrl } = await generateVideoThumbnail(item.file);
try {
const res = await fetch(thumbnailUrl);
const blob = await res.blob();
await storageService.saveMediaThumbnail(projectId, item.id, blob);
} catch (error) {
console.error(
`Failed to generate thumbnail for video ${item.id}:`,
error
);
} finally {
set((state) => ({
mediaFiles: state.mediaFiles.map((m) =>
m.id === item.id ? { ...m, thumbnailUrl } : m
),
}));
}
})()
);
🤖 Prompt for AI Agents
In apps/web/src/stores/media-store.ts around lines 233 to 258, the current flow
waits for storageService.saveMediaThumbnail to succeed before updating in-memory
state, which means a failed persistence (e.g. OPFS absent) prevents the
generated thumbnail from being shown; change the order so you immediately update
state with the new thumbnailUrl (set state before attempting persistence) and
then call saveMediaThumbnail as a best-effort asynchronous operation inside its
own try/catch so failures are logged but do not prevent UI update.


set({ mediaFiles: updatedMediaItems });
void Promise.allSettled(tasks);
} catch (error) {
console.error("Failed to load media items:", error);
} finally {
Expand Down
16 changes: 6 additions & 10 deletions apps/web/src/stores/timeline-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1230,9 +1230,8 @@ export const useTimelineStore = create<TimelineStore>((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)
Expand All @@ -1241,17 +1240,14 @@ export const useTimelineStore = create<TimelineStore>((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;
Expand Down