From 480b4f7c24e9b7909b06bb335a431bf5a6b362d4 Mon Sep 17 00:00:00 2001 From: Miguel Pedrosa Date: Sat, 29 Mar 2025 15:29:30 +0000 Subject: [PATCH 1/2] Implemented first version of video rotate --- src/pages/tools/video/index.ts | 4 +- src/pages/tools/video/rotate/index.tsx | 161 +++++++++++++++++++++++++ src/pages/tools/video/rotate/meta.ts | 13 ++ 3 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 src/pages/tools/video/rotate/index.tsx create mode 100644 src/pages/tools/video/rotate/meta.ts diff --git a/src/pages/tools/video/index.ts b/src/pages/tools/video/index.ts index bbcc497..9a9dd35 100644 --- a/src/pages/tools/video/index.ts +++ b/src/pages/tools/video/index.ts @@ -1,4 +1,6 @@ +import { rotate } from '../string/rotate/service'; import { gifTools } from './gif'; import { tool as trimVideo } from './trim/meta'; +import { tool as rotateVideo } from './rotate/meta'; -export const videoTools = [...gifTools, trimVideo]; +export const videoTools = [...gifTools, trimVideo, rotateVideo]; diff --git a/src/pages/tools/video/rotate/index.tsx b/src/pages/tools/video/rotate/index.tsx new file mode 100644 index 0000000..63ef1b3 --- /dev/null +++ b/src/pages/tools/video/rotate/index.tsx @@ -0,0 +1,161 @@ +import { Box, CircularProgress } from '@mui/material'; +import React, { useCallback, 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'; + +const ffmpeg = new FFmpeg(); + +const initialValues = { + rotation: 90 +}; + +const validationSchema = Yup.object({ + rotation: Yup.number() + .oneOf([0, 90, 180, 270], 'Rotation must be 0, 90, 180, or 270 degrees') + .required('Rotation is required') +}); + +export default function RotateVideo({ title }: ToolComponentProps) { + const [input, setInput] = useState(null); + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const compute = async ( + optionsValues: typeof initialValues, + input: File | null + ) => { + if (!input) return; + + try { + await validationSchema.validate(optionsValues); + } catch (validationError) { + setError((validationError as Yup.ValidationError).message); + return; + } + + const { rotation } = optionsValues; + setLoading(true); + setError(null); + + try { + 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)); + + // Determine FFmpeg transpose filter based on rotation + const rotateMap: Record = { + 90: 'transpose=1', + 180: 'transpose=2,transpose=2', + 270: 'transpose=2', + 0: '' + }; + const rotateFilter = rotateMap[rotation]; + + const args = ['-i', inputName]; + + if (rotateFilter) { + args.push('-vf', rotateFilter); + } + + args.push('-c:v', 'libx264', '-preset', 'ultrafast', outputName); + + console.log('Executing FFmpeg with args:', args); + await ffmpeg.exec(args); + + const rotatedData = await ffmpeg.readFile(outputName); + const rotatedBlob = new Blob([rotatedData], { type: 'video/mp4' }); + const rotatedFile = new File( + [rotatedBlob], + `${input.name.replace(/\.[^/.]+$/, '')}_rotated.mp4`, + { + type: 'video/mp4' + } + ); + + setResult(rotatedFile); + } catch (error) { + console.error('Error rotating video:', error); + setError('Failed to rotate video. Please try again.'); + } finally { + setLoading(false); + } + }; + + const debouncedCompute = useCallback(debounce(compute, 1000), []); + + const getGroups: GetGroupsType = ({ + values, + updateField + }) => [ + { + title: 'Rotation', + component: ( + + + updateNumberField(value, 'rotation', updateField) + } + value={values.rotation} + label={'Rotation (degrees)'} + helperText={error || 'Valid values: 0, 90, 180, 270'} + error={!!error} + sx={{ mb: 2, backgroundColor: 'white' }} + /> + + ) + } + ]; + + return ( + ( + + )} + resultComponent={ + loading ? ( + + ) : ( + + ) + } + initialValues={initialValues} + getGroups={getGroups} + compute={debouncedCompute} + setInput={setInput} + validationSchema={validationSchema} + /> + ); +} diff --git a/src/pages/tools/video/rotate/meta.ts b/src/pages/tools/video/rotate/meta.ts new file mode 100644 index 0000000..0bcf327 --- /dev/null +++ b/src/pages/tools/video/rotate/meta.ts @@ -0,0 +1,13 @@ +import { defineTool } from '@tools/defineTool'; +import { lazy } from 'react'; + +export const tool = defineTool('video', { + name: 'Rotate Video', + path: 'rotate', + icon: 'mdi:rotate-right', + description: + 'This online utility lets you rotate videos by 90, 180, or 270 degrees. You can preview the rotated video before processing. Supports common video formats like MP4, WebM, and OGG.', + shortDescription: 'Rotate videos by 90, 180, or 270 degrees', + keywords: ['rotate', 'video', 'flip', 'edit', 'adjust'], + component: lazy(() => import('./index')) +}); From 19d157eec430e24ed4e62efbda3edeb1450e7492 Mon Sep 17 00:00:00 2001 From: Miguel Pedrosa Date: Sat, 29 Mar 2025 15:38:33 +0000 Subject: [PATCH 2/2] Refactor: moved rotation logic to a service.ts file --- src/pages/tools/video/rotate/index.tsx | 56 +++---------------------- src/pages/tools/video/rotate/service.ts | 44 +++++++++++++++++++ 2 files changed, 50 insertions(+), 50 deletions(-) create mode 100644 src/pages/tools/video/rotate/service.ts diff --git a/src/pages/tools/video/rotate/index.tsx b/src/pages/tools/video/rotate/index.tsx index 63ef1b3..5b8770c 100644 --- a/src/pages/tools/video/rotate/index.tsx +++ b/src/pages/tools/video/rotate/index.tsx @@ -1,5 +1,5 @@ -import { Box, CircularProgress } from '@mui/material'; -import React, { useCallback, useState } from 'react'; +import { Box } from '@mui/material'; +import { useCallback, useState } from 'react'; import * as Yup from 'yup'; import ToolFileResult from '@components/result/ToolFileResult'; import ToolContent from '@components/ToolContent'; @@ -7,18 +7,15 @@ 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 { rotateVideo } from './service'; -const ffmpeg = new FFmpeg(); - -const initialValues = { +export const initialValues = { rotation: 90 }; -const validationSchema = Yup.object({ +export const validationSchema = Yup.object({ rotation: Yup.number() .oneOf([0, 90, 180, 270], 'Rotation must be 0, 90, 180, or 270 degrees') .required('Rotation is required') @@ -43,52 +40,11 @@ export default function RotateVideo({ title }: ToolComponentProps) { return; } - const { rotation } = optionsValues; setLoading(true); setError(null); try { - 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)); - - // Determine FFmpeg transpose filter based on rotation - const rotateMap: Record = { - 90: 'transpose=1', - 180: 'transpose=2,transpose=2', - 270: 'transpose=2', - 0: '' - }; - const rotateFilter = rotateMap[rotation]; - - const args = ['-i', inputName]; - - if (rotateFilter) { - args.push('-vf', rotateFilter); - } - - args.push('-c:v', 'libx264', '-preset', 'ultrafast', outputName); - - console.log('Executing FFmpeg with args:', args); - await ffmpeg.exec(args); - - const rotatedData = await ffmpeg.readFile(outputName); - const rotatedBlob = new Blob([rotatedData], { type: 'video/mp4' }); - const rotatedFile = new File( - [rotatedBlob], - `${input.name.replace(/\.[^/.]+$/, '')}_rotated.mp4`, - { - type: 'video/mp4' - } - ); - + const rotatedFile = await rotateVideo(input, optionsValues.rotation); setResult(rotatedFile); } catch (error) { console.error('Error rotating video:', error); diff --git a/src/pages/tools/video/rotate/service.ts b/src/pages/tools/video/rotate/service.ts new file mode 100644 index 0000000..6b31770 --- /dev/null +++ b/src/pages/tools/video/rotate/service.ts @@ -0,0 +1,44 @@ +import { FFmpeg } from '@ffmpeg/ffmpeg'; +import { fetchFile } from '@ffmpeg/util'; + +const ffmpeg = new FFmpeg(); + +export async function rotateVideo( + input: File, + rotation: number +): 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 rotateMap: Record = { + 90: 'transpose=1', + 180: 'transpose=2,transpose=2', + 270: 'transpose=2', + 0: '' + }; + const rotateFilter = rotateMap[rotation]; + + const args = ['-i', inputName]; + if (rotateFilter) { + args.push('-vf', rotateFilter); + } + + args.push('-c:v', 'libx264', '-preset', 'ultrafast', outputName); + + await ffmpeg.exec(args); + + const rotatedData = await ffmpeg.readFile(outputName); + return new File( + [new Blob([rotatedData], { type: 'video/mp4' })], + `${input.name.replace(/\.[^/.]+$/, '')}_rotated.mp4`, + { type: 'video/mp4' } + ); +}