feat: crop video init

This commit is contained in:
Ibrahima G. Coulibaly 2025-04-07 01:44:24 +01:00
commit 45af1b0735
7 changed files with 403 additions and 60 deletions

121
.idea/workspace.xml generated
View file

@ -5,9 +5,13 @@
</component>
<component name="ChangeListManager">
<list default="true" id="b30e2810-c4c1-4aad-b134-794e52cc1c7d" name="Changes" comment="chore: readme img and fix broken link">
<change afterPath="$PROJECT_DIR$/src/pages/tools/video/crop-video/crop-video.service.test.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/pages/tools/video/crop-video/index.tsx" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/pages/tools/video/crop-video/meta.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/pages/tools/video/crop-video/types.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/components/Hero.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/Hero.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools-by-category/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools-by-category/index.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/components/ToolContent.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/ToolContent.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/video/index.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/video/index.ts" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@ -24,7 +28,7 @@
<option name="PUSH_AUTO_UPDATE" value="true" />
<option name="RECENT_BRANCH_BY_REPOSITORY">
<map>
<entry key="$PROJECT_DIR$" value="chesterkxng" />
<entry key="$PROJECT_DIR$" value="main" />
</map>
</option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
@ -159,56 +163,57 @@
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;ASKED_ADD_EXTERNAL_FILES&quot;: &quot;true&quot;,
&quot;ASKED_SHARE_PROJECT_CONFIGURATION_FILES&quot;: &quot;true&quot;,
&quot;Docker.Dockerfile build.executor&quot;: &quot;Run&quot;,
&quot;Docker.Dockerfile.executor&quot;: &quot;Run&quot;,
&quot;Playwright.Create transparent PNG.should make png color transparent.executor&quot;: &quot;Run&quot;,
&quot;Playwright.JoinText Component.executor&quot;: &quot;Run&quot;,
&quot;Playwright.JoinText Component.should merge text pieces with specified join character.executor&quot;: &quot;Run&quot;,
&quot;RunOnceActivity.OpenProjectViewOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;Vitest.compute function (1).executor&quot;: &quot;Run&quot;,
&quot;Vitest.compute function.executor&quot;: &quot;Run&quot;,
&quot;Vitest.mergeText.executor&quot;: &quot;Run&quot;,
&quot;Vitest.mergeText.should merge lines and preserve blank lines when deleteBlankLines is false.executor&quot;: &quot;Run&quot;,
&quot;Vitest.mergeText.should merge lines, preserve blank lines and trailing spaces when both deleteBlankLines and deleteTrailingSpaces are false.executor&quot;: &quot;Run&quot;,
&quot;Vitest.parsePageRanges.executor&quot;: &quot;Run&quot;,
&quot;Vitest.removeDuplicateLines function.executor&quot;: &quot;Run&quot;,
&quot;Vitest.removeDuplicateLines function.newlines option.executor&quot;: &quot;Run&quot;,
&quot;Vitest.removeDuplicateLines function.newlines option.should filter newlines when newlines is set to filter.executor&quot;: &quot;Run&quot;,
&quot;Vitest.replaceText function (regexp mode).should return the original text when passed an invalid regexp.executor&quot;: &quot;Run&quot;,
&quot;Vitest.replaceText function.executor&quot;: &quot;Run&quot;,
&quot;Vitest.timeBetweenDates.executor&quot;: &quot;Run&quot;,
&quot;git-widget-placeholder&quot;: &quot;main&quot;,
&quot;ignore.virus.scanning.warn.message&quot;: &quot;true&quot;,
&quot;kotlin-language-version-configured&quot;: &quot;true&quot;,
&quot;last_opened_file_path&quot;: &quot;C:/Users/Ibrahima/IdeaProjects/omni-tools/src&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;npm.build.executor&quot;: &quot;Run&quot;,
&quot;npm.dev.executor&quot;: &quot;Run&quot;,
&quot;npm.lint.executor&quot;: &quot;Run&quot;,
&quot;npm.prebuild.executor&quot;: &quot;Run&quot;,
&quot;npm.script:create:tool.executor&quot;: &quot;Run&quot;,
&quot;npm.test.executor&quot;: &quot;Run&quot;,
&quot;npm.test:e2e.executor&quot;: &quot;Run&quot;,
&quot;npm.test:e2e:run.executor&quot;: &quot;Run&quot;,
&quot;prettierjs.PrettierConfiguration.Package&quot;: &quot;C:\\Users\\Ibrahima\\IdeaProjects\\omni-tools\\node_modules\\prettier&quot;,
&quot;project.structure.last.edited&quot;: &quot;Problems&quot;,
&quot;project.structure.proportion&quot;: &quot;0.0&quot;,
&quot;project.structure.side.proportion&quot;: &quot;0.2&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;refactai_advanced_settings&quot;,
&quot;ts.external.directory.path&quot;: &quot;C:\\Users\\Ibrahima\\IdeaProjects\\omni-tools\\node_modules\\typescript\\lib&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
<component name="PropertiesComponent"><![CDATA[{
"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"
}
}</component>
}]]></component>
<component name="ReactDesignerToolWindowState">
<option name="myId2Visible">
<map>
@ -234,17 +239,17 @@
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\src\pages\categories" />
</key>
</component>
<component name="RunManager" selected="npm.dev">
<configuration name="calculateTimeBetweenDates" type="JavaScriptTestRunnerVitest" temporary="true" nameIsGenerated="true">
<component name="RunManager" selected="Vitest.crop-video">
<configuration name="crop-video" type="JavaScriptTestRunnerVitest" temporary="true" nameIsGenerated="true">
<node-interpreter value="project" />
<vitest-package value="$PROJECT_DIR$/node_modules/vitest" />
<working-dir value="$PROJECT_DIR$" />
<vitest-options value="--run" />
<envs />
<scope-kind value="SUITE" />
<test-file value="$PROJECT_DIR$/src/pages/tools/time/time-between-dates/time-between-dates.service.test.ts" />
<test-file value="$PROJECT_DIR$/src/pages/tools/video/crop-video/crop-video.service.test.ts" />
<test-names>
<test-name value="calculateTimeBetweenDates" />
<test-name value="crop-video" />
</test-names>
<method v="2" />
</configuration>
@ -306,18 +311,18 @@
</configuration>
<list>
<item itemvalue="npm.dev" />
<item itemvalue="Vitest.calculateTimeBetweenDates" />
<item itemvalue="Vitest.crop-video" />
<item itemvalue="Vitest.timeBetweenDates" />
<item itemvalue="Vitest.parsePageRanges" />
<item itemvalue="Vitest.replaceText function (regexp mode).should return the original text when passed an invalid regexp" />
</list>
<recent_temporary>
<list>
<item itemvalue="Vitest.crop-video" />
<item itemvalue="npm.dev" />
<item itemvalue="Vitest.replaceText function (regexp mode).should return the original text when passed an invalid regexp" />
<item itemvalue="Vitest.parsePageRanges" />
<item itemvalue="Vitest.timeBetweenDates" />
<item itemvalue="Vitest.calculateTimeBetweenDates" />
</list>
</recent_temporary>
</component>

