diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index 0e86a3c..e536cf3 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -5,9 +5,13 @@
+
+
+
+
-
-
+
+
@@ -24,7 +28,7 @@
@@ -159,56 +163,57 @@
- {
- "keyToString": {
- "ASKED_ADD_EXTERNAL_FILES": "true",
- "ASKED_SHARE_PROJECT_CONFIGURATION_FILES": "true",
- "Docker.Dockerfile build.executor": "Run",
- "Docker.Dockerfile.executor": "Run",
- "Playwright.Create transparent PNG.should make png color transparent.executor": "Run",
- "Playwright.JoinText Component.executor": "Run",
- "Playwright.JoinText Component.should merge text pieces with specified join character.executor": "Run",
- "RunOnceActivity.OpenProjectViewOnStart": "true",
- "RunOnceActivity.ShowReadmeOnStart": "true",
- "RunOnceActivity.git.unshallow": "true",
- "Vitest.compute function (1).executor": "Run",
- "Vitest.compute function.executor": "Run",
- "Vitest.mergeText.executor": "Run",
- "Vitest.mergeText.should merge lines and preserve blank lines when deleteBlankLines is false.executor": "Run",
- "Vitest.mergeText.should merge lines, preserve blank lines and trailing spaces when both deleteBlankLines and deleteTrailingSpaces are false.executor": "Run",
- "Vitest.parsePageRanges.executor": "Run",
- "Vitest.removeDuplicateLines function.executor": "Run",
- "Vitest.removeDuplicateLines function.newlines option.executor": "Run",
- "Vitest.removeDuplicateLines function.newlines option.should filter newlines when newlines is set to filter.executor": "Run",
- "Vitest.replaceText function (regexp mode).should return the original text when passed an invalid regexp.executor": "Run",
- "Vitest.replaceText function.executor": "Run",
- "Vitest.timeBetweenDates.executor": "Run",
- "git-widget-placeholder": "main",
- "ignore.virus.scanning.warn.message": "true",
- "kotlin-language-version-configured": "true",
- "last_opened_file_path": "C:/Users/Ibrahima/IdeaProjects/omni-tools/src",
- "node.js.detected.package.eslint": "true",
- "node.js.detected.package.tslint": "true",
- "node.js.selected.package.eslint": "(autodetect)",
- "node.js.selected.package.tslint": "(autodetect)",
- "nodejs_package_manager_path": "npm",
- "npm.build.executor": "Run",
- "npm.dev.executor": "Run",
- "npm.lint.executor": "Run",
- "npm.prebuild.executor": "Run",
- "npm.script:create:tool.executor": "Run",
- "npm.test.executor": "Run",
- "npm.test:e2e.executor": "Run",
- "npm.test:e2e:run.executor": "Run",
- "prettierjs.PrettierConfiguration.Package": "C:\\Users\\Ibrahima\\IdeaProjects\\omni-tools\\node_modules\\prettier",
- "project.structure.last.edited": "Problems",
- "project.structure.proportion": "0.0",
- "project.structure.side.proportion": "0.2",
- "settings.editor.selected.configurable": "refactai_advanced_settings",
- "ts.external.directory.path": "C:\\Users\\Ibrahima\\IdeaProjects\\omni-tools\\node_modules\\typescript\\lib",
- "vue.rearranger.settings.migration": "true"
+
+}]]>
-
-
+
+
-
+
-
+
@@ -306,18 +311,18 @@
-
+
+
-
diff --git a/src/components/ToolContent.tsx b/src/components/ToolContent.tsx
index 2e3a6b6..83a2767 100644
--- a/src/components/ToolContent.tsx
+++ b/src/components/ToolContent.tsx
@@ -30,7 +30,7 @@ const FormikListenerComponent = ({
if (exception instanceof Error) showSnackBar(exception.message, 'error');
else console.error(exception);
}
- }, [values, input, showSnackBar]);
+ }, [compute, values, input, showSnackBar]);
useEffect(() => {
onValuesChange?.(values);
diff --git a/src/pages/tools/video/crop-video/crop-video.service.test.ts b/src/pages/tools/video/crop-video/crop-video.service.test.ts
new file mode 100644
index 0000000..6d553ff
--- /dev/null
+++ b/src/pages/tools/video/crop-video/crop-video.service.test.ts
@@ -0,0 +1,86 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { cropVideo } from './service';
+
+// Mock FFmpeg
+vi.mock('@ffmpeg/ffmpeg', () => {
+ return {
+ FFmpeg: vi.fn().mockImplementation(() => {
+ return {
+ 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]))
+ };
+ })
+ };
+});
+
+// Mock fetchFile
+vi.mock('@ffmpeg/util', () => {
+ return {
+ fetchFile: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3]))
+ };
+});
+
+describe('crop-video', () => {
+ let mockFile: File;
+ let mockVideoInfo: { width: number; height: number };
+
+ beforeEach(() => {
+ mockFile = new File(['test'], 'test.mp4', { type: 'video/mp4' });
+ mockVideoInfo = { width: 1280, height: 720 };
+
+ // Reset global File constructor
+ global.File = vi.fn().mockImplementation((bits, name, options) => {
+ return new File(bits, name, options);
+ });
+ });
+
+ it('should crop a video with valid parameters', async () => {
+ const options = {
+ width: 640,
+ height: 360,
+ x: 0,
+ y: 0,
+ maintainAspectRatio: false
+ };
+
+ const result = await cropVideo(mockFile, options, mockVideoInfo);
+
+ expect(result).toBeDefined();
+ expect(result.name).toContain('_cropped');
+ expect(result.type).toBe('video/mp4');
+ });
+
+ it('should throw error if videoInfo is not provided', async () => {
+ const options = {
+ width: 640,
+ height: 360,
+ x: 0,
+ y: 0,
+ maintainAspectRatio: false
+ };
+
+ await expect(cropVideo(mockFile, options, null)).rejects.toThrow(
+ 'Video information is required'
+ );
+ });
+
+ it('should adjust crop dimensions to fit within video bounds', async () => {
+ const options = {
+ width: 2000, // Larger than video width
+ height: 1000, // Larger than video height
+ x: 500,
+ y: 500,
+ maintainAspectRatio: false
+ };
+
+ const result = await cropVideo(mockFile, options, mockVideoInfo);
+
+ expect(result).toBeDefined();
+ // We can't test the actual crop dimensions since FFmpeg is mocked
+ // but we can verify the file was created
+ expect(result.name).toContain('_cropped');
+ });
+});
diff --git a/src/pages/tools/video/crop-video/index.tsx b/src/pages/tools/video/crop-video/index.tsx
new file mode 100644
index 0000000..02f6105
--- /dev/null
+++ b/src/pages/tools/video/crop-video/index.tsx
@@ -0,0 +1,223 @@
+import { Box, Checkbox, FormControlLabel } from '@mui/material';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import * as Yup from 'yup';
+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';
+import ToolVideoInput from '@components/input/ToolVideoInput';
+import { InitialValuesType } from './types';
+
+const ffmpeg = new FFmpeg();
+
+const initialValues: InitialValuesType = {
+ width: 640,
+ height: 360,
+ x: 0,
+ y: 0,
+ maintainAspectRatio: true
+};
+
+const validationSchema = Yup.object({
+ width: Yup.number()
+ .min(1, 'Width must be at least 1px')
+ .required('Width is required'),
+ height: Yup.number()
+ .min(1, 'Height must be at least 1px')
+ .required('Height is required'),
+ x: Yup.number()
+ .min(0, 'X position must be positive')
+ .required('X position is required'),
+ y: Yup.number()
+ .min(0, 'Y position must be positive')
+ .required('Y position is required')
+});
+
+export default function CropVideo({ title }: ToolComponentProps) {
+ const [input, setInput] = useState(null);
+ const [result, setResult] = useState(null);
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [videoInfo, setVideoInfo] = useState<{
+ width: number;
+ height: number;
+ } | null>(null);
+ const videoRef = useRef(null);
+
+ // Get video dimensions when a video is loaded
+ useEffect(() => {
+ if (input) {
+ const video = document.createElement('video');
+ video.onloadedmetadata = () => {
+ console.log('loadedmetadata', video.videoWidth, video.videoHeight);
+ setVideoInfo({
+ width: video.videoWidth,
+ height: video.videoHeight
+ });
+ };
+ video.src = URL.createObjectURL(input);
+ } else {
+ setVideoInfo(null);
+ }
+ }, [input]);
+
+ const compute = async (
+ optionsValues: InitialValuesType,
+ input: File | null
+ ) => {
+ if (!input || !videoInfo) return;
+ try {
+ setIsProcessing(true);
+
+ // Ensure values are within video bounds
+ const cropWidth = Math.min(
+ optionsValues.width,
+ videoInfo.width - optionsValues.x
+ );
+ const cropHeight = Math.min(
+ optionsValues.height,
+ videoInfo.height - optionsValues.y
+ );
+ const cropX = Math.min(optionsValues.x, videoInfo.width - cropWidth);
+ const cropY = Math.min(optionsValues.y, videoInfo.height - cropHeight);
+
+ if (!ffmpeg.loaded) {
+ await ffmpeg.load({
+ wasmURL:
+ 'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.9/dist/esm/ffmpeg-core.wasm'
+ });
+ }
+
+ 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 crop video
+ // The crop filter format is: crop=width:height:x:y
+ await ffmpeg.exec([
+ '-i',
+ inputName,
+ '-vf',
+ `crop=${cropWidth}:${cropHeight}:${cropX}:${cropY}`,
+ '-c:a',
+ 'copy',
+ outputName
+ ]);
+
+ // Retrieve the processed file
+ const croppedData = await ffmpeg.readFile(outputName);
+ const croppedBlob = new Blob([croppedData], { type: 'video/mp4' });
+ const croppedFile = new File(
+ [croppedBlob],
+ `${input.name.replace(/\.[^/.]+$/, '')}_cropped.mp4`,
+ {
+ type: 'video/mp4'
+ }
+ );
+
+ setResult(croppedFile);
+ } catch (error) {
+ console.error('Error cropping video:', error);
+ } finally {
+ setIsProcessing(false);
+ }
+ };
+
+ const debouncedCompute = useCallback(debounce(compute, 1000), [videoInfo]);
+
+ const getGroups: GetGroupsType = ({
+ values,
+ updateField,
+ setFieldValue
+ }) => [
+ {
+ title: 'Crop Settings',
+ component: (
+
+
+ updateNumberField(value, 'width', updateField)
+ }
+ value={values.width}
+ label={'Width (pixels)'}
+ sx={{ mb: 2, backgroundColor: 'background.paper' }}
+ helperText={videoInfo ? `Original width: ${videoInfo.width}px` : ''}
+ />
+
+ updateNumberField(value, 'height', updateField)
+ }
+ value={values.height}
+ label={'Height (pixels)'}
+ sx={{ mb: 2, backgroundColor: 'background.paper' }}
+ helperText={
+ videoInfo ? `Original height: ${videoInfo.height}px` : ''
+ }
+ />
+ updateNumberField(value, 'x', updateField)}
+ value={values.x}
+ label={'X Position (pixels)'}
+ sx={{ mb: 2, backgroundColor: 'background.paper' }}
+ />
+ updateNumberField(value, 'y', updateField)}
+ value={values.y}
+ label={'Y Position (pixels)'}
+ sx={{ mb: 2, backgroundColor: 'background.paper' }}
+ />
+ {
+ setFieldValue('maintainAspectRatio', e.target.checked);
+ }}
+ />
+ }
+ label="Maintain aspect ratio"
+ />
+
+ )
+ }
+ ];
+
+ return (
+
+ }
+ resultComponent={
+
+ }
+ initialValues={initialValues}
+ getGroups={getGroups}
+ compute={debouncedCompute}
+ setInput={setInput}
+ validationSchema={validationSchema}
+ toolInfo={{
+ title: 'How to crop a video',
+ description:
+ 'Video cropping allows you to remove unwanted outer areas from your video frames. Specify the width, height, X position, and Y position to define the crop region. The X and Y positions determine the top-left corner of the crop area.'
+ }}
+ />
+ );
+}
diff --git a/src/pages/tools/video/crop-video/meta.ts b/src/pages/tools/video/crop-video/meta.ts
new file mode 100644
index 0000000..965eebf
--- /dev/null
+++ b/src/pages/tools/video/crop-video/meta.ts
@@ -0,0 +1,15 @@
+import { defineTool } from '@tools/defineTool';
+import { lazy } from 'react';
+
+export const tool = defineTool('video', {
+ name: 'Crop Video',
+ path: 'crop-video',
+ icon: 'mdi:crop',
+ description:
+ 'This online tool lets you crop videos by specifying width, height, and position coordinates. Remove unwanted areas from your videos and keep only the parts you need. Supports common video formats like MP4, WebM, and OGG.',
+ shortDescription: 'Crop videos by specifying width, height and position',
+ keywords: ['crop', 'video', 'resize', 'trim', 'edit', 'ffmpeg'],
+ longDescription:
+ 'Video cropping is the process of removing unwanted outer areas from a video frame. This tool allows you to specify exact dimensions and coordinates to crop your video, helping you focus on the important parts of your footage or adjust aspect ratios.',
+ component: lazy(() => import('./index'))
+});
diff --git a/src/pages/tools/video/crop-video/types.ts b/src/pages/tools/video/crop-video/types.ts
new file mode 100644
index 0000000..930e205
--- /dev/null
+++ b/src/pages/tools/video/crop-video/types.ts
@@ -0,0 +1,7 @@
+export type InitialValuesType = {
+ width: number;
+ height: number;
+ x: number;
+ y: number;
+ maintainAspectRatio: boolean;
+};
diff --git a/src/pages/tools/video/index.ts b/src/pages/tools/video/index.ts
index 07405b1..a460104 100644
--- a/src/pages/tools/video/index.ts
+++ b/src/pages/tools/video/index.ts
@@ -1,7 +1,14 @@
+import { tool as videoCropVideo } from './crop-video/meta';
import { rotate } from '../string/rotate/service';
import { gifTools } from './gif';
import { tool as trimVideo } from './trim/meta';
import { tool as rotateVideo } from './rotate/meta';
import { tool as compressVideo } from './compress/meta';
-export const videoTools = [...gifTools, trimVideo, rotateVideo, compressVideo];
+export const videoTools = [
+ ...gifTools,
+ trimVideo,
+ rotateVideo,
+ compressVideo,
+ videoCropVideo
+];