OpenCut/apps/web/src/services/storage/service.ts

493 lines
13 KiB
TypeScript

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 { Bookmark, TimelineTrack, TScene } from "@/types/timeline";
function normalizeBookmarks({ raw }: { raw: unknown }): Bookmark[] {
if (!Array.isArray(raw)) return [];
return raw
.map((item): Bookmark | null => {
if (typeof item === "number") return { time: item };
const obj = item as Record<string, unknown>;
if (
typeof obj !== "object" ||
obj === null ||
typeof obj.time !== "number"
) {
return null;
}
return {
time: obj.time,
...(typeof obj.note === "string" && { note: obj.note }),
...(typeof obj.color === "string" && { color: obj.color }),
...(typeof obj.duration === "number" && { duration: obj.duration }),
};
})
.filter((b): b is Bookmark => b !== null);
}
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: 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:
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 };