From bc08b2254cf3295dfbee505cc218f10b2027a7a8 Mon Sep 17 00:00:00 2001 From: chavanGaneshDatta Date: Fri, 22 May 2026 00:36:16 +0530 Subject: [PATCH] feat: add client-side video merge support --- src/components/FileUpload.tsx | 25 ++--- src/components/VideoEditor.tsx | 110 ++++++++++++++++++- src/hooks/useVideoEditor.ts | 47 +++++++- src/lib/ffmpeg.ts | 191 +++++++++++++++++++++++++++++++++ 4 files changed, 350 insertions(+), 23 deletions(-) diff --git a/src/components/FileUpload.tsx b/src/components/FileUpload.tsx index 6e10fd77..c491d66b 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) => 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, @@ -78,9 +80,7 @@ export default function FileUpload({ const handleDrop = (e: React.DragEvent) => { e.preventDefault(); setDragging(false); - - const file = e.dataTransfer.files?.[0]; - if (file) handleFile(file); + onMultipleFileSelect?.(e.dataTransfer.files) }; const FileInfo = () => ( @@ -135,16 +135,6 @@ export default function FileUpload({ {fileError}

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

@@ -158,11 +148,11 @@ export default function FileUpload({ { - const f = e.target.files?.[0]; - if (f) handleFile(f); + onMultipleFileSelect?.(e.target.files) }} /> @@ -238,12 +228,11 @@ export default function FileUpload({ { - const f = e.target.files?.[0]; - - if (f) handleFile(f); + onMultipleFileSelect?.(e.target.files) }} /> diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx index 69265646..31050a75 100644 --- a/src/components/VideoEditor.tsx +++ b/src/components/VideoEditor.tsx @@ -111,7 +111,7 @@ function KeyboardShortcutsPanel() { export default function VideoEditor() { const { - file, duration, recipe, status, progress, + file, setFile, clips, setClips, handleMultipleFilesSelect, duration, recipe, status, progress, result, error, updateRecipe, handleFileSelect, fileError, handleExport, cancelExport, reset, resetSettings, videoRef, @@ -193,7 +193,111 @@ export default function VideoEditor() {

- + + + {clips.length > 0 && ( +
+

+ Selected Clips +

+ + + {clips.map((clip, index) => ( +
+ {/* Clickable preview area */} + + + {/* Action buttons */} +
+ {/* Move Up */} + + + {/* Move Down */} + + + {/* Remove */} + +
+
+ ))} + +
+ )} {!file && (
@@ -204,7 +308,7 @@ export default function VideoEditor() { {file && (
- +
(null); + const [clips, setClips] = useState([]) const [duration, setDuration] = useState(0); const [videoMetadata, setVideoMetadata] = useState<{ width: number; @@ -385,6 +386,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") { @@ -414,6 +437,21 @@ export function useVideoEditor() { setStatus("exporting"); + if (clips.length > 1) { + const mergedResult = await mergeVideos( + ffmpeg, + clips, + setProgress, + abortController.signal + ) + + if (exportCancelledRef.current) return + + setResult(mergedResult) + setStatus("done") + return + } + const exportResult = await exportVideo( ffmpeg, file, @@ -457,7 +495,7 @@ export function useVideoEditor() { exportAbortControllerRef.current = null; } } - }, [file, recipe, result, status, overlayFile, overlayPosition, overlaySize, overlayOpacity, duration, loopMusic, musicFile, musicVolume, originalAudioVolume]); + }, [file, clips, recipe, result, status, overlayFile, overlayPosition, overlaySize, overlayOpacity, duration, loopMusic, musicFile, musicVolume, originalAudioVolume]); useEffect(() => { @@ -574,6 +612,7 @@ export function useVideoEditor() { const reset = useCallback(() => { if (result?.blobUrl) URL.revokeObjectURL(result.blobUrl); setFile(null); + setClips([]); setVideoMetadata(null); setDuration(0); setRecipe(DEFAULT_RECIPE); @@ -604,6 +643,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 4f215075..9c3dab69 100644 --- a/src/lib/ffmpeg.ts +++ b/src/lib/ffmpeg.ts @@ -474,6 +474,197 @@ export async function exportVideo( } } +export async function mergeVideos( + ffmpeg: FFmpeg, + clips: File[], + onProgress: (percent: number) => void, + signal?: AbortSignal +): Promise { + const sessionId = buildSessionId() + + 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 { + ffmpeg.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" + + // Raw uploaded file + const inputName = `clip_${sessionId}_${i}.${ext}` + + // Normalized file + const normalizedName = `normalized_${sessionId}_${i}.mp4` + + cleanupFiles.add(inputName) + cleanupFiles.add(normalizedName) + + // Write original clip + await ffmpeg.writeFile( + inputName, + await fetchFile(clip), + { signal } + ) + + + // Normalize clip + const normalizeExit = await ffmpeg.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, +], + undefined, + { signal } + ) + + if (normalizeExit !== 0) { + throw new Error(`Normalization failed for clip ${i + 1}`) + } + + // Add NORMALIZED clip + concatLines.push(`file '${normalizedName}'`) +} + + await ffmpeg.writeFile( + concatFileName, + new TextEncoder().encode(concatLines.join("\n")) + ) + + const exitCode = await ffmpeg.exec( + [ + "-f", + "concat", + "-safe", + "0", + "-i", + concatFileName, + + "-c", + "copy", + + "-y", + outputName, + ], + undefined, + { signal } +) + + if (exitCode !== 0) { + throw new Error("Video merge failed") + } + + const data = await ffmpeg.readFile( + outputName, + undefined, + { signal } + ) + + 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 { + blobUrl, + size: blob.size, + width: dimensions.width, + height: dimensions.height, + format: "mp4", +} + + } finally { + ffmpeg.off("progress", handleProgress) + + for (const path of cleanupFiles) { + try { + await ffmpeg.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`;