mirror of
https://github.com/iib0011/omni-tools.git
synced 2025-09-18 05:29:33 +02:00
feat: compress video
This commit is contained in:
183
src/pages/tools/video/compress/index.tsx
Normal file
183
src/pages/tools/video/compress/index.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
20
src/pages/tools/video/compress/meta.ts
Normal file
20
src/pages/tools/video/compress/meta.ts
Normal 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'))
|
||||
});
|
61
src/pages/tools/video/compress/service.ts
Normal file
61
src/pages/tools/video/compress/service.ts
Normal 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' }
|
||||
);
|
||||
}
|
@@ -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];
|
||||
|
Reference in New Issue
Block a user