Merge pull request #73 from lfsjesus/feature/rotate-video

Feature: video rotation
This commit is contained in:
Ibrahima G. Coulibaly
2025-03-29 16:08:42 +00:00
committed by GitHub
4 changed files with 177 additions and 1 deletions

View File

@@ -1,4 +1,6 @@
import { rotate } from '../string/rotate/service';
import { gifTools } from './gif'; import { gifTools } from './gif';
import { tool as trimVideo } from './trim/meta'; 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];

View File

@@ -0,0 +1,117 @@
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';
import { ToolComponentProps } from '@tools/defineTool';
import { GetGroupsType } from '@components/options/ToolOptions';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import { updateNumberField } from '@utils/string';
import { debounce } from 'lodash';
import ToolVideoInput from '@components/input/ToolVideoInput';
import { rotateVideo } from './service';
export const initialValues = {
rotation: 90
};
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')
});
export default function RotateVideo({ title }: ToolComponentProps) {
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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;
}
setLoading(true);
setError(null);
try {
const rotatedFile = await rotateVideo(input, optionsValues.rotation);
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<typeof initialValues> = ({
values,
updateField
}) => [
{
title: 'Rotation',
component: (
<Box>
<TextFieldWithDesc
onOwnChange={(value) =>
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' }}
/>
</Box>
)
}
];
return (
<ToolContent
title={title}
input={input}
renderCustomInput={(_, setFieldValue) => (
<ToolVideoInput
value={input}
onChange={setInput}
accept={['video/mp4', 'video/webm', 'video/ogg']}
title={'Input Video'}
/>
)}
resultComponent={
loading ? (
<ToolFileResult
title={'Rotating Video'}
value={null}
loading={true}
extension={''}
/>
) : (
<ToolFileResult
title={'Rotated Video'}
value={result}
extension={'mp4'}
/>
)
}
initialValues={initialValues}
getGroups={getGroups}
compute={debouncedCompute}
setInput={setInput}
validationSchema={validationSchema}
/>
);
}

View File

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

View File

@@ -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<File> {
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<number, string> = {
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' }
);
}