diff --git a/bun.lock b/bun.lock index 5d472fd5..0b6e38d2 100644 --- a/bun.lock +++ b/bun.lock @@ -21,8 +21,8 @@ }, "devDependencies": { "@testing-library/jest-dom": "^6.0.0", - "@testing-library/react": "^14.0.0", - "@testing-library/user-event": "^14.4.3", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/bun": "^1.3.14", "@types/node": "^25", "@types/react": "^19", @@ -247,7 +247,7 @@ "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], - "@testing-library/react": ["@testing-library/react@14.3.1", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@testing-library/dom": "^9.0.0", "@types/react-dom": "^18.0.0" }, "peerDependencies": { "react": "^18.0.0", "react-dom": "^18.0.0" } }, "sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ=="], + "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="], "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="], @@ -1093,8 +1093,6 @@ "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], - "@testing-library/react/@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="], - "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], diff --git a/src/components/FileUpload.tsx b/src/components/FileUpload.tsx index f94c17ef..acbccadd 100644 --- a/src/components/FileUpload.tsx +++ b/src/components/FileUpload.tsx @@ -9,6 +9,7 @@ import { MAX_FILE_SIZE, WARNING_FILE_SIZE } from "@/lib/types"; interface Props { onFileSelect: (file: File | null) => void; + onMultipleFileSelect?: (files: FileList | null) => void; currentFile: File | null; fileError: string; duration: number; @@ -16,6 +17,7 @@ interface Props { export default function FileUpload({ onFileSelect, + onMultipleFileSelect, currentFile, fileError, duration, @@ -114,44 +116,60 @@ export default function FileUpload({ // ── Drop zone (inner) handler ───────────────────────── const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); - setDragging(false); - const file = e.dataTransfer.files?.[0]; - if (file) handleFile(file); - }; + e.preventDefault(); + setDragging(false); + + const files = e.dataTransfer.files; + + if (!files?.length) return; + + if (files.length === 1 && files[0]) { + handleFile(files[0]); + } + + onMultipleFileSelect?.(files); +}; // ── File info (shown after upload) ─────────────────── - const FileInfo = () => ( -
-
-
-
- +const FileInfo = () => ( +
+
+
+
+ +
+ + + +
+
+

+ {currentFile?.name} +

+ + {currentFile && ( + + {currentFile.name.includes(".") + ? currentFile.name.split(".").pop() + : "VIDEO"} + + )}
- -
-
-

- {currentFile?.name} -

- {currentFile && ( - - {currentFile.name.includes(".") - ? currentFile.name.split(".").pop() - : "VIDEO"} - - )} -
-
-

{formatBytes(currentFile?.size ?? 0)}

-

- {duration > 0 - ? `Duration: ${formatDuration(duration)}` - : "Loading duration..."} -

-
+ +
+

{formatBytes(currentFile?.size ?? 0)}

+ +

+ {duration > 0 + ? `Duration: ${formatDuration(duration)}` + : "Loading duration..."} +

+
-

- Supports: MP4, MOV, AVI, MKV, WebM, and most video formats -

- - {fileError && ( -

{fileError}

- )} - - { - const f = e.target.files?.[0]; - if (f) handleFile(f); - }} - /> -
- ); - - // ── Drop zone (inner) ───────────────────────────────── +

+ Supports: MP4, MOV, AVI, MKV, WebM, and most video formats +

+ +{fileError && ( +

+ {fileError} +

+)} + + { + onMultipleFileSelect?.(e.target.files) + }} + /> +
+); const DropZone = () => (
-

+

Supports: MP4, MOV, AVI, MKV, WebM, and most video formats up to 2GB

{fileError && (

{fileError}

)} -
+ { + onMultipleFileSelect?.(e.target.files) + }} + /> +
); return ( @@ -315,4 +343,4 @@ export default function FileUpload({
); -} +} \ No newline at end of file diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx index 26464263..c781740c 100644 --- a/src/components/ThemeToggle.tsx +++ b/src/components/ThemeToggle.tsx @@ -28,6 +28,7 @@ export function ThemeToggle() { ); } + const isDark = theme === "dark"; return ( + + {/* Action buttons */} +
+ {/* Move Up */} + + + {/* Move Down */} + + + {/* Remove */} + +
+
+ ))} + +
+ )} {!file && (
@@ -423,6 +527,7 @@ return () => { {file && (
| null { export function useVideoEditor() { const [file, setFile] = useState(null); + const [clips, setClips] = useState([]) const [duration, setDuration] = useState(0); const [videoMetadata, setVideoMetadata] = useState<{ width: number; @@ -435,6 +436,28 @@ export function useVideoEditor() { }, []); + const handleMultipleFilesSelect = useCallback( + async (selectedFiles: FileList | null) => { + if (!selectedFiles) return + + const filesArray = Array.from(selectedFiles) + + const validFiles = filesArray.filter((file) => + file.type.startsWith("video/") + ) + + if (validFiles.length === 0) { + setFileError("Please upload valid video files.") + return + } + + setClips(validFiles) + await handleFileSelect(validFiles[0]!) + setFileError("") + }, + [handleFileSelect] +) + const handleExport = useCallback(async () => { if (!file) return; if (status === "loading-engine" || status === "exporting") { @@ -467,6 +490,21 @@ export function useVideoEditor() { setExportStartedAt(startedAt); setStatus("exporting"); + if (clips.length > 1) { + + const mergedResult = await mergeVideos( + clips, + setProgress, + abortController.signal + ) + + if (exportCancelledRef.current) return + + setResult(mergedResult) + setStatus("done") + return + } + const exportResult = await exportVideo( file, recipe, @@ -514,20 +552,21 @@ export function useVideoEditor() { } } }, [ - duration, - file, - loopMusic, - musicFile, - musicVolume, - originalAudioVolume, - overlayFile, - overlayOpacity, - overlayPosition, - overlaySize, - recipe, - result, - status, - ]); + file, + clips, + recipe, + result, + status, + overlayFile, + overlayPosition, + overlaySize, + overlayOpacity, + duration, + loopMusic, + musicFile, + musicVolume, + originalAudioVolume, +]); useEffect(() => { @@ -646,6 +685,7 @@ export function useVideoEditor() { const reset = useCallback(() => { if (result?.blobUrl) URL.revokeObjectURL(result.blobUrl); setFile(null); + setClips([]); setVideoMetadata(null); setDuration(0); setRecipe(DEFAULT_RECIPE); @@ -685,6 +725,10 @@ export function useVideoEditor() { return { file, + setFile, + clips, + setClips, + handleMultipleFilesSelect, duration, recipe, status, diff --git a/src/lib/ffmpeg.ts b/src/lib/ffmpeg.ts index 625387d2..6e475f7a 100644 --- a/src/lib/ffmpeg.ts +++ b/src/lib/ffmpeg.ts @@ -1,5 +1,7 @@ import { EditRecipe, ExportResult, BackgroundMusicOptions, ImageOverlayOptions } from "./types"; import { getPresetById } from "./presets"; +import { fetchFile } from "@ffmpeg/util"; +import { FFmpeg } from "@ffmpeg/ffmpeg"; import { buildTextFilter } from "./text-overlay"; export class FFmpegLoadError extends Error {} @@ -45,6 +47,7 @@ type WorkerResponse = | WorkerErrorResponse | WorkerCancelledResponse; +let mergeFFmpeg: FFmpeg | null = null let ffmpegWorker: Worker | null = null; let workerReady: Promise | null = null; let workerReadyResolve: (() => void) | null = null; @@ -568,6 +571,187 @@ interface PositionCoords { return args; } +export async function mergeVideos( + clips: File[], + onProgress: (percent: number) => void, + signal?: AbortSignal +): Promise { + const sessionId = buildSessionId() +if (typeof window === "undefined") { + throw new Error("FFmpeg is only available in the browser") +} + +if (!mergeFFmpeg) { + mergeFFmpeg = new FFmpeg() +} + if (!mergeFFmpeg!.loaded) { + await mergeFFmpeg!.load() + } + + const concatFileName = `concat_${sessionId}.txt` + const outputName = `merged_${sessionId}.mp4` + + const cleanupFiles = new Set([ + concatFileName, + outputName, + ]) + + const handleProgress = ({ progress }: { progress: number }) => { + if (!Number.isFinite(progress) || progress < 0) { + return + } + + onProgress( + Math.min(99, Math.max(0, Math.round(progress * 100))) + ) + } + + try { + mergeFFmpeg!.on("progress", handleProgress) + + const concatLines: string[] = [] + + for (let i = 0; i < clips.length; i++) { + const clip = clips[i]! + + const ext = clip.name.split(".").pop() ?? "mp4" + + const inputName = `clip_${sessionId}_${i}.${ext}` + const normalizedName = `normalized_${sessionId}_${i}.mp4` + + cleanupFiles.add(inputName) + cleanupFiles.add(normalizedName) + + await mergeFFmpeg!.writeFile( + inputName, + await fetchFile(clip) + ) + + const normalizeExit = await mergeFFmpeg!.exec([ + "-fflags", + "+genpts", + + "-i", + inputName, + + "-vf", + "fps=30,format=yuv420p", + + "-r", + "30", + + "-vsync", + "cfr", + + "-af", + "aresample=async=1", + + "-c:v", + "libx264", + + "-preset", + "veryfast", + + "-pix_fmt", + "yuv420p", + + "-c:a", + "aac", + + "-ar", + "48000", + + "-avoid_negative_ts", + "make_zero", + + "-movflags", + "+faststart", + + "-y", + normalizedName, + ]) + + if (normalizeExit !== 0) { + throw new Error(`Normalization failed for clip ${i + 1}`) + } + + concatLines.push(`file '${normalizedName}'`) + } + + await mergeFFmpeg!.writeFile( + concatFileName, + new TextEncoder().encode(concatLines.join("\n")) + ) + + const exitCode = await mergeFFmpeg!.exec([ + "-f", + "concat", + "-safe", + "0", + "-i", + concatFileName, + + "-c", + "copy", + + "-y", + outputName, + ]) + + if (exitCode !== 0) { + throw new Error("Video merge failed") + } + + const data = await mergeFFmpeg!.readFile(outputName) + + const blob = new Blob( + [new Uint8Array(data as Uint8Array)], + { type: "video/mp4" } + ) + + onProgress(100) + + const blobUrl = URL.createObjectURL(blob) + + const dimensions = await new Promise<{ + width: number + height: number + }>((resolve) => { + const video = document.createElement("video") + + video.preload = "metadata" + + video.onloadedmetadata = () => { + resolve({ + width: video.videoWidth, + height: video.videoHeight, + }) + } + + video.src = blobUrl + }) + + return { + blob, + blobUrl, + size: blob.size, + width: dimensions.width, + height: dimensions.height, + format: "mp4", + } + + } finally { + mergeFFmpeg!.off("progress", handleProgress) + + for (const path of cleanupFiles) { + try { + await mergeFFmpeg!.deleteFile(path) + } catch {} + } + } +} + + export function formatBytes(bytes: number): string { if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;