From de19febda7cd32d1079fedb746a6e305f44ec848 Mon Sep 17 00:00:00 2001 From: AshAnand34 Date: Mon, 7 Jul 2025 22:44:26 -0700 Subject: [PATCH 1/8] feat: add video merging tool with multiple video input component --- .../input/ToolMultipleVideoInput.tsx | 92 +++++++++++++++++++ src/pages/tools/video/index.ts | 4 +- src/pages/tools/video/merge-video/index.tsx | 64 +++++++++++++ .../merge-video/merge-video.service.test.ts | 20 ++++ src/pages/tools/video/merge-video/meta.ts | 14 +++ src/pages/tools/video/merge-video/service.ts | 53 +++++++++++ src/pages/tools/video/merge-video/types.ts | 9 ++ 7 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 src/components/input/ToolMultipleVideoInput.tsx create mode 100644 src/pages/tools/video/merge-video/index.tsx create mode 100644 src/pages/tools/video/merge-video/merge-video.service.test.ts create mode 100644 src/pages/tools/video/merge-video/meta.ts create mode 100644 src/pages/tools/video/merge-video/service.ts create mode 100644 src/pages/tools/video/merge-video/types.ts diff --git a/src/components/input/ToolMultipleVideoInput.tsx b/src/components/input/ToolMultipleVideoInput.tsx new file mode 100644 index 0000000..e3eede2 --- /dev/null +++ b/src/components/input/ToolMultipleVideoInput.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { Box, Typography, IconButton } from '@mui/material'; +import BaseFileInput from './BaseFileInput'; +import { BaseFileInputProps } from './file-input-utils'; +import DeleteIcon from '@mui/icons-material/Delete'; + +interface ToolMultipleVideoInputProps + extends Omit { + value: File[] | null; + onChange: (files: File[]) => void; + accept?: string[]; + title?: string; +} + +export default function ToolMultipleVideoInput({ + value, + onChange, + accept = ['video/*', '.mkv'], + title, + ...props +}: ToolMultipleVideoInputProps) { + // For preview, use the first file if available + const preview = + value && value.length > 0 ? URL.createObjectURL(value[0]) : undefined; + + // Handler for file selection + const handleFileChange = (file: File | null) => { + if (file) { + // Add to existing files, avoiding duplicates by name + const files = value ? [...value] : []; + if (!files.some((f) => f.name === file.name && f.size === file.size)) { + onChange([...files, file]); + } + } + }; + + // Handler for removing a file + const handleRemove = (idx: number) => { + if (!value) return; + const newFiles = value.slice(); + newFiles.splice(idx, 1); + onChange(newFiles); + }; + + return ( + + {() => ( + + {preview && ( + + )} + + ); +} 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..11f170b --- /dev/null +++ b/src/pages/tools/video/merge-video/index.tsx @@ -0,0 +1,64 @@ +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 from '@components/input/ToolMultipleVideoInput'; +import { main } from './service'; +import { InitialValuesType } from './types'; + +const initialValues: InitialValuesType = {}; + +export default function MergeVideo({ + title, + longDescription +}: ToolComponentProps) { + const [input, setInput] = useState(null); + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + + const compute = async (_values: InitialValuesType, input: File[] | null) => { + if (!input || input.length < 2) return; + setLoading(true); + try { + const mergedBlob = await main(input, initialValues); + const mergedFile = new File([mergedBlob], 'merged-video.mp4', { + type: 'video/mp4' + }); + setResult(mergedFile); + } catch (err) { + setResult(null); + } finally { + setLoading(false); + } + }; + + const getGroups = () => []; + + return ( + + } + resultComponent={ + + } + initialValues={initialValues} + getGroups={getGroups} + 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..6efe27d --- /dev/null +++ b/src/pages/tools/video/merge-video/merge-video.service.test.ts @@ -0,0 +1,20 @@ +import { expect, describe, it } from 'vitest'; +import { main } 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(main([], {})).rejects.toThrow(); + await expect(main([createMockFile('a.mp4')], {})).rejects.toThrow(); + }); + + it('merges two video files (mocked)', async () => { + // This will throw until ffmpeg logic is implemented + await expect( + main([createMockFile('a.mp4'), createMockFile('b.mp4')], {}) + ).rejects.toThrow('Video merging not yet implemented.'); + }); +}); 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..7f459c5 --- /dev/null +++ b/src/pages/tools/video/merge-video/meta.ts @@ -0,0 +1,14 @@ +import { defineTool } from '@tools/defineTool'; +import { lazy } from 'react'; + +export const tool = defineTool('video', { + name: 'Merge Videos', + path: 'merge-video', + icon: 'merge_type', // Material icon for merging + description: 'Combine multiple video files into one continuous video.', + shortDescription: 'Append and merge videos easily.', + keywords: ['merge', 'video', 'append', 'combine'], + 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.', + component: lazy(() => import('./index')) +}); 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..71569a5 --- /dev/null +++ b/src/pages/tools/video/merge-video/service.ts @@ -0,0 +1,53 @@ +import { InitialValuesType, MergeVideoInput, MergeVideoOutput } from './types'; +import { FFmpeg } from '@ffmpeg/ffmpeg'; +import { fetchFile } from '@ffmpeg/util'; + +const ffmpeg = new FFmpeg(); + +// This function will use ffmpeg.wasm to merge multiple video files in the browser. +// Returns a Promise that resolves to a Blob of the merged video. +export async function main( + input: MergeVideoInput, + options: InitialValuesType +): Promise { + if (!Array.isArray(input) || input.length < 2) { + throw new Error('Please provide at least two video files to merge.'); + } + + if (!ffmpeg.loaded) { + await ffmpeg.load({ + wasmURL: + 'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.9/dist/esm/ffmpeg-core.wasm' + }); + } + + // Write all input files to ffmpeg FS + const fileNames = input.map((file, idx) => `input${idx}.mp4`); + for (let i = 0; i < input.length; i++) { + await ffmpeg.writeFile(fileNames[i], await fetchFile(input[i])); + } + + // Create concat list file + const concatList = fileNames.map((name) => `file '${name}'`).join('\n'); + await ffmpeg.writeFile( + 'concat_list.txt', + new TextEncoder().encode(concatList) + ); + + // Run ffmpeg concat demuxer + const outputName = 'output.mp4'; + await ffmpeg.exec([ + '-f', + 'concat', + '-safe', + '0', + '-i', + 'concat_list.txt', + '-c', + 'copy', + outputName + ]); + + const mergedData = await ffmpeg.readFile(outputName); + return new Blob([mergedData], { type: 'video/mp4' }); +} 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; From 2c7fc0b2d0a6007677176572a56b0884b7e477b7 Mon Sep 17 00:00:00 2001 From: AshAnand34 Date: Mon, 7 Jul 2025 23:04:20 -0700 Subject: [PATCH 2/8] Added icon for video merging --- src/pages/tools/video/merge-video/meta.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/tools/video/merge-video/meta.ts b/src/pages/tools/video/merge-video/meta.ts index 7f459c5..97a038d 100644 --- a/src/pages/tools/video/merge-video/meta.ts +++ b/src/pages/tools/video/merge-video/meta.ts @@ -4,7 +4,7 @@ import { lazy } from 'react'; export const tool = defineTool('video', { name: 'Merge Videos', path: 'merge-video', - icon: 'merge_type', // Material icon for merging + icon: 'carbon:video-add', description: 'Combine multiple video files into one continuous video.', shortDescription: 'Append and merge videos easily.', keywords: ['merge', 'video', 'append', 'combine'], From f64e91472fe173cc561285bc0b55317fbd6bc0c3 Mon Sep 17 00:00:00 2001 From: "Ibrahima G. Coulibaly" Date: Tue, 8 Jul 2025 22:17:51 +0100 Subject: [PATCH 3/8] chore: change icon --- src/pages/tools/video/merge-video/meta.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/tools/video/merge-video/meta.ts b/src/pages/tools/video/merge-video/meta.ts index 97a038d..bbd90ec 100644 --- a/src/pages/tools/video/merge-video/meta.ts +++ b/src/pages/tools/video/merge-video/meta.ts @@ -4,7 +4,7 @@ import { lazy } from 'react'; export const tool = defineTool('video', { name: 'Merge Videos', path: 'merge-video', - icon: 'carbon:video-add', + icon: 'fluent:merge-20-regular', description: 'Combine multiple video files into one continuous video.', shortDescription: 'Append and merge videos easily.', keywords: ['merge', 'video', 'append', 'combine'], From 49b0ecb318c385e931b6c10639db735aaebde278 Mon Sep 17 00:00:00 2001 From: AshAnand34 Date: Fri, 11 Jul 2025 15:13:40 -0700 Subject: [PATCH 4/8] Fix rendering issue with ToolMultipleVideoInput as well as merge functionality. --- .../input/ToolMultipleVideoInput.tsx | 217 ++++++++++++------ src/pages/tools/video/merge-video/index.tsx | 39 +++- .../merge-video/merge-video.service.test.ts | 48 +++- src/pages/tools/video/merge-video/service.ts | 90 +++++--- 4 files changed, 276 insertions(+), 118 deletions(-) diff --git a/src/components/input/ToolMultipleVideoInput.tsx b/src/components/input/ToolMultipleVideoInput.tsx index e3eede2..9f42b8a 100644 --- a/src/components/input/ToolMultipleVideoInput.tsx +++ b/src/components/input/ToolMultipleVideoInput.tsx @@ -1,92 +1,177 @@ -import React from 'react'; -import { Box, Typography, IconButton } from '@mui/material'; -import BaseFileInput from './BaseFileInput'; -import { BaseFileInputProps } from './file-input-utils'; -import DeleteIcon from '@mui/icons-material/Delete'; +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 ToolMultipleVideoInputProps - extends Omit { - value: File[] | null; - onChange: (files: File[]) => void; - accept?: string[]; +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 = ['video/*', '.mkv'], + accept, title, - ...props -}: ToolMultipleVideoInputProps) { - // For preview, use the first file if available - const preview = - value && value.length > 0 ? URL.createObjectURL(value[0]) : undefined; + type +}: MultiVideoInputComponentProps) { + console.log('ToolMultipleVideoInput rendering with value:', value); - // Handler for file selection - const handleFileChange = (file: File | null) => { - if (file) { - // Add to existing files, avoiding duplicates by name - const files = value ? [...value] : []; - if (!files.some((f) => f.name === file.name && f.size === file.size)) { - onChange([...files, file]); - } - } + 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 })) + ]); }; - // Handler for removing a file - const handleRemove = (idx: number) => { - if (!value) return; - const newFiles = value.slice(); - newFiles.splice(idx, 1); - onChange(newFiles); + 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 ( - - {() => ( + + + - {preview && ( - + + + + + ); } diff --git a/src/pages/tools/video/merge-video/index.tsx b/src/pages/tools/video/merge-video/index.tsx index 11f170b..fb35c0a 100644 --- a/src/pages/tools/video/merge-video/index.tsx +++ b/src/pages/tools/video/merge-video/index.tsx @@ -3,7 +3,9 @@ import React, { useState } from 'react'; import ToolContent from '@components/ToolContent'; import { ToolComponentProps } from '@tools/defineTool'; import ToolFileResult from '@components/result/ToolFileResult'; -import ToolMultipleVideoInput from '@components/input/ToolMultipleVideoInput'; +import ToolMultipleVideoInput, { + MultiVideoInput +} from '@components/input/ToolMultipleVideoInput'; import { main } from './service'; import { InitialValuesType } from './types'; @@ -13,28 +15,42 @@ export default function MergeVideo({ title, longDescription }: ToolComponentProps) { - const [input, setInput] = useState(null); + const [input, setInput] = useState([]); const [result, setResult] = useState(null); const [loading, setLoading] = useState(false); - const compute = async (_values: InitialValuesType, input: File[] | null) => { - if (!input || input.length < 2) return; + console.log('MergeVideo component rendering, input:', input); + + const compute = async ( + _values: InitialValuesType, + input: MultiVideoInput[] + ) => { + console.log('Compute called with input:', input); + if (!input || input.length < 2) { + console.log('Not enough files to merge'); + return; + } setLoading(true); try { - const mergedBlob = await main(input, initialValues); + const files = input.map((item) => item.file); + console.log( + 'Files to merge:', + files.map((f) => f.name) + ); + const mergedBlob = await main(files, initialValues); const mergedFile = new File([mergedBlob], 'merged-video.mp4', { type: 'video/mp4' }); setResult(mergedFile); + console.log('Merge successful'); } catch (err) { + console.error(`Failed to merge video: ${err}`); setResult(null); } finally { setLoading(false); } }; - const getGroups = () => []; - return ( { + console.log('Input changed:', newInput); + setInput(newInput); + }} + accept={['video/*', '.mp4', '.avi', '.mov', '.mkv']} title="Input Videos" + type="video" /> } resultComponent={ @@ -55,7 +76,7 @@ export default function MergeVideo({ /> } initialValues={initialValues} - getGroups={getGroups} + 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 index 6efe27d..ee5a8b2 100644 --- a/src/pages/tools/video/merge-video/merge-video.service.test.ts +++ b/src/pages/tools/video/merge-video/merge-video.service.test.ts @@ -1,4 +1,22 @@ -import { expect, describe, it } from 'vitest'; +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 { main } from './service'; function createMockFile(name: string, type = 'video/mp4') { @@ -7,14 +25,28 @@ function createMockFile(name: string, type = 'video/mp4') { describe('merge-video', () => { it('throws if less than two files are provided', async () => { - await expect(main([], {})).rejects.toThrow(); - await expect(main([createMockFile('a.mp4')], {})).rejects.toThrow(); + await expect(main([], {})).rejects.toThrow( + 'Please provide at least two video files to merge.' + ); + await expect(main([createMockFile('a.mp4')], {})).rejects.toThrow( + 'Please provide at least two video files to merge.' + ); }); - it('merges two video files (mocked)', async () => { - // This will throw until ffmpeg logic is implemented - await expect( - main([createMockFile('a.mp4'), createMockFile('b.mp4')], {}) - ).rejects.toThrow('Video merging not yet implemented.'); + it('throws if input is not an array', async () => { + // @ts-ignore - testing invalid input + await expect(main(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 main([mockFile1, mockFile2], {}); + + expect(result).toBeInstanceOf(Blob); + expect(result.type).toBe('video/mp4'); }); }); diff --git a/src/pages/tools/video/merge-video/service.ts b/src/pages/tools/video/merge-video/service.ts index 71569a5..8a374d5 100644 --- a/src/pages/tools/video/merge-video/service.ts +++ b/src/pages/tools/video/merge-video/service.ts @@ -2,8 +2,6 @@ import { InitialValuesType, MergeVideoInput, MergeVideoOutput } from './types'; import { FFmpeg } from '@ffmpeg/ffmpeg'; import { fetchFile } from '@ffmpeg/util'; -const ffmpeg = new FFmpeg(); - // This function will use ffmpeg.wasm to merge multiple video files in the browser. // Returns a Promise that resolves to a Blob of the merged video. export async function main( @@ -14,40 +12,62 @@ export async function main( throw new Error('Please provide at least two video files to merge.'); } - if (!ffmpeg.loaded) { - await ffmpeg.load({ - wasmURL: - 'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.9/dist/esm/ffmpeg-core.wasm' - }); - } + // Create a new FFmpeg instance for each operation to avoid conflicts + const ffmpeg = new FFmpeg(); - // Write all input files to ffmpeg FS - const fileNames = input.map((file, idx) => `input${idx}.mp4`); - for (let i = 0; i < input.length; i++) { - await ffmpeg.writeFile(fileNames[i], await fetchFile(input[i])); - } - - // Create concat list file - const concatList = fileNames.map((name) => `file '${name}'`).join('\n'); - await ffmpeg.writeFile( - 'concat_list.txt', - new TextEncoder().encode(concatList) - ); - - // Run ffmpeg concat demuxer + const fileNames: string[] = []; const outputName = 'output.mp4'; - await ffmpeg.exec([ - '-f', - 'concat', - '-safe', - '0', - '-i', - 'concat_list.txt', - '-c', - 'copy', - outputName - ]); - const mergedData = await ffmpeg.readFile(outputName); - return new Blob([mergedData], { type: 'video/mp4' }); + try { + // Load FFmpeg + if (!ffmpeg.loaded) { + await ffmpeg.load({ + wasmURL: + 'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.9/dist/esm/ffmpeg-core.wasm' + }); + } + + // Write all input files to ffmpeg FS + fileNames.push(...input.map((file, idx) => `input${idx}.mp4`)); + for (let i = 0; i < input.length; i++) { + await ffmpeg.writeFile(fileNames[i], await fetchFile(input[i])); + } + + // Create concat list file + const concatList = fileNames.map((name) => `file '${name}'`).join('\n'); + await ffmpeg.writeFile( + 'concat_list.txt', + new TextEncoder().encode(concatList) + ); + + // Run ffmpeg concat demuxer + await ffmpeg.exec([ + '-f', + 'concat', + '-safe', + '0', + '-i', + 'concat_list.txt', + '-c', + 'copy', + outputName + ]); + + const mergedData = await ffmpeg.readFile(outputName); + return new Blob([mergedData], { type: 'video/mp4' }); + } catch (error) { + console.error('Error merging videos:', error); + throw new Error(`Failed to merge videos: ${error}`); + } finally { + // Clean up temporary files + try { + for (const fileName of fileNames) { + await ffmpeg.deleteFile(fileName); + } + await ffmpeg.deleteFile('concat_list.txt'); + await ffmpeg.deleteFile(outputName); + } catch (cleanupError) { + console.warn('Error cleaning up temporary files:', cleanupError); + } + } } From f730c0548e65ffe2437cb7e1f30a8d3ded604d5f Mon Sep 17 00:00:00 2001 From: AshAnand34 Date: Fri, 11 Jul 2025 15:15:39 -0700 Subject: [PATCH 5/8] Renaming service function to mergeVideos --- src/pages/tools/video/merge-video/index.tsx | 4 ++-- .../video/merge-video/merge-video.service.test.ts | 10 +++++----- src/pages/tools/video/merge-video/service.ts | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/pages/tools/video/merge-video/index.tsx b/src/pages/tools/video/merge-video/index.tsx index fb35c0a..3866b32 100644 --- a/src/pages/tools/video/merge-video/index.tsx +++ b/src/pages/tools/video/merge-video/index.tsx @@ -6,7 +6,7 @@ import ToolFileResult from '@components/result/ToolFileResult'; import ToolMultipleVideoInput, { MultiVideoInput } from '@components/input/ToolMultipleVideoInput'; -import { main } from './service'; +import { mergeVideos } from './service'; import { InitialValuesType } from './types'; const initialValues: InitialValuesType = {}; @@ -37,7 +37,7 @@ export default function MergeVideo({ 'Files to merge:', files.map((f) => f.name) ); - const mergedBlob = await main(files, initialValues); + const mergedBlob = await mergeVideos(files, initialValues); const mergedFile = new File([mergedBlob], 'merged-video.mp4', { type: 'video/mp4' }); 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 index ee5a8b2..ccc81e6 100644 --- a/src/pages/tools/video/merge-video/merge-video.service.test.ts +++ b/src/pages/tools/video/merge-video/merge-video.service.test.ts @@ -17,7 +17,7 @@ vi.mock('@ffmpeg/util', () => ({ })); // Import after mocking -import { main } from './service'; +import { mergeVideos } from './service'; function createMockFile(name: string, type = 'video/mp4') { return new File([new Uint8Array([0, 1, 2])], name, { type }); @@ -25,17 +25,17 @@ function createMockFile(name: string, type = 'video/mp4') { describe('merge-video', () => { it('throws if less than two files are provided', async () => { - await expect(main([], {})).rejects.toThrow( + await expect(mergeVideos([], {})).rejects.toThrow( 'Please provide at least two video files to merge.' ); - await expect(main([createMockFile('a.mp4')], {})).rejects.toThrow( + 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(main(null, {})).rejects.toThrow( + await expect(mergeVideos(null, {})).rejects.toThrow( 'Please provide at least two video files to merge.' ); }); @@ -44,7 +44,7 @@ describe('merge-video', () => { const mockFile1 = createMockFile('video1.mp4'); const mockFile2 = createMockFile('video2.mp4'); - const result = await main([mockFile1, mockFile2], {}); + 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/service.ts b/src/pages/tools/video/merge-video/service.ts index 8a374d5..c376681 100644 --- a/src/pages/tools/video/merge-video/service.ts +++ b/src/pages/tools/video/merge-video/service.ts @@ -4,7 +4,7 @@ import { fetchFile } from '@ffmpeg/util'; // This function will use ffmpeg.wasm to merge multiple video files in the browser. // Returns a Promise that resolves to a Blob of the merged video. -export async function main( +export async function mergeVideos( input: MergeVideoInput, options: InitialValuesType ): Promise { From 9fa493e27b2673ae44376f8ad9043b7f55071306 Mon Sep 17 00:00:00 2001 From: "Ibrahima G. Coulibaly" Date: Fri, 18 Jul 2025 02:34:32 +0100 Subject: [PATCH 6/8] fix: merge videos --- src/pages/tools/video/merge-video/index.tsx | 11 -- .../merge-video/merge-video.service.test.ts | 10 ++ src/pages/tools/video/merge-video/service.ts | 129 +++++++++++++----- 3 files changed, 105 insertions(+), 45 deletions(-) diff --git a/src/pages/tools/video/merge-video/index.tsx b/src/pages/tools/video/merge-video/index.tsx index 3866b32..8a7de65 100644 --- a/src/pages/tools/video/merge-video/index.tsx +++ b/src/pages/tools/video/merge-video/index.tsx @@ -19,32 +19,22 @@ export default function MergeVideo({ const [result, setResult] = useState(null); const [loading, setLoading] = useState(false); - console.log('MergeVideo component rendering, input:', input); - const compute = async ( _values: InitialValuesType, input: MultiVideoInput[] ) => { - console.log('Compute called with input:', input); if (!input || input.length < 2) { - console.log('Not enough files to merge'); return; } setLoading(true); try { const files = input.map((item) => item.file); - console.log( - 'Files to merge:', - files.map((f) => f.name) - ); const mergedBlob = await mergeVideos(files, initialValues); const mergedFile = new File([mergedBlob], 'merged-video.mp4', { type: 'video/mp4' }); setResult(mergedFile); - console.log('Merge successful'); } catch (err) { - console.error(`Failed to merge video: ${err}`); setResult(null); } finally { setLoading(false); @@ -59,7 +49,6 @@ export default function MergeVideo({ { - console.log('Input changed:', newInput); setInput(newInput); }} accept={['video/*', '.mp4', '.avi', '.mov', '.mkv']} 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 index ccc81e6..311c067 100644 --- a/src/pages/tools/video/merge-video/merge-video.service.test.ts +++ b/src/pages/tools/video/merge-video/merge-video.service.test.ts @@ -49,4 +49,14 @@ describe('merge-video', () => { 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/service.ts b/src/pages/tools/video/merge-video/service.ts index c376681..026c2b1 100644 --- a/src/pages/tools/video/merge-video/service.ts +++ b/src/pages/tools/video/merge-video/service.ts @@ -2,8 +2,6 @@ import { InitialValuesType, MergeVideoInput, MergeVideoOutput } from './types'; import { FFmpeg } from '@ffmpeg/ffmpeg'; import { fetchFile } from '@ffmpeg/util'; -// This function will use ffmpeg.wasm to merge multiple video files in the browser. -// Returns a Promise that resolves to a Blob of the merged video. export async function mergeVideos( input: MergeVideoInput, options: InitialValuesType @@ -19,55 +17,118 @@ export async function mergeVideos( const outputName = 'output.mp4'; try { - // Load FFmpeg + // 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' + '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 - fileNames.push(...input.map((file, idx) => `input${idx}.mp4`)); + // Write all input files to ffmpeg FS with better error handling for (let i = 0; i < input.length; i++) { - await ffmpeg.writeFile(fileNames[i], await fetchFile(input[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}`); + } } - // Create concat list file - const concatList = fileNames.map((name) => `file '${name}'`).join('\n'); - await ffmpeg.writeFile( - 'concat_list.txt', - new TextEncoder().encode(concatList) - ); + // 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]`; - // Run ffmpeg concat demuxer - await ffmpeg.exec([ - '-f', - 'concat', - '-safe', - '0', - '-i', - 'concat_list.txt', - '-c', - 'copy', - outputName - ]); + // 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'); + } - const mergedData = await ffmpeg.readFile(outputName); return new Blob([mergedData], { type: 'video/mp4' }); } catch (error) { console.error('Error merging videos:', error); - throw new Error(`Failed to merge videos: ${error}`); + throw error instanceof Error + ? error + : new Error('Unknown error occurred during video merge'); } finally { - // Clean up temporary files - try { - for (const fileName of fileNames) { + // 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); } - await ffmpeg.deleteFile('concat_list.txt'); - await ffmpeg.deleteFile(outputName); - } catch (cleanupError) { - console.warn('Error cleaning up temporary files:', cleanupError); } } } From ecf62b0059ff9d4255f4986a39b7f6962de40741 Mon Sep 17 00:00:00 2001 From: "Ibrahima G. Coulibaly" Date: Fri, 18 Jul 2025 02:42:11 +0100 Subject: [PATCH 7/8] chore: translate merge videos --- .idea/workspace.xml | 8 +++++--- public/locales/en/video.json | 6 ++++++ src/pages/tools/video/merge-video/meta.ts | 13 +++++++------ 3 files changed, 18 insertions(+), 9 deletions(-) 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..27a5b97 100644 --- a/public/locales/en/video.json +++ b/public/locales/en/video.json @@ -109,5 +109,11 @@ "description": "Convert video files to animated GIF format. Extract specific time ranges and create shareable animated images.", "shortDescription": "Convert video to animated GIF", "title": "Video to GIF" + }, + "mergeVideo": { + "description": "Combine multiple video files into one continuous video.", + "shortDescription": "Append and merge videos easily.", + "title": "Merge videos", + "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." } } diff --git a/src/pages/tools/video/merge-video/meta.ts b/src/pages/tools/video/merge-video/meta.ts index bbd90ec..d593f42 100644 --- a/src/pages/tools/video/merge-video/meta.ts +++ b/src/pages/tools/video/merge-video/meta.ts @@ -2,13 +2,14 @@ import { defineTool } from '@tools/defineTool'; import { lazy } from 'react'; export const tool = defineTool('video', { - name: 'Merge Videos', path: 'merge-video', icon: 'fluent:merge-20-regular', - description: 'Combine multiple video files into one continuous video.', - shortDescription: 'Append and merge videos easily.', keywords: ['merge', 'video', 'append', 'combine'], - 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.', - component: lazy(() => import('./index')) + component: lazy(() => import('./index')), + i18n: { + name: 'video:mergeVideo.title', + description: 'video:mergeVideo.description', + shortDescription: 'video:mergeVideo.shortDescription', + longDescription: 'video:mergeVideo.longDescription' + } }); From 5d8a3370cd4ab332a8b25edda711bf0406af73ca Mon Sep 17 00:00:00 2001 From: "Ibrahima G. Coulibaly" Date: Fri, 18 Jul 2025 02:45:05 +0100 Subject: [PATCH 8/8] chore: sync locize --- public/locales/en/video.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/public/locales/en/video.json b/public/locales/en/video.json index 27a5b97..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)", @@ -109,11 +115,5 @@ "description": "Convert video files to animated GIF format. Extract specific time ranges and create shareable animated images.", "shortDescription": "Convert video to animated GIF", "title": "Video to GIF" - }, - "mergeVideo": { - "description": "Combine multiple video files into one continuous video.", - "shortDescription": "Append and merge videos easily.", - "title": "Merge videos", - "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." } }