mirror of
https://github.com/iib0011/omni-tools.git
synced 2025-12-29 16:16:02 +00:00
Merge branch 'main' into tools-filtering
This commit is contained in:
@@ -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
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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'}
|
||||
/>
|
||||
|
||||
@@ -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'))
|
||||
});
|
||||
|
||||
@@ -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'}
|
||||
/>
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
|
||||
];
|
||||
|
||||
@@ -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
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
});
|
||||
|
||||
74
src/pages/tools/video/merge-video/index.tsx
Normal file
74
src/pages/tools/video/merge-video/index.tsx
Normal 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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
15
src/pages/tools/video/merge-video/meta.ts
Normal file
15
src/pages/tools/video/merge-video/meta.ts
Normal 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'
|
||||
}
|
||||
});
|
||||
134
src/pages/tools/video/merge-video/service.ts
Normal file
134
src/pages/tools/video/merge-video/service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
9
src/pages/tools/video/merge-video/types.ts
Normal file
9
src/pages/tools/video/merge-video/types.ts
Normal 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;
|
||||
@@ -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'}
|
||||
/>
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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'}
|
||||
/>
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user