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..07872b3 --- /dev/null +++ b/src/pages/tools/video/crop-video/index.tsx @@ -0,0 +1,219 @@ +import { Box, TextField, Typography, Alert } from '@mui/material'; +import { useCallback, useState, useEffect } from 'react'; +import ToolFileResult from '@components/result/ToolFileResult'; +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'; + +const initialValues: InitialValuesType = { + x: 0, + y: 0, + width: 100, + height: 100 +}; + +export default function CropVideo({ title }: ToolComponentProps) { + const [input, setInput] = useState(null); + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + const [videoDimensions, setVideoDimensions] = useState<{ + width: number; + height: number; + } | null>(null); + const [processingError, setProcessingError] = useState(''); + + const validateDimensions = (values: InitialValuesType): string => { + if (!videoDimensions) return ''; + + if (values.x < 0 || values.y < 0) { + return 'X and Y coordinates must be non-negative'; + } + + if (values.width <= 0 || values.height <= 0) { + return 'Width and height must be positive'; + } + + if (values.x + values.width > videoDimensions.width) { + return `Crop area extends beyond video width (${videoDimensions.width}px)`; + } + + if (values.y + values.height > videoDimensions.height) { + return `Crop area extends beyond video height (${videoDimensions.height}px)`; + } + + return ''; + }; + + const compute = async ( + optionsValues: InitialValuesType, + input: File | null + ) => { + if (!input) return; + + const error = validateDimensions(optionsValues); + if (error) { + setProcessingError(error); + return; + } + + setProcessingError(''); + setLoading(true); + + try { + const croppedFile = await cropVideo(input, optionsValues); + setResult(croppedFile); + } catch (error) { + console.error('Error cropping video:', error); + setProcessingError( + 'Error cropping video. Please check parameters and video file.' + ); + } finally { + setLoading(false); + } + }; + + // 2 seconds to avoid starting job half way through + const debouncedCompute = useCallback(debounce(compute, 2000), [ + videoDimensions + ]); + + const getGroups: GetGroupsType = ({ + values, + updateField + }) => [ + { + title: 'Video Information', + component: ( + + {videoDimensions ? ( + + Video dimensions: {videoDimensions.width} ×{' '} + {videoDimensions.height} pixels + + ) : ( + + Load a video to see dimensions + + )} + + ) + }, + { + title: 'Crop Coordinates', + component: ( + + {processingError && ( + + {processingError} + + )} + + updateField('x', parseInt(e.target.value) || 0)} + size="small" + inputProps={{ min: 0 }} + /> + updateField('y', parseInt(e.target.value) || 0)} + size="small" + inputProps={{ min: 0 }} + /> + + + + updateField('width', parseInt(e.target.value) || 0) + } + size="small" + inputProps={{ min: 1 }} + /> + + updateField('height', parseInt(e.target.value) || 0) + } + size="small" + inputProps={{ min: 1 }} + /> + + + ) + } + ]; + + return ( + ( + { + if (video) { + getVideoDimensions(video) + .then((dimensions) => { + const newOptions: InitialValuesType = { + x: dimensions.width / 4, + y: dimensions.height / 4, + width: dimensions.width / 2, + height: dimensions.height / 2 + }; + setFieldValue('x', newOptions.x); + setFieldValue('y', newOptions.y); + setFieldValue('width', newOptions.width); + setFieldValue('height', newOptions.height); + + setVideoDimensions(dimensions); + setProcessingError(''); + }) + .catch((error) => { + console.error('Error getting video dimensions:', error); + setProcessingError('Failed to load video dimensions'); + }); + } else { + setVideoDimensions(null); + setProcessingError(''); + } + setInput(video); + }} + title={'Input Video'} + /> + )} + resultComponent={ + loading ? ( + + ) : ( + + ) + } + initialValues={initialValues} + getGroups={getGroups} + compute={debouncedCompute} + setInput={setInput} + /> + ); +} 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..184c809 --- /dev/null +++ b/src/pages/tools/video/crop-video/meta.ts @@ -0,0 +1,14 @@ +import { defineTool } from '@tools/defineTool'; +import { lazy } from 'react'; + +export const tool = defineTool('video', { + name: 'Crop video', + path: 'crop-video', + icon: 'mdi:crop', + description: 'Crop a video by specifying coordinates and dimensions', + shortDescription: 'Crop video to specific area', + keywords: ['crop', 'video', 'trim', 'cut', 'resize'], + longDescription: + 'Remove unwanted parts from the edges of your video by cropping it to a specific rectangular area. Define the starting coordinates (X, Y) and the width and height of the area you want to keep.', + component: lazy(() => import('./index')) +}); diff --git a/src/pages/tools/video/crop-video/service.ts b/src/pages/tools/video/crop-video/service.ts new file mode 100644 index 0000000..010cf84 --- /dev/null +++ b/src/pages/tools/video/crop-video/service.ts @@ -0,0 +1,67 @@ +import { FFmpeg } from '@ffmpeg/ffmpeg'; +import { fetchFile } from '@ffmpeg/util'; +import { InitialValuesType } from './types'; + +const ffmpeg = new FFmpeg(); + +export async function getVideoDimensions( + file: File +): Promise<{ width: number; height: number }> { + return new Promise((resolve, reject) => { + const video = document.createElement('video'); + const url = URL.createObjectURL(file); + + video.onloadedmetadata = () => { + URL.revokeObjectURL(url); + resolve({ + width: video.videoWidth, + height: video.videoHeight + }); + }; + + video.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error('Failed to load video metadata')); + }; + + video.src = url; + }); +} + +export async function cropVideo( + input: File, + options: InitialValuesType +): Promise { + 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'; + await ffmpeg.writeFile(inputName, await fetchFile(input)); + + const args = []; + + if (options.width <= 0 || options.height <= 0) { + throw new Error('Width and height must be positive'); + } + + args.push('-i', inputName); + args.push( + '-vf', + `crop=${options.width}:${options.height}:${options.x}:${options.y}` + ); + args.push('-c:v', 'libx264', '-preset', 'ultrafast', outputName); + + await ffmpeg.exec(args); + + const croppedData = await ffmpeg.readFile(outputName); + return await new File( + [new Blob([croppedData], { type: 'video/mp4' })], + `${input.name.replace(/\.[^/.]+$/, '')}_cropped.mp4`, + { type: 'video/mp4' } + ); +} 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..60ee53d --- /dev/null +++ b/src/pages/tools/video/crop-video/types.ts @@ -0,0 +1,6 @@ +export type InitialValuesType = { + x: number; + y: number; + width: number; + height: number; +}; diff --git a/src/pages/tools/video/index.ts b/src/pages/tools/video/index.ts index d3507f2..99bb8e7 100644 --- a/src/pages/tools/video/index.ts +++ b/src/pages/tools/video/index.ts @@ -7,6 +7,7 @@ import { tool as rotateVideo } from './rotate/meta'; import { tool as compressVideo } from './compress/meta'; import { tool as loopVideo } from './loop/meta'; import { tool as flipVideo } from './flip/meta'; +import { tool as cropVideo } from './crop-video/meta'; import { tool as changeSpeed } from './change-speed/meta'; export const videoTools = [ @@ -16,5 +17,6 @@ export const videoTools = [ compressVideo, loopVideo, flipVideo, + cropVideo, changeSpeed ];