Skip to content

Commit 59174ae

Browse files
authored
Merge pull request #168 from AshAnand34/merge-video-tool
Merge video tool
2 parents 69d5a02 + 5d8a337 commit 59174ae

File tree

9 files changed

+485
-4
lines changed

9 files changed

+485
-4
lines changed

.idea/workspace.xml

Lines changed: 5 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

public/locales/en/video.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,12 @@
8383
"title": "What is a {{title}}?"
8484
}
8585
},
86+
"mergeVideo": {
87+
"description": "Combine multiple video files into one continuous video.",
88+
"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.",
89+
"shortDescription": "Append and merge videos easily.",
90+
"title": "Merge videos"
91+
},
8692
"rotate": {
8793
"180Degrees": "180° (Upside down)",
8894
"270Degrees": "270° (90° Counter-clockwise)",
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { ReactNode, useContext, useEffect, useRef, useState } from 'react';
2+
import { Box, useTheme } from '@mui/material';
3+
import Typography from '@mui/material/Typography';
4+
import InputHeader from '../InputHeader';
5+
import InputFooter from './InputFooter';
6+
import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext';
7+
import { isArray } from 'lodash';
8+
import VideoFileIcon from '@mui/icons-material/VideoFile';
9+
10+
interface MultiVideoInputComponentProps {
11+
accept: string[];
12+
title?: string;
13+
type: 'video';
14+
value: MultiVideoInput[];
15+
onChange: (file: MultiVideoInput[]) => void;
16+
}
17+
18+
export interface MultiVideoInput {
19+
file: File;
20+
order: number;
21+
}
22+
23+
export default function ToolMultipleVideoInput({
24+
value,
25+
onChange,
26+
accept,
27+
title,
28+
type
29+
}: MultiVideoInputComponentProps) {
30+
console.log('ToolMultipleVideoInput rendering with value:', value);
31+
32+
const fileInputRef = useRef<HTMLInputElement>(null);
33+
34+
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
35+
const files = event.target.files;
36+
console.log('File change event:', files);
37+
if (files)
38+
onChange([
39+
...value,
40+
...Array.from(files).map((file) => ({ file, order: value.length }))
41+
]);
42+
};
43+
44+
const handleImportClick = () => {
45+
console.log('Import clicked');
46+
fileInputRef.current?.click();
47+
};
48+
49+
function handleClear() {
50+
console.log('Clear clicked');
51+
onChange([]);
52+
}
53+
54+
function fileNameTruncate(fileName: string) {
55+
const maxLength = 15;
56+
if (fileName.length > maxLength) {
57+
return fileName.slice(0, maxLength) + '...';
58+
}
59+
return fileName;
60+
}
61+
62+
const sortList = () => {
63+
const list = [...value];
64+
list.sort((a, b) => a.order - b.order);
65+
onChange(list);
66+
};
67+
68+
const reorderList = (sourceIndex: number, destinationIndex: number) => {
69+
if (destinationIndex === sourceIndex) {
70+
return;
71+
}
72+
const list = [...value];
73+
74+
if (destinationIndex === 0) {
75+
list[sourceIndex].order = list[0].order - 1;
76+
sortList();
77+
return;
78+
}
79+
80+
if (destinationIndex === list.length - 1) {
81+
list[sourceIndex].order = list[list.length - 1].order + 1;
82+
sortList();
83+
return;
84+
}
85+
86+
if (destinationIndex < sourceIndex) {
87+
list[sourceIndex].order =
88+
(list[destinationIndex].order + list[destinationIndex - 1].order) / 2;
89+
sortList();
90+
return;
91+
}
92+
93+
list[sourceIndex].order =
94+
(list[destinationIndex].order + list[destinationIndex + 1].order) / 2;
95+
sortList();
96+
};
97+
98+
return (
99+
<Box>
100+
<InputHeader
101+
title={title || 'Input ' + type.charAt(0).toUpperCase() + type.slice(1)}
102+
/>
103+
<Box
104+
sx={{
105+
width: '100%',
106+
height: '300px',
107+
border: value?.length ? 0 : 1,
108+
borderRadius: 2,
109+
boxShadow: '5',
110+
bgcolor: 'background.paper',
111+
position: 'relative'
112+
}}
113+
>
114+
<Box
115+
width="100%"
116+
height="100%"
117+
sx={{
118+
overflow: 'auto',
119+
display: 'flex',
120+
alignItems: 'center',
121+
justifyContent: 'center',
122+
flexWrap: 'wrap',
123+
position: 'relative'
124+
}}
125+
>
126+
{value?.length ? (
127+
value.map((file, index) => (
128+
<Box
129+
key={index}
130+
sx={{
131+
margin: 1,
132+
display: 'flex',
133+
alignItems: 'center',
134+
justifyContent: 'space-between',
135+
width: '200px',
136+
border: 1,
137+
borderRadius: 1,
138+
padding: 1
139+
}}
140+
>
141+
<Box sx={{ display: 'flex', alignItems: 'center' }}>
142+
<VideoFileIcon />
143+
<Typography sx={{ marginLeft: 1 }}>
144+
{fileNameTruncate(file.file.name)}
145+
</Typography>
146+
</Box>
147+
<Box
148+
sx={{ cursor: 'pointer' }}
149+
onClick={() => {
150+
const updatedFiles = value.filter((_, i) => i !== index);
151+
onChange(updatedFiles);
152+
}}
153+
>
154+
155+
</Box>
156+
</Box>
157+
))
158+
) : (
159+
<Typography variant="body2" color="text.secondary">
160+
No files selected
161+
</Typography>
162+
)}
163+
</Box>
164+
</Box>
165+
166+
<InputFooter handleImport={handleImportClick} handleClear={handleClear} />
167+
<input
168+
ref={fileInputRef}
169+
style={{ display: 'none' }}
170+
type="file"
171+
accept={accept.join(',')}
172+
onChange={handleFileChange}
173+
multiple={true}
174+
/>
175+
</Box>
176+
);
177+
}