View file

@ -30,7 +30,7 @@ const FormikListenerComponent = <T,>({
if (exception instanceof Error) showSnackBar(exception.message, 'error');
else console.error(exception);
}
}, [values, input, showSnackBar]);
}, [compute, values, input, showSnackBar]);
useEffect(() => {
onValuesChange?.(values);

View file

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

View file

@ -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<File | null>(null);
const [result, setResult] = useState<File | null>(null);
const [isProcessing, setIsProcessing] = useState<boolean>(false);
const [videoInfo, setVideoInfo] = useState<{
width: number;
height: number;
} | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(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<InitialValuesType> = ({
values,
updateField,
setFieldValue
}) => [
{
title: 'Crop Settings',
component: (
<Box>
<TextFieldWithDesc
onOwnChange={(value) =>
updateNumberField(value, 'width', updateField)
}
value={values.width}
label={'Width (pixels)'}
sx={{ mb: 2, backgroundColor: 'background.paper' }}
helperText={videoInfo ? `Original width: ${videoInfo.width}px` : ''}
/>
<TextFieldWithDesc
onOwnChange={(value) =>
updateNumberField(value, 'height', updateField)
}
value={values.height}
label={'Height (pixels)'}
sx={{ mb: 2, backgroundColor: 'background.paper' }}
helperText={
videoInfo ? `Original height: ${videoInfo.height}px` : ''
}
/>
<TextFieldWithDesc
onOwnChange={(value) => updateNumberField(value, 'x', updateField)}
value={values.x}
label={'X Position (pixels)'}
sx={{ mb: 2, backgroundColor: 'background.paper' }}
/>
<TextFieldWithDesc
onOwnChange={(value) => updateNumberField(value, 'y', updateField)}
value={values.y}
label={'Y Position (pixels)'}
sx={{ mb: 2, backgroundColor: 'background.paper' }}
/>
<FormControlLabel
control={
<Checkbox
checked={values.maintainAspectRatio}
onChange={(e) => {
setFieldValue('maintainAspectRatio', e.target.checked);
}}
/>
}
label="Maintain aspect ratio"
/>
</Box>
)
}
];
return (
<ToolContent
title={title}
input={input}
inputComponent={
<ToolVideoInput
value={input}
onChange={setInput}
accept={['video/mp4', 'video/webm', 'video/ogg']}
title={'Input Video'}
/>
}
resultComponent={
<ToolFileResult
title={'Cropped Video'}
value={result}
extension={'mp4'}
loading={isProcessing}
/>
}
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.'
}}
/>
);
}

View file

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

View file

@ -0,0 +1,7 @@
export type InitialValuesType = {
width: number;
height: number;
x: number;
y: number;
maintainAspectRatio: boolean;
};

View file

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