diff options
| author | Hyteq <DerpsDer.es@gmail.com> | 2025-07-20 11:00:22 +0300 |
|---|---|---|
| committer | Hyteq <DerpsDer.es@gmail.com> | 2025-07-20 11:00:22 +0300 |
| commit | be1ae20d9089e81cb84a8294be76fe680a24b60f (patch) | |
| tree | 48472c1122d35f45f802bbd1c3cf2c15f653f9cf /apps/web/src/lib | |
| parent | ede08dac416b47eac2db151969c164bdf0f06002 (diff) | |
| download | opencut-be1ae20d9089e81cb84a8294be76fe680a24b60f.tar.gz | |
format
Diffstat (limited to 'apps/web/src/lib')
| -rw-r--r-- | apps/web/src/lib/fetch-github-stars.ts | 48 | ||||
| -rw-r--r-- | apps/web/src/lib/ffmpeg-utils.ts | 431 | ||||
| -rw-r--r-- | apps/web/src/lib/font-config.ts | 78 | ||||
| -rw-r--r-- | apps/web/src/lib/media-processing.ts | 162 | ||||
| -rw-r--r-- | apps/web/src/lib/rate-limit.ts | 32 | ||||
| -rw-r--r-- | apps/web/src/lib/storage/indexeddb-adapter.ts | 178 | ||||
| -rw-r--r-- | apps/web/src/lib/storage/opfs-adapter.ts | 114 | ||||
| -rw-r--r-- | apps/web/src/lib/storage/storage-service.ts | 440 | ||||
| -rw-r--r-- | apps/web/src/lib/storage/types.ts | 54 | ||||
| -rw-r--r-- | apps/web/src/lib/time.ts | 50 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline.ts | 108 | ||||
| -rw-r--r-- | apps/web/src/lib/utils.ts | 84 | ||||
| -rw-r--r-- | apps/web/src/lib/waitlist.ts | 26 |
13 files changed, 922 insertions, 883 deletions
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<string> { - 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<FFmpeg> => { - if (ffmpeg) 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'), - }); + ffmpeg = new FFmpeg(); - return 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<string> => { - 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<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; + 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 ffmpeg = await initFFmpeg(); - const inputName = 'input.mp4'; + const inputName = "input.mp4"; - // Write input file - await ffmpeg.writeFile(inputName, new Uint8Array(await videoFile.arrayBuffer())); + // 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)); + // 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.'); - } + // 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; + // Disable listener after exec completes + listening = false; - // Cleanup - await ffmpeg.deleteFile(inputName); + // 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 + // 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 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]); - } + 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 - }; + return { + duration, + width, + height, + fps, + }; }; export const convertToWebM = async ( - videoFile: File, - onProgress?: (progress: number) => void + videoFile: File, + onProgress?: (progress: number) => void, ): Promise<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; + 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<Blob> => { - 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<MediaItem, "id"> {} export async function processMediaFiles( - files: FileList | File[], - onProgress?: (progress: number) => void + files: FileList | File[], + onProgress?: (progress: number) => void, ): Promise<ProcessedMediaItem[]> { - 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<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();
- });
- }
-}
+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 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<File> { - private directoryName: string; + private directoryName: string; - constructor(directoryName: string = "media") { - this.directoryName = directoryName; - } + 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, - }); - } + 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 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(); + 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(); - } + 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 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[] = []; + async list(): Promise<string[]> { + 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<void> { - const directory = await this.getDirectory(); + async clear(): Promise<void> { + 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<SerializedProject>; - private config: StorageConfig; + private projectsAdapter: IndexedDBAdapter<SerializedProject>; + private config: StorageConfig; - constructor() { - this.config = { - projectsDb: "video-editor-projects", - mediaDb: "video-editor-media", - timelineDb: "video-editor-timelines", - version: 1, - }; + constructor() { + this.config = { + projectsDb: "video-editor-projects", + mediaDb: "video-editor-media", + timelineDb: "video-editor-timelines", + version: 1, + }; - this.projectsAdapter = new IndexedDBAdapter<SerializedProject>( - this.config.projectsDb, - "projects", - this.config.version - ); - } + this.projectsAdapter = new IndexedDBAdapter<SerializedProject>( + this.config.projectsDb, + "projects", + this.config.version, + ); + } - // Helper to get project-specific media adapters - private getProjectMediaAdapters(projectId: string) { - const mediaMetadataAdapter = new IndexedDBAdapter<MediaFileData>( - `${this.config.mediaDb}-${projectId}`, - "media-metadata", - this.config.version - ); + // Helper to get project-specific media adapters + private getProjectMediaAdapters(projectId: string) { + const mediaMetadataAdapter = new IndexedDBAdapter<MediaFileData>( + `${this.config.mediaDb}-${projectId}`, + "media-metadata", + this.config.version, + ); - const mediaFilesAdapter = new OPFSAdapter(`media-files-${projectId}`); + const mediaFilesAdapter = new OPFSAdapter(`media-files-${projectId}`); - return { mediaMetadataAdapter, mediaFilesAdapter }; - } + return { mediaMetadataAdapter, mediaFilesAdapter }; + } - // Helper to get project-specific timeline adapter - private getProjectTimelineAdapter(projectId: string) { - return new IndexedDBAdapter<TimelineData>( - `${this.config.timelineDb}-${projectId}`, - "timeline", - this.config.version - ); - } + // Helper to get project-specific timeline adapter + private getProjectTimelineAdapter(projectId: string) { + return new IndexedDBAdapter<TimelineData>( + `${this.config.timelineDb}-${projectId}`, + "timeline", + this.config.version, + ); + } - // Project operations - async saveProject(project: TProject): Promise<void> { - // Convert TProject to serializable format - const serializedProject: SerializedProject = { - id: project.id, - name: project.name, - thumbnail: project.thumbnail, - createdAt: project.createdAt.toISOString(), - updatedAt: project.updatedAt.toISOString(), - backgroundColor: project.backgroundColor, - backgroundType: project.backgroundType, - blurIntensity: project.blurIntensity, - }; + // 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(), + backgroundColor: project.backgroundColor, + backgroundType: project.backgroundType, + blurIntensity: project.blurIntensity, + }; - await this.projectsAdapter.set(project.id, serializedProject); - } + await this.projectsAdapter.set(project.id, serializedProject); + } - async loadProject(id: string): Promise<TProject | null> { - const serializedProject = await this.projectsAdapter.get(id); + async loadProject(id: string): Promise<TProject | null> { + const serializedProject = await this.projectsAdapter.get(id); - if (!serializedProject) return null; + 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, - }; - } + // 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<TProject[]> { - const projectIds = await this.projectsAdapter.list(); - const projects: TProject[] = []; + 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); - } - } + 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() - ); - } + // 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); - } + async deleteProject(id: string): Promise<void> { + await this.projectsAdapter.remove(id); + } - // Media operations - now project-specific - async saveMediaItem(projectId: string, mediaItem: MediaItem): Promise<void> { - const { mediaMetadataAdapter, mediaFilesAdapter } = - this.getProjectMediaAdapters(projectId); + // Media operations - now project-specific + async saveMediaItem(projectId: string, mediaItem: MediaItem): Promise<void> { + const { mediaMetadataAdapter, mediaFilesAdapter } = + this.getProjectMediaAdapters(projectId); - // Save file to project-specific OPFS - await mediaFilesAdapter.set(mediaItem.id, mediaItem.file); + // Save 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, - }; + // 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); - } + await mediaMetadataAdapter.set(mediaItem.id, metadata); + } - async loadMediaItem( - projectId: string, - id: string - ): Promise<MediaItem | null> { - const { mediaMetadataAdapter, mediaFilesAdapter } = - this.getProjectMediaAdapters(projectId); + async loadMediaItem( + projectId: string, + id: string, + ): Promise<MediaItem | null> { + const { mediaMetadataAdapter, mediaFilesAdapter } = + this.getProjectMediaAdapters(projectId); - const [file, metadata] = await Promise.all([ - mediaFilesAdapter.get(id), - mediaMetadataAdapter.get(id), - ]); + const [file, metadata] = await Promise.all([ + mediaFilesAdapter.get(id), + mediaMetadataAdapter.get(id), + ]); - if (!file || !metadata) return null; + if (!file || !metadata) return null; - // Create new object URL for the file - const url = URL.createObjectURL(file); + // 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 - }; - } + return { + id: metadata.id, + name: metadata.name, + type: metadata.type, + file, + url, + width: metadata.width, + height: metadata.height, + duration: metadata.duration, + // thumbnailUrl would need to be regenerated or cached separately + }; + } - async loadAllMediaItems(projectId: string): Promise<MediaItem[]> { - const { mediaMetadataAdapter } = this.getProjectMediaAdapters(projectId); + async loadAllMediaItems(projectId: string): Promise<MediaItem[]> { + const { mediaMetadataAdapter } = this.getProjectMediaAdapters(projectId); - const mediaIds = await mediaMetadataAdapter.list(); - const mediaItems: MediaItem[] = []; + 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); - } - } + for (const id of mediaIds) { + const item = await this.loadMediaItem(projectId, id); + if (item) { + mediaItems.push(item); + } + } - return mediaItems; - } + return mediaItems; + } - async deleteMediaItem(projectId: string, id: string): Promise<void> { - const { mediaMetadataAdapter, mediaFilesAdapter } = - this.getProjectMediaAdapters(projectId); + async deleteMediaItem(projectId: string, id: string): Promise<void> { + const { mediaMetadataAdapter, mediaFilesAdapter } = + this.getProjectMediaAdapters(projectId); - await Promise.all([ - mediaFilesAdapter.remove(id), - mediaMetadataAdapter.remove(id), - ]); - } + await Promise.all([ + mediaFilesAdapter.remove(id), + mediaMetadataAdapter.remove(id), + ]); + } - async deleteProjectMedia(projectId: string): Promise<void> { - const { mediaMetadataAdapter, mediaFilesAdapter } = - this.getProjectMediaAdapters(projectId); + async deleteProjectMedia(projectId: string): Promise<void> { + const { mediaMetadataAdapter, mediaFilesAdapter } = + this.getProjectMediaAdapters(projectId); - await Promise.all([ - mediaMetadataAdapter.clear(), - mediaFilesAdapter.clear(), - ]); - } + await Promise.all([ + mediaMetadataAdapter.clear(), + mediaFilesAdapter.clear(), + ]); + } - // Timeline operations - now project-specific - async saveTimeline( - projectId: string, - tracks: TimelineTrack[] - ): Promise<void> { - const timelineAdapter = this.getProjectTimelineAdapter(projectId); - const timelineData: TimelineData = { - tracks, - lastModified: new Date().toISOString(), - }; - await timelineAdapter.set("timeline", timelineData); - } + // Timeline operations - now project-specific + async saveTimeline( + projectId: string, + tracks: TimelineTrack[], + ): Promise<void> { + const timelineAdapter = this.getProjectTimelineAdapter(projectId); + const timelineData: TimelineData = { + tracks, + lastModified: new Date().toISOString(), + }; + await timelineAdapter.set("timeline", timelineData); + } - async loadTimeline(projectId: string): Promise<TimelineTrack[] | null> { - const timelineAdapter = this.getProjectTimelineAdapter(projectId); - const timelineData = await timelineAdapter.get("timeline"); - return timelineData ? timelineData.tracks : null; - } + async loadTimeline(projectId: string): Promise<TimelineTrack[] | null> { + const timelineAdapter = this.getProjectTimelineAdapter(projectId); + const timelineData = await timelineAdapter.get("timeline"); + return timelineData ? timelineData.tracks : null; + } - async deleteProjectTimeline(projectId: string): Promise<void> { - const timelineAdapter = this.getProjectTimelineAdapter(projectId); - await timelineAdapter.remove("timeline"); - } + async deleteProjectTimeline(projectId: string): Promise<void> { + const timelineAdapter = this.getProjectTimelineAdapter(projectId); + await timelineAdapter.remove("timeline"); + } - // Utility methods - async clearAllData(): Promise<void> { - // Clear all projects - await this.projectsAdapter.clear(); + // Utility methods + async clearAllData(): Promise<void> { + // Clear all projects + await this.projectsAdapter.clear(); - // Note: Project-specific media and timelines will be cleaned up when projects are deleted - } + // 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(); + 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(), - }; - } + 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); + 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"), - ]); + const [mediaIds, timelineData] = await Promise.all([ + mediaMetadataAdapter.list(), + timelineAdapter.get("timeline"), + ]); - return { - mediaItems: mediaIds.length, - hasTimeline: !!timelineData, - }; - } + return { + mediaItems: mediaIds.length, + hasTimeline: !!timelineData, + }; + } - // Check browser support - isOPFSSupported(): boolean { - return OPFSAdapter.isSupported(); - } + // Check browser support + isOPFSSupported(): boolean { + return OPFSAdapter.isSupported(); + } - isIndexedDBSupported(): boolean { - return "indexedDB" in window; - } + isIndexedDBSupported(): boolean { + return "indexedDB" in window; + } - isFullySupported(): boolean { - return this.isIndexedDBSupported() && this.isOPFSSupported(); - } + 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<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>; + 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 + 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<TProject, "createdAt" | "updatedAt"> & { - createdAt: string; - updatedAt: string; + 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]>; - } + 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 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<number>`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<number>`count(*)` }) + .from(waitlist); + return result[0]?.count || 0; + } catch (error) { + console.error("Failed to fetch waitlist count:", error); + return 0; + } +} |
