diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index d64ee74..a35cc2f 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -6,6 +6,8 @@
+
+
@@ -283,7 +285,7 @@
"Vitest.replaceText function (regexp mode).should return the original text when passed an invalid regexp.executor": "Run",
"Vitest.replaceText function.executor": "Run",
"Vitest.timeBetweenDates.executor": "Run",
- "git-widget-placeholder": "main",
+ "git-widget-placeholder": "#168 on fork/AshAnand34/merge-video-tool",
"ignore.virus.scanning.warn.message": "true",
"kotlin-language-version-configured": "true",
"last_opened_file_path": "C:/Users/Ibrahima/IdeaProjects/omni-tools",
@@ -338,7 +340,7 @@
-
+
@@ -412,9 +414,9 @@
+
-
diff --git a/public/locales/en/video.json b/public/locales/en/video.json
index d150ca8..397df0e 100644
--- a/public/locales/en/video.json
+++ b/public/locales/en/video.json
@@ -83,6 +83,12 @@
"title": "What is a {{title}}?"
}
},
+ "mergeVideo": {
+ "description": "Combine multiple video files into one continuous video.",
+ "longDescription": "This tool allows you to merge or append multiple video files into a single continuous video. Simply upload your video files, arrange them in the desired order, and merge them into one file for easy sharing or editing.",
+ "shortDescription": "Append and merge videos easily.",
+ "title": "Merge videos"
+ },
"rotate": {
"180Degrees": "180° (Upside down)",
"270Degrees": "270° (90° Counter-clockwise)",
diff --git a/src/components/input/ToolMultipleVideoInput.tsx b/src/components/input/ToolMultipleVideoInput.tsx
new file mode 100644
index 0000000..9f42b8a
--- /dev/null
+++ b/src/components/input/ToolMultipleVideoInput.tsx
@@ -0,0 +1,177 @@
+import { ReactNode, useContext, useEffect, useRef, useState } from 'react';
+import { Box, useTheme } from '@mui/material';
+import Typography from '@mui/material/Typography';
+import InputHeader from '../InputHeader';
+import InputFooter from './InputFooter';
+import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext';
+import { isArray } from 'lodash';
+import VideoFileIcon from '@mui/icons-material/VideoFile';
+
+interface MultiVideoInputComponentProps {
+ accept: string[];
+ title?: string;
+ type: 'video';
+ value: MultiVideoInput[];
+ onChange: (file: MultiVideoInput[]) => void;
+}
+
+export interface MultiVideoInput {
+ file: File;
+ order: number;
+}
+
+export default function ToolMultipleVideoInput({
+ value,
+ onChange,
+ accept,
+ title,
+ type
+}: MultiVideoInputComponentProps) {
+ console.log('ToolMultipleVideoInput rendering with value:', value);
+
+ const fileInputRef = useRef(null);
+
+ const handleFileChange = (event: React.ChangeEvent) => {
+ const files = event.target.files;
+ console.log('File change event:', files);
+ if (files)
+ onChange([
+ ...value,
+ ...Array.from(files).map((file) => ({ file, order: value.length }))
+ ]);
+ };
+
+ const handleImportClick = () => {
+ console.log('Import clicked');
+ fileInputRef.current?.click();
+ };
+
+ function handleClear() {
+ console.log('Clear clicked');
+ onChange([]);
+ }
+
+ function fileNameTruncate(fileName: string) {
+ const maxLength = 15;
+ if (fileName.length > maxLength) {
+ return fileName.slice(0, maxLength) + '...';
+ }
+ return fileName;
+ }
+
+ const sortList = () => {
+ const list = [...value];
+ list.sort((a, b) => a.order - b.order);
+ onChange(list);
+ };
+
+ const reorderList = (sourceIndex: number, destinationIndex: number) => {
+ if (destinationIndex === sourceIndex) {
+ return;
+ }
+ const list = [...value];
+
+ if (destinationIndex === 0) {
+ list[sourceIndex].order = list[0].order - 1;
+ sortList();
+ return;
+ }
+
+ if (destinationIndex === list.length - 1) {
+ list[sourceIndex].order = list[list.length - 1].order + 1;
+ sortList();
+ return;
+ }
+
+ if (destinationIndex < sourceIndex) {
+ list[sourceIndex].order =
+ (list[destinationIndex].order + list[destinationIndex - 1].order) / 2;
+ sortList();
+ return;
+ }
+
+ list[sourceIndex].order =
+ (list[destinationIndex].order + list[destinationIndex + 1].order) / 2;
+ sortList();
+ };
+
+ return (
+
+
+
+
+ {value?.length ? (
+ value.map((file, index) => (
+
+
+
+
+ {fileNameTruncate(file.file.name)}
+
+
+ {
+ const updatedFiles = value.filter((_, i) => i !== index);
+ onChange(updatedFiles);
+ }}
+ >
+ ✖
+
+
+ ))
+ ) : (
+
+ No files selected
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/tools/video/index.ts b/src/pages/tools/video/index.ts
index 7cbf9f9..be61367 100644
--- a/src/pages/tools/video/index.ts
+++ b/src/pages/tools/video/index.ts
@@ -1,3 +1,4 @@
+import { tool as videoMergeVideo } from './merge-video/meta';
import { tool as videoToGif } from './video-to-gif/meta';
import { tool as changeSpeed } from './change-speed/meta';
import { tool as flipVideo } from './flip/meta';
@@ -17,5 +18,6 @@ export const videoTools = [
flipVideo,
cropVideo,
changeSpeed,
- videoToGif
+ videoToGif,
+ videoMergeVideo
];
diff --git a/src/pages/tools/video/merge-video/index.tsx b/src/pages/tools/video/merge-video/index.tsx
new file mode 100644
index 0000000..8a7de65
--- /dev/null
+++ b/src/pages/tools/video/merge-video/index.tsx
@@ -0,0 +1,74 @@
+import { Box } from '@mui/material';
+import React, { useState } from 'react';
+import ToolContent from '@components/ToolContent';
+import { ToolComponentProps } from '@tools/defineTool';
+import ToolFileResult from '@components/result/ToolFileResult';
+import ToolMultipleVideoInput, {
+ MultiVideoInput
+} from '@components/input/ToolMultipleVideoInput';
+import { mergeVideos } from './service';
+import { InitialValuesType } from './types';
+
+const initialValues: InitialValuesType = {};
+
+export default function MergeVideo({
+ title,
+ longDescription
+}: ToolComponentProps) {
+ const [input, setInput] = useState([]);
+ const [result, setResult] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ const compute = async (
+ _values: InitialValuesType,
+ input: MultiVideoInput[]
+ ) => {
+ if (!input || input.length < 2) {
+ return;
+ }
+ setLoading(true);
+ try {
+ const files = input.map((item) => item.file);
+ const mergedBlob = await mergeVideos(files, initialValues);
+ const mergedFile = new File([mergedBlob], 'merged-video.mp4', {
+ type: 'video/mp4'
+ });
+ setResult(mergedFile);
+ } catch (err) {
+ setResult(null);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+ {
+ setInput(newInput);
+ }}
+ accept={['video/*', '.mp4', '.avi', '.mov', '.mkv']}
+ title="Input Videos"
+ type="video"
+ />
+ }
+ resultComponent={
+
+ }
+ initialValues={initialValues}
+ getGroups={null}
+ setInput={setInput}
+ compute={compute}
+ toolInfo={{ title: `What is a ${title}?`, description: longDescription }}
+ />
+ );
+}
diff --git a/src/pages/tools/video/merge-video/merge-video.service.test.ts b/src/pages/tools/video/merge-video/merge-video.service.test.ts
new file mode 100644
index 0000000..311c067
--- /dev/null
+++ b/src/pages/tools/video/merge-video/merge-video.service.test.ts
@@ -0,0 +1,62 @@
+import { expect, describe, it, vi } from 'vitest';
+
+// Mock FFmpeg and fetchFile to avoid Node.js compatibility issues
+vi.mock('@ffmpeg/ffmpeg', () => ({
+ FFmpeg: vi.fn().mockImplementation(() => ({
+ loaded: false,
+ load: vi.fn().mockResolvedValue(undefined),
+ writeFile: vi.fn().mockResolvedValue(undefined),
+ exec: vi.fn().mockResolvedValue(undefined),
+ readFile: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3, 4])),
+ deleteFile: vi.fn().mockResolvedValue(undefined)
+ }))
+}));
+
+vi.mock('@ffmpeg/util', () => ({
+ fetchFile: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3, 4]))
+}));
+
+// Import after mocking
+import { mergeVideos } from './service';
+
+function createMockFile(name: string, type = 'video/mp4') {
+ return new File([new Uint8Array([0, 1, 2])], name, { type });
+}
+
+describe('merge-video', () => {
+ it('throws if less than two files are provided', async () => {
+ await expect(mergeVideos([], {})).rejects.toThrow(
+ 'Please provide at least two video files to merge.'
+ );
+ await expect(mergeVideos([createMockFile('a.mp4')], {})).rejects.toThrow(
+ 'Please provide at least two video files to merge.'
+ );
+ });
+
+ it('throws if input is not an array', async () => {
+ // @ts-ignore - testing invalid input
+ await expect(mergeVideos(null, {})).rejects.toThrow(
+ 'Please provide at least two video files to merge.'
+ );
+ });
+
+ it('successfully merges video files (mocked)', async () => {
+ const mockFile1 = createMockFile('video1.mp4');
+ const mockFile2 = createMockFile('video2.mp4');
+
+ const result = await mergeVideos([mockFile1, mockFile2], {});
+
+ expect(result).toBeInstanceOf(Blob);
+ expect(result.type).toBe('video/mp4');
+ });
+
+ it('handles different video formats by re-encoding', async () => {
+ const mockFile1 = createMockFile('video1.avi', 'video/x-msvideo');
+ const mockFile2 = createMockFile('video2.mov', 'video/quicktime');
+
+ const result = await mergeVideos([mockFile1, mockFile2], {});
+
+ expect(result).toBeInstanceOf(Blob);
+ expect(result.type).toBe('video/mp4');
+ });
+});
diff --git a/src/pages/tools/video/merge-video/meta.ts b/src/pages/tools/video/merge-video/meta.ts
new file mode 100644
index 0000000..d593f42
--- /dev/null
+++ b/src/pages/tools/video/merge-video/meta.ts
@@ -0,0 +1,15 @@
+import { defineTool } from '@tools/defineTool';
+import { lazy } from 'react';
+
+export const tool = defineTool('video', {
+ path: 'merge-video',
+ icon: 'fluent:merge-20-regular',
+ keywords: ['merge', 'video', 'append', 'combine'],
+ component: lazy(() => import('./index')),
+ i18n: {
+ name: 'video:mergeVideo.title',
+ description: 'video:mergeVideo.description',
+ shortDescription: 'video:mergeVideo.shortDescription',
+ longDescription: 'video:mergeVideo.longDescription'
+ }
+});
diff --git a/src/pages/tools/video/merge-video/service.ts b/src/pages/tools/video/merge-video/service.ts
new file mode 100644
index 0000000..026c2b1
--- /dev/null
+++ b/src/pages/tools/video/merge-video/service.ts
@@ -0,0 +1,134 @@
+import { InitialValuesType, MergeVideoInput, MergeVideoOutput } from './types';
+import { FFmpeg } from '@ffmpeg/ffmpeg';
+import { fetchFile } from '@ffmpeg/util';
+
+export async function mergeVideos(
+ input: MergeVideoInput,
+ options: InitialValuesType
+): Promise {
+ if (!Array.isArray(input) || input.length < 2) {
+ throw new Error('Please provide at least two video files to merge.');
+ }
+
+ // Create a new FFmpeg instance for each operation to avoid conflicts
+ const ffmpeg = new FFmpeg();
+
+ const fileNames: string[] = [];
+ const outputName = 'output.mp4';
+
+ try {
+ // Load FFmpeg with proper error handling
+ if (!ffmpeg.loaded) {
+ await ffmpeg.load({
+ wasmURL:
+ 'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.9/dist/esm/ffmpeg-core.wasm',
+ workerURL:
+ 'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.9/dist/esm/ffmpeg-core.worker.js'
+ });
+ }
+
+ // Write all input files to ffmpeg FS with better error handling
+ for (let i = 0; i < input.length; i++) {
+ const fileName = `input${i}.mp4`;
+ fileNames.push(fileName);
+
+ try {
+ const fileData = await fetchFile(input[i]);
+ await ffmpeg.writeFile(fileName, fileData);
+ console.log(`Successfully wrote ${fileName}`);
+ } catch (fileError) {
+ console.error(`Failed to write ${fileName}:`, fileError);
+ throw new Error(`Failed to process input file ${i + 1}: ${fileError}`);
+ }
+ }
+
+ // Build the filter_complex string for concat filter
+ const videoInputs = fileNames.map((_, idx) => `[${idx}:v]`).join(' ');
+ const audioInputs = fileNames.map((_, idx) => `[${idx}:a]`).join(' ');
+ const filterComplex = `${videoInputs} ${audioInputs} concat=n=${input.length}:v=1:a=1 [v] [a]`;
+
+ // Build input arguments
+ const inputArgs = [];
+ for (const fileName of fileNames) {
+ inputArgs.push('-i', fileName);
+ }
+
+ console.log('Starting FFmpeg processing...');
+ console.log('Filter complex:', filterComplex);
+
+ // Method 2: Fallback to concat demuxer
+ try {
+ console.log('Trying concat demuxer method...');
+
+ const concatList = fileNames.map((name) => `file '${name}'`).join('\n');
+ await ffmpeg.writeFile(
+ 'concat_list.txt',
+ new TextEncoder().encode(concatList)
+ );
+
+ await ffmpeg.exec([
+ '-f',
+ 'concat',
+ '-safe',
+ '0',
+ '-i',
+ 'concat_list.txt',
+ '-c:v',
+ 'libx264',
+ '-preset',
+ 'ultrafast',
+ '-crf',
+ '30',
+ '-threads',
+ '0',
+ '-y',
+ outputName
+ ]);
+
+ // Check if output was created
+ try {
+ const testRead = await ffmpeg.readFile(outputName);
+ if (testRead && testRead.length > 0) {
+ console.log('Concat demuxer method succeeded');
+ }
+ } catch (readError) {
+ console.log('Concat demuxer method failed to produce output');
+ }
+ } catch (execError) {
+ console.error('Concat demuxer method failed:', execError);
+ }
+
+ // Check if output file exists and read it
+ let mergedData;
+ try {
+ mergedData = await ffmpeg.readFile(outputName);
+ console.log('Successfully read output file');
+ } catch (readError) {
+ console.error('Failed to read output file:', readError);
+ throw new Error('Failed to read merged video file');
+ }
+
+ if (!mergedData || mergedData.length === 0) {
+ throw new Error('Output file is empty or corrupted');
+ }
+
+ return new Blob([mergedData], { type: 'video/mp4' });
+ } catch (error) {
+ console.error('Error merging videos:', error);
+ throw error instanceof Error
+ ? error
+ : new Error('Unknown error occurred during video merge');
+ } finally {
+ // Clean up temporary files with better error handling
+ const filesToClean = [...fileNames, outputName, 'concat_list.txt'];
+
+ for (const fileName of filesToClean) {
+ try {
+ await ffmpeg.deleteFile(fileName);
+ } catch (cleanupError) {
+ // Ignore cleanup errors - they're not critical
+ console.warn(`Could not delete ${fileName}:`, cleanupError);
+ }
+ }
+ }
+}
diff --git a/src/pages/tools/video/merge-video/types.ts b/src/pages/tools/video/merge-video/types.ts
new file mode 100644
index 0000000..7cb6a52
--- /dev/null
+++ b/src/pages/tools/video/merge-video/types.ts
@@ -0,0 +1,9 @@
+export type InitialValuesType = {
+ // Add any future options here (e.g., output format, resolution)
+};
+
+// Type for the main function input
+export type MergeVideoInput = File[];
+
+// Type for the main function output
+export type MergeVideoOutput = Blob;