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