diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index e536cf3..0fd9063 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -4,14 +4,15 @@
-
-
-
-
-
+
+
-
-
+
+
+
+
+
+
@@ -163,57 +164,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.crop-video.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": "crop-video",
+ "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"
}
-}]]>
+}
-
+
@@ -318,8 +319,8 @@
-
+
@@ -417,14 +418,8 @@
-
-
-
- 1741417920442
-
-
-
- 1741417920442
+
+
@@ -810,7 +805,15 @@
1743811980098
-
+
+
+ 1743986670751
+
+
+
+ 1743986670751
+
+
@@ -857,7 +860,6 @@
-
@@ -882,7 +884,8 @@
-
+
+
diff --git a/package-lock.json b/package-lock.json
index 3e2f349..e9d0778 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -37,6 +37,7 @@
"rc-slider": "^11.1.8",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+ "react-easy-crop": "^5.4.1",
"react-helmet": "^6.1.0",
"react-image-crop": "^11.0.7",
"react-router-dom": "^6.23.1",
@@ -7876,6 +7877,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/normalize-wheel": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz",
+ "integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/notistack": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/notistack/-/notistack-3.0.1.tgz",
@@ -8884,6 +8891,20 @@
"react": "^18.3.1"
}
},
+ "node_modules/react-easy-crop": {
+ "version": "5.4.1",
+ "resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-5.4.1.tgz",
+ "integrity": "sha512-Djtsi7bWO75vkKYkVxNRrJWY69pXLahIAkUN0mmt9cXNnaq2tpG59ctSY6P7ipJgBc7COJDRMRuwb2lYwtACNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "normalize-wheel": "^1.0.1",
+ "tslib": "^2.0.1"
+ },
+ "peerDependencies": {
+ "react": ">=16.4.0",
+ "react-dom": ">=16.4.0"
+ }
+ },
"node_modules/react-fast-compare": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz",
diff --git a/package.json b/package.json
index c0c605b..04d0c0f 100644
--- a/package.json
+++ b/package.json
@@ -54,6 +54,7 @@
"rc-slider": "^11.1.8",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+ "react-easy-crop": "^5.4.1",
"react-helmet": "^6.1.0",
"react-image-crop": "^11.0.7",
"react-router-dom": "^6.23.1",
diff --git a/src/components/input/ToolVideoInput.tsx b/src/components/input/ToolVideoInput.tsx
index e1b0136..4f6f5a1 100644
--- a/src/components/input/ToolVideoInput.tsx
+++ b/src/components/input/ToolVideoInput.tsx
@@ -1,15 +1,23 @@
-import React, { useRef, useState } from 'react';
+import React, { useEffect, useRef, useState } from 'react';
import { Box, Typography } from '@mui/material';
import Slider from 'rc-slider';
import 'rc-slider/assets/index.css';
import BaseFileInput from './BaseFileInput';
import { BaseFileInputProps, formatTime } from './file-input-utils';
+import Cropper, { MediaSize, Point, Size, Area } from 'react-easy-crop';
interface VideoFileInputProps extends BaseFileInputProps {
showTrimControls?: boolean;
onTrimChange?: (trimStart: number, trimEnd: number) => void;
trimStart?: number;
trimEnd?: number;
+ showCropOverlay?: boolean;
+ cropPosition?: { x: number; y: number };
+ cropSize?: { width: number; height: number };
+ onCropChange?: (
+ position: { x: number; y: number },
+ size: { width: number; height: number }
+ ) => void;
}
export default function ToolVideoInput({
@@ -17,11 +25,28 @@ export default function ToolVideoInput({
onTrimChange,
trimStart = 0,
trimEnd = 100,
+ showCropOverlay,
+ cropPosition = { x: 0, y: 0 },
+ cropSize = { width: 100, height: 100 },
+ onCropChange,
...props
}: VideoFileInputProps) {
- const videoRef = useRef(null);
+ let videoRef = useRef(null);
const [videoDuration, setVideoDuration] = useState(0);
+ const [videoWidth, setVideoWidth] = useState(0);
+ const [videoHeight, setVideoHeight] = useState(0);
+ const [crop, setCrop] = useState<{
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ }>({
+ x: 0,
+ y: 0,
+ width: 0,
+ height: 0
+ });
const onVideoLoad = (e: React.SyntheticEvent) => {
const duration = e.currentTarget.duration;
setVideoDuration(duration);
@@ -30,13 +55,59 @@ export default function ToolVideoInput({
onTrimChange(0, duration);
}
};
+ useEffect(() => {
+ if (
+ cropPosition.x !== 0 ||
+ cropPosition.y !== 0 ||
+ cropSize.width !== 100 ||
+ cropSize.height !== 100
+ ) {
+ setCrop({ ...cropPosition, ...cropSize });
+ }
+ }, [cropPosition, cropSize]);
+ const onCropMediaLoaded = (mediaSize: MediaSize) => {
+ const { width, height } = mediaSize;
+ setVideoWidth(width);
+ setVideoHeight(height);
+ if (!crop.width && !crop.height && onCropChange) {
+ const initialCrop = {
+ x: Math.floor(width / 4),
+ y: Math.floor(height / 4),
+ width: Math.floor(width / 2),
+ height: Math.floor(height / 2)
+ };
+ console.log('initialCrop', initialCrop);
+ setCrop(initialCrop);
+
+ onCropChange(
+ { x: initialCrop.x, y: initialCrop.y },
+ { width: initialCrop.width, height: initialCrop.height }
+ );
+ }
+ };
const handleTrimChange = (start: number, end: number) => {
if (onTrimChange) {
onTrimChange(start, end);
}
};
+ const handleCropChange = (newCrop: Point) => {
+ setCrop((prevState) => ({ ...prevState, x: newCrop.x, y: newCrop.y }));
+ };
+ const handleCropSizeChange = (newCrop: Size) => {
+ setCrop((prevState) => ({
+ ...prevState,
+ height: newCrop.height,
+ width: newCrop.width
+ }));
+ };
+
+ const handleCropComplete = (crop: Area, croppedAreaPixels: Area) => {
+ if (onCropChange) {
+ onCropChange(croppedAreaPixels, croppedAreaPixels);
+ }
+ };
return (
{({ preview }) => (
@@ -51,16 +122,38 @@ export default function ToolVideoInput({
justifyContent: 'center'
}}
>
-
+ {showCropOverlay ? (
+ {
+ videoRef = ref;
+ }}
+ video={preview}
+ crop={crop}
+ cropSize={crop}
+ onCropSizeChange={handleCropSizeChange}
+ onCropChange={handleCropChange}
+ onCropComplete={handleCropComplete}
+ onMediaLoaded={onCropMediaLoaded}
+ initialCroppedAreaPercentages={{
+ height: 0.5,
+ width: 0.5,
+ x: 0.5,
+ y: 0.5
+ }}
+ // controls={!showTrimControls}
+ />
+ ) : (
+
+ )}
{showTrimControls && videoDuration > 0 && (
{
- 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
index 02f6105..f50c6f7 100644
--- a/src/pages/tools/video/crop-video/index.tsx
+++ b/src/pages/tools/video/crop-video/index.tsx
@@ -1,10 +1,10 @@
-import { Box, Checkbox, FormControlLabel } from '@mui/material';
-import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { Box } from '@mui/material';
+import React, { useCallback, useEffect, 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 { GetGroupsType, UpdateField } from '@components/options/ToolOptions';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import { updateNumberField } from '@utils/string';
import { FFmpeg } from '@ffmpeg/ffmpeg';
@@ -19,8 +19,7 @@ const initialValues: InitialValuesType = {
width: 640,
height: 360,
x: 0,
- y: 0,
- maintainAspectRatio: true
+ y: 0
};
const validationSchema = Yup.object({
@@ -46,14 +45,12 @@ export default function CropVideo({ title }: ToolComponentProps) {
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
@@ -133,8 +130,7 @@ export default function CropVideo({ title }: ToolComponentProps) {
const getGroups: GetGroupsType = ({
values,
- updateField,
- setFieldValue
+ updateField
}) => [
{
title: 'Crop Settings',
@@ -172,34 +168,48 @@ export default function CropVideo({ title }: ToolComponentProps) {
label={'Y Position (pixels)'}
sx={{ mb: 2, backgroundColor: 'background.paper' }}
/>
- {
- setFieldValue('maintainAspectRatio', e.target.checked);
- }}
- />
- }
- label="Maintain aspect ratio"
- />
)
}
];
-
+ const handleCropChange =
+ (values: InitialValuesType, updateField: UpdateField) =>
+ (
+ position: { x: number; y: number },
+ size: { width: number; height: number }
+ ) => {
+ console.log('Crop position:', position, size);
+ updateField('x', position.x);
+ updateField('y', position.y);
+ updateField('width', size.width);
+ updateField('height', size.height);
+ };
+ const renderCustomInput = (
+ values: InitialValuesType,
+ updateField: UpdateField
+ ) => (
+
+ );
return (
- }
+ renderCustomInput={renderCustomInput}
resultComponent={