From be1ae20d9089e81cb84a8294be76fe680a24b60f Mon Sep 17 00:00:00 2001 From: Hyteq Date: Sun, 20 Jul 2025 11:00:22 +0300 Subject: format --- apps/web/src/lib/fetch-github-stars.ts | 48 +-- apps/web/src/lib/ffmpeg-utils.ts | 455 ++++++++++++---------- apps/web/src/lib/font-config.ts | 78 ++-- apps/web/src/lib/media-processing.ts | 162 ++++---- apps/web/src/lib/rate-limit.ts | 32 +- apps/web/src/lib/storage/indexeddb-adapter.ts | 178 ++++----- apps/web/src/lib/storage/opfs-adapter.ts | 114 +++--- apps/web/src/lib/storage/storage-service.ts | 530 +++++++++++++------------- apps/web/src/lib/storage/types.ts | 54 +-- apps/web/src/lib/time.ts | 50 +-- apps/web/src/lib/timeline.ts | 108 +++--- apps/web/src/lib/utils.ts | 84 ++-- apps/web/src/lib/waitlist.ts | 26 +- 13 files changed, 979 insertions(+), 940 deletions(-) (limited to 'apps/web/src/lib') diff --git a/apps/web/src/lib/fetch-github-stars.ts b/apps/web/src/lib/fetch-github-stars.ts index c4444a62..74675be9 100644 --- a/apps/web/src/lib/fetch-github-stars.ts +++ b/apps/web/src/lib/fetch-github-stars.ts @@ -1,29 +1,29 @@ export async function getStars(): Promise { - try { - const res = await fetch( - "https://api.github.com/repos/OpenCut-app/OpenCut", - { - next: { revalidate: 3600 }, - } - ); + try { + const res = await fetch( + "https://api.github.com/repos/OpenCut-app/OpenCut", + { + next: { revalidate: 3600 }, + }, + ); - if (!res.ok) { - throw new Error(`GitHub API error: ${res.status} ${res.statusText}`); - } - const data = (await res.json()) as { stargazers_count: number }; - const count = data.stargazers_count; + if (!res.ok) { + throw new Error(`GitHub API error: ${res.status} ${res.statusText}`); + } + const data = (await res.json()) as { stargazers_count: number }; + const count = data.stargazers_count; - if (typeof count !== "number") { - throw new Error("Invalid stargazers_count from GitHub API"); - } + if (typeof count !== "number") { + throw new Error("Invalid stargazers_count from GitHub API"); + } - if (count >= 1_000_000) - return (count / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M"; - if (count >= 1_000) - return (count / 1_000).toFixed(1).replace(/\.0$/, "") + "k"; - return count.toString(); - } catch (error) { - console.error("Failed to fetch GitHub stars:", error); - return "1.5k"; - } + if (count >= 1_000_000) + return (count / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M"; + if (count >= 1_000) + return (count / 1_000).toFixed(1).replace(/\.0$/, "") + "k"; + return count.toString(); + } catch (error) { + console.error("Failed to fetch GitHub stars:", error); + return "1.5k"; + } } diff --git a/apps/web/src/lib/ffmpeg-utils.ts b/apps/web/src/lib/ffmpeg-utils.ts index ae0c267b..80d28c21 100644 --- a/apps/web/src/lib/ffmpeg-utils.ts +++ b/apps/web/src/lib/ffmpeg-utils.ts @@ -1,231 +1,270 @@ -import { FFmpeg } from '@ffmpeg/ffmpeg'; -import { toBlobURL } from '@ffmpeg/util'; +import { FFmpeg } from "@ffmpeg/ffmpeg"; +import { toBlobURL } from "@ffmpeg/util"; let ffmpeg: FFmpeg | null = null; export const initFFmpeg = async (): Promise => { - if (ffmpeg) return ffmpeg; - - ffmpeg = new FFmpeg(); - - // Use locally hosted files instead of CDN - const baseURL = '/ffmpeg'; - - await ffmpeg.load({ - coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'), - wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'), - }); - - return ffmpeg; + if (ffmpeg) return ffmpeg; + + ffmpeg = new FFmpeg(); + + // Use locally hosted files instead of CDN + const baseURL = "/ffmpeg"; + + await ffmpeg.load({ + coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, "text/javascript"), + wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, "application/wasm"), + }); + + return ffmpeg; }; export const generateThumbnail = async ( - videoFile: File, - timeInSeconds: number = 1 + videoFile: File, + timeInSeconds: number = 1, ): Promise => { - const ffmpeg = await initFFmpeg(); - - const inputName = 'input.mp4'; - const outputName = 'thumbnail.jpg'; - - // Write input file - await ffmpeg.writeFile(inputName, new Uint8Array(await videoFile.arrayBuffer())); - - // Generate thumbnail at specific time - await ffmpeg.exec([ - '-i', inputName, - '-ss', timeInSeconds.toString(), - '-vframes', '1', - '-vf', 'scale=320:240', - '-q:v', '2', - outputName - ]); - - // Read output file - const data = await ffmpeg.readFile(outputName); - const blob = new Blob([data], { type: 'image/jpeg' }); - - // Cleanup - await ffmpeg.deleteFile(inputName); - await ffmpeg.deleteFile(outputName); - - return URL.createObjectURL(blob); + const ffmpeg = await initFFmpeg(); + + const inputName = "input.mp4"; + const outputName = "thumbnail.jpg"; + + // Write input file + await ffmpeg.writeFile( + inputName, + new Uint8Array(await videoFile.arrayBuffer()), + ); + + // Generate thumbnail at specific time + await ffmpeg.exec([ + "-i", + inputName, + "-ss", + timeInSeconds.toString(), + "-vframes", + "1", + "-vf", + "scale=320:240", + "-q:v", + "2", + outputName, + ]); + + // Read output file + const data = await ffmpeg.readFile(outputName); + const blob = new Blob([data], { type: "image/jpeg" }); + + // Cleanup + await ffmpeg.deleteFile(inputName); + await ffmpeg.deleteFile(outputName); + + return URL.createObjectURL(blob); }; export const trimVideo = async ( - videoFile: File, - startTime: number, - endTime: number, - onProgress?: (progress: number) => void + videoFile: File, + startTime: number, + endTime: number, + onProgress?: (progress: number) => void, ): Promise => { - const ffmpeg = await initFFmpeg(); - - const inputName = 'input.mp4'; - const outputName = 'output.mp4'; - - // Set up progress callback - if (onProgress) { - ffmpeg.on('progress', ({ progress }) => { - onProgress(progress * 100); - }); - } - - // Write input file - await ffmpeg.writeFile(inputName, new Uint8Array(await videoFile.arrayBuffer())); - - const duration = endTime - startTime; - - // Trim video - await ffmpeg.exec([ - '-i', inputName, - '-ss', startTime.toString(), - '-t', duration.toString(), - '-c', 'copy', // Use stream copy for faster processing - outputName - ]); - - // Read output file - const data = await ffmpeg.readFile(outputName); - const blob = new Blob([data], { type: 'video/mp4' }); - - // Cleanup - await ffmpeg.deleteFile(inputName); - await ffmpeg.deleteFile(outputName); - - return blob; + const ffmpeg = await initFFmpeg(); + + const inputName = "input.mp4"; + const outputName = "output.mp4"; + + // Set up progress callback + if (onProgress) { + ffmpeg.on("progress", ({ progress }) => { + onProgress(progress * 100); + }); + } + + // Write input file + await ffmpeg.writeFile( + inputName, + new Uint8Array(await videoFile.arrayBuffer()), + ); + + const duration = endTime - startTime; + + // Trim video + await ffmpeg.exec([ + "-i", + inputName, + "-ss", + startTime.toString(), + "-t", + duration.toString(), + "-c", + "copy", // Use stream copy for faster processing + outputName, + ]); + + // Read output file + const data = await ffmpeg.readFile(outputName); + const blob = new Blob([data], { type: "video/mp4" }); + + // Cleanup + await ffmpeg.deleteFile(inputName); + await ffmpeg.deleteFile(outputName); + + return blob; }; -export const getVideoInfo = async (videoFile: File): Promise<{ - duration: number; - width: number; - height: number; - fps: number; +export const getVideoInfo = async ( + videoFile: File, +): Promise<{ + duration: number; + width: number; + height: number; + fps: number; }> => { - const ffmpeg = await initFFmpeg(); - - const inputName = 'input.mp4'; - - // Write input file - await ffmpeg.writeFile(inputName, new Uint8Array(await videoFile.arrayBuffer())); - - // Capture FFmpeg stderr output with a one-time listener pattern - let ffmpegOutput = ''; - let listening = true; - const listener = (data: string) => { - if (listening) ffmpegOutput += data; - }; - ffmpeg.on('log', ({ message }) => listener(message)); - - // Run ffmpeg to get info (stderr will contain the info) - try { - await ffmpeg.exec(['-i', inputName, '-f', 'null', '-']); - } catch (error) { - listening = false; - await ffmpeg.deleteFile(inputName); - console.error('FFmpeg execution failed:', error); - throw new Error('Failed to extract video info. The file may be corrupted or in an unsupported format.'); - } - - // Disable listener after exec completes - listening = false; - - // Cleanup - await ffmpeg.deleteFile(inputName); - - // Parse output for duration, resolution, and fps - // Example: Duration: 00:00:10.00, start: 0.000000, bitrate: 1234 kb/s - // Example: Stream #0:0: Video: h264 (High), yuv420p(progressive), 1920x1080 [SAR 1:1 DAR 16:9], 30 fps, 30 tbr, 90k tbn, 60 tbc - - const durationMatch = ffmpegOutput.match(/Duration: (\d+):(\d+):([\d.]+)/); - let duration = 0; - if (durationMatch) { - const [, h, m, s] = durationMatch; - duration = parseInt(h) * 3600 + parseInt(m) * 60 + parseFloat(s); - } - - const videoStreamMatch = ffmpegOutput.match(/Video:.* (\d+)x(\d+)[^,]*, ([\d.]+) fps/); - let width = 0, height = 0, fps = 0; - if (videoStreamMatch) { - width = parseInt(videoStreamMatch[1]); - height = parseInt(videoStreamMatch[2]); - fps = parseFloat(videoStreamMatch[3]); - } - - return { - duration, - width, - height, - fps - }; + const ffmpeg = await initFFmpeg(); + + const inputName = "input.mp4"; + + // Write input file + await ffmpeg.writeFile( + inputName, + new Uint8Array(await videoFile.arrayBuffer()), + ); + + // Capture FFmpeg stderr output with a one-time listener pattern + let ffmpegOutput = ""; + let listening = true; + const listener = (data: string) => { + if (listening) ffmpegOutput += data; + }; + ffmpeg.on("log", ({ message }) => listener(message)); + + // Run ffmpeg to get info (stderr will contain the info) + try { + await ffmpeg.exec(["-i", inputName, "-f", "null", "-"]); + } catch (error) { + listening = false; + await ffmpeg.deleteFile(inputName); + console.error("FFmpeg execution failed:", error); + throw new Error( + "Failed to extract video info. The file may be corrupted or in an unsupported format.", + ); + } + + // Disable listener after exec completes + listening = false; + + // Cleanup + await ffmpeg.deleteFile(inputName); + + // Parse output for duration, resolution, and fps + // Example: Duration: 00:00:10.00, start: 0.000000, bitrate: 1234 kb/s + // Example: Stream #0:0: Video: h264 (High), yuv420p(progressive), 1920x1080 [SAR 1:1 DAR 16:9], 30 fps, 30 tbr, 90k tbn, 60 tbc + + const durationMatch = ffmpegOutput.match(/Duration: (\d+):(\d+):([\d.]+)/); + let duration = 0; + if (durationMatch) { + const [, h, m, s] = durationMatch; + duration = parseInt(h) * 3600 + parseInt(m) * 60 + parseFloat(s); + } + + const videoStreamMatch = ffmpegOutput.match( + /Video:.* (\d+)x(\d+)[^,]*, ([\d.]+) fps/, + ); + let width = 0, + height = 0, + fps = 0; + if (videoStreamMatch) { + width = parseInt(videoStreamMatch[1]); + height = parseInt(videoStreamMatch[2]); + fps = parseFloat(videoStreamMatch[3]); + } + + return { + duration, + width, + height, + fps, + }; }; export const convertToWebM = async ( - videoFile: File, - onProgress?: (progress: number) => void + videoFile: File, + onProgress?: (progress: number) => void, ): Promise => { - const ffmpeg = await initFFmpeg(); - - const inputName = 'input.mp4'; - const outputName = 'output.webm'; - - // Set up progress callback - if (onProgress) { - ffmpeg.on('progress', ({ progress }) => { - onProgress(progress * 100); - }); - } - - // Write input file - await ffmpeg.writeFile(inputName, new Uint8Array(await videoFile.arrayBuffer())); - - // Convert to WebM - await ffmpeg.exec([ - '-i', inputName, - '-c:v', 'libvpx-vp9', - '-crf', '30', - '-b:v', '0', - '-c:a', 'libopus', - outputName - ]); - - // Read output file - const data = await ffmpeg.readFile(outputName); - const blob = new Blob([data], { type: 'video/webm' }); - - // Cleanup - await ffmpeg.deleteFile(inputName); - await ffmpeg.deleteFile(outputName); - - return blob; + const ffmpeg = await initFFmpeg(); + + const inputName = "input.mp4"; + const outputName = "output.webm"; + + // Set up progress callback + if (onProgress) { + ffmpeg.on("progress", ({ progress }) => { + onProgress(progress * 100); + }); + } + + // Write input file + await ffmpeg.writeFile( + inputName, + new Uint8Array(await videoFile.arrayBuffer()), + ); + + // Convert to WebM + await ffmpeg.exec([ + "-i", + inputName, + "-c:v", + "libvpx-vp9", + "-crf", + "30", + "-b:v", + "0", + "-c:a", + "libopus", + outputName, + ]); + + // Read output file + const data = await ffmpeg.readFile(outputName); + const blob = new Blob([data], { type: "video/webm" }); + + // Cleanup + await ffmpeg.deleteFile(inputName); + await ffmpeg.deleteFile(outputName); + + return blob; }; export const extractAudio = async ( - videoFile: File, - format: 'mp3' | 'wav' = 'mp3' + videoFile: File, + format: "mp3" | "wav" = "mp3", ): Promise => { - const ffmpeg = await initFFmpeg(); - - const inputName = 'input.mp4'; - const outputName = `output.${format}`; - - // Write input file - await ffmpeg.writeFile(inputName, new Uint8Array(await videoFile.arrayBuffer())); - - // Extract audio - await ffmpeg.exec([ - '-i', inputName, - '-vn', // Disable video - '-acodec', format === 'mp3' ? 'libmp3lame' : 'pcm_s16le', - outputName - ]); - - // Read output file - const data = await ffmpeg.readFile(outputName); - const blob = new Blob([data], { type: `audio/${format}` }); - - // Cleanup - await ffmpeg.deleteFile(inputName); - await ffmpeg.deleteFile(outputName); - - return blob; -}; \ No newline at end of file + const ffmpeg = await initFFmpeg(); + + const inputName = "input.mp4"; + const outputName = `output.${format}`; + + // Write input file + await ffmpeg.writeFile( + inputName, + new Uint8Array(await videoFile.arrayBuffer()), + ); + + // Extract audio + await ffmpeg.exec([ + "-i", + inputName, + "-vn", // Disable video + "-acodec", + format === "mp3" ? "libmp3lame" : "pcm_s16le", + outputName, + ]); + + // Read output file + const data = await ffmpeg.readFile(outputName); + const blob = new Blob([data], { type: `audio/${format}` }); + + // Cleanup + await ffmpeg.deleteFile(inputName); + await ffmpeg.deleteFile(outputName); + + return blob; +}; diff --git a/apps/web/src/lib/font-config.ts b/apps/web/src/lib/font-config.ts index 0389054a..8b19609a 100644 --- a/apps/web/src/lib/font-config.ts +++ b/apps/web/src/lib/font-config.ts @@ -1,39 +1,39 @@ -import { - Inter, - Roboto, - Open_Sans, - Playfair_Display, - Comic_Neue, -} from "next/font/google"; - -// Configure all fonts -const inter = Inter({ subsets: ["latin"] }); -const roboto = Roboto({ subsets: ["latin"], weight: ["400", "700"] }); -const openSans = Open_Sans({ subsets: ["latin"] }); -const playfairDisplay = Playfair_Display({ subsets: ["latin"] }); -const comicNeue = Comic_Neue({ subsets: ["latin"], weight: ["400", "700"] }); - -// Export font class mapping for use in components -export const FONT_CLASS_MAP = { - Inter: inter.className, - Roboto: roboto.className, - "Open Sans": openSans.className, - "Playfair Display": playfairDisplay.className, - "Comic Neue": comicNeue.className, - Arial: "", - Helvetica: "", - "Times New Roman": "", - Georgia: "", -} as const; - -// Export individual fonts for use in layout -export const fonts = { - inter, - roboto, - openSans, - playfairDisplay, - comicNeue, -}; - -// Default font for the body -export const defaultFont = inter; +import { + Inter, + Roboto, + Open_Sans, + Playfair_Display, + Comic_Neue, +} from "next/font/google"; + +// Configure all fonts +const inter = Inter({ subsets: ["latin"] }); +const roboto = Roboto({ subsets: ["latin"], weight: ["400", "700"] }); +const openSans = Open_Sans({ subsets: ["latin"] }); +const playfairDisplay = Playfair_Display({ subsets: ["latin"] }); +const comicNeue = Comic_Neue({ subsets: ["latin"], weight: ["400", "700"] }); + +// Export font class mapping for use in components +export const FONT_CLASS_MAP = { + Inter: inter.className, + Roboto: roboto.className, + "Open Sans": openSans.className, + "Playfair Display": playfairDisplay.className, + "Comic Neue": comicNeue.className, + Arial: "", + Helvetica: "", + "Times New Roman": "", + Georgia: "", +} as const; + +// Export individual fonts for use in layout +export const fonts = { + inter, + roboto, + openSans, + playfairDisplay, + comicNeue, +}; + +// Default font for the body +export const defaultFont = inter; diff --git a/apps/web/src/lib/media-processing.ts b/apps/web/src/lib/media-processing.ts index 353c3113..738621b7 100644 --- a/apps/web/src/lib/media-processing.ts +++ b/apps/web/src/lib/media-processing.ts @@ -1,101 +1,101 @@ import { toast } from "sonner"; import { - getFileType, - generateVideoThumbnail, - getMediaDuration, - getImageDimensions, - type MediaItem, + getFileType, + generateVideoThumbnail, + getMediaDuration, + getImageDimensions, + type MediaItem, } from "@/stores/media-store"; import { generateThumbnail, getVideoInfo } from "./ffmpeg-utils"; export interface ProcessedMediaItem extends Omit {} export async function processMediaFiles( - files: FileList | File[], - onProgress?: (progress: number) => void + files: FileList | File[], + onProgress?: (progress: number) => void, ): Promise { - const fileArray = Array.from(files); - const processedItems: ProcessedMediaItem[] = []; + const fileArray = Array.from(files); + const processedItems: ProcessedMediaItem[] = []; - const total = fileArray.length; - let completed = 0; + const total = fileArray.length; + let completed = 0; - for (const file of fileArray) { - const fileType = getFileType(file); + for (const file of fileArray) { + const fileType = getFileType(file); - if (!fileType) { - toast.error(`Unsupported file type: ${file.name}`); - continue; - } + 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; - let fps: number | undefined; + const url = URL.createObjectURL(file); + let thumbnailUrl: string | undefined; + let duration: number | undefined; + let width: number | undefined; + let height: number | undefined; + let fps: number | undefined; - try { - if (fileType === "image") { - // Get image dimensions - const dimensions = await getImageDimensions(file); - width = dimensions.width; - height = dimensions.height; - } else if (fileType === "video") { - try { - // Use FFmpeg for comprehensive video info extraction - const videoInfo = await getVideoInfo(file); - duration = videoInfo.duration; - width = videoInfo.width; - height = videoInfo.height; - fps = videoInfo.fps; + try { + if (fileType === "image") { + // Get image dimensions + const dimensions = await getImageDimensions(file); + width = dimensions.width; + height = dimensions.height; + } else if (fileType === "video") { + try { + // Use FFmpeg for comprehensive video info extraction + const videoInfo = await getVideoInfo(file); + duration = videoInfo.duration; + width = videoInfo.width; + height = videoInfo.height; + fps = videoInfo.fps; - // Generate thumbnail using FFmpeg - thumbnailUrl = await generateThumbnail(file, 1); - } catch (error) { - console.warn( - "FFmpeg processing failed, falling back to basic processing:", - error - ); - // Fallback to basic processing - const videoResult = await generateVideoThumbnail(file); - thumbnailUrl = videoResult.thumbnailUrl; - width = videoResult.width; - height = videoResult.height; - duration = await getMediaDuration(file); - // FPS will remain undefined for fallback - } - } else if (fileType === "audio") { - // For audio, we don't set width/height/fps (they'll be undefined) - duration = await getMediaDuration(file); - } + // Generate thumbnail using FFmpeg + thumbnailUrl = await generateThumbnail(file, 1); + } catch (error) { + console.warn( + "FFmpeg processing failed, falling back to basic processing:", + error, + ); + // Fallback to basic processing + const videoResult = await generateVideoThumbnail(file); + thumbnailUrl = videoResult.thumbnailUrl; + width = videoResult.width; + height = videoResult.height; + duration = await getMediaDuration(file); + // FPS will remain undefined for fallback + } + } else if (fileType === "audio") { + // For audio, we don't set width/height/fps (they'll be undefined) + duration = await getMediaDuration(file); + } - processedItems.push({ - name: file.name, - type: fileType, - file, - url, - thumbnailUrl, - duration, - width, - height, - fps, - }); + processedItems.push({ + name: file.name, + type: fileType, + file, + url, + thumbnailUrl, + duration, + width, + height, + fps, + }); - // Yield back to the event loop to keep the UI responsive - await new Promise((resolve) => setTimeout(resolve, 0)); + // 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 - } - } + 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; + return processedItems; } diff --git a/apps/web/src/lib/rate-limit.ts b/apps/web/src/lib/rate-limit.ts index ac63a64c..c139471c 100644 --- a/apps/web/src/lib/rate-limit.ts +++ b/apps/web/src/lib/rate-limit.ts @@ -1,16 +1,16 @@ -// lib/rate-limit.ts -import { Ratelimit } from "@upstash/ratelimit"; -import { Redis } from "@upstash/redis"; -import { env } from "@/env"; - -const redis = new Redis({ - url: env.UPSTASH_REDIS_REST_URL, - token: env.UPSTASH_REDIS_REST_TOKEN, -}); - -export const waitlistRateLimit = new Ratelimit({ - redis, - limiter: Ratelimit.slidingWindow(5, "1 m"), // 5 requests per minute - analytics: true, - prefix: "waitlist-rate-limit", -}); +// lib/rate-limit.ts +import { Ratelimit } from "@upstash/ratelimit"; +import { Redis } from "@upstash/redis"; +import { env } from "@/env"; + +const redis = new Redis({ + url: env.UPSTASH_REDIS_REST_URL, + token: env.UPSTASH_REDIS_REST_TOKEN, +}); + +export const waitlistRateLimit = new Ratelimit({ + redis, + limiter: Ratelimit.slidingWindow(5, "1 m"), // 5 requests per minute + analytics: true, + prefix: "waitlist-rate-limit", +}); diff --git a/apps/web/src/lib/storage/indexeddb-adapter.ts b/apps/web/src/lib/storage/indexeddb-adapter.ts index f43a8798..b599e2df 100644 --- a/apps/web/src/lib/storage/indexeddb-adapter.ts +++ b/apps/web/src/lib/storage/indexeddb-adapter.ts @@ -1,89 +1,89 @@ -import { StorageAdapter } from "./types"; - -export class IndexedDBAdapter implements StorageAdapter { - 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 { - 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 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(); - }); - } -} +import { StorageAdapter } from "./types"; + +export class IndexedDBAdapter implements StorageAdapter { + 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 { + 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 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/lib/storage/opfs-adapter.ts b/apps/web/src/lib/storage/opfs-adapter.ts index c01954e5..ccd9735d 100644 --- a/apps/web/src/lib/storage/opfs-adapter.ts +++ b/apps/web/src/lib/storage/opfs-adapter.ts @@ -1,73 +1,73 @@ import { StorageAdapter } from "./types"; export class OPFSAdapter implements StorageAdapter { - private directoryName: string; + private directoryName: string; - constructor(directoryName: string = "media") { - this.directoryName = directoryName; - } + constructor(directoryName: string = "media") { + this.directoryName = directoryName; + } - private async getDirectory(): Promise { - const opfsRoot = await navigator.storage.getDirectory(); - return await opfsRoot.getDirectoryHandle(this.directoryName, { - create: true, - }); - } + 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 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(); + 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(); - } + 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 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[] = []; + async list(): Promise { + const directory = await this.getDirectory(); + const keys: string[] = []; - for await (const name of directory.keys()) { - keys.push(name); - } + for await (const name of directory.keys()) { + keys.push(name); + } - return keys; - } + return keys; + } - async clear(): Promise { - const directory = await this.getDirectory(); + async clear(): Promise { + const directory = await this.getDirectory(); - for await (const name of directory.keys()) { - await directory.removeEntry(name); - } - } + 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; - } + // 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 index 0037dbdb..044f2dff 100644 --- a/apps/web/src/lib/storage/storage-service.ts +++ b/apps/web/src/lib/storage/storage-service.ts @@ -3,275 +3,275 @@ import { MediaItem } from "@/stores/media-store"; import { IndexedDBAdapter } from "./indexeddb-adapter"; import { OPFSAdapter } from "./opfs-adapter"; import { - MediaFileData, - StorageConfig, - SerializedProject, - TimelineData, + MediaFileData, + StorageConfig, + SerializedProject, + TimelineData, } from "./types"; import { TimelineTrack } from "@/types/timeline"; class StorageService { - private projectsAdapter: IndexedDBAdapter; - private config: StorageConfig; - - constructor() { - this.config = { - projectsDb: "video-editor-projects", - mediaDb: "video-editor-media", - timelineDb: "video-editor-timelines", - version: 1, - }; - - this.projectsAdapter = new IndexedDBAdapter( - this.config.projectsDb, - "projects", - this.config.version - ); - } - - // Helper to get project-specific media adapters - private getProjectMediaAdapters(projectId: string) { - const mediaMetadataAdapter = new IndexedDBAdapter( - `${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( - `${this.config.timelineDb}-${projectId}`, - "timeline", - this.config.version - ); - } - - // Project operations - async saveProject(project: TProject): Promise { - // 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(), - backgroundColor: project.backgroundColor, - backgroundType: project.backgroundType, - blurIntensity: project.blurIntensity, - }; - - await this.projectsAdapter.set(project.id, serializedProject); - } - - async loadProject(id: string): Promise { - 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), - backgroundColor: serializedProject.backgroundColor, - backgroundType: serializedProject.backgroundType, - blurIntensity: serializedProject.blurIntensity, - }; - } - - async loadAllProjects(): Promise { - 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 { - await this.projectsAdapter.remove(id); - } - - // Media operations - now project-specific - async saveMediaItem(projectId: string, mediaItem: MediaItem): Promise { - 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 { - 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 { - 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 { - const { mediaMetadataAdapter, mediaFilesAdapter } = - this.getProjectMediaAdapters(projectId); - - await Promise.all([ - mediaFilesAdapter.remove(id), - mediaMetadataAdapter.remove(id), - ]); - } - - async deleteProjectMedia(projectId: string): Promise { - 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 { - const timelineAdapter = this.getProjectTimelineAdapter(projectId); - const timelineData: TimelineData = { - tracks, - lastModified: new Date().toISOString(), - }; - await timelineAdapter.set("timeline", timelineData); - } - - async loadTimeline(projectId: string): Promise { - const timelineAdapter = this.getProjectTimelineAdapter(projectId); - const timelineData = await timelineAdapter.get("timeline"); - return timelineData ? timelineData.tracks : null; - } - - async deleteProjectTimeline(projectId: string): Promise { - const timelineAdapter = this.getProjectTimelineAdapter(projectId); - await timelineAdapter.remove("timeline"); - } - - // 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: 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(); - } + private projectsAdapter: IndexedDBAdapter; + private config: StorageConfig; + + constructor() { + this.config = { + projectsDb: "video-editor-projects", + mediaDb: "video-editor-media", + timelineDb: "video-editor-timelines", + version: 1, + }; + + this.projectsAdapter = new IndexedDBAdapter( + this.config.projectsDb, + "projects", + this.config.version, + ); + } + + // Helper to get project-specific media adapters + private getProjectMediaAdapters(projectId: string) { + const mediaMetadataAdapter = new IndexedDBAdapter( + `${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( + `${this.config.timelineDb}-${projectId}`, + "timeline", + this.config.version, + ); + } + + // Project operations + async saveProject(project: TProject): Promise { + // 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(), + backgroundColor: project.backgroundColor, + backgroundType: project.backgroundType, + blurIntensity: project.blurIntensity, + }; + + await this.projectsAdapter.set(project.id, serializedProject); + } + + async loadProject(id: string): Promise { + 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), + backgroundColor: serializedProject.backgroundColor, + backgroundType: serializedProject.backgroundType, + blurIntensity: serializedProject.blurIntensity, + }; + } + + async loadAllProjects(): Promise { + 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 { + await this.projectsAdapter.remove(id); + } + + // Media operations - now project-specific + async saveMediaItem(projectId: string, mediaItem: MediaItem): Promise { + 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 { + 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 { + 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 { + const { mediaMetadataAdapter, mediaFilesAdapter } = + this.getProjectMediaAdapters(projectId); + + await Promise.all([ + mediaFilesAdapter.remove(id), + mediaMetadataAdapter.remove(id), + ]); + } + + async deleteProjectMedia(projectId: string): Promise { + 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 { + const timelineAdapter = this.getProjectTimelineAdapter(projectId); + const timelineData: TimelineData = { + tracks, + lastModified: new Date().toISOString(), + }; + await timelineAdapter.set("timeline", timelineData); + } + + async loadTimeline(projectId: string): Promise { + const timelineAdapter = this.getProjectTimelineAdapter(projectId); + const timelineData = await timelineAdapter.get("timeline"); + return timelineData ? timelineData.tracks : null; + } + + async deleteProjectTimeline(projectId: string): Promise { + const timelineAdapter = this.getProjectTimelineAdapter(projectId); + await timelineAdapter.remove("timeline"); + } + + // 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: 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 diff --git a/apps/web/src/lib/storage/types.ts b/apps/web/src/lib/storage/types.ts index b662c164..1adbb3b8 100644 --- a/apps/web/src/lib/storage/types.ts +++ b/apps/web/src/lib/storage/types.ts @@ -2,48 +2,48 @@ import { TProject } from "@/types/project"; import { TimelineTrack } from "@/types/timeline"; export interface StorageAdapter { - get(key: string): Promise; - set(key: string, value: T): Promise; - remove(key: string): Promise; - list(): Promise; - clear(): Promise; + get(key: string): Promise; + set(key: string, value: T): Promise; + remove(key: string): Promise; + list(): Promise; + clear(): Promise; } 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 + 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 TimelineData { - tracks: TimelineTrack[]; - lastModified: string; + tracks: TimelineTrack[]; + lastModified: string; } export interface StorageConfig { - projectsDb: string; - mediaDb: string; - timelineDb: string; - version: number; + projectsDb: string; + mediaDb: string; + timelineDb: string; + version: number; } // Helper type for serialization - converts Date objects to strings export type SerializedProject = Omit & { - createdAt: string; - updatedAt: string; + createdAt: string; + updatedAt: string; }; // Extend FileSystemDirectoryHandle with missing async iterator methods declare global { - interface FileSystemDirectoryHandle { - keys(): AsyncIterableIterator; - values(): AsyncIterableIterator; - entries(): AsyncIterableIterator<[string, FileSystemHandle]>; - } + interface FileSystemDirectoryHandle { + keys(): AsyncIterableIterator; + values(): AsyncIterableIterator; + entries(): AsyncIterableIterator<[string, FileSystemHandle]>; + } } diff --git a/apps/web/src/lib/time.ts b/apps/web/src/lib/time.ts index b3147946..6afcd03e 100644 --- a/apps/web/src/lib/time.ts +++ b/apps/web/src/lib/time.ts @@ -1,25 +1,25 @@ -// Time-related utility functions - -// Helper function to format time in various formats (MM:SS, HH:MM:SS, HH:MM:SS:CS, HH:MM:SS:FF) -export const formatTimeCode = ( - timeInSeconds: number, - format: "MM:SS" | "HH:MM:SS" | "HH:MM:SS:CS" | "HH:MM:SS:FF" = "HH:MM:SS:CS", - fps: number = 30 -): 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); - const frames = Math.floor((timeInSeconds % 1) * fps); - - 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")}`; - case "HH:MM:SS:FF": - return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${frames.toString().padStart(2, "0")}`; - } -}; +// Time-related utility functions + +// Helper function to format time in various formats (MM:SS, HH:MM:SS, HH:MM:SS:CS, HH:MM:SS:FF) +export const formatTimeCode = ( + timeInSeconds: number, + format: "MM:SS" | "HH:MM:SS" | "HH:MM:SS:CS" | "HH:MM:SS:FF" = "HH:MM:SS:CS", + fps: number = 30, +): 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); + const frames = Math.floor((timeInSeconds % 1) * fps); + + 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")}`; + case "HH:MM:SS:FF": + return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${frames.toString().padStart(2, "0")}`; + } +}; diff --git a/apps/web/src/lib/timeline.ts b/apps/web/src/lib/timeline.ts index 531defac..8e7648f4 100644 --- a/apps/web/src/lib/timeline.ts +++ b/apps/web/src/lib/timeline.ts @@ -1,54 +1,54 @@ -import { TimelineElement } from "@/types/timeline"; - -// Helper function to check for element overlaps and prevent invalid timeline states -export const checkElementOverlaps = (elements: TimelineElement[]): boolean => { - // Sort elements by start time - const sortedElements = [...elements].sort( - (a, b) => a.startTime - b.startTime - ); - - for (let i = 0; i < sortedElements.length - 1; i++) { - const current = sortedElements[i]; - const next = sortedElements[i + 1]; - - const currentEnd = - current.startTime + - (current.duration - current.trimStart - current.trimEnd); - - // Check if current element overlaps with next element - if (currentEnd > next.startTime) return true; // Overlap detected - } - - return false; // No overlaps -}; - -// Helper function to resolve overlaps by adjusting element positions -export const resolveElementOverlaps = ( - elements: TimelineElement[] -): TimelineElement[] => { - // Sort elements by start time - const sortedElements = [...elements].sort( - (a, b) => a.startTime - b.startTime - ); - const resolvedElements: TimelineElement[] = []; - - for (let i = 0; i < sortedElements.length; i++) { - const current = { ...sortedElements[i] }; - - if (resolvedElements.length > 0) { - const previous = resolvedElements[resolvedElements.length - 1]; - const previousEnd = - previous.startTime + - (previous.duration - previous.trimStart - previous.trimEnd); - - // If current element would overlap with previous, push it after previous ends - if (current.startTime < previousEnd) { - current.startTime = previousEnd; - } - } - - resolvedElements.push(current); - } - - return resolvedElements; -}; +import { TimelineElement } from "@/types/timeline"; + +// Helper function to check for element overlaps and prevent invalid timeline states +export const checkElementOverlaps = (elements: TimelineElement[]): boolean => { + // Sort elements by start time + const sortedElements = [...elements].sort( + (a, b) => a.startTime - b.startTime, + ); + + for (let i = 0; i < sortedElements.length - 1; i++) { + const current = sortedElements[i]; + const next = sortedElements[i + 1]; + + const currentEnd = + current.startTime + + (current.duration - current.trimStart - current.trimEnd); + + // Check if current element overlaps with next element + if (currentEnd > next.startTime) return true; // Overlap detected + } + + return false; // No overlaps +}; + +// Helper function to resolve overlaps by adjusting element positions +export const resolveElementOverlaps = ( + elements: TimelineElement[], +): TimelineElement[] => { + // Sort elements by start time + const sortedElements = [...elements].sort( + (a, b) => a.startTime - b.startTime, + ); + const resolvedElements: TimelineElement[] = []; + + for (let i = 0; i < sortedElements.length; i++) { + const current = { ...sortedElements[i] }; + + if (resolvedElements.length > 0) { + const previous = resolvedElements[resolvedElements.length - 1]; + const previousEnd = + previous.startTime + + (previous.duration - previous.trimStart - previous.trimEnd); + + // If current element would overlap with previous, push it after previous ends + if (current.startTime < previousEnd) { + current.startTime = previousEnd; + } + } + + resolvedElements.push(current); + } + + return resolvedElements; +}; diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index 8d2ee03a..4eb1d409 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -4,7 +4,7 @@ import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); + return twMerge(clsx(inputs)); } /** @@ -12,65 +12,65 @@ export function cn(...inputs: ClassValue[]) { * Uses crypto.randomUUID() if available, otherwise falls back to a custom implementation */ export function generateUUID(): string { - // Use the native crypto.randomUUID if available - if ( - typeof crypto !== "undefined" && - typeof crypto.randomUUID === "function" - ) { - return crypto.randomUUID(); - } + // Use the native crypto.randomUUID if available + if ( + typeof crypto !== "undefined" && + typeof crypto.randomUUID === "function" + ) { + return crypto.randomUUID(); + } - // Secure fallback using crypto.getRandomValues - const bytes = new Uint8Array(16); - crypto.getRandomValues(bytes); + // Secure fallback using crypto.getRandomValues + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); - // Set version 4 (UUIDv4) - bytes[6] = (bytes[6] & 0x0f) | 0x40; - // Set variant 10xxxxxx - bytes[8] = (bytes[8] & 0x3f) | 0x80; + // Set version 4 (UUIDv4) + bytes[6] = (bytes[6] & 0x0f) | 0x40; + // Set variant 10xxxxxx + bytes[8] = (bytes[8] & 0x3f) | 0x80; - const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")); + const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")); - return ( - hex.slice(0, 4).join("") + - "-" + - hex.slice(4, 6).join("") + - "-" + - hex.slice(6, 8).join("") + - "-" + - hex.slice(8, 10).join("") + - "-" + - hex.slice(10, 16).join("") - ); + return ( + hex.slice(0, 4).join("") + + "-" + + hex.slice(4, 6).join("") + + "-" + + hex.slice(6, 8).join("") + + "-" + + hex.slice(8, 10).join("") + + "-" + + hex.slice(10, 16).join("") + ); } export function isDOMElement(el: any): el is HTMLElement { - return !!el && (el instanceof Element || el instanceof HTMLElement); + return !!el && (el instanceof Element || el instanceof HTMLElement); } export function isTypableElement(el: HTMLElement): boolean { - // If content editable, then it is editable - if (el.isContentEditable) return true; + // If content editable, then it is editable + if (el.isContentEditable) return true; - // If element is an input and the input is enabled, then it is typable - if (el.tagName === "INPUT") { - return !(el as HTMLInputElement).disabled; - } - // If element is a textarea and the input is enabled, then it is typable - if (el.tagName === "TEXTAREA") { - return !(el as HTMLTextAreaElement).disabled; - } + // If element is an input and the input is enabled, then it is typable + if (el.tagName === "INPUT") { + return !(el as HTMLInputElement).disabled; + } + // If element is a textarea and the input is enabled, then it is typable + if (el.tagName === "TEXTAREA") { + return !(el as HTMLTextAreaElement).disabled; + } - return false; + return false; } export function isAppleDevice() { - return /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform); + return /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform); } export function getPlatformSpecialKey() { - return isAppleDevice() ? "⌘" : "Ctrl"; + return isAppleDevice() ? "⌘" : "Ctrl"; } export function getPlatformAlternateKey() { - return isAppleDevice() ? "⌥" : "Alt"; + return isAppleDevice() ? "⌥" : "Alt"; } diff --git a/apps/web/src/lib/waitlist.ts b/apps/web/src/lib/waitlist.ts index 22b15b78..dacf2615 100644 --- a/apps/web/src/lib/waitlist.ts +++ b/apps/web/src/lib/waitlist.ts @@ -1,13 +1,13 @@ -import { db, sql, waitlist } from "@opencut/db"; - -export async function getWaitlistCount() { - try { - const result = await db - .select({ count: sql`count(*)` }) - .from(waitlist); - return result[0]?.count || 0; - } catch (error) { - console.error("Failed to fetch waitlist count:", error); - return 0; - } -} +import { db, sql, waitlist } from "@opencut/db"; + +export async function getWaitlistCount() { + try { + const result = await db + .select({ count: sql`count(*)` }) + .from(waitlist); + return result[0]?.count || 0; + } catch (error) { + console.error("Failed to fetch waitlist count:", error); + return 0; + } +} -- cgit v1.3.1