import type { TProject, TProjectMetadata } from "@/project/types"; import { getProjectDurationFromScenes } from "@/timeline/scenes"; import type { MediaAsset } from "@/media/types"; import { IndexedDBAdapter } from "./indexeddb-adapter"; import { OPFSAdapter } from "./opfs-adapter"; import { type StorageCapacityCheckResult, StorageQuotaExceededError, evaluateStorageCapacity, isStorageQuotaExceededError, readStorageQuotaStatus, } from "./quota"; import type { MediaAssetData, StorageConfig, SerializedProject, SerializedScene, } from "./types"; import type { SavedSoundsData, SavedSound, SoundEffect } from "@/sounds/types"; import { migrations, runStorageMigrations, } from "@/services/storage/migrations"; import type { Bookmark, SceneTracks, TScene } from "@/timeline"; import { roundMediaTime } from "@/wasm"; function normalizeBookmarks({ raw }: { raw: unknown }): Bookmark[] { if (!Array.isArray(raw)) return []; return raw .map((item): Bookmark | null => { if (typeof item === "number") { return { time: roundMediaTime({ time: item }) }; } const obj = item as Record; if ( typeof obj !== "object" || obj === null || typeof obj.time !== "number" ) { return null; } return { time: roundMediaTime({ time: obj.time }), ...(typeof obj.note === "string" && { note: obj.note }), ...(typeof obj.color === "string" && { color: obj.color }), ...(typeof obj.duration === "number" && { duration: roundMediaTime({ time: obj.duration }), }), }; }) .filter((b): b is Bookmark => b !== null); } class StorageService { private projectsAdapter: IndexedDBAdapter; private savedSoundsAdapter: IndexedDBAdapter; private config: StorageConfig; private migrationsPromise: Promise | null = null; constructor() { this.config = { projectsDb: "video-editor-projects", mediaDb: "video-editor-media", savedSoundsDb: "video-editor-saved-sounds", version: 1, }; this.projectsAdapter = new IndexedDBAdapter( this.config.projectsDb, "projects", this.config.version, ); this.savedSoundsAdapter = new IndexedDBAdapter( this.config.savedSoundsDb, "saved-sounds", this.config.version, ); } private async ensureMigrations(): Promise { if (this.migrationsPromise) { await this.migrationsPromise; return; } this.migrationsPromise = runStorageMigrations({ migrations }).then( () => undefined, ); await this.migrationsPromise; } private getProjectMediaAdapters({ projectId }: { projectId: string }) { const mediaMetadataAdapter = new IndexedDBAdapter( `${this.config.mediaDb}-${projectId}`, "media-metadata", this.config.version, ); const mediaAssetsAdapter = new OPFSAdapter(`media-files-${projectId}`); return { mediaMetadataAdapter, mediaAssetsAdapter }; } async canStoreFile({ size, }: { size: number; }): Promise { const quotaStatus = await readStorageQuotaStatus(); return evaluateStorageCapacity({ requiredBytes: size, quotaStatus, }); } isQuotaExceededError({ error }: { error: unknown }): boolean { return isStorageQuotaExceededError({ error }); } private stripAudioBuffers({ tracks }: { tracks: SceneTracks }): SceneTracks { return { ...tracks, audio: tracks.audio.map((track) => ({ ...track, elements: track.elements.map((element) => { const { buffer: _buffer, ...rest } = element; return rest; }), })), }; } async saveProject({ project }: { project: TProject }): Promise { const duration = project.metadata.duration ?? getProjectDurationFromScenes({ scenes: project.scenes }); const serializedScenes: SerializedScene[] = project.scenes.map((scene) => ({ id: scene.id, name: scene.name, isMain: scene.isMain, tracks: this.stripAudioBuffers({ tracks: scene.tracks }), bookmarks: scene.bookmarks, createdAt: scene.createdAt.toISOString(), updatedAt: scene.updatedAt.toISOString(), })); const serializedProject: SerializedProject = { metadata: { id: project.metadata.id, name: project.metadata.name, thumbnail: project.metadata.thumbnail, duration, createdAt: project.metadata.createdAt.toISOString(), updatedAt: project.metadata.updatedAt.toISOString(), }, scenes: serializedScenes, currentSceneId: project.currentSceneId, settings: project.settings, version: project.version, timelineViewState: project.timelineViewState, }; await this.projectsAdapter.set(project.metadata.id, serializedProject); } async loadProject({ id, }: { id: string; }): Promise<{ project: TProject } | null> { await this.ensureMigrations(); const serializedProject = await this.projectsAdapter.get(id); if (!serializedProject) return null; if ( typeof serializedProject !== "object" || serializedProject === null || typeof serializedProject.metadata !== "object" || serializedProject.metadata === null ) { console.warn( "[storage] Skipping malformed project entry (missing metadata):", { id, entry: serializedProject }, ); return null; } const scenes = serializedProject.scenes?.map((scene) => ({ id: scene.id, name: scene.name, isMain: scene.isMain, tracks: scene.tracks, bookmarks: normalizeBookmarks({ raw: scene.bookmarks }), createdAt: new Date(scene.createdAt), updatedAt: new Date(scene.updatedAt), })) ?? []; const project: TProject = { metadata: { id: serializedProject.metadata.id, name: serializedProject.metadata.name, thumbnail: serializedProject.metadata.thumbnail, duration: roundMediaTime({ time: serializedProject.metadata.duration ?? getProjectDurationFromScenes({ scenes }), }), createdAt: new Date(serializedProject.metadata.createdAt), updatedAt: new Date(serializedProject.metadata.updatedAt), }, scenes, currentSceneId: serializedProject.currentSceneId || "", settings: serializedProject.settings, version: serializedProject.version, timelineViewState: serializedProject.timelineViewState, }; return { project }; } async loadAllProjects(): Promise { const projectIds = await this.projectsAdapter.list(); const projects: TProject[] = []; for (const id of projectIds) { const result = await this.loadProject({ id }); if (result?.project) { projects.push(result.project); } } return projects.sort( (a, b) => b.metadata.updatedAt.getTime() - a.metadata.updatedAt.getTime(), ); } async loadAllProjectsMetadata(): Promise { await this.ensureMigrations(); const serializedProjects = await this.projectsAdapter.getAll(); const metadata: TProjectMetadata[] = []; for (const serializedProject of serializedProjects) { if ( typeof serializedProject !== "object" || serializedProject === null || typeof serializedProject.metadata !== "object" || serializedProject.metadata === null ) { console.warn( "[storage] Skipping malformed project entry (missing metadata):", serializedProject, ); continue; } metadata.push({ id: serializedProject.metadata.id, name: serializedProject.metadata.name, thumbnail: serializedProject.metadata.thumbnail, duration: roundMediaTime({ time: serializedProject.metadata.duration ?? getProjectDurationFromScenes({ scenes: (serializedProject.scenes ?? []) as unknown as TScene[], }), }), createdAt: new Date(serializedProject.metadata.createdAt), updatedAt: new Date(serializedProject.metadata.updatedAt), }); } return metadata.sort( (a, b) => b.updatedAt.getTime() - a.updatedAt.getTime(), ); } async deleteProject({ id }: { id: string }): Promise { await this.projectsAdapter.remove(id); } async saveMediaAsset({ projectId, mediaAsset, }: { projectId: string; mediaAsset: MediaAsset; }): Promise { const { mediaMetadataAdapter, mediaAssetsAdapter } = this.getProjectMediaAdapters({ projectId }); const metadata: MediaAssetData = { id: mediaAsset.id, name: mediaAsset.name, type: mediaAsset.type, size: mediaAsset.file.size, lastModified: mediaAsset.file.lastModified, width: mediaAsset.width, height: mediaAsset.height, duration: mediaAsset.duration, thumbnailUrl: mediaAsset.thumbnailUrl, ephemeral: mediaAsset.ephemeral, }; try { await mediaAssetsAdapter.set(mediaAsset.id, mediaAsset.file); await mediaMetadataAdapter.set(mediaAsset.id, metadata); } catch (error) { try { await mediaAssetsAdapter.remove(mediaAsset.id); } catch { // Ignore cleanup failures so the original storage error is preserved. } if (this.isQuotaExceededError({ error })) { throw new StorageQuotaExceededError({ requiredBytes: mediaAsset.file.size, }); } throw error; } } async loadMediaAsset({ projectId, id, }: { projectId: string; id: string; }): Promise { const { mediaMetadataAdapter, mediaAssetsAdapter } = this.getProjectMediaAdapters({ projectId }); const [file, metadata] = await Promise.all([ mediaAssetsAdapter.get(id), mediaMetadataAdapter.get(id), ]); if (!file || !metadata) return null; let url: string; if (metadata.type === "image" && (!file.type || file.type === "")) { try { const text = await file.text(); if (text.trim().startsWith(" { const { mediaMetadataAdapter } = this.getProjectMediaAdapters({ projectId, }); const mediaIds = await mediaMetadataAdapter.list(); const mediaItems: MediaAsset[] = []; for (const id of mediaIds) { const item = await this.loadMediaAsset({ projectId, id }); if (item) { mediaItems.push(item); } } return mediaItems; } async deleteMediaAsset({ projectId, id, }: { projectId: string; id: string; }): Promise { const { mediaMetadataAdapter, mediaAssetsAdapter } = this.getProjectMediaAdapters({ projectId }); await Promise.all([ mediaAssetsAdapter.remove(id), mediaMetadataAdapter.remove(id), ]); } async deleteProjectMedia({ projectId, }: { projectId: string; }): Promise { const { mediaMetadataAdapter, mediaAssetsAdapter } = this.getProjectMediaAdapters({ projectId }); await Promise.all([ mediaMetadataAdapter.clear(), mediaAssetsAdapter.clear(), ]); } async clearAllData(): Promise { await this.projectsAdapter.clear(); // project-specific media and timelines cleaned up when projects are deleted } async getStorageInfo(): Promise<{ projects: number; isOPFSSupported: boolean; isIndexedDBSupported: boolean; }> { const projectIds = await this.projectsAdapter.list(); return { projects: projectIds.length, isOPFSSupported: this.isOPFSSupported(), isIndexedDBSupported: this.isIndexedDBSupported(), }; } async getProjectStorageInfo({ projectId }: { projectId: string }): Promise<{ mediaItems: number; }> { const { mediaMetadataAdapter } = this.getProjectMediaAdapters({ projectId, }); const mediaIds = await mediaMetadataAdapter.list(); return { mediaItems: mediaIds.length, }; } async loadSavedSounds(): Promise { try { const savedSoundsData = await this.savedSoundsAdapter.get("user-sounds"); return ( savedSoundsData || { sounds: [], lastModified: new Date().toISOString(), } ); } catch (error) { console.error("Failed to load saved sounds:", error); return { sounds: [], lastModified: new Date().toISOString() }; } } async saveSoundEffect({ soundEffect, }: { soundEffect: SoundEffect; }): Promise { try { const currentData = await this.loadSavedSounds(); if (currentData.sounds.some((sound) => sound.id === soundEffect.id)) { return; // Already saved } const savedSound: SavedSound = { id: soundEffect.id, name: soundEffect.name, username: soundEffect.username, previewUrl: soundEffect.previewUrl, downloadUrl: soundEffect.downloadUrl, duration: soundEffect.duration, tags: soundEffect.tags, license: soundEffect.license, savedAt: new Date().toISOString(), }; const updatedData: SavedSoundsData = { sounds: [...currentData.sounds, savedSound], lastModified: new Date().toISOString(), }; await this.savedSoundsAdapter.set("user-sounds", updatedData); } catch (error) { console.error("Failed to save sound effect:", error); throw error; } } async removeSavedSound({ soundId }: { soundId: number }): Promise { try { const currentData = await this.loadSavedSounds(); const updatedData: SavedSoundsData = { sounds: currentData.sounds.filter((sound) => sound.id !== soundId), lastModified: new Date().toISOString(), }; await this.savedSoundsAdapter.set("user-sounds", updatedData); } catch (error) { console.error("Failed to remove saved sound:", error); throw error; } } async isSoundSaved({ soundId }: { soundId: number }): Promise { try { const currentData = await this.loadSavedSounds(); return currentData.sounds.some((sound) => sound.id === soundId); } catch (error) { console.error("Failed to check if sound is saved:", error); return false; } } async clearSavedSounds(): Promise { try { await this.savedSoundsAdapter.remove("user-sounds"); } catch (error) { console.error("Failed to clear saved sounds:", error); throw error; } } isOPFSSupported(): boolean { return OPFSAdapter.isSupported(); } isIndexedDBSupported(): boolean { return "indexedDB" in window; } isFullySupported(): boolean { return this.isIndexedDBSupported() && this.isOPFSSupported(); } } export const storageService = new StorageService(); export { StorageService };