diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index aaa3fc4..b15d720 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -4,8 +4,23 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -335,14 +350,7 @@
-
-
-
- 1740464250449
-
-
-
- 1740464250449
+
@@ -728,7 +736,15 @@
1741548044897
-
+
+
+ 1741568170877
+
+
+
+ 1741568170877
+
+
@@ -775,7 +791,6 @@
-
@@ -800,7 +815,8 @@
-
+
+
diff --git a/package-lock.json b/package-lock.json
index 93905e8..e96d993 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,10 +10,14 @@
"dependencies": {
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5",
+ "@ffmpeg/core": "^0.12.10",
+ "@ffmpeg/ffmpeg": "^0.12.15",
+ "@ffmpeg/util": "^0.12.2",
"@jimp/types": "^1.6.0",
"@mui/icons-material": "^5.15.20",
"@mui/material": "^5.15.20",
"@playwright/test": "^1.45.0",
+ "@types/ffmpeg": "^1.0.7",
"@types/lodash": "^4.17.5",
"@types/morsee": "^1.0.2",
"@types/omggif": "^1.0.5",
@@ -33,6 +37,7 @@
"react-image-crop": "^11.0.7",
"react-router-dom": "^6.23.1",
"type-fest": "^4.35.0",
+ "use-deep-compare-effect": "^1.8.1",
"yup": "^1.4.0"
},
"devDependencies": {
@@ -1354,6 +1359,45 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
+ "node_modules/@ffmpeg/core": {
+ "version": "0.12.10",
+ "resolved": "https://registry.npmjs.org/@ffmpeg/core/-/core-0.12.10.tgz",
+ "integrity": "sha512-dzNplnn2Nxle2c2i2rrDhqcB19q9cglCkWnoMTDN9Q9l3PvdjZWd1HfSPjCNWc/p8Q3CT+Es9fWOR0UhAeYQZA==",
+ "license": "GPL-2.0-or-later",
+ "engines": {
+ "node": ">=16.x"
+ }
+ },
+ "node_modules/@ffmpeg/ffmpeg": {
+ "version": "0.12.15",
+ "resolved": "https://registry.npmjs.org/@ffmpeg/ffmpeg/-/ffmpeg-0.12.15.tgz",
+ "integrity": "sha512-1C8Obr4GsN3xw+/1Ww6PFM84wSQAGsdoTuTWPOj2OizsRDLT4CXTaVjPhkw6ARyDus1B9X/L2LiXHqYYsGnRFw==",
+ "license": "MIT",
+ "dependencies": {
+ "@ffmpeg/types": "^0.12.4"
+ },
+ "engines": {
+ "node": ">=18.x"
+ }
+ },
+ "node_modules/@ffmpeg/types": {
+ "version": "0.12.4",
+ "resolved": "https://registry.npmjs.org/@ffmpeg/types/-/types-0.12.4.tgz",
+ "integrity": "sha512-k9vJQNBGTxE5AhYDtOYR5rO5fKsspbg51gbcwtbkw2lCdoIILzklulcjJfIDwrtn7XhDeF2M+THwJ2FGrLeV6A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.x"
+ }
+ },
+ "node_modules/@ffmpeg/util": {
+ "version": "0.12.2",
+ "resolved": "https://registry.npmjs.org/@ffmpeg/util/-/util-0.12.2.tgz",
+ "integrity": "sha512-ouyoW+4JB7WxjeZ2y6KpRvB+dLp7Cp4ro8z0HIVpZVCM7AwFlHa0c4R8Y/a4M3wMqATpYKhC7lSFHQ0T11MEDw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.x"
+ }
+ },
"node_modules/@floating-ui/core": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.2.tgz",
@@ -2942,6 +2986,12 @@
"integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==",
"dev": true
},
+ "node_modules/@types/ffmpeg": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/@types/ffmpeg/-/ffmpeg-1.0.7.tgz",
+ "integrity": "sha512-7Pw61IDG9Tj+gXGNshJ7JIM2fhDe0IrK7/F+b8midmsiljiugWKbW5KoNmhtZS3pPXWVfVHP3wOwquF/wbVxiw==",
+ "license": "MIT"
+ },
"node_modules/@types/hoist-non-react-statics": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz",
@@ -4629,7 +4679,6 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
- "dev": true,
"engines": {
"node": ">=6"
}
@@ -10140,6 +10189,23 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/use-deep-compare-effect": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/use-deep-compare-effect/-/use-deep-compare-effect-1.8.1.tgz",
+ "integrity": "sha512-kbeNVZ9Zkc0RFGpfMN3MNfaKNvcLNyxOAAd9O4CBZ+kCBXXscn9s/4I+8ytUER4RDpEYs5+O6Rs4PqiZ+rHr5Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5",
+ "dequal": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=10",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "react": ">=16.13"
+ }
+ },
"node_modules/utif2": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/utif2/-/utif2-4.1.0.tgz",
diff --git a/package.json b/package.json
index e3f7b13..c63182d 100644
--- a/package.json
+++ b/package.json
@@ -27,10 +27,14 @@
"dependencies": {
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5",
+ "@ffmpeg/core": "^0.12.10",
+ "@ffmpeg/ffmpeg": "^0.12.15",
+ "@ffmpeg/util": "^0.12.2",
"@jimp/types": "^1.6.0",
"@mui/icons-material": "^5.15.20",
"@mui/material": "^5.15.20",
"@playwright/test": "^1.45.0",
+ "@types/ffmpeg": "^1.0.7",
"@types/lodash": "^4.17.5",
"@types/morsee": "^1.0.2",
"@types/omggif": "^1.0.5",
@@ -50,6 +54,7 @@
"react-image-crop": "^11.0.7",
"react-router-dom": "^6.23.1",
"type-fest": "^4.35.0",
+ "use-deep-compare-effect": "^1.8.1",
"yup": "^1.4.0"
},
"devDependencies": {
diff --git a/src/components/ToolContent.tsx b/src/components/ToolContent.tsx
index afd7ec5..1eb802f 100644
--- a/src/components/ToolContent.tsx
+++ b/src/components/ToolContent.tsx
@@ -1,10 +1,7 @@
-import React, { useRef, useState, ReactNode, useEffect } from 'react';
+import React, { useRef, ReactNode, useState } from 'react';
import { Box } from '@mui/material';
-import { FormikProps, FormikValues } from 'formik';
-import ToolOptions, {
- GetGroupsType,
- UpdateField
-} from '@components/options/ToolOptions';
+import { Formik, FormikProps, FormikValues } from 'formik';
+import ToolOptions, { GetGroupsType } from '@components/options/ToolOptions';
import ToolInputAndResult from '@components/ToolInputAndResult';
import ToolInfo from '@components/ToolInfo';
import Separator from '@components/Separator';
@@ -60,54 +57,53 @@ export default function ToolContent({
validationSchema,
renderCustomInput
}: ToolContentProps) {
- const formRef = useRef>(null);
-
- const [initialized, forceUpdate] = useState(0);
- useEffect(() => {
- if (formRef.current && !initialized) {
- forceUpdate((n) => n + 1);
- }
- }, [initialized]);
return (
-
-
-
+ onSubmit={() => {}}
+ >
+ {({ values, setFieldValue }) => {
+ return (
+ <>
+
- {toolInfo && toolInfo.title && toolInfo.description && (
-
- )}
+
- {exampleCards && exampleCards.length > 0 && (
- <>
-
-
- >
- )}
+ {toolInfo && toolInfo.title && toolInfo.description && (
+
+ )}
+
+ {exampleCards && exampleCards.length > 0 && (
+ <>
+
+
+ >
+ )}
+ >
+ );
+ }}
+
);
}
diff --git a/src/components/examples/ToolExamples.tsx b/src/components/examples/ToolExamples.tsx
index 84f562e..c1c00c6 100644
--- a/src/components/examples/ToolExamples.tsx
+++ b/src/components/examples/ToolExamples.tsx
@@ -2,7 +2,7 @@ import { Box, Grid, Stack, Typography } from '@mui/material';
import ExampleCard, { ExampleCardProps } from './ExampleCard';
import React from 'react';
import { GetGroupsType } from '@components/options/ToolOptions';
-import { FormikProps } from 'formik';
+import { useFormikContext } from 'formik';
export type CardExampleType = Omit<
ExampleCardProps,
@@ -14,7 +14,6 @@ export interface ExampleProps {
subtitle?: string;
exampleCards: CardExampleType[];
getGroups: GetGroupsType | null;
- formRef: React.RefObject>;
setInput?: React.Dispatch>;
}
@@ -23,12 +22,13 @@ export default function ToolExamples({
subtitle,
exampleCards,
getGroups,
- formRef,
setInput
}: ExampleProps) {
+ const { setValues } = useFormikContext();
+
function changeInputResult(newInput: string | undefined, newOptions: T) {
setInput?.(newInput);
- formRef.current?.setValues(newOptions);
+ setValues(newOptions);
const toolsElement = document.getElementById('tool');
if (toolsElement) {
toolsElement.scrollIntoView({ behavior: 'smooth' });
diff --git a/src/components/input/ToolFileInput.tsx b/src/components/input/ToolFileInput.tsx
index 10fb064..4cb7b88 100644
--- a/src/components/input/ToolFileInput.tsx
+++ b/src/components/input/ToolFileInput.tsx
@@ -22,6 +22,12 @@ interface ToolFileInputProps {
position: { x: number; y: number },
size: { width: number; height: number }
) => void;
+ type?: 'image' | 'video' | 'audio';
+ // Video specific props
+ showTrimControls?: boolean;
+ onTrimChange?: (trimStart: number, trimEnd: number) => void;
+ trimStart?: number;
+ trimEnd?: number;
}
export default function ToolFileInput({
@@ -33,15 +39,22 @@ export default function ToolFileInput({
cropShape = 'rectangular',
cropPosition = { x: 0, y: 0 },
cropSize = { width: 100, height: 100 },
- onCropChange
+ onCropChange,
+ type = 'image',
+ showTrimControls = false,
+ onTrimChange,
+ trimStart = 0,
+ trimEnd = 100
}: ToolFileInputProps) {
const [preview, setPreview] = useState(null);
const theme = useTheme();
const { showSnackBar } = useContext(CustomSnackBarContext);
const fileInputRef = useRef(null);
const imageRef = useRef(null);
+ const videoRef = useRef(null);
const [imgWidth, setImgWidth] = useState(0);
const [imgHeight, setImgHeight] = useState(0);
+ const [videoDuration, setVideoDuration] = useState(0);
// Convert position and size to crop format used by ReactCrop
const [crop, setCrop] = useState({
@@ -129,6 +142,17 @@ export default function ToolFileInput({
}
};
+ // Handle video load to set duration
+ const onVideoLoad = (e: React.SyntheticEvent) => {
+ const duration = e.currentTarget.duration;
+ setVideoDuration(duration);
+
+ // Initialize trim with full duration if needed
+ if (onTrimChange && trimStart === 0 && trimEnd === 100) {
+ onTrimChange(0, duration);
+ }
+ };
+
const handleCropChange = (newCrop: Crop) => {
setCrop(newCrop);
};
@@ -145,11 +169,20 @@ export default function ToolFileInput({
}
};
+ const handleTrimChange = (start: number, end: number) => {
+ if (onTrimChange) {
+ onTrimChange(start, end);
+ }
+ };
+
useEffect(() => {
const handlePaste = (event: ClipboardEvent) => {
const clipboardItems = event.clipboardData?.items ?? [];
const item = clipboardItems[0];
- if (item && item.type.includes('image')) {
+ if (
+ item &&
+ (item.type.includes('image') || item.type.includes('video'))
+ ) {
const file = item.getAsFile();
if (file) onChange(file);
}
@@ -161,6 +194,15 @@ export default function ToolFileInput({
};
}, [onChange]);
+ // Format seconds to MM:SS format
+ const formatTime = (seconds: number) => {
+ const minutes = Math.floor(seconds / 60);
+ const remainingSeconds = Math.floor(seconds % 60);
+ return `${minutes.toString().padStart(2, '0')}:${remainingSeconds
+ .toString()
+ .padStart(2, '0')}`;
+ };
+
return (
@@ -188,14 +230,24 @@ export default function ToolFileInput({
overflow: 'hidden'
}}
>
- {showCropOverlay ? (
-
+ {type === 'image' &&
+ (showCropOverlay ? (
+
+
+
+ ) : (
-
- ) : (
-
+
+
+ {showTrimControls && videoDuration > 0 && (
+
+
+
+ Start: {formatTime(trimStart || 0)}
+
+
+ End: {formatTime(trimEnd || videoDuration)}
+
+
+
+
+ handleTrimChange(
+ parseFloat(e.target.value),
+ trimEnd || videoDuration
+ )
+ }
+ style={{ flex: 1 }}
+ />
+
+ handleTrimChange(
+ trimStart || 0,
+ parseFloat(e.target.value)
+ )
+ }
+ style={{ flex: 1 }}
+ />
+
+
+ )}
+
+ )}
+ {type === 'audio' && (
+
)}
@@ -228,8 +364,8 @@ export default function ToolFileInput({
}}
>
- Click here to select an image from your device, press Ctrl+V to
- use an image from your clipboard, drag and drop a file from
+ Click here to select a {type} from your device, press Ctrl+V to
+ use a {type} from your clipboard, drag and drop a file from
desktop
diff --git a/src/components/options/ToolOptions.tsx b/src/components/options/ToolOptions.tsx
index 1125df6..a0f62d6 100644
--- a/src/components/options/ToolOptions.tsx
+++ b/src/components/options/ToolOptions.tsx
@@ -1,99 +1,62 @@
import { Box, Stack, useTheme } from '@mui/material';
import SettingsIcon from '@mui/icons-material/Settings';
import Typography from '@mui/material/Typography';
-import React, { ReactNode, RefObject, useContext, useEffect } from 'react';
-import { Formik, FormikProps, FormikValues, useFormikContext } from 'formik';
+import React, { ReactNode, useContext } from 'react';
+import { FormikProps, FormikValues, useFormikContext } from 'formik';
import ToolOptionGroups, { ToolOptionGroup } from './ToolOptionGroups';
import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext';
export type UpdateField = (field: Y, value: T[Y]) => void;
const FormikListenerComponent = ({
- initialValues,
input,
compute
}: {
- initialValues: T;
input: any;
compute: (optionsValues: T, input: any) => void;
}) => {
- const { values } = useFormikContext();
+ const { values } = useFormikContext();
const { showSnackBar } = useContext(CustomSnackBarContext);
- useEffect(() => {
+ React.useEffect(() => {
try {
compute(values, input);
} catch (exception: unknown) {
if (exception instanceof Error) showSnackBar(exception.message, 'error');
else console.error(exception);
}
- }, [values, input]);
+ }, [values, input, showSnackBar]);
return null; // This component doesn't render anything
};
-interface FormikHelperProps {
- compute: (optionsValues: T, input: any) => void;
- input: any;
- children?: ReactNode;
- getGroups:
- | null
- | ((
- formikProps: FormikProps & { updateField: UpdateField }
- ) => ToolOptionGroup[]);
- formikProps: FormikProps;
-}
-
-const ToolBody = ({
- compute,
- input,
- children,
- getGroups,
- formikProps
-}: FormikHelperProps) => {
- const { values, setFieldValue } = useFormikContext();
-
- const updateField: UpdateField = (field, value) => {
- // @ts-ignore
- setFieldValue(field, value);
- };
-
- return (
-
-
- compute={compute}
- input={input}
- initialValues={values}
- />
-
- {children}
-
- );
-};
-
export type GetGroupsType = (
formikProps: FormikProps & { updateField: UpdateField }
) => ToolOptionGroup[];
+
export default function ToolOptions({
children,
- initialValues,
- validationSchema,
compute,
input,
- getGroups,
- formRef
+ getGroups
}: {
children?: ReactNode;
- initialValues: T;
- validationSchema?: any | (() => any);
compute: (optionsValues: T, input: any) => void;
input?: any;
getGroups: GetGroupsType | null;
- formRef?: RefObject>;
}) {
const theme = useTheme();
+ const formikContext = useFormikContext();
+
+ // Early return if no groups to display
+ if (!getGroups) {
+ return null;
+ }
+
+ const updateField: UpdateField = (field, value) => {
+ formikContext.setFieldValue(field as string, value);
+ };
+
return (
({
borderRadius: 2,
padding: 2,
backgroundColor: theme.palette.background.default,
- boxShadow: '2',
- display: getGroups ? 'block' : 'none'
+ boxShadow: '2'
}}
mt={2}
>
@@ -111,23 +73,13 @@ export default function ToolOptions({
Tool options
- {}}
- >
- {(formikProps) => (
-
- {children}
-
- )}
-
+
+ compute={compute} input={input} />
+
+ {children}
+
);
diff --git a/src/components/result/ToolFileResult.tsx b/src/components/result/ToolFileResult.tsx
index e9b7ca6..d39afeb 100644
--- a/src/components/result/ToolFileResult.tsx
+++ b/src/components/result/ToolFileResult.tsx
@@ -58,6 +58,18 @@ export default function ToolFileResult({
window.URL.revokeObjectURL(url);
}
};
+
+ // Determine the file type based on MIME type
+ const getFileType = () => {
+ if (!value) return 'unknown';
+ if (value.type.startsWith('image/')) return 'image';
+ if (value.type.startsWith('video/')) return 'video';
+ if (value.type.startsWith('audio/')) return 'audio';
+ return 'unknown';
+ };
+
+ const fileType = getFileType();
+
return (
@@ -82,11 +94,32 @@ export default function ToolFileResult({
backgroundImage: `url(${greyPattern})`
}}
>
-
+ {fileType === 'image' && (
+
+ )}
+ {fileType === 'video' && (
+
+ )}
+ {fileType === 'audio' && (
+
+ )}
+ {fileType === 'unknown' && (
+
+ File processed successfully. Click download to save the result.
+
+ )}
)}
diff --git a/src/pages/tools/list/shuffle/shuffle.service.test.ts b/src/pages/tools/list/shuffle/shuffle.service.test.ts
index 6b09828..4508c9b 100644
--- a/src/pages/tools/list/shuffle/shuffle.service.test.ts
+++ b/src/pages/tools/list/shuffle/shuffle.service.test.ts
@@ -31,7 +31,6 @@ describe('shuffle function', () => {
joinSeparator,
length
);
- console.log(result);
expect(result.split(joinSeparator).length).toBe(2);
});
@@ -49,7 +48,6 @@ describe('shuffle function', () => {
joinSeparator,
length
);
- console.log(result);
expect(result.split(joinSeparator).length).toBe(4);
});
@@ -66,7 +64,6 @@ describe('shuffle function', () => {
joinSeparator,
length
);
- console.log(result);
expect(result.split(joinSeparator)).toContain('apple');
});
@@ -83,7 +80,6 @@ describe('shuffle function', () => {
joinSeparator,
length
);
- console.log(result);
expect(result).toBe('');
});
});
diff --git a/src/pages/tools/string/to-morse/index.tsx b/src/pages/tools/string/to-morse/index.tsx
index aae17f1..866fb93 100644
--- a/src/pages/tools/string/to-morse/index.tsx
+++ b/src/pages/tools/string/to-morse/index.tsx
@@ -13,7 +13,6 @@ const initialValues = {
export default function ToMorse() {
const [input, setInput] = useState('');
const [result, setResult] = useState('');
- // const formRef = useRef>(null);
const computeOptions = (optionsValues: typeof initialValues, input: any) => {
const { dotSymbol, dashSymbol } = optionsValues;
setResult(compute(input, dotSymbol, dashSymbol));
diff --git a/src/pages/tools/video/index.ts b/src/pages/tools/video/index.ts
index db8a652..bbcc497 100644
--- a/src/pages/tools/video/index.ts
+++ b/src/pages/tools/video/index.ts
@@ -1,3 +1,4 @@
import { gifTools } from './gif';
+import { tool as trimVideo } from './trim/meta';
-export const videoTools = [...gifTools];
+export const videoTools = [...gifTools, trimVideo];
diff --git a/src/pages/tools/video/trim/index.tsx b/src/pages/tools/video/trim/index.tsx
new file mode 100644
index 0000000..2c3e2ac
--- /dev/null
+++ b/src/pages/tools/video/trim/index.tsx
@@ -0,0 +1,143 @@
+import { Box } from '@mui/material';
+import React, { useCallback, useEffect, useState } from 'react';
+import * as Yup from 'yup';
+import ToolFileInput from '@components/input/ToolFileInput';
+import ToolFileResult from '@components/result/ToolFileResult';
+import ToolContent from '@components/ToolContent';
+import { ToolComponentProps } from '@tools/defineTool';
+import { GetGroupsType } from '@components/options/ToolOptions';
+import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
+import { updateNumberField } from '@utils/string';
+import { FFmpeg } from '@ffmpeg/ffmpeg';
+import { fetchFile } from '@ffmpeg/util';
+import { debounce } from 'lodash';
+
+const ffmpeg = new FFmpeg();
+
+const initialValues = {
+ trimStart: 0,
+ trimEnd: 100
+};
+
+const validationSchema = Yup.object({
+ trimStart: Yup.number().min(0, 'Start time must be positive'),
+ trimEnd: Yup.number().min(
+ Yup.ref('trimStart'),
+ 'End time must be greater than start time'
+ )
+});
+
+export default function TrimVideo({ title }: ToolComponentProps) {
+ const [input, setInput] = useState(null);
+ const [result, setResult] = useState(null);
+
+ const compute = async (
+ optionsValues: typeof initialValues,
+ input: File | null
+ ) => {
+ console.log('compute', optionsValues, input);
+ if (!input) return;
+
+ const { trimStart, trimEnd } = optionsValues;
+
+ try {
+ if (!ffmpeg.loaded) {
+ await ffmpeg.load();
+ }
+
+ const inputName = 'input.mp4';
+ const outputName = 'output.mp4';
+ // Load file into FFmpeg's virtual filesystem
+ await ffmpeg.writeFile(inputName, await fetchFile(input));
+ // Run FFmpeg command to trim video
+ await ffmpeg.exec([
+ '-i',
+ inputName,
+ '-ss',
+ trimStart.toString(),
+ '-to',
+ trimEnd.toString(),
+ '-c',
+ 'copy',
+ outputName
+ ]);
+ // Retrieve the processed file
+ const trimmedData = await ffmpeg.readFile(outputName);
+ const trimmedBlob = new Blob([trimmedData], { type: 'video/mp4' });
+ const trimmedFile = new File(
+ [trimmedBlob],
+ `${input.name.replace(/\.[^/.]+$/, '')}_trimmed.mp4`,
+ {
+ type: 'video/mp4'
+ }
+ );
+
+ setResult(trimmedFile);
+ } catch (error) {
+ console.error('Error trimming video:', error);
+ }
+ };
+ const debouncedCompute = useCallback(debounce(compute, 1000), []);
+ const getGroups: GetGroupsType = ({
+ values,
+ updateField
+ }) => [
+ {
+ title: 'Timestamps',
+ component: (
+
+
+ updateNumberField(value, 'trimStart', updateField)
+ }
+ value={values.trimStart}
+ label={'Start Time'}
+ />
+
+ updateNumberField(value, 'trimEnd', updateField)
+ }
+ value={values.trimEnd}
+ label={'End Time'}
+ />
+
+ )
+ }
+ ];
+ return (
+ {
+ return (
+ {
+ setFieldValue('trimStart', trimStart);
+ setFieldValue('trimEnd', trimEnd);
+ }}
+ trimStart={trimStart}
+ trimEnd={trimEnd}
+ />
+ );
+ }}
+ resultComponent={
+
+ }
+ initialValues={initialValues}
+ getGroups={getGroups}
+ compute={debouncedCompute}
+ setInput={setInput}
+ validationSchema={validationSchema}
+ />
+ );
+}
diff --git a/src/pages/tools/video/trim/meta.ts b/src/pages/tools/video/trim/meta.ts
new file mode 100644
index 0000000..082ae0a
--- /dev/null
+++ b/src/pages/tools/video/trim/meta.ts
@@ -0,0 +1,13 @@
+import { defineTool } from '@tools/defineTool';
+import { lazy } from 'react';
+
+export const tool = defineTool('video', {
+ name: 'Trim Video',
+ path: 'trim',
+ icon: 'mdi:scissors',
+ description:
+ 'This online utility lets you trim videos by setting start and end points. You can preview the trimmed section before processing. Supports common video formats like MP4, WebM, and OGG.',
+ shortDescription: 'Trim videos by setting start and end points',
+ keywords: ['trim', 'cut', 'video', 'clip', 'edit'],
+ component: lazy(() => import('./index'))
+});
diff --git a/src/tools/defineTool.tsx b/src/tools/defineTool.tsx
index 98bcfff..d86387e 100644
--- a/src/tools/defineTool.tsx
+++ b/src/tools/defineTool.tsx
@@ -18,6 +18,7 @@ export type ToolCategory =
| 'png'
| 'number'
| 'gif'
+ | 'video'
| 'list'
| 'json'
| 'csv';
diff --git a/src/tools/index.ts b/src/tools/index.ts
index 5031000..15d7b66 100644
--- a/src/tools/index.ts
+++ b/src/tools/index.ts
@@ -67,6 +67,12 @@ const categoriesConfig: {
icon: 'material-symbols-light:csv-outline',
value:
'Tools for working with CSV files - convert CSV to different formats, manipulate CSV data, validate CSV structure, and process CSV files efficiently.'
+ },
+ {
+ type: 'video',
+ icon: 'lets-icons:video-light',
+ value:
+ 'Tools for working with videos – extract frames from videos, create GIFs from videos, convert videos to different formats, and much more.'
}
];
export const filterTools = (
diff --git a/vite.config.ts b/vite.config.ts
index 0597438..8115376 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -6,6 +6,9 @@ import tsconfigPaths from 'vite-tsconfig-paths';
// https://vitejs.dev/config https://vitest.dev/config
export default defineConfig({
plugins: [react(), tsconfigPaths()],
+ optimizeDeps: {
+ exclude: ['@ffmpeg/ffmpeg', '@ffmpeg/util']
+ },
test: {
globals: true,
environment: 'happy-dom',