diff options
Diffstat (limited to 'apps/web/src/services/storage/service.ts')
| -rw-r--r-- | apps/web/src/services/storage/service.ts | 469 |
1 files changed, 469 insertions, 0 deletions
diff --git a/apps/web/src/services/storage/service.ts b/apps/web/src/services/storage/service.ts new file mode 100644 index 00000000..02f8d290 --- /dev/null +++ b/apps/web/src/services/storage/service.ts @@ -0,0 +1,469 @@ +import type { TProject, TProjectMetadata } from "@/types/project"; +import { getProjectDurationFromScenes } from "@/lib/scenes"; +import type { MediaAsset } from "@/types/assets"; +import { IndexedDBAdapter } from "./indexeddb-adapter"; +import { OPFSAdapter } from "./opfs-adapter"; +import type { + MediaAssetData, + StorageConfig, + SerializedProject, + SerializedScene, +} from "./types"; +import type { SavedSoundsData, SavedSound, SoundEffect } from "@/types/sounds"; +import { + migrations, + runStorageMigrations, +} from "@/services/storage/migrations"; +import type { TimelineTrack, TScene } from "@/types/timeline"; + +class StorageService { + private projectsAdapter: IndexedDBAdapter<SerializedProject>; + private savedSoundsAdapter: IndexedDBAdapter<SavedSoundsData>; + private config: StorageConfig; + private migrationsPromise: Promise<void> | null = null; + + constructor() { + this.config = { + projectsDb: "video-editor-projects", + mediaDb: "video-editor-media", + savedSoundsDb: "video-editor-saved-sounds", + version: 1, + }; + + this.projectsAdapter = new IndexedDBAdapter<SerializedProject>( + this.config.projectsDb, + "projects", + this.config.version, + ); + + this.savedSoundsAdapter = new IndexedDBAdapter<SavedSoundsData>( + this.config.savedSoundsDb, + "saved-sounds", + this.config.version, + ); + } + + private async ensureMigrations(): Promise<void> { + 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<MediaAssetData>( + `${this.config.mediaDb}-${projectId}`, + "media-metadata", + this.config.version, + ); + + const mediaAssetsAdapter = new OPFSAdapter(`media-files-${projectId}`); + + return { mediaMetadataAdapter, mediaAssetsAdapter }; + } + + private stripAudioBuffers({ + tracks, + }: { + tracks: TimelineTrack[]; + }): TimelineTrack[] { + return tracks.map((track) => { + if (track.type !== "audio") return track; + return { + ...track, + elements: track.elements.map((element) => { + const { buffer: _buffer, ...rest } = element; + return rest; + }), + }; + }); + } + + async saveProject({ project }: { project: TProject }): Promise<void> { + 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; + + const scenes = + serializedProject.scenes?.map((scene) => ({ + id: scene.id, + name: scene.name, + isMain: scene.isMain, + tracks: (scene.tracks ?? []).map((track) => + track.type === "video" + ? { ...track, isMain: track.isMain ?? false } // legacy: isMain was optional + : track, + ), + bookmarks: 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: + 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<TProject[]> { + 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<TProjectMetadata[]> { + await this.ensureMigrations(); + const serializedProjects = await this.projectsAdapter.getAll(); + + const metadata = serializedProjects.map((serializedProject) => ({ + id: serializedProject.metadata.id, + name: serializedProject.metadata.name, + thumbnail: serializedProject.metadata.thumbnail, + duration: + 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<void> { + await this.projectsAdapter.remove(id); + } + + async saveMediaAsset({ + projectId, + mediaAsset, + }: { + projectId: string; + mediaAsset: MediaAsset; + }): Promise<void> { + const { mediaMetadataAdapter, mediaAssetsAdapter } = + this.getProjectMediaAdapters({ projectId }); + + await mediaAssetsAdapter.set(mediaAsset.id, mediaAsset.file); + + 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, + }; + + await mediaMetadataAdapter.set(mediaAsset.id, metadata); + } + + async loadMediaAsset({ + projectId, + id, + }: { + projectId: string; + id: string; + }): Promise<MediaAsset | null> { + 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("<svg")) { + const svgBlob = new Blob([text], { type: "image/svg+xml" }); + url = URL.createObjectURL(svgBlob); + } else { + url = URL.createObjectURL(file); + } + } catch { + url = URL.createObjectURL(file); + } + } else { + url = URL.createObjectURL(file); + } + + return { + id: metadata.id, + name: metadata.name, + type: metadata.type, + file, + url, + width: metadata.width, + height: metadata.height, + duration: metadata.duration, + thumbnailUrl: metadata.thumbnailUrl, + ephemeral: metadata.ephemeral, + }; + } + + async loadAllMediaAssets({ + projectId, + }: { + projectId: string; + }): Promise<MediaAsset[]> { + 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<void> { + const { mediaMetadataAdapter, mediaAssetsAdapter } = + this.getProjectMediaAdapters({ projectId }); + + await Promise.all([ + mediaAssetsAdapter.remove(id), + mediaMetadataAdapter.remove(id), + ]); + } + + async deleteProjectMedia({ + projectId, + }: { + projectId: string; + }): Promise<void> { + const { mediaMetadataAdapter, mediaAssetsAdapter } = + this.getProjectMediaAdapters({ projectId }); + + await Promise.all([ + mediaMetadataAdapter.clear(), + mediaAssetsAdapter.clear(), + ]); + } + + async clearAllData(): Promise<void> { + 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<SavedSoundsData> { + 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<void> { + 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<void> { + 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<boolean> { + 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<void> { + 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 }; |
