493 lines
13 KiB
TypeScript
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 };
|