diff options
Diffstat (limited to 'apps/web/src/lib')
| -rw-r--r-- | apps/web/src/lib/media-processing.ts | 166 | ||||
| -rw-r--r-- | apps/web/src/lib/storage/indexeddb-adapter.ts | 89 | ||||
| -rw-r--r-- | apps/web/src/lib/storage/opfs-adapter.ts | 73 | ||||
| -rw-r--r-- | apps/web/src/lib/storage/storage-service.ts | 194 | ||||
| -rw-r--r-- | apps/web/src/lib/storage/types.ts | 42 | ||||
| -rw-r--r-- | apps/web/src/lib/time.ts | 21 |
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")}`;
+ }
+};
|
