summaryrefslogtreecommitdiff
path: root/apps/web/src/lib
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/lib')
-rw-r--r--apps/web/src/lib/media-processing.ts166
-rw-r--r--apps/web/src/lib/storage/indexeddb-adapter.ts89
-rw-r--r--apps/web/src/lib/storage/opfs-adapter.ts73
-rw-r--r--apps/web/src/lib/storage/storage-service.ts194
-rw-r--r--apps/web/src/lib/storage/types.ts42
-rw-r--r--apps/web/src/lib/time.ts21
6 files changed, 504 insertions, 81 deletions
diff --git a/apps/web/src/lib/media-processing.ts b/apps/web/src/lib/media-processing.ts
index daf22b34..4c1294ae 100644
--- a/apps/web/src/lib/media-processing.ts
+++ b/apps/web/src/lib/media-processing.ts
@@ -1,81 +1,85 @@
-import { toast } from "sonner";
-import {
- getFileType,
- generateVideoThumbnail,
- getMediaDuration,
- getImageAspectRatio,
- type MediaItem,
-} from "@/stores/media-store";
-// import { generateThumbnail, getVideoInfo } from "./ffmpeg-utils"; // Temporarily disabled
-
-export interface ProcessedMediaItem extends Omit<MediaItem, "id"> {}
-
-export async function processMediaFiles(
- files: FileList | File[],
- onProgress?: (progress: number) => void
-): Promise<ProcessedMediaItem[]> {
- const fileArray = Array.from(files);
- const processedItems: ProcessedMediaItem[] = [];
-
- const total = fileArray.length;
- let completed = 0;
-
- for (const file of fileArray) {
- const fileType = getFileType(file);
-
- if (!fileType) {
- toast.error(`Unsupported file type: ${file.name}`);
- continue;
- }
-
- const url = URL.createObjectURL(file);
- let thumbnailUrl: string | undefined;
- let duration: number | undefined;
- let aspectRatio: number = 16 / 9; // Default fallback
-
- try {
- if (fileType === "image") {
- // Get image aspect ratio
- aspectRatio = await getImageAspectRatio(file);
- } else if (fileType === "video") {
- // Use basic thumbnail generation for now
- const videoResult = await generateVideoThumbnail(file);
- thumbnailUrl = videoResult.thumbnailUrl;
- aspectRatio = videoResult.aspectRatio;
- } else if (fileType === "audio") {
- // For audio, use a square aspect ratio
- aspectRatio = 1;
- }
-
- // Get duration for videos and audio (if not already set by FFmpeg)
- if ((fileType === "video" || fileType === "audio") && !duration) {
- duration = await getMediaDuration(file);
- }
-
- processedItems.push({
- name: file.name,
- type: fileType,
- file,
- url,
- thumbnailUrl,
- duration,
- aspectRatio,
- });
-
- // Yield back to the event loop to keep the UI responsive
- await new Promise((resolve) => setTimeout(resolve, 0));
-
- completed += 1;
- if (onProgress) {
- const percent = Math.round((completed / total) * 100);
- onProgress(percent);
- }
- } catch (error) {
- console.error("Error processing file:", file.name, error);
- toast.error(`Failed to process ${file.name}`);
- URL.revokeObjectURL(url); // Clean up on error
- }
- }
-
- return processedItems;
-}
+import { toast } from "sonner";
+import {
+ getFileType,
+ generateVideoThumbnail,
+ getMediaDuration,
+ getImageDimensions,
+ type MediaItem,
+} from "@/stores/media-store";
+// import { generateThumbnail, getVideoInfo } from "./ffmpeg-utils"; // Temporarily disabled
+
+export interface ProcessedMediaItem extends Omit<MediaItem, "id"> {}
+
+export async function processMediaFiles(
+ files: FileList | File[],
+ onProgress?: (progress: number) => void
+): Promise<ProcessedMediaItem[]> {
+ const fileArray = Array.from(files);
+ const processedItems: ProcessedMediaItem[] = [];
+
+ const total = fileArray.length;
+ let completed = 0;
+
+ for (const file of fileArray) {
+ const fileType = getFileType(file);
+
+ if (!fileType) {
+ toast.error(`Unsupported file type: ${file.name}`);
+ continue;
+ }
+
+ const url = URL.createObjectURL(file);
+ let thumbnailUrl: string | undefined;
+ let duration: number | undefined;
+ let width: number | undefined;
+ let height: number | undefined;
+
+ try {
+ if (fileType === "image") {
+ // Get image dimensions
+ const dimensions = await getImageDimensions(file);
+ width = dimensions.width;
+ height = dimensions.height;
+ } else if (fileType === "video") {
+ // Use basic thumbnail generation for now
+ const videoResult = await generateVideoThumbnail(file);
+ thumbnailUrl = videoResult.thumbnailUrl;
+ width = videoResult.width;
+ height = videoResult.height;
+ } else if (fileType === "audio") {
+ // For audio, we don't set width/height (they'll be undefined)
+ }
+
+ // Get duration for videos and audio (if not already set by FFmpeg)
+ if ((fileType === "video" || fileType === "audio") && !duration) {
+ duration = await getMediaDuration(file);
+ }
+
+ processedItems.push({
+ name: file.name,
+ type: fileType,
+ file,
+ url,
+ thumbnailUrl,
+ duration,
+ width,
+ height,
+ });
+
+ // Yield back to the event loop to keep the UI responsive
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ completed += 1;
+ if (onProgress) {
+ const percent = Math.round((completed / total) * 100);
+ onProgress(percent);
+ }
+ } catch (error) {
+ console.error("Error processing file:", file.name, error);
+ toast.error(`Failed to process ${file.name}`);
+ URL.revokeObjectURL(url); // Clean up on error
+ }
+ }
+
+ return processedItems;
+}
diff --git a/apps/web/src/lib/storage/indexeddb-adapter.ts b/apps/web/src/lib/storage/indexeddb-adapter.ts
new file mode 100644
index 00000000..f43a8798
--- /dev/null
+++ b/apps/web/src/lib/storage/indexeddb-adapter.ts
@@ -0,0 +1,89 @@
+import { StorageAdapter } from "./types";
+
+export class IndexedDBAdapter<T> implements StorageAdapter<T> {
+ private dbName: string;
+ private storeName: string;
+ private version: number;
+
+ constructor(dbName: string, storeName: string, version: number = 1) {
+ this.dbName = dbName;
+ this.storeName = storeName;
+ this.version = version;
+ }
+
+ private async getDB(): Promise<IDBDatabase> {
+ 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<T | null> {
+ 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<void> {
+ 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<void> {
+ 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<string[]> {
+ 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 clear(): Promise<void> {
+ 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/lib/storage/opfs-adapter.ts b/apps/web/src/lib/storage/opfs-adapter.ts
new file mode 100644
index 00000000..c01954e5
--- /dev/null
+++ b/apps/web/src/lib/storage/opfs-adapter.ts
@@ -0,0 +1,73 @@
+import { StorageAdapter } from "./types";
+
+export class OPFSAdapter implements StorageAdapter<File> {
+ private directoryName: string;
+
+ constructor(directoryName: string = "media") {
+ this.directoryName = directoryName;
+ }
+
+ private async getDirectory(): Promise<FileSystemDirectoryHandle> {
+ const opfsRoot = await navigator.storage.getDirectory();
+ return await opfsRoot.getDirectoryHandle(this.directoryName, {
+ create: true,
+ });
+ }
+
+ async get(key: string): Promise<File | null> {
+ 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<void> {
+ 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<void> {
+ try {
+ const directory = await this.getDirectory();
+ await directory.removeEntry(key);
+ } catch (error) {
+ if ((error as Error).name !== "NotFoundError") {
+ throw error;
+ }
+ }
+ }
+
+ async list(): Promise<string[]> {
+ const directory = await this.getDirectory();
+ const keys: string[] = [];
+
+ for await (const name of directory.keys()) {
+ keys.push(name);
+ }
+
+ return keys;
+ }
+
+ async clear(): Promise<void> {
+ 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/lib/storage/storage-service.ts b/apps/web/src/lib/storage/storage-service.ts
new file mode 100644
index 00000000..65b3c134
--- /dev/null
+++ b/apps/web/src/lib/storage/storage-service.ts
@@ -0,0 +1,194 @@
+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 };
diff --git a/apps/web/src/lib/storage/types.ts b/apps/web/src/lib/storage/types.ts
new file mode 100644
index 00000000..7ad18e19
--- /dev/null
+++ b/apps/web/src/lib/storage/types.ts
@@ -0,0 +1,42 @@
+import { TProject } from "@/types/project";
+
+export interface StorageAdapter<T> {
+ get(key: string): Promise<T | null>;
+ set(key: string, value: T): Promise<void>;
+ remove(key: string): Promise<void>;
+ list(): Promise<string[]>;
+ clear(): Promise<void>;
+}
+
+export interface MediaFileData {
+ id: string;
+ name: string;
+ type: "image" | "video" | "audio";
+ size: number;
+ lastModified: number;
+ width?: number;
+ height?: number;
+ duration?: number;
+ // File will be stored separately in OPFS
+}
+
+export interface StorageConfig {
+ projectsDb: string;
+ mediaDb: string;
+ version: number;
+}
+
+// Helper type for serialization - converts Date objects to strings
+export type SerializedProject = Omit<TProject, "createdAt" | "updatedAt"> & {
+ createdAt: string;
+ updatedAt: string;
+};
+
+// Extend FileSystemDirectoryHandle with missing async iterator methods
+declare global {
+ interface FileSystemDirectoryHandle {
+ keys(): AsyncIterableIterator<string>;
+ values(): AsyncIterableIterator<FileSystemHandle>;
+ entries(): AsyncIterableIterator<[string, FileSystemHandle]>;
+ }
+}
diff --git a/apps/web/src/lib/time.ts b/apps/web/src/lib/time.ts
new file mode 100644
index 00000000..e2db28c2
--- /dev/null
+++ b/apps/web/src/lib/time.ts
@@ -0,0 +1,21 @@
+// Time-related utility functions
+
+// Helper function to format time in various formats (MM:SS, HH:MM:SS, HH:MM:SS:CS)
+export const formatTimeCode = (
+ timeInSeconds: number,
+ format: "MM:SS" | "HH:MM:SS" | "HH:MM:SS:CS" = "HH:MM:SS:CS"
+): string => {
+ const hours = Math.floor(timeInSeconds / 3600);
+ const minutes = Math.floor((timeInSeconds % 3600) / 60);
+ const seconds = Math.floor(timeInSeconds % 60);
+ const centiseconds = Math.floor((timeInSeconds % 1) * 100);
+
+ switch (format) {
+ case "MM:SS":
+ return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
+ case "HH:MM:SS":
+ return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
+ case "HH:MM:SS:CS":
+ return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${centiseconds.toString().padStart(2, "0")}`;
+ }
+};