src/pages/tools/video/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { tool as videoMergeVideo } from './merge-video/meta';
12
import { tool as videoToGif } from './video-to-gif/meta';
23
import { tool as changeSpeed } from './change-speed/meta';
34
import { tool as flipVideo } from './flip/meta';
@@ -17,5 +18,6 @@ export const videoTools = [
1718
flipVideo,
1819
cropVideo,
1920
changeSpeed,
20-
videoToGif
21+
videoToGif,
22+
videoMergeVideo
2123
];
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { Box } from '@mui/material';
2+
import React, { useState } from 'react';
3+
import ToolContent from '@components/ToolContent';
4+
import { ToolComponentProps } from '@tools/defineTool';
5+
import ToolFileResult from '@components/result/ToolFileResult';
6+
import ToolMultipleVideoInput, {
7+
MultiVideoInput
8+
} from '@components/input/ToolMultipleVideoInput';
9+
import { mergeVideos } from './service';
10+
import { InitialValuesType } from './types';
11+
12+
const initialValues: InitialValuesType = {};
13+
14+
export default function MergeVideo({
15+
title,
16+
longDescription
17+
}: ToolComponentProps) {
18+
const [input, setInput] = useState<MultiVideoInput[]>([]);
19+
const [result, setResult] = useState<File | null>(null);
20+
const [loading, setLoading] = useState(false);
21+
22+
const compute = async (
23+
_values: InitialValuesType,
24+
input: MultiVideoInput[]
25+
) => {
26+
if (!input || input.length < 2) {
27+
return;
28+
}
29+
setLoading(true);
30+
try {
31+
const files = input.map((item) => item.file);
32+
const mergedBlob = await mergeVideos(files, initialValues);
33+
const mergedFile = new File([mergedBlob], 'merged-video.mp4', {
34+
type: 'video/mp4'
35+
});
36+
setResult(mergedFile);
37+
} catch (err) {
38+
setResult(null);
39+
} finally {
40+
setLoading(false);
41+
}
42+
};
43+
44+
return (
45+
<ToolContent
46+
title={title}
47+
input={input}
48+
inputComponent={
49+
<ToolMultipleVideoInput
50+
value={input}
51+
onChange={(newInput) => {
52+
setInput(newInput);
53+
}}
54+
accept={['video/*', '.mp4', '.avi', '.mov', '.mkv']}
55+
title="Input Videos"
56+
type="video"
57+
/>
58+
}
59+
resultComponent={
60+
<ToolFileResult
61+
value={result}
62+
title={loading ? 'Merging Videos...' : 'Merged Video'}
63+
loading={loading}
64+
extension={'mp4'}
65+
/>
66+
}
67+
initialValues={initialValues}
68+
getGroups={null}
69+
setInput={setInput}
70+
compute={compute}
71+
toolInfo={{ title: `What is a ${title}?`, description: longDescription }}
72+
/>
73+
);
74+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { expect, describe, it, vi } from 'vitest';
2+
3+
// Mock FFmpeg and fetchFile to avoid Node.js compatibility issues
4+
vi.mock('@ffmpeg/ffmpeg', () => ({
5+
FFmpeg: vi.fn().mockImplementation(() => ({
6+
loaded: false,
7+
load: vi.fn().mockResolvedValue(undefined),
8+
writeFile: vi.fn().mockResolvedValue(undefined),
9+
exec: vi.fn().mockResolvedValue(undefined),
10+
readFile: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3, 4])),
11+
deleteFile: vi.fn().mockResolvedValue(undefined)
12+
}))
13+
}));
14+
15+
vi.mock('@ffmpeg/util', () => ({
16+
fetchFile: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3, 4]))
17+
}));
18+
19+
// Import after mocking
20+
import { mergeVideos } from './service';
21+
22+
function createMockFile(name: string, type = 'video/mp4') {
23+
return new File([new Uint8Array([0, 1, 2])], name, { type });
24+
}
25+
26+
describe('merge-video', () => {
27+
it('throws if less than two files are provided', async () => {
28+
await expect(mergeVideos([], {})).rejects.toThrow(
29+
'Please provide at least two video files to merge.'
30+
);
31+
await expect(mergeVideos([createMockFile('a.mp4')], {})).rejects.toThrow(
32+
'Please provide at least two video files to merge.'
33+
);
34+
});
35+
36+
it('throws if input is not an array', async () => {
37+
// @ts-ignore - testing invalid input
38+
await expect(mergeVideos(null, {})).rejects.toThrow(
39+
'Please provide at least two video files to merge.'
40+
);
41+
});
42+
43+
it('successfully merges video files (mocked)', async () => {
44+
const mockFile1 = createMockFile('video1.mp4');
45+
const mockFile2 = createMockFile('video2.mp4');
46+
47+
const result = await mergeVideos([mockFile1, mockFile2], {});
48+
49+
expect(result).toBeInstanceOf(Blob);
50+
expect(result.type).toBe('video/mp4');
51+
});
52+
53+
it('handles different video formats by re-encoding', async () => {
54+
const mockFile1 = createMockFile('video1.avi', 'video/x-msvideo');
55+
const mockFile2 = createMockFile('video2.mov', 'video/quicktime');
56+
57+
const result = await mergeVideos([mockFile1, mockFile2], {});
58+
59+
expect(result).toBeInstanceOf(Blob);
60+
expect(result.type).toBe('video/mp4');
61+
});
62+
});
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { defineTool } from '@tools/defineTool';
2+
import { lazy } from 'react';
3+
4+
export const tool = defineTool('video', {
5+
path: 'merge-video',
6+
icon: 'fluent:merge-20-regular',
7+
keywords: ['merge', 'video', 'append', 'combine'],
8+
component: lazy(() => import('./index')),
9+
i18n: {
10+
name: 'video:mergeVideo.title',
11+
description: 'video:mergeVideo.description',
12+
shortDescription: 'video:mergeVideo.shortDescription',
13+
longDescription: 'video:mergeVideo.longDescription'
14+
}
15+
});

0 commit comments

Comments
 (0)