Merge branch 'main' into tools-filtering

This commit is contained in:
AshAnand34
2025-07-18 14:45:15 -07:00
336 changed files with 21767 additions and 2122 deletions

View File

@@ -9,6 +9,7 @@ import ToolFileResult from '@components/result/ToolFileResult';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile } from '@ffmpeg/util';
import { useTranslation } from 'react-i18next';
const initialValues: InitialValuesType = {
newSpeed: 2
@@ -18,6 +19,7 @@ export default function ChangeSpeed({
title,
longDescription
}: ToolComponentProps) {
const { t } = useTranslation('video');
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
@@ -128,13 +130,13 @@ export default function ChangeSpeed({
updateField
}) => [
{
title: 'New Video Speed',
title: t('changeSpeed.newVideoSpeed'),
component: (
<Box>
<TextFieldWithDesc
value={values.newSpeed.toString()}
onOwnChange={(val) => updateField('newSpeed', Number(val))}
description="Default multiplier: 2 means 2x faster"
description={t('changeSpeed.defaultMultiplier')}
type="number"
/>
</Box>
@@ -149,21 +151,32 @@ export default function ChangeSpeed({
<ToolVideoInput
value={input}
onChange={setInput}
title={'Input Video'}
title={t('changeSpeed.inputTitle')}
/>
}
resultComponent={
loading ? (
<ToolFileResult title="Setting Speed" value={null} loading={true} />
<ToolFileResult
title={t('changeSpeed.settingSpeed')}
value={null}
loading={true}
/>
) : (
<ToolFileResult title="Edited Video" value={result} extension="mp4" />
<ToolFileResult
title={t('changeSpeed.resultTitle')}
value={result}
extension="mp4"
/>
)
}
initialValues={initialValues}
getGroups={getGroups}
setInput={setInput}
compute={compute}
toolInfo={{ title: `What is a ${title}?`, description: longDescription }}
toolInfo={{
title: t('changeSpeed.toolInfo.title', { title }),
description: longDescription
}}
/>
);
}

View File

@@ -2,26 +2,15 @@ import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('video', {
name: 'Change video speed',
path: 'change-speed',
icon: 'material-symbols:speed',
description:
'Change the playback speed of video files. Speed up or slow down videos while maintaining audio pitch.',
shortDescription:
'Change the speed of video files (MP4, MOV, AVI) with audio control.',
keywords: [
'speed',
'video',
'tempo',
'pitch',
'mp4',
'mov',
'avi',
'video editing',
'playback'
],
longDescription:
'This tool allows you to change the playback speed of video files. You can speed up or slow down videos while maintaining the original audio pitch, or with pitch correction. Supports various video formats including MP4, MOV, and AVI. Perfect for creating time-lapse videos, slow-motion effects, or adjusting video speed for different purposes.',
userTypes: ['General Users', 'Students', 'Developers'],
component: lazy(() => import('./index'))
keywords: ['video', 'speed', 'playback', 'fast', 'slow'],
component: lazy(() => import('./index')),
i18n: {
name: 'video:changeSpeed.title',
description: 'video:changeSpeed.description',
shortDescription: 'video:changeSpeed.shortDescription',
userTypes: ['General Users', 'Students', 'Developers']
}
});

View File

@@ -11,6 +11,7 @@ import { compressVideo, VideoResolution } from './service';
import SimpleRadio from '@components/options/SimpleRadio';
import Slider from 'rc-slider';
import 'rc-slider/assets/index.css';
import { useTranslation } from 'react-i18next';
export const initialValues = {
width: 480 as VideoResolution,
@@ -68,6 +69,7 @@ const presetOptions = [
];
export default function CompressVideo({ title }: ToolComponentProps) {
const { t } = useTranslation('video');
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
@@ -100,7 +102,7 @@ export default function CompressVideo({ title }: ToolComponentProps) {
updateField
}) => [
{
title: 'Resolution',
title: t('compress.resolution'),
component: (
<Box>
{resolutionOptions.map((option) => (
@@ -117,7 +119,7 @@ export default function CompressVideo({ title }: ToolComponentProps) {
)
},
{
title: 'Quality (CRF)',
title: t('compress.quality'),
component: (
<Box sx={{ mb: 2 }}>
<Slider
@@ -129,9 +131,9 @@ export default function CompressVideo({ title }: ToolComponentProps) {
updateField('crf', typeof value === 'number' ? value : value[0]);
}}
marks={{
0: 'Lossless',
23: 'Default',
51: 'Worst'
0: t('compress.lossless'),
23: t('compress.default'),
51: t('compress.worst')
}}
/>
</Box>
@@ -160,16 +162,16 @@ export default function CompressVideo({ title }: ToolComponentProps) {
<ToolVideoInput
value={input}
onChange={setInput}
title={'Input Video'}
title={t('compress.inputTitle')}
/>
}
resultComponent={
<ToolFileResult
title={'Compressed Video'}
title={t('compress.resultTitle')}
value={result}
extension={'mp4'}
loading={loading}
loadingText={'Compressing video...'}
loadingText={t('compress.loadingText')}
/>
}
initialValues={initialValues}

View File

@@ -2,12 +2,9 @@ import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('video', {
name: 'Compress video',
path: 'compress',
icon: 'material-symbols:compress',
description:
'Reduce video file size while maintaining quality. Compress videos for easier sharing, uploading, or storage.',
shortDescription: 'Compress video files to reduce size (MP4, MOV, AVI).',
icon: 'icon-park-outline:compression',
keywords: [
'compress',
'video',
@@ -20,8 +17,11 @@ export const tool = defineTool('video', {
'video editing',
'shrink'
],
longDescription:
'This tool allows you to compress video files to reduce their size while maintaining acceptable quality. Useful for sharing videos via email, uploading to websites with size limits, or saving storage space. Supports various video formats including MP4, MOV, and AVI. You can adjust compression settings to balance file size and quality.',
userTypes: ['General Users', 'Students', 'Developers'],
component: lazy(() => import('./index'))
component: lazy(() => import('./index')),
i18n: {
name: 'video:compress.title',
description: 'video:compress.description',
shortDescription: 'video:compress.shortDescription',
userTypes: ['General Users', 'Students', 'Developers']
}
});

View File

@@ -1,13 +1,14 @@
import { Box, TextField, Typography, Alert } from '@mui/material';
import { useCallback, useState, useEffect } from 'react';
import ToolFileResult from '@components/result/ToolFileResult';
import { Box, Typography, TextField, Alert } from '@mui/material';
import React, { useState, useCallback } from 'react';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import { GetGroupsType } from '@components/options/ToolOptions';
import { debounce } from 'lodash';
import ToolVideoInput from '@components/input/ToolVideoInput';
import { cropVideo, getVideoDimensions } from './service';
import { InitialValuesType } from './types';
import ToolVideoInput from '@components/input/ToolVideoInput';
import { GetGroupsType } from '@components/options/ToolOptions';
import ToolFileResult from '@components/result/ToolFileResult';
import { debounce } from 'lodash';
import { useTranslation } from 'react-i18next';
const initialValues: InitialValuesType = {
x: 0,
@@ -17,6 +18,7 @@ const initialValues: InitialValuesType = {
};
export default function CropVideo({ title }: ToolComponentProps) {
const { t } = useTranslation('video');
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
@@ -30,19 +32,23 @@ export default function CropVideo({ title }: ToolComponentProps) {
if (!videoDimensions) return '';
if (values.x < 0 || values.y < 0) {
return 'X and Y coordinates must be non-negative';
return t('cropVideo.errorNonNegativeCoordinates');
}
if (values.width <= 0 || values.height <= 0) {
return 'Width and height must be positive';
return t('cropVideo.errorPositiveDimensions');
}
if (values.x + values.width > videoDimensions.width) {
return `Crop area extends beyond video width (${videoDimensions.width}px)`;
return t('cropVideo.errorBeyondWidth', {
width: videoDimensions.width
});
}
if (values.y + values.height > videoDimensions.height) {
return `Crop area extends beyond video height (${videoDimensions.height}px)`;
return t('cropVideo.errorBeyondHeight', {
height: videoDimensions.height
});
}
return '';
@@ -68,9 +74,7 @@ export default function CropVideo({ title }: ToolComponentProps) {
setResult(croppedFile);
} catch (error) {
console.error('Error cropping video:', error);
setProcessingError(
'Error cropping video. Please check parameters and video file.'
);
setProcessingError(t('cropVideo.errorCroppingVideo'));
} finally {
setLoading(false);
}
@@ -86,24 +90,26 @@ export default function CropVideo({ title }: ToolComponentProps) {
updateField
}) => [
{
title: 'Video Information',
title: t('cropVideo.videoInformation'),
component: (
<Box>
{videoDimensions ? (
<Typography variant="body2" sx={{ mb: 2 }}>
Video dimensions: {videoDimensions.width} ×{' '}
{videoDimensions.height} pixels
{t('cropVideo.videoDimensions', {
width: videoDimensions.width,
height: videoDimensions.height
})}
</Typography>
) : (
<Typography variant="body2" sx={{ mb: 2 }}>
Load a video to see dimensions
{t('cropVideo.loadVideoForDimensions')}
</Typography>
)}
</Box>
)
},
{
title: 'Crop Coordinates',
title: t('cropVideo.cropCoordinates'),
component: (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{processingError && (
@@ -113,7 +119,7 @@ export default function CropVideo({ title }: ToolComponentProps) {
)}
<Box sx={{ display: 'flex', gap: 2 }}>
<TextField
label="X (left)"
label={t('cropVideo.xCoordinate')}
type="number"
value={values.x}
onChange={(e) => updateField('x', parseInt(e.target.value) || 0)}
@@ -121,7 +127,7 @@ export default function CropVideo({ title }: ToolComponentProps) {
inputProps={{ min: 0 }}
/>
<TextField
label="Y (top)"
label={t('cropVideo.yCoordinate')}
type="number"
value={values.y}
onChange={(e) => updateField('y', parseInt(e.target.value) || 0)}
@@ -131,7 +137,7 @@ export default function CropVideo({ title }: ToolComponentProps) {
</Box>
<Box sx={{ display: 'flex', gap: 2 }}>
<TextField
label="Width"
label={t('cropVideo.width')}
type="number"
value={values.width}
onChange={(e) =>
@@ -141,7 +147,7 @@ export default function CropVideo({ title }: ToolComponentProps) {
inputProps={{ min: 1 }}
/>
<TextField
label="Height"
label={t('cropVideo.height')}
type="number"
value={values.height}
onChange={(e) =>
@@ -183,7 +189,7 @@ export default function CropVideo({ title }: ToolComponentProps) {
})
.catch((error) => {
console.error('Error getting video dimensions:', error);
setProcessingError('Failed to load video dimensions');
setProcessingError(t('cropVideo.errorLoadingDimensions'));
});
} else {
setVideoDimensions(null);
@@ -191,20 +197,20 @@ export default function CropVideo({ title }: ToolComponentProps) {
}
setInput(video);
}}
title={'Input Video'}
title={t('cropVideo.inputTitle')}
/>
)}
resultComponent={
loading ? (
<ToolFileResult
title={'Cropping Video'}
title={t('cropVideo.croppingVideo')}
value={null}
loading={true}
extension={''}
/>
) : (
<ToolFileResult
title={'Cropped Video'}
title={t('cropVideo.resultTitle')}
value={result}
extension={'mp4'}
/>

View File

@@ -2,13 +2,8 @@ import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('video', {
name: 'Crop video',
path: 'crop-video',
icon: 'material-symbols:crop',
description:
'Crop video files to remove unwanted areas or focus on specific parts. Adjust aspect ratios and remove black bars.',
shortDescription:
'Crop video files to remove unwanted areas (MP4, MOV, AVI).',
keywords: [
'crop',
'video',
@@ -20,8 +15,11 @@ export const tool = defineTool('video', {
'video editing',
'resize'
],
longDescription:
'This tool allows you to crop video files to remove unwanted areas or focus on specific parts of the video. Useful for removing black bars, adjusting aspect ratios, or focusing on important content. Supports various video formats including MP4, MOV, and AVI.',
userTypes: ['General Users', 'Students', 'Developers'],
i18n: {
name: 'video:cropVideo.title',
description: 'video:cropVideo.description',
shortDescription: 'video:cropVideo.shortDescription',
userTypes: ['General Users', 'Students', 'Developers']
},
component: lazy(() => import('./index'))
});

View File

@@ -10,6 +10,7 @@ import ToolVideoInput from '@components/input/ToolVideoInput';
import { flipVideo } from './service';
import { FlipOrientation, InitialValuesType } from './types';
import SimpleRadio from '@components/options/SimpleRadio';
import { useTranslation } from 'react-i18next';
export const initialValues: InitialValuesType = {
orientation: 'horizontal'
@@ -30,6 +31,7 @@ const orientationOptions: { value: FlipOrientation; label: string }[] = [
];
export default function FlipVideo({ title }: ToolComponentProps) {
const { t } = useTranslation('video');
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
@@ -58,13 +60,13 @@ export default function FlipVideo({ title }: ToolComponentProps) {
updateField
}) => [
{
title: 'Orientation',
title: t('flip.orientation'),
component: (
<Box>
{orientationOptions.map((orientationOption) => (
<SimpleRadio
key={orientationOption.value}
title={orientationOption.label}
title={t(`flip.${orientationOption.value}Label`)}
checked={values.orientation === orientationOption.value}
onClick={() => {
updateField('orientation', orientationOption.value);
@@ -84,20 +86,20 @@ export default function FlipVideo({ title }: ToolComponentProps) {
<ToolVideoInput
value={input}
onChange={setInput}
title={'Input Video'}
title={t('flip.inputTitle')}
/>
}
resultComponent={
loading ? (
<ToolFileResult
title={'Flipping Video'}
title={t('flip.flippingVideo')}
value={null}
loading={true}
extension={''}
/>
) : (
<ToolFileResult
title={'Flipped Video'}
title={t('flip.resultTitle')}
value={result}
extension={'mp4'}
/>

View File

@@ -2,27 +2,15 @@ import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('video', {
name: 'Flip video',
path: 'flip',
icon: 'material-symbols:flip',
description:
'Flip video files horizontally or vertically. Mirror videos or create flipped content for creative purposes.',
shortDescription:
'Flip video files horizontally or vertically (MP4, MOV, AVI).',
keywords: [
'flip',
'video',
'mirror',
'horizontal',
'vertical',
'mp4',
'mov',
'avi',
'video editing',
'transform'
],
longDescription:
'This tool allows you to flip video files horizontally or vertically. Useful for creating mirror effects, correcting incorrectly oriented videos, or creating creative content. Supports various video formats including MP4, MOV, and AVI.',
userTypes: ['General Users', 'Students', 'Developers'],
component: lazy(() => import('./index'))
keywords: ['video', 'flip', 'mirror', 'horizontal', 'vertical'],
component: lazy(() => import('./index')),
i18n: {
name: 'video:flip.title',
description: 'video:flip.description',
shortDescription: 'video:flip.shortDescription',
userTypes: ['General Users', 'Students', 'Developers']
}
});

View File

@@ -3,12 +3,14 @@ import { lazy } from 'react';
// import image from '@assets/text.png';
export const tool = defineTool('gif', {
name: 'Change speed',
path: 'change-speed',
icon: 'material-symbols-light:speed-outline',
description:
'This online utility lets you change the speed of a GIF animation. You can speed it up or slow it down. You can set the same constant delay between all frames or change the delays of individual frames. You can also play both the input and output GIFs at the same time and compare their speeds',
shortDescription: 'Quickly change GIF speed',
keywords: ['change', 'speed'],
component: lazy(() => import('./index'))
icon: 'material-symbols:speed',
keywords: ['gif', 'speed', 'animation', 'fast', 'slow'],
component: lazy(() => import('./index')),
i18n: {
name: 'video:gif.changeSpeed.title',
description: 'video:gif.changeSpeed.description',
shortDescription: 'video:gif.changeSpeed.shortDescription'
}
});

View File

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

View File

@@ -11,6 +11,7 @@ import ToolFileResult from '@components/result/ToolFileResult';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import { updateNumberField } from '@utils/string';
import * as Yup from 'yup';
import { useTranslation } from 'react-i18next';
const initialValues: InitialValuesType = {
loops: 2
@@ -21,6 +22,7 @@ const validationSchema = Yup.object({
});
export default function Loop({ title, longDescription }: ToolComponentProps) {
const { t } = useTranslation('video');
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
@@ -43,7 +45,7 @@ export default function Loop({ title, longDescription }: ToolComponentProps) {
updateField
}) => [
{
title: 'Loops',
title: t('loop.loops'),
component: (
<Box>
<TextFieldWithDesc
@@ -51,7 +53,7 @@ export default function Loop({ title, longDescription }: ToolComponentProps) {
updateNumberField(value, 'loops', updateField)
}
value={values.loops}
label={'Number of Loops'}
label={t('loop.numberOfLoops')}
/>
</Box>
)
@@ -66,14 +68,14 @@ export default function Loop({ title, longDescription }: ToolComponentProps) {
loading ? (
<ToolFileResult
value={null}
title={'Looping Video'}
title={t('loop.loopingVideo')}
loading={true}
extension={''}
/>
) : (
<ToolFileResult
value={result}
title={'Looped Video'}
title={t('loop.resultTitle')}
extension={'mp4'}
/>
)
@@ -83,7 +85,10 @@ export default function Loop({ title, longDescription }: ToolComponentProps) {
getGroups={getGroups}
setInput={setInput}
compute={compute}
toolInfo={{ title: `What is a ${title}?`, description: longDescription }}
toolInfo={{
title: t('loop.toolInfo.title', { title }),
description: longDescription
}}
/>
);
}

View File

@@ -2,26 +2,15 @@ import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('video', {
name: 'Loop video',
path: 'loop',
icon: 'material-symbols:loop',
description:
'Create looping videos by repeating the video content. Set the number of loops or create infinite loops.',
shortDescription:
'Create looping videos by repeating content (MP4, MOV, AVI).',
keywords: [
'loop',
'video',
'repeat',
'cycle',
'mp4',
'mov',
'avi',
'video editing',
'playback'
],
longDescription:
'This tool allows you to create looping videos by repeating the video content multiple times. You can set the number of loops or create infinite loops. Useful for creating background videos, animated content, or repeating sequences. Supports various video formats including MP4, MOV, and AVI.',
userTypes: ['General Users', 'Students', 'Developers'],
component: lazy(() => import('./index'))
keywords: ['video', 'loop', 'repeat', 'continuous'],
component: lazy(() => import('./index')),
i18n: {
name: 'video:loop.title',
description: 'video:loop.description',
shortDescription: 'video:loop.shortDescription',
userTypes: ['General Users', 'Students', 'Developers']
}
});

View File

@@ -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<MultiVideoInput[]>([]);
const [result, setResult] = useState<File | null>(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 (
<ToolContent
title={title}
input={input}
inputComponent={
<ToolMultipleVideoInput
value={input}
onChange={(newInput) => {
setInput(newInput);
}}
accept={['video/*', '.mp4', '.avi', '.mov', '.mkv']}
title="Input Videos"
type="video"
/>
}
resultComponent={
<ToolFileResult
value={result}
title={loading ? 'Merging Videos...' : 'Merged Video'}
loading={loading}
extension={'mp4'}
/>
}
initialValues={initialValues}
getGroups={null}
setInput={setInput}
compute={compute}
toolInfo={{ title: `What is a ${title}?`, description: longDescription }}
/>
);
}

View File

@@ -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');
});
});

View File

@@ -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'
}
});

View File

@@ -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<MergeVideoOutput> {
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);
}
}
}
}

View File

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

View File

@@ -10,6 +10,7 @@ import ToolVideoInput from '@components/input/ToolVideoInput';
import { rotateVideo } from './service';
import { RotationAngle } from '../../pdf/rotate-pdf/types';
import SimpleRadio from '@components/options/SimpleRadio';
import { useTranslation } from 'react-i18next';
export const initialValues = {
rotation: 90
@@ -27,6 +28,7 @@ const angleOptions: { value: RotationAngle; label: string }[] = [
{ value: 270, label: '270° (90° Counter-clockwise)' }
];
export default function RotateVideo({ title }: ToolComponentProps) {
const { t } = useTranslation('video');
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
@@ -55,13 +57,13 @@ export default function RotateVideo({ title }: ToolComponentProps) {
updateField
}) => [
{
title: 'Rotation',
title: t('rotate.rotation'),
component: (
<Box>
{angleOptions.map((angleOption) => (
<SimpleRadio
key={angleOption.value}
title={angleOption.label}
title={t(`rotate.${angleOption.value}Degrees`)}
checked={values.rotation === angleOption.value}
onClick={() => {
updateField('rotation', angleOption.value);
@@ -81,20 +83,20 @@ export default function RotateVideo({ title }: ToolComponentProps) {
<ToolVideoInput
value={input}
onChange={setInput}
title={'Input Video'}
title={t('rotate.inputTitle')}
/>
}
resultComponent={
loading ? (
<ToolFileResult
title={'Rotating Video'}
title={t('rotate.rotatingVideo')}
value={null}
loading={true}
extension={''}
/>
) : (
<ToolFileResult
title={'Rotated Video'}
title={t('rotate.resultTitle')}
value={result}
extension={'mp4'}
/>

View File

@@ -2,25 +2,15 @@ import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('video', {
name: 'Rotate video',
path: 'rotate',
icon: 'material-symbols:rotate-right',
description:
'Rotate video files by 90, 180, or 270 degrees. Fix incorrectly oriented videos or create rotated content.',
shortDescription:
'Rotate video files by 90, 180, or 270 degrees (MP4, MOV, AVI).',
keywords: [
'rotate',
'video',
'orientation',
'mp4',
'mov',
'avi',
'video editing',
'flip'
],
longDescription:
'This tool allows you to rotate video files by 90, 180, or 270 degrees. Useful for fixing incorrectly oriented videos (like those recorded with phones), creating rotated content, or adjusting video orientation for different platforms. Supports various video formats including MP4, MOV, and AVI.',
userTypes: ['General Users', 'Students', 'Developers'],
component: lazy(() => import('./index'))
keywords: ['video', 'rotate', 'orientation', 'degrees'],
component: lazy(() => import('./index')),
i18n: {
name: 'video:rotate.title',
description: 'video:rotate.description',
shortDescription: 'video:rotate.shortDescription',
userTypes: ['General Users', 'Students', 'Developers']
}
});

View File

@@ -11,6 +11,7 @@ import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile } from '@ffmpeg/util';
import { debounce } from 'lodash';
import ToolVideoInput from '@components/input/ToolVideoInput';
import { useTranslation } from 'react-i18next';
const ffmpeg = new FFmpeg();
@@ -28,6 +29,7 @@ const validationSchema = Yup.object({
});
export default function TrimVideo({ title }: ToolComponentProps) {
const { t } = useTranslation('video');
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
@@ -85,7 +87,7 @@ export default function TrimVideo({ title }: ToolComponentProps) {
updateField
}) => [
{
title: 'Timestamps',
title: t('trim.timestamps'),
component: (
<Box>
<TextFieldWithDesc
@@ -93,7 +95,7 @@ export default function TrimVideo({ title }: ToolComponentProps) {
updateNumberField(value, 'trimStart', updateField)
}
value={values.trimStart}
label={'Start Time'}
label={t('trim.startTime')}
sx={{ mb: 2, backgroundColor: 'background.paper' }}
/>
<TextFieldWithDesc
@@ -101,7 +103,7 @@ export default function TrimVideo({ title }: ToolComponentProps) {
updateNumberField(value, 'trimEnd', updateField)
}
value={values.trimEnd}
label={'End Time'}
label={t('trim.endTime')}
/>
</Box>
)
@@ -116,7 +118,7 @@ export default function TrimVideo({ title }: ToolComponentProps) {
<ToolVideoInput
value={input}
onChange={setInput}
title={'Input Video'}
title={t('trim.inputTitle')}
showTrimControls={true}
onTrimChange={(trimStart, trimEnd) => {
setFieldValue('trimStart', trimStart);
@@ -129,7 +131,7 @@ export default function TrimVideo({ title }: ToolComponentProps) {
}}
resultComponent={
<ToolFileResult
title={'Trimmed Video'}
title={t('trim.resultTitle')}
value={result}
extension={'mp4'}
/>

View File

@@ -2,27 +2,14 @@ import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('video', {
name: 'Trim video',
path: 'trim',
icon: 'material-symbols:content-cut',
description:
'Cut and trim video files to extract specific segments by specifying start and end times.',
shortDescription:
'Trim video files to extract specific time segments (MP4, MOV, AVI).',
keywords: [
'trim',
'video',
'cut',
'segment',
'extract',
'mp4',
'mov',
'avi',
'video editing',
'time'
],
longDescription:
'This tool allows you to trim video files by specifying start and end times. You can extract specific segments from longer videos, remove unwanted parts, or create shorter clips. Supports various video formats including MP4, MOV, and AVI. Perfect for video editing, content creation, or any video processing needs.',
userTypes: ['General Users', 'Students', 'Developers'],
component: lazy(() => import('./index'))
keywords: ['video', 'trim', 'cut', 'edit', 'time'],
component: lazy(() => import('./index')),
i18n: {
name: 'video:trim.title',
description: 'video:trim.description',
shortDescription: 'video:trim.shortDescription',
userTypes: ['General Users', 'Students', 'Developers']
}
});

View File

@@ -2,26 +2,14 @@ import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('video', {
name: 'Convert video to GIF',
path: 'video-to-gif',
icon: 'material-symbols:gif',
description:
'Convert video files to animated GIF format. Create animated GIFs from video clips with customizable settings.',
shortDescription:
'Convert video files to animated GIF format (MP4, MOV, AVI to GIF).',
keywords: [
'video',
'gif',
'convert',
'animated',
'mp4',
'mov',
'avi',
'video editing',
'animation'
],
longDescription:
'This tool allows you to convert video files to animated GIF format. You can create animated GIFs from video clips with customizable settings like frame rate, quality, and duration. Supports various video formats including MP4, MOV, and AVI. Perfect for creating animated content for social media, websites, or presentations.',
userTypes: ['General Users', 'Students', 'Developers'],
component: lazy(() => import('./index'))
keywords: ['video', 'gif', 'convert', 'animated', 'image'],
component: lazy(() => import('./index')),
i18n: {
name: 'video:videoToGif.title',
description: 'video:videoToGif.description',
shortDescription: 'video:videoToGif.shortDescription',
userTypes: ['General Users', 'Students', 'Developers']
}
});