From 7bf0984698e43b9aa2dd879f65951318ed410390 Mon Sep 17 00:00:00 2001 From: Maze <167211895+mazeincoding@users.noreply.github.com> Date: Sat, 31 Jan 2026 00:20:04 +0100 Subject: codebase overhaul (#697) --- apps/web/src/services/storage/indexeddb-adapter.ts | 101 +++++ apps/web/src/services/storage/migrations/base.ts | 5 + apps/web/src/services/storage/migrations/index.ts | 14 + apps/web/src/services/storage/migrations/runner.ts | 134 ++++++ .../src/services/storage/migrations/v0-to-v1.ts | 93 ++++ .../src/services/storage/migrations/v1-to-v2.ts | 339 +++++++++++++++ .../src/services/storage/migrations/v2-to-v3.ts | 92 ++++ .../services/storage/migrations/version-manager.ts | 73 ++++ apps/web/src/services/storage/opfs-adapter.ts | 73 ++++ apps/web/src/services/storage/storage-service.ts | 475 +++++++++++++++++++++ apps/web/src/services/storage/types.ts | 66 +++ 11 files changed, 1465 insertions(+) create mode 100644 apps/web/src/services/storage/indexeddb-adapter.ts create mode 100644 apps/web/src/services/storage/migrations/base.ts create mode 100644 apps/web/src/services/storage/migrations/index.ts create mode 100644 apps/web/src/services/storage/migrations/runner.ts create mode 100644 apps/web/src/services/storage/migrations/v0-to-v1.ts create mode 100644 apps/web/src/services/storage/migrations/v1-to-v2.ts create mode 100644 apps/web/src/services/storage/migrations/v2-to-v3.ts create mode 100644 apps/web/src/services/storage/migrations/version-manager.ts create mode 100644 apps/web/src/services/storage/opfs-adapter.ts create mode 100644 apps/web/src/services/storage/storage-service.ts create mode 100644 apps/web/src/services/storage/types.ts (limited to 'apps/web/src/services/storage') diff --git a/apps/web/src/services/storage/indexeddb-adapter.ts b/apps/web/src/services/storage/indexeddb-adapter.ts new file mode 100644 index 00000000..cc4d03a0 --- /dev/null +++ b/apps/web/src/services/storage/indexeddb-adapter.ts @@ -0,0 +1,101 @@ +import type { StorageAdapter } from "./types"; + +export class IndexedDBAdapter implements StorageAdapter { + private dbName: string; + private storeName: string; + private version: number; + + constructor(dbName: string, storeName: string, version = 1) { + this.dbName = dbName; + this.storeName = storeName; + this.version = version; + } + + private async getDB(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(this.dbName, this.version); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(this.storeName)) { + db.createObjectStore(this.storeName, { keyPath: "id" }); + } + }; + }); + } + + async get(key: string): Promise { + const db = await this.getDB(); + const transaction = db.transaction([this.storeName], "readonly"); + const store = transaction.objectStore(this.storeName); + + return new Promise((resolve, reject) => { + const request = store.get(key); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result || null); + }); + } + + async set(key: string, value: T): Promise { + const db = await this.getDB(); + const transaction = db.transaction([this.storeName], "readwrite"); + const store = transaction.objectStore(this.storeName); + + return new Promise((resolve, reject) => { + const request = store.put({ id: key, ...value }); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(); + }); + } + + async remove(key: string): Promise { + const db = await this.getDB(); + const transaction = db.transaction([this.storeName], "readwrite"); + const store = transaction.objectStore(this.storeName); + + return new Promise((resolve, reject) => { + const request = store.delete(key); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(); + }); + } + + async list(): Promise { + const db = await this.getDB(); + const transaction = db.transaction([this.storeName], "readonly"); + const store = transaction.objectStore(this.storeName); + + return new Promise((resolve, reject) => { + const request = store.getAllKeys(); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result as string[]); + }); + } + + async getAll(): Promise { + const db = await this.getDB(); + const transaction = db.transaction([this.storeName], "readonly"); + const store = transaction.objectStore(this.storeName); + + return new Promise((resolve, reject) => { + const request = store.getAll(); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result || []); + }); + } + + async clear(): Promise { + const db = await this.getDB(); + const transaction = db.transaction([this.storeName], "readwrite"); + const store = transaction.objectStore(this.storeName); + + return new Promise((resolve, reject) => { + const request = store.clear(); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(); + }); + } +} diff --git a/apps/web/src/services/storage/migrations/base.ts b/apps/web/src/services/storage/migrations/base.ts new file mode 100644 index 00000000..c7558264 --- /dev/null +++ b/apps/web/src/services/storage/migrations/base.ts @@ -0,0 +1,5 @@ +export abstract class StorageMigration { + abstract from: number; + abstract to: number; + abstract run(): Promise; +} diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts new file mode 100644 index 00000000..2343362f --- /dev/null +++ b/apps/web/src/services/storage/migrations/index.ts @@ -0,0 +1,14 @@ +export { StorageMigration } from "./base"; +export { StorageVersionManager } from "./version-manager"; +export { runStorageMigrations } from "./runner"; +import { V0toV1Migration } from "./v0-to-v1"; +import { V1toV2Migration } from "./v1-to-v2"; +import { V2toV3Migration } from "./v2-to-v3"; + +export const CURRENT_STORAGE_VERSION = 3; + +export const migrations = [ + new V0toV1Migration(), + new V1toV2Migration(), + new V2toV3Migration(), +]; diff --git a/apps/web/src/services/storage/migrations/runner.ts b/apps/web/src/services/storage/migrations/runner.ts new file mode 100644 index 00000000..d9277cb0 --- /dev/null +++ b/apps/web/src/services/storage/migrations/runner.ts @@ -0,0 +1,134 @@ +import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter"; +import type { StorageMigration } from "./base"; +import { StorageVersionManager } from "./version-manager"; + +export interface StorageMigrationResult { + fromVersion: number; + toVersion: number; + migrated: boolean; +} + +export type StorageMigrationCallbacks = { + onMigrationStart?: ({ + fromVersion, + toVersion, + }: { + fromVersion: number; + toVersion: number; + }) => void; + onMigrationComplete?: ({ + fromVersion, + toVersion, + }: { + fromVersion: number; + toVersion: number; + }) => void; +}; + +type ProjectRecord = Record; + +export async function runStorageMigrations({ + migrations, + versionManager = new StorageVersionManager(), + callbacks, +}: { + migrations: StorageMigration[]; + versionManager?: StorageVersionManager; + callbacks?: StorageMigrationCallbacks; +}): Promise { + const versionRecord = await versionManager.getVersionRecord(); + const inferredVersion = versionRecord + ? null + : await inferStorageVersionFromProjects(); + const fromVersion = + versionRecord?.inProgress?.from ?? + versionRecord?.version ?? + inferredVersion ?? + 0; + + if (!versionRecord) { + await versionManager.setVersion({ version: fromVersion }); + } + + const orderedMigrations = [...migrations].sort((a, b) => a.from - b.from); + let currentVersion = fromVersion; + + for (const migration of orderedMigrations) { + if (migration.from !== currentVersion) { + continue; + } + + await versionManager.setInProgress({ + from: migration.from, + to: migration.to, + }); + callbacks?.onMigrationStart?.({ + fromVersion: migration.from, + toVersion: migration.to, + }); + await migration.run(); + currentVersion = migration.to; + await versionManager.setVersion({ version: currentVersion }); + await versionManager.clearInProgress(); + callbacks?.onMigrationComplete?.({ + fromVersion: migration.from, + toVersion: migration.to, + }); + } + + return { + fromVersion, + toVersion: currentVersion, + migrated: currentVersion !== fromVersion, + }; +} + +async function inferStorageVersionFromProjects(): Promise { + const projectsAdapter = new IndexedDBAdapter( + "video-editor-projects", + "projects", + 1, + ); + const projects = await projectsAdapter.getAll(); + + if (projects.length === 0) { + return 0; + } + + let lowestVersion = Number.POSITIVE_INFINITY; + + for (const project of projects) { + const projectVersion = checkProjectVersion({ project }); + if (projectVersion < lowestVersion) { + lowestVersion = projectVersion; + } + } + + if (lowestVersion === Number.POSITIVE_INFINITY) { + return 0; + } + + return lowestVersion; +} + +function checkProjectVersion({ project }: { project: unknown }): number { + if (!isRecord(project)) { + return 0; + } + + const versionValue = project.version; + if (typeof versionValue === "number") { + return versionValue; + } + + const scenesValue = project.scenes; + if (Array.isArray(scenesValue) && scenesValue.length > 0) { + return 1; + } + + return 0; +} + +function isRecord(value: unknown): value is ProjectRecord { + return typeof value === "object" && value !== null; +} diff --git a/apps/web/src/services/storage/migrations/v0-to-v1.ts b/apps/web/src/services/storage/migrations/v0-to-v1.ts new file mode 100644 index 00000000..7ec388f1 --- /dev/null +++ b/apps/web/src/services/storage/migrations/v0-to-v1.ts @@ -0,0 +1,93 @@ +import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter"; +import type { SerializedScene } from "@/services/storage/types"; +import { buildDefaultScene } from "@/lib/scenes"; +import type { TScene } from "@/types/timeline"; +import { StorageMigration } from "./base"; + +type ProjectRecord = Record; + +export class V0toV1Migration extends StorageMigration { + from = 0; + to = 1; + + async run(): Promise { + const projectsAdapter = new IndexedDBAdapter( + "video-editor-projects", + "projects", + 1, + ); + const projects = await projectsAdapter.getAll(); + + for (const project of projects) { + if (!isRecord(project)) { + continue; + } + + const scenesValue = project.scenes; + if (Array.isArray(scenesValue) && scenesValue.length > 0) { + continue; + } + + const mainScene = buildDefaultScene({ isMain: true, name: "Main scene" }); + const serializedScene = serializeScene({ scene: mainScene }); + const updatedProject: ProjectRecord = { + ...project, + scenes: [serializedScene], + currentSceneId: mainScene.id, + version: 1, + }; + + const updatedAt = new Date().toISOString(); + if (isRecord(project.metadata)) { + updatedProject.metadata = { + ...project.metadata, + updatedAt, + }; + } else { + updatedProject.updatedAt = updatedAt; + } + + const projectId = getProjectId({ project: updatedProject }); + if (!projectId) { + continue; + } + + await projectsAdapter.set(projectId, updatedProject); + } + } +} + +function serializeScene({ scene }: { scene: TScene }): SerializedScene { + return { + id: scene.id, + name: scene.name, + isMain: scene.isMain, + tracks: scene.tracks, + bookmarks: scene.bookmarks, + createdAt: scene.createdAt.toISOString(), + updatedAt: scene.updatedAt.toISOString(), + }; +} + +function getProjectId({ project }: { project: ProjectRecord }): string | null { + const idValue = project.id; + if (typeof idValue === "string" && idValue.length > 0) { + return idValue; + } + + const metadataValue = project.metadata; + if (!isRecord(metadataValue)) { + return null; + } + + const metadataId = metadataValue.id; + if (typeof metadataId === "string" && metadataId.length > 0) { + return metadataId; + } + + return null; +} + +function isRecord(value: unknown): value is ProjectRecord { + return typeof value === "object" && value !== null; +} diff --git a/apps/web/src/services/storage/migrations/v1-to-v2.ts b/apps/web/src/services/storage/migrations/v1-to-v2.ts new file mode 100644 index 00000000..05d302ea --- /dev/null +++ b/apps/web/src/services/storage/migrations/v1-to-v2.ts @@ -0,0 +1,339 @@ +import { + DEFAULT_BLUR_INTENSITY, + DEFAULT_CANVAS_SIZE, + DEFAULT_COLOR, + DEFAULT_FPS, +} from "@/constants/project-constants"; +import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter"; +import { StorageMigration } from "./base"; + +type ProjectRecord = Record; + +export class V1toV2Migration extends StorageMigration { + from = 1; + to = 2; + + async run(): Promise { + const projectsAdapter = new IndexedDBAdapter( + "video-editor-projects", + "projects", + 1, + ); + const projects = await projectsAdapter.getAll(); + + for (const project of projects) { + if (!isRecord(project)) { + continue; + } + + const projectId = getProjectId({ project }); + if (!projectId) { + continue; + } + + if (isV2Project({ project })) { + continue; + } + + const migratedProject = migrateProject({ project, projectId }); + await projectsAdapter.set(projectId, migratedProject); + } + } +} + +function migrateProject({ + project, + projectId, +}: { + project: ProjectRecord; + projectId: string; +}): ProjectRecord { + const createdAt = normalizeDateString({ value: project.createdAt }); + const updatedAt = normalizeDateString({ value: project.updatedAt }); + const metadataValue = project.metadata; + + const metadata = isRecord(metadataValue) + ? { + id: getStringValue({ value: metadataValue.id, fallback: projectId }), + name: getStringValue({ value: metadataValue.name, fallback: "" }), + thumbnail: getStringValue({ value: metadataValue.thumbnail }), + createdAt: normalizeDateString({ value: metadataValue.createdAt }), + updatedAt: normalizeDateString({ value: metadataValue.updatedAt }), + } + : { + id: projectId, + name: getStringValue({ value: project.name, fallback: "" }), + thumbnail: getStringValue({ value: project.thumbnail }), + createdAt, + updatedAt, + }; + + const scenesValue = project.scenes; + const scenes = Array.isArray(scenesValue) ? scenesValue : []; + const legacyBookmarks = Array.isArray(project.bookmarks) + ? project.bookmarks + : null; + const normalizedScenes = applyLegacyBookmarks({ + scenes, + legacyBookmarks, + }); + + const settingsValue = project.settings; + const settings = isRecord(settingsValue) + ? { + fps: getNumberValue({ + value: settingsValue.fps, + fallback: DEFAULT_FPS, + }), + canvasSize: getCanvasSizeValue({ + value: settingsValue.canvasSize, + fallback: DEFAULT_CANVAS_SIZE, + }), + background: getBackgroundValue({ + value: settingsValue.background, + }), + } + : { + fps: getNumberValue({ value: project.fps, fallback: DEFAULT_FPS }), + canvasSize: getCanvasSizeValue({ + value: project.canvasSize, + fallback: DEFAULT_CANVAS_SIZE, + }), + background: getBackgroundValue({ + value: project.background, + backgroundType: project.backgroundType, + backgroundColor: project.backgroundColor, + blurIntensity: project.blurIntensity, + }), + }; + + const currentSceneId = getCurrentSceneId({ + value: project.currentSceneId, + scenes: normalizedScenes, + }); + + return { + ...project, + metadata, + scenes: normalizedScenes, + currentSceneId, + settings, + version: 2, + }; +} + +function getProjectId({ project }: { project: ProjectRecord }): string | null { + const idValue = project.id; + if (typeof idValue === "string" && idValue.length > 0) { + return idValue; + } + + const metadataValue = project.metadata; + if (!isRecord(metadataValue)) { + return null; + } + + const metadataId = metadataValue.id; + if (typeof metadataId === "string" && metadataId.length > 0) { + return metadataId; + } + + return null; +} + +function getCurrentSceneId({ + value, + scenes, +}: { + value: unknown; + scenes: unknown[]; +}): string { + if (typeof value === "string" && value.length > 0) { + return value; + } + + const mainSceneId = findMainSceneId({ scenes }); + if (mainSceneId) { + return mainSceneId; + } + + return ""; +} + +function findMainSceneId({ scenes }: { scenes: unknown[] }): string | null { + for (const scene of scenes) { + if (!isRecord(scene)) { + continue; + } + + if (scene.isMain === true && typeof scene.id === "string") { + return scene.id; + } + } + + for (const scene of scenes) { + if (!isRecord(scene)) { + continue; + } + + if (typeof scene.id === "string") { + return scene.id; + } + } + + return null; +} + +function applyLegacyBookmarks({ + scenes, + legacyBookmarks, +}: { + scenes: unknown[]; + legacyBookmarks: unknown[] | null; +}): unknown[] { + if (!legacyBookmarks || legacyBookmarks.length === 0) { + return scenes; + } + + const mainSceneId = findMainSceneId({ scenes }); + + return scenes.map((scene) => { + if (!isRecord(scene)) { + return scene; + } + + if (mainSceneId && scene.id !== mainSceneId) { + return scene; + } + + if (Array.isArray(scene.bookmarks) && scene.bookmarks.length > 0) { + return scene; + } + + return { + ...scene, + bookmarks: legacyBookmarks, + }; + }); +} + +function getBackgroundValue({ + value, + backgroundType, + backgroundColor, + blurIntensity, +}: { + value: unknown; + backgroundType?: unknown; + backgroundColor?: unknown; + blurIntensity?: unknown; +}): { + type: "color" | "blur"; + color?: string; + blurIntensity?: number; +} { + if (isRecord(value)) { + const typeValue = value.type; + if (typeValue === "blur") { + return { + type: "blur", + blurIntensity: getNumberValue({ + value: value.blurIntensity, + fallback: DEFAULT_BLUR_INTENSITY, + }), + }; + } + + return { + type: "color", + color: getStringValue({ value: value.color, fallback: DEFAULT_COLOR }), + }; + } + + if (backgroundType === "blur") { + return { + type: "blur", + blurIntensity: getNumberValue({ + value: blurIntensity, + fallback: DEFAULT_BLUR_INTENSITY, + }), + }; + } + + return { + type: "color", + color: getStringValue({ value: backgroundColor, fallback: DEFAULT_COLOR }), + }; +} + +function getCanvasSizeValue({ + value, + fallback, +}: { + value: unknown; + fallback: { width: number; height: number }; +}): { width: number; height: number } { + if (isRecord(value)) { + const width = getNumberValue({ + value: value.width, + fallback: fallback.width, + }); + const height = getNumberValue({ + value: value.height, + fallback: fallback.height, + }); + + return { width, height }; + } + + return fallback; +} + +function getNumberValue({ + value, + fallback, +}: { + value: unknown; + fallback: number; +}): number { + return typeof value === "number" ? value : fallback; +} + +function getStringValue({ + value, + fallback, +}: { + value: unknown; + fallback?: string; +}): string | undefined { + if (typeof value === "string") { + return value; + } + + return fallback; +} + +function normalizeDateString({ value }: { value: unknown }): string { + if (value instanceof Date) { + return value.toISOString(); + } + + if (typeof value === "string") { + return value; + } + + return new Date().toISOString(); +} + +function isV2Project({ project }: { project: ProjectRecord }): boolean { + const versionValue = project.version; + if (typeof versionValue === "number" && versionValue >= 2) { + return true; + } + + return isRecord(project.metadata) && isRecord(project.settings); +} + +function isRecord(value: unknown): value is ProjectRecord { + return typeof value === "object" && value !== null; +} diff --git a/apps/web/src/services/storage/migrations/v2-to-v3.ts b/apps/web/src/services/storage/migrations/v2-to-v3.ts new file mode 100644 index 00000000..eb6db26d --- /dev/null +++ b/apps/web/src/services/storage/migrations/v2-to-v3.ts @@ -0,0 +1,92 @@ +import { getProjectDurationFromScenes } from "@/lib/scenes"; +import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter"; +import type { TScene } from "@/types/timeline"; +import { StorageMigration } from "./base"; + +type ProjectRecord = Record; + +export class V2toV3Migration extends StorageMigration { + from = 2; + to = 3; + + async run(): Promise { + const projectsAdapter = new IndexedDBAdapter( + "video-editor-projects", + "projects", + 1, + ); + const projects = await projectsAdapter.getAll(); + + for (const project of projects) { + if (!isRecord(project)) { + continue; + } + + const projectId = getProjectId({ project }); + if (!projectId) { + continue; + } + + if (isV3Project({ project })) { + continue; + } + + const scenes = getScenes({ project }); + const duration = getProjectDurationFromScenes({ scenes }); + + const metadataValue = project.metadata; + const metadata = isRecord(metadataValue) + ? { ...metadataValue, duration } + : { duration }; + + const migratedProject = { + ...project, + metadata, + version: 3, + }; + + await projectsAdapter.set(projectId, migratedProject); + } + } +} + +function getProjectId({ project }: { project: ProjectRecord }): string | null { + const idValue = project.id; + if (typeof idValue === "string" && idValue.length > 0) { + return idValue; + } + + const metadataValue = project.metadata; + if (!isRecord(metadataValue)) { + return null; + } + + const metadataId = metadataValue.id; + if (typeof metadataId === "string" && metadataId.length > 0) { + return metadataId; + } + + return null; +} + +function getScenes({ project }: { project: ProjectRecord }): TScene[] { + const scenesValue = project.scenes; + if (!Array.isArray(scenesValue)) { + return []; + } + + return scenesValue.filter(isRecord) as unknown as TScene[]; +} + +function isV3Project({ project }: { project: ProjectRecord }): boolean { + const versionValue = project.version; + if (typeof versionValue === "number" && versionValue >= 3) { + return true; + } + + return isRecord(project.metadata) && typeof project.metadata.duration === "number"; +} + +function isRecord(value: unknown): value is ProjectRecord { + return typeof value === "object" && value !== null; +} diff --git a/apps/web/src/services/storage/migrations/version-manager.ts b/apps/web/src/services/storage/migrations/version-manager.ts new file mode 100644 index 00000000..879498ce --- /dev/null +++ b/apps/web/src/services/storage/migrations/version-manager.ts @@ -0,0 +1,73 @@ +import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter"; + +type StorageVersionRecord = { + version: number; + inProgress?: { + from: number; + to: number; + }; +}; + +const DEFAULT_DB_NAME = "video-editor-meta"; +const DEFAULT_STORE_NAME = "storage-version"; +const DEFAULT_DB_VERSION = 1; +const STORAGE_VERSION_KEY = "storage-version"; + +export class StorageVersionManager { + private adapter: IndexedDBAdapter; + + constructor({ + dbName = DEFAULT_DB_NAME, + storeName = DEFAULT_STORE_NAME, + version = DEFAULT_DB_VERSION, + }: { + dbName?: string; + storeName?: string; + version?: number; + } = {}) { + this.adapter = new IndexedDBAdapter( + dbName, + storeName, + version, + ); + } + + async getVersion(): Promise { + const record = await this.adapter.get(STORAGE_VERSION_KEY); + return record?.version ?? 0; + } + + async getVersionRecord(): Promise { + return this.adapter.get(STORAGE_VERSION_KEY); + } + + async setVersion({ version }: { version: number }): Promise { + const record = await this.getVersionRecord(); + const inProgress = record?.inProgress; + await this.adapter.set(STORAGE_VERSION_KEY, { + version, + ...(inProgress ? { inProgress } : {}), + }); + } + + async setInProgress({ + from, + to, + }: { + from: number; + to: number; + }): Promise { + const record = await this.getVersionRecord(); + const version = record?.version ?? 0; + await this.adapter.set(STORAGE_VERSION_KEY, { + version, + inProgress: { from, to }, + }); + } + + async clearInProgress(): Promise { + const record = await this.getVersionRecord(); + const version = record?.version ?? 0; + await this.adapter.set(STORAGE_VERSION_KEY, { version }); + } +} diff --git a/apps/web/src/services/storage/opfs-adapter.ts b/apps/web/src/services/storage/opfs-adapter.ts new file mode 100644 index 00000000..879223b5 --- /dev/null +++ b/apps/web/src/services/storage/opfs-adapter.ts @@ -0,0 +1,73 @@ +import type { StorageAdapter } from "./types"; + +export class OPFSAdapter implements StorageAdapter { + private directoryName: string; + + constructor(directoryName = "media") { + this.directoryName = directoryName; + } + + private async getDirectory(): Promise { + const opfsRoot = await navigator.storage.getDirectory(); + return await opfsRoot.getDirectoryHandle(this.directoryName, { + create: true, + }); + } + + async get(key: string): Promise { + try { + const directory = await this.getDirectory(); + const fileHandle = await directory.getFileHandle(key); + return await fileHandle.getFile(); + } catch (error) { + if ((error as Error).name === "NotFoundError") { + return null; + } + throw error; + } + } + + async set(key: string, file: File): Promise { + const directory = await this.getDirectory(); + const fileHandle = await directory.getFileHandle(key, { create: true }); + const writable = await fileHandle.createWritable(); + + await writable.write(file); + await writable.close(); + } + + async remove(key: string): Promise { + try { + const directory = await this.getDirectory(); + await directory.removeEntry(key); + } catch (error) { + if ((error as Error).name !== "NotFoundError") { + throw error; + } + } + } + + async list(): Promise { + const directory = await this.getDirectory(); + const keys: string[] = []; + + for await (const name of directory.keys()) { + keys.push(name); + } + + return keys; + } + + async clear(): Promise { + const directory = await this.getDirectory(); + + for await (const name of directory.keys()) { + await directory.removeEntry(name); + } + } + + // Helper method to check OPFS support + static isSupported(): boolean { + return "storage" in navigator && "getDirectory" in navigator.storage; + } +} diff --git a/apps/web/src/services/storage/storage-service.ts b/apps/web/src/services/storage/storage-service.ts new file mode 100644 index 00000000..cb0b2ced --- /dev/null +++ b/apps/web/src/services/storage/storage-service.ts @@ -0,0 +1,475 @@ +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; + 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 }; + } + + 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 { + 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 { + 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 = 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 { + await this.projectsAdapter.remove(id); + } + + async saveMediaAsset({ + projectId, + mediaAsset, + }: { + projectId: string; + mediaAsset: MediaAsset; + }): Promise { + 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 { + 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(), + ]); + } + + // Utility methods + async clearAllData(): Promise { + // Clear all projects + await this.projectsAdapter.clear(); + + // Note: Project-specific media and timelines will be 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(); + + // Check if sound is already saved + 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; + } + } + + // Check browser support + isOPFSSupported(): boolean { + return OPFSAdapter.isSupported(); + } + + isIndexedDBSupported(): boolean { + return "indexedDB" in window; + } + + isFullySupported(): boolean { + return this.isIndexedDBSupported() && this.isOPFSSupported(); + } +} + +// Export singleton instance +export const storageService = new StorageService(); +export { StorageService }; diff --git a/apps/web/src/services/storage/types.ts b/apps/web/src/services/storage/types.ts new file mode 100644 index 00000000..e7e045f5 --- /dev/null +++ b/apps/web/src/services/storage/types.ts @@ -0,0 +1,66 @@ +import type { MediaType } from "@/types/assets"; +import type { + TProject, + TProjectMetadata, + TTimelineViewState, +} from "@/types/project"; +import type { TScene } from "@/types/timeline"; + +export interface StorageAdapter { + get(key: string): Promise; + set(key: string, value: T): Promise; + remove(key: string): Promise; + list(): Promise; + clear(): Promise; +} + +export interface MediaAssetData { + id: string; + name: string; + type: MediaType; + size: number; + lastModified: number; + width?: number; + height?: number; + duration?: number; + fps?: number; + ephemeral?: boolean; + thumbnailUrl?: string; + sourceStickerIconName?: string; +} + +export type SerializedScene = Omit & { + createdAt: string; + updatedAt: string; +}; + +export type SerializedProjectMetadata = Omit< + TProjectMetadata, + "createdAt" | "updatedAt" +> & { + createdAt: string; + updatedAt: string; +}; + +export type SerializedProject = Omit & { + metadata: SerializedProjectMetadata; + scenes: SerializedScene[]; + timelineViewState?: TTimelineViewState; +}; + +export interface StorageConfig { + projectsDb: string; + mediaDb: string; + savedSoundsDb: string; + version: number; +} + +// TypeScript type augmentation to add async iterator methods to FileSystemDirectoryHandle +// These methods are part of the File System Access API spec but may not be in all type definitions +declare global { + interface FileSystemDirectoryHandle { + keys(): AsyncIterableIterator; + values(): AsyncIterableIterator; + entries(): AsyncIterableIterator<[string, FileSystemHandle]>; + } +} -- cgit v1.3.1