From 37c8b30a118240ff981dd5fa6519ee0ee06c6454 Mon Sep 17 00:00:00 2001 From: nevolodia Date: Sun, 25 May 2025 13:38:23 +0200 Subject: [PATCH 1/6] Crop video logic added. --- src/pages/tools/video/crop-video/service.ts | 63 +++++++++++++++++++++ src/pages/tools/video/crop-video/types.ts | 6 ++ 2 files changed, 69 insertions(+) create mode 100644 src/pages/tools/video/crop-video/service.ts create mode 100644 src/pages/tools/video/crop-video/types.ts 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..0d16341 --- /dev/null +++ b/src/pages/tools/video/crop-video/service.ts @@ -0,0 +1,63 @@ +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 = []; + + 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; +}; From 8d2e0ab8fd96fc19e7cb02dd5ae3615aeb99aeee Mon Sep 17 00:00:00 2001 From: nevolodia Date: Sun, 25 May 2025 13:38:43 +0200 Subject: [PATCH 2/6] Crop video meta added. --- src/pages/tools/video/crop-video/meta.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/pages/tools/video/crop-video/meta.ts 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')) +}); From fd28651c78a96f3eb9beb0ae93199d15fb8dc508 Mon Sep 17 00:00:00 2001 From: nevolodia Date: Sun, 25 May 2025 13:39:21 +0200 Subject: [PATCH 3/6] Crop video main page added. --- src/pages/tools/video/crop-video/index.tsx | 167 +++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 src/pages/tools/video/crop-video/index.tsx 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..e756fdc --- /dev/null +++ b/src/pages/tools/video/crop-video/index.tsx @@ -0,0 +1,167 @@ +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'; + +export 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); + + useEffect(() => { + if (input) { + getVideoDimensions(input) + .then((dimensions) => { + setVideoDimensions(dimensions); + }) + .catch((error) => { + console.error('Error getting video dimensions:', error); + }); + } else { + setVideoDimensions(null); + } + }, [input]); + + const compute = async ( + optionsValues: InitialValuesType, + input: File | null + ) => { + if (!input) return; + + setLoading(true); + + try { + const croppedFile = await cropVideo(input, optionsValues); + setResult(croppedFile); + } catch (error) { + console.error('Error cropping video:', error); + } finally { + setLoading(false); + } + }; + + const debouncedCompute = useCallback(debounce(compute, 1000), [ + 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: ( + + + 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 ( + + } + resultComponent={ + loading ? ( + + ) : ( + + ) + } + initialValues={initialValues} + getGroups={getGroups} + compute={debouncedCompute} + setInput={setInput} + /> + ); +} From 13a566566f806f30b6d5d9b3384a53d8400ce5ed Mon Sep 17 00:00:00 2001 From: nevolodia Date: Sun, 25 May 2025 13:39:38 +0200 Subject: [PATCH 4/6] Crop video added to video tools page. --- src/pages/tools/video/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pages/tools/video/index.ts b/src/pages/tools/video/index.ts index 3e6659d..569665f 100644 --- a/src/pages/tools/video/index.ts +++ b/src/pages/tools/video/index.ts @@ -6,6 +6,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'; export const videoTools = [ ...gifTools, @@ -13,5 +14,6 @@ export const videoTools = [ rotateVideo, compressVideo, loopVideo, - flipVideo + flipVideo, + cropVideo ]; From 2f104f5a1b1339d3b8ff17d262175563f8ab2396 Mon Sep 17 00:00:00 2001 From: nevolodia Date: Sun, 25 May 2025 14:14:01 +0200 Subject: [PATCH 5/6] Check for valid coordinates added, bugs fixed. --- src/pages/tools/video/crop-video/index.tsx | 44 ++++++++++++++++++++- src/pages/tools/video/crop-video/service.ts | 4 ++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/pages/tools/video/crop-video/index.tsx b/src/pages/tools/video/crop-video/index.tsx index e756fdc..b1bcbb6 100644 --- a/src/pages/tools/video/crop-video/index.tsx +++ b/src/pages/tools/video/crop-video/index.tsx @@ -24,27 +24,60 @@ export default function CropVideo({ title }: ToolComponentProps) { width: number; height: number; } | null>(null); + const [processingError, setProcessingError] = useState(''); useEffect(() => { if (input) { getVideoDimensions(input) .then((dimensions) => { setVideoDimensions(dimensions); + setProcessingError(''); }) .catch((error) => { console.error('Error getting video dimensions:', error); + setProcessingError('Failed to load video dimensions'); }); } else { setVideoDimensions(null); + setProcessingError(''); } }, [input]); + 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 { @@ -52,12 +85,16 @@ export default function CropVideo({ title }: ToolComponentProps) { setResult(croppedFile); } catch (error) { console.error('Error cropping video:', error); + setProcessingError( + 'Error cropping video. Please check parameters and video file.' + ); } finally { setLoading(false); } }; - const debouncedCompute = useCallback(debounce(compute, 1000), [ + // 2 seconds to avoid starting job half way through + const debouncedCompute = useCallback(debounce(compute, 2000), [ videoDimensions ]); @@ -86,6 +123,11 @@ export default function CropVideo({ title }: ToolComponentProps) { title: 'Crop Coordinates', component: ( + {processingError && ( + + {processingError} + + )} Date: Mon, 26 May 2025 19:41:27 +0100 Subject: [PATCH 6/6] chore: change default dimensions --- .idea/workspace.xml | 123 ++++++++++----------- src/pages/tools/video/crop-video/index.tsx | 52 +++++---- 2 files changed, 92 insertions(+), 83 deletions(-) diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 8b52a7f..cce7313 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -6,8 +6,7 @@ - - + - { + "prStates": [ { - "id": { - "id": "PR_kwDOMJIfts51PkS9", - "number": 22 + "id": { + "id": "PR_kwDOMJIfts51PkS9", + "number": 22 }, - "lastSeen": 1741207144695 + "lastSeen": 1741207144695 }, { - "id": { - "id": "PR_kwDOMJIfts6NiNYl", - "number": 32 + "id": { + "id": "PR_kwDOMJIfts6NiNYl", + "number": 32 }, - "lastSeen": 1741209723869 + "lastSeen": 1741209723869 }, { - "id": { - "id": "PR_kwDOMJIfts6Nheyd", - "number": 31 + "id": { + "id": "PR_kwDOMJIfts6Nheyd", + "number": 31 }, - "lastSeen": 1741213371410 + "lastSeen": 1741213371410 }, { - "id": { - "id": "PR_kwDOMJIfts6NmRBs", - "number": 33 + "id": { + "id": "PR_kwDOMJIfts6NmRBs", + "number": 33 }, - "lastSeen": 1741282429036 + "lastSeen": 1741282429036 }, { - "id": { - "id": "PR_kwDOMJIfts5zyFTs", - "number": 15 + "id": { + "id": "PR_kwDOMJIfts5zyFTs", + "number": 15 }, - "lastSeen": 1741535540953 + "lastSeen": 1741535540953 }, { - "id": { - "id": "PR_kwDOMJIfts6QQB3c", - "number": 59 + "id": { + "id": "PR_kwDOMJIfts6QQB3c", + "number": 59 }, - "lastSeen": 1743018960900 + "lastSeen": 1743018960900 }, { - "id": { - "id": "PR_kwDOMJIfts6QMPEg", - "number": 58 + "id": { + "id": "PR_kwDOMJIfts6QMPEg", + "number": 58 }, - "lastSeen": 1743019452983 + "lastSeen": 1743019452983 }, { - "id": { - "id": "PR_kwDOMJIfts6QZvRI", - "number": 61 + "id": { + "id": "PR_kwDOMJIfts6QZvRI", + "number": 61 }, - "lastSeen": 1743103196866 + "lastSeen": 1743103196866 }, { - "id": { - "id": "PR_kwDOMJIfts6QqPrQ", - "number": 73 + "id": { + "id": "PR_kwDOMJIfts6QqPrQ", + "number": 73 }, - "lastSeen": 1743265865001 + "lastSeen": 1743265865001 }, { - "id": { - "id": "PR_kwDOMJIfts6Qp5nI", - "number": 72 + "id": { + "id": "PR_kwDOMJIfts6Qp5nI", + "number": 72 }, - "lastSeen": 1743338472110 + "lastSeen": 1743338472110 }, { - "id": { - "id": "PR_kwDOMJIfts6QsjlS", - "number": 76 + "id": { + "id": "PR_kwDOMJIfts6QsjlS", + "number": 76 }, - "lastSeen": 1743352150953 + "lastSeen": 1743352150953 }, { - "id": { - "id": "PR_kwDOMJIfts6Q0JBe", - "number": 82 + "id": { + "id": "PR_kwDOMJIfts6Q0JBe", + "number": 82 }, - "lastSeen": 1743470267269 + "lastSeen": 1743470267269 }, { - "id": { - "id": "PR_kwDOMJIfts6UE9-x", - "number": 102 + "id": { + "id": "PR_kwDOMJIfts6UE9-x", + "number": 102 }, - "lastSeen": 1747171977348 + "lastSeen": 1747171977348 }, { - "id": { - "id": "PR_kwDOMJIfts6XPua_", - "number": 117 + "id": { + "id": "PR_kwDOMJIfts6XPua_", + "number": 117 }, - "lastSeen": 1747929835864 + "lastSeen": 1747929835864 } ] -}]]> +} { "selectedUrlAndAccountId": { "url": "https://github.com/iib0011/omni-tools.git", @@ -197,7 +196,7 @@ "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": "#117 on fork/nevolodia/flip-video", + "git-widget-placeholder": "#122 on fork/nevolodia/crop-video", "ignore.virus.scanning.warn.message": "true", "kotlin-language-version-configured": "true", "last_opened_file_path": "C:/Users/Ibrahima/IdeaProjects/omni-tools/src", diff --git a/src/pages/tools/video/crop-video/index.tsx b/src/pages/tools/video/crop-video/index.tsx index b1bcbb6..07872b3 100644 --- a/src/pages/tools/video/crop-video/index.tsx +++ b/src/pages/tools/video/crop-video/index.tsx @@ -9,7 +9,7 @@ import ToolVideoInput from '@components/input/ToolVideoInput'; import { cropVideo, getVideoDimensions } from './service'; import { InitialValuesType } from './types'; -export const initialValues: InitialValuesType = { +const initialValues: InitialValuesType = { x: 0, y: 0, width: 100, @@ -26,23 +26,6 @@ export default function CropVideo({ title }: ToolComponentProps) { } | null>(null); const [processingError, setProcessingError] = useState(''); - useEffect(() => { - if (input) { - getVideoDimensions(input) - .then((dimensions) => { - setVideoDimensions(dimensions); - setProcessingError(''); - }) - .catch((error) => { - console.error('Error getting video dimensions:', error); - setProcessingError('Failed to load video dimensions'); - }); - } else { - setVideoDimensions(null); - setProcessingError(''); - } - }, [input]); - const validateDimensions = (values: InitialValuesType): string => { if (!videoDimensions) return ''; @@ -177,13 +160,40 @@ export default function CropVideo({ title }: ToolComponentProps) { ( { + 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 ? (