@@ -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`;