refactor: store media relative to project, add storage for timeline data, and other things

This commit is contained in:
Maze Winther
2025-07-07 19:06:36 +02:00
parent 11c0b89bd1
commit bd0c7f2206
13 changed files with 573 additions and 514 deletions

View File

@ -1,194 +1,273 @@
import { TProject } from "@/types/project";
import { MediaItem } from "@/stores/media-store";
import { IndexedDBAdapter } from "./indexeddb-adapter";
import { OPFSAdapter } from "./opfs-adapter";
import { MediaFileData, StorageConfig, SerializedProject } from "./types";
class StorageService {
private projectsAdapter: IndexedDBAdapter<SerializedProject>;
private mediaMetadataAdapter: IndexedDBAdapter<MediaFileData>;
private mediaFilesAdapter: OPFSAdapter;
private config: StorageConfig;
constructor() {
this.config = {
projectsDb: "video-editor-projects",
mediaDb: "video-editor-media",
version: 1,
};
this.projectsAdapter = new IndexedDBAdapter<SerializedProject>(
this.config.projectsDb,
"projects",
this.config.version
);
this.mediaMetadataAdapter = new IndexedDBAdapter<MediaFileData>(
this.config.mediaDb,
"media-metadata",
this.config.version
);
this.mediaFilesAdapter = new OPFSAdapter("media-files");
}
// Project operations
async saveProject(project: TProject): Promise<void> {
// Convert TProject to serializable format
const serializedProject: SerializedProject = {
id: project.id,
name: project.name,
thumbnail: project.thumbnail,
createdAt: project.createdAt.toISOString(),
updatedAt: project.updatedAt.toISOString(),
};
await this.projectsAdapter.set(project.id, serializedProject);
}
async loadProject(id: string): Promise<TProject | null> {
const serializedProject = await this.projectsAdapter.get(id);
if (!serializedProject) return null;
// Convert back to TProject format
return {
id: serializedProject.id,
name: serializedProject.name,
thumbnail: serializedProject.thumbnail,
createdAt: new Date(serializedProject.createdAt),
updatedAt: new Date(serializedProject.updatedAt),
};
}
async loadAllProjects(): Promise<TProject[]> {
const projectIds = await this.projectsAdapter.list();
const projects: TProject[] = [];
for (const id of projectIds) {
const project = await this.loadProject(id);
if (project) {
projects.push(project);
}
}
// Sort by last updated (most recent first)
return projects.sort(
(a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()
);
}
async deleteProject(id: string): Promise<void> {
await this.projectsAdapter.remove(id);
}
// Media operations
async saveMediaItem(mediaItem: MediaItem): Promise<void> {
// Save file to OPFS
await this.mediaFilesAdapter.set(mediaItem.id, mediaItem.file);
// Save metadata to IndexedDB
const metadata: MediaFileData = {
id: mediaItem.id,
name: mediaItem.name,
type: mediaItem.type,
size: mediaItem.file.size,
lastModified: mediaItem.file.lastModified,
width: mediaItem.width,
height: mediaItem.height,
duration: mediaItem.duration,
};
await this.mediaMetadataAdapter.set(mediaItem.id, metadata);
}
async loadMediaItem(id: string): Promise<MediaItem | null> {
const [file, metadata] = await Promise.all([
this.mediaFilesAdapter.get(id),
this.mediaMetadataAdapter.get(id),
]);
if (!file || !metadata) return null;
// Create new object URL for the file
const 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 would need to be regenerated or cached separately
};
}
async loadAllMediaItems(): Promise<MediaItem[]> {
const mediaIds = await this.mediaMetadataAdapter.list();
const mediaItems: MediaItem[] = [];
for (const id of mediaIds) {
const item = await this.loadMediaItem(id);
if (item) {
mediaItems.push(item);
}
}
return mediaItems;
}
async deleteMediaItem(id: string): Promise<void> {
await Promise.all([
this.mediaFilesAdapter.remove(id),
this.mediaMetadataAdapter.remove(id),
]);
}
// Utility methods
async clearAllData(): Promise<void> {
await Promise.all([
this.projectsAdapter.clear(),
this.mediaMetadataAdapter.clear(),
this.mediaFilesAdapter.clear(),
]);
}
async getStorageInfo(): Promise<{
projects: number;
mediaItems: number;
isOPFSSupported: boolean;
isIndexedDBSupported: boolean;
}> {
const [projectIds, mediaIds] = await Promise.all([
this.projectsAdapter.list(),
this.mediaMetadataAdapter.list(),
]);
return {
projects: projectIds.length,
mediaItems: mediaIds.length,
isOPFSSupported: this.isOPFSSupported(),
isIndexedDBSupported: this.isIndexedDBSupported(),
};
}
// 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 };
import { TProject } from "@/types/project";
import { MediaItem } from "@/stores/media-store";
import { IndexedDBAdapter } from "./indexeddb-adapter";
import { OPFSAdapter } from "./opfs-adapter";
import {
MediaFileData,
StorageConfig,
SerializedProject,
TimelineData,
} from "./types";
import { TimelineTrack } from "@/types/timeline";
class StorageService {
private projectsAdapter: IndexedDBAdapter<SerializedProject>;
private config: StorageConfig;
constructor() {
this.config = {
projectsDb: "video-editor-projects",
mediaDb: "video-editor-media",
timelineDb: "video-editor-timelines",
version: 1,
};
this.projectsAdapter = new IndexedDBAdapter<SerializedProject>(
this.config.projectsDb,
"projects",
this.config.version
);
}
// Helper to get project-specific media adapters
private getProjectMediaAdapters(projectId: string) {
const mediaMetadataAdapter = new IndexedDBAdapter<MediaFileData>(
`${this.config.mediaDb}-${projectId}`,
"media-metadata",
this.config.version
);
const mediaFilesAdapter = new OPFSAdapter(`media-files-${projectId}`);
return { mediaMetadataAdapter, mediaFilesAdapter };
}
// Helper to get project-specific timeline adapter
private getProjectTimelineAdapter(projectId: string) {
return new IndexedDBAdapter<TimelineData>(
`${this.config.timelineDb}-${projectId}`,
"timeline",
this.config.version
);
}
// Project operations
async saveProject(project: TProject): Promise<void> {
// Convert TProject to serializable format
const serializedProject: SerializedProject = {
id: project.id,
name: project.name,
thumbnail: project.thumbnail,
createdAt: project.createdAt.toISOString(),
updatedAt: project.updatedAt.toISOString(),
};
await this.projectsAdapter.set(project.id, serializedProject);
}
async loadProject(id: string): Promise<TProject | null> {
const serializedProject = await this.projectsAdapter.get(id);
if (!serializedProject) return null;
// Convert back to TProject format
return {
id: serializedProject.id,
name: serializedProject.name,
thumbnail: serializedProject.thumbnail,
createdAt: new Date(serializedProject.createdAt),
updatedAt: new Date(serializedProject.updatedAt),
};
}
async loadAllProjects(): Promise<TProject[]> {
const projectIds = await this.projectsAdapter.list();
const projects: TProject[] = [];
for (const id of projectIds) {
const project = await this.loadProject(id);
if (project) {
projects.push(project);
}
}
// Sort by last updated (most recent first)
return projects.sort(
(a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()
);
}
async deleteProject(id: string): Promise<void> {
await this.projectsAdapter.remove(id);
}
// Media operations - now project-specific
async saveMediaItem(projectId: string, mediaItem: MediaItem): Promise<void> {
const { mediaMetadataAdapter, mediaFilesAdapter } =
this.getProjectMediaAdapters(projectId);
// Save file to project-specific OPFS
await mediaFilesAdapter.set(mediaItem.id, mediaItem.file);
// Save metadata to project-specific IndexedDB
const metadata: MediaFileData = {
id: mediaItem.id,
name: mediaItem.name,
type: mediaItem.type,
size: mediaItem.file.size,
lastModified: mediaItem.file.lastModified,
width: mediaItem.width,
height: mediaItem.height,
duration: mediaItem.duration,
};
await mediaMetadataAdapter.set(mediaItem.id, metadata);
}
async loadMediaItem(
projectId: string,
id: string
): Promise<MediaItem | null> {
const { mediaMetadataAdapter, mediaFilesAdapter } =
this.getProjectMediaAdapters(projectId);
const [file, metadata] = await Promise.all([
mediaFilesAdapter.get(id),
mediaMetadataAdapter.get(id),
]);
if (!file || !metadata) return null;
// Create new object URL for the file
const 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 would need to be regenerated or cached separately
};
}
async loadAllMediaItems(projectId: string): Promise<MediaItem[]> {
const { mediaMetadataAdapter } = this.getProjectMediaAdapters(projectId);
const mediaIds = await mediaMetadataAdapter.list();
const mediaItems: MediaItem[] = [];
for (const id of mediaIds) {
const item = await this.loadMediaItem(projectId, id);
if (item) {
mediaItems.push(item);
}
}
return mediaItems;
}
async deleteMediaItem(projectId: string, id: string): Promise<void> {
const { mediaMetadataAdapter, mediaFilesAdapter } =
this.getProjectMediaAdapters(projectId);
await Promise.all([
mediaFilesAdapter.remove(id),
mediaMetadataAdapter.remove(id),
]);
}
async deleteProjectMedia(projectId: string): Promise<void> {
const { mediaMetadataAdapter, mediaFilesAdapter } =
this.getProjectMediaAdapters(projectId);
await Promise.all([
mediaMetadataAdapter.clear(),
mediaFilesAdapter.clear(),
]);
}
// Timeline operations - now project-specific
async saveTimeline(
projectId: string,
tracks: TimelineTrack[]
): Promise<void> {
const timelineAdapter = this.getProjectTimelineAdapter(projectId);
const timelineData: TimelineData = {
tracks,
lastModified: new Date().toISOString(),
};
await timelineAdapter.set("timeline", timelineData);
}
async loadTimeline(projectId: string): Promise<TimelineTrack[] | null> {
const timelineAdapter = this.getProjectTimelineAdapter(projectId);
const timelineData = await timelineAdapter.get("timeline");
return timelineData ? timelineData.tracks : null;
}
async deleteProjectTimeline(projectId: string): Promise<void> {
const timelineAdapter = this.getProjectTimelineAdapter(projectId);
await timelineAdapter.remove("timeline");
}
// Utility methods
async clearAllData(): Promise<void> {
// 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: string): Promise<{
mediaItems: number;
hasTimeline: boolean;
}> {
const { mediaMetadataAdapter } = this.getProjectMediaAdapters(projectId);
const timelineAdapter = this.getProjectTimelineAdapter(projectId);
const [mediaIds, timelineData] = await Promise.all([
mediaMetadataAdapter.list(),
timelineAdapter.get("timeline"),
]);
return {
mediaItems: mediaIds.length,
hasTimeline: !!timelineData,
};
}
// 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 };

View File

@ -1,4 +1,5 @@
import { TProject } from "@/types/project";
import { TimelineTrack } from "@/types/timeline";
export interface StorageAdapter<T> {
get(key: string): Promise<T | null>;
@ -20,9 +21,15 @@ export interface MediaFileData {
// File will be stored separately in OPFS
}
export interface TimelineData {
tracks: TimelineTrack[];
lastModified: string;
}
export interface StorageConfig {
projectsDb: string;
mediaDb: string;
timelineDb: string;
version: number;
}