feat: compress video

This commit is contained in:
Ibrahima G. Coulibaly
2025-03-30 17:18:06 +00:00
parent ab56a370f5
commit 8b4384b499
4 changed files with 266 additions and 1 deletions

View File

@@ -0,0 +1,183 @@
import { Box } 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 { debounce } from 'lodash';
import ToolVideoInput from '@components/input/ToolVideoInput';
import { compressVideo, VideoResolution } from './service';
import SimpleRadio from '@components/options/SimpleRadio';
import Slider from 'rc-slider';
import 'rc-slider/assets/index.css';
export const initialValues = {
width: 480 as VideoResolution,
crf: 23,
preset: 'medium'
};
export const validationSchema = Yup.object({
width: Yup.number()
.oneOf(
[240, 360, 480, 720, 1080],
'Width must be one of the standard resolutions'
)
.required('Width is required'),
crf: Yup.number()
.min(0, 'CRF must be at least 0')
.max(51, 'CRF must be at most 51')
.required('CRF is required'),
preset: Yup.string()
.oneOf(
[
'ultrafast',
'superfast',
'veryfast',
'faster',
'fast',
'medium',
'slow',
'slower',
'veryslow'
],
'Preset must be a valid ffmpeg preset'
)
.required('Preset is required')
});
const resolutionOptions: { value: VideoResolution; label: string }[] = [
{ value: 240, label: '240p' },
{ value: 360, label: '360p' },
{ value: 480, label: '480p' },
{ value: 720, label: '720p' },
{ value: 1080, label: '1080p' }
];
const presetOptions = [
{ value: 'ultrafast', label: 'Ultrafast (Lowest Quality, Smallest Size)' },
{ value: 'superfast', label: 'Superfast' },
{ value: 'veryfast', label: 'Very Fast' },
{ value: 'faster', label: 'Faster' },
{ value: 'fast', label: 'Fast' },
{ value: 'medium', label: 'Medium (Balanced)' },
{ value: 'slow', label: 'Slow' },
{ value: 'slower', label: 'Slower' },
{ value: 'veryslow', label: 'Very Slow (Highest Quality, Largest Size)' }
];
export default function CompressVideo({ title }: ToolComponentProps) {
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const compute = async (
optionsValues: typeof initialValues,
input: File | null
) => {
if (!input) return;
setLoading(true);
try {
const compressedFile = await compressVideo(input, {
width: optionsValues.width,
crf: optionsValues.crf,
preset: optionsValues.preset
});
setResult(compressedFile);
} catch (error) {
console.error('Error compressing video:', error);
} finally {
setLoading(false);
}
};
const debouncedCompute = useCallback(debounce(compute, 1000), []);
const getGroups: GetGroupsType<typeof initialValues> = ({
values,
updateField
}) => [
{
title: 'Resolution',
component: (
<Box>
{resolutionOptions.map((option) => (
<SimpleRadio
key={option.value}
title={option.label}
checked={values.width === option.value}
onClick={() => {
updateField('width', option.value);
}}
/>
))}
</Box>
)
},
{
title: 'Quality (CRF)',
component: (
<Box sx={{ mb: 2 }}>
<Slider
min={0}
max={51}
style={{ width: '90%' }}
value={values.crf}
onChange={(value) => {
updateField('crf', typeof value === 'number' ? value : value[0]);
}}
marks={{
0: 'Lossless',
23: 'Default',
51: 'Worst'
}}
/>
</Box>
)
}
// {
// title: 'Encoding Preset',
// component: (
// <SelectWithDesc
// selected={values.preset}
// onChange={(value) => updateField('preset', value)}
// options={presetOptions}
// description={
// 'Determines the compression speed. Slower presets provide better compression (quality per filesize) but take more time.'
// }
// />
// )
// }
];
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={'Compressed Video'}
value={result}
extension={'mp4'}
loading={loading}
loadingText={'Compressing video...'}
/>
}
initialValues={initialValues}
getGroups={getGroups}
compute={debouncedCompute}
setInput={setInput}
validationSchema={validationSchema}
/>
);
}

View File

@@ -0,0 +1,20 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('video', {
name: 'Compress Video',
path: 'compress',
icon: 'mdi:video-box',
description:
'Compress videos by scaling them to different resolutions like 240p, 480p, 720p, etc. This tool helps reduce file size while maintaining acceptable quality. Supports common video formats like MP4, WebM, and OGG.',
shortDescription: 'Compress videos by scaling to different resolutions',
keywords: [
'compress',
'video',
'resize',
'scale',
'resolution',
'reduce size'
],
component: lazy(() => import('./index'))
});

View File

@@ -0,0 +1,61 @@
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile } from '@ffmpeg/util';
const ffmpeg = new FFmpeg();
export type VideoResolution = 240 | 360 | 480 | 720 | 1080;
export interface CompressVideoOptions {
width: VideoResolution;
crf: number; // Constant Rate Factor (quality): lower = better quality, higher = smaller file
preset: string; // Encoding speed preset
}
export async function compressVideo(
input: File,
options: CompressVideoOptions
): Promise<File> {
console.log('Compressing video...', options);
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));
// Calculate height as -1 to maintain aspect ratio
const scaleFilter = `scale=${options.width}:-2`;
const args = [
'-i',
inputName,
'-vf',
scaleFilter,
'-c:v',
'libx264',
'-crf',
options.crf.toString(),
'-preset',
options.preset,
'-c:a',
'aac', // Copy audio stream
outputName
];
try {
await ffmpeg.exec(args);
} catch (error) {
console.error('FFmpeg execution failed:', error);
}
const compressedData = await ffmpeg.readFile(outputName);
return new File(
[new Blob([compressedData], { type: 'video/mp4' })],
`${input.name.replace(/\.[^/.]+$/, '')}_compressed_${options.width}p.mp4`,
{ type: 'video/mp4' }
);
}

View File

@@ -2,5 +2,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';
import { tool as compressVideo } from './compress/meta';
export const videoTools = [...gifTools, trimVideo, rotateVideo];
export const videoTools = [...gifTools, trimVideo, rotateVideo, compressVideo];