mirror of
https://github.com/iib0011/omni-tools.git
synced 2025-12-29 16:16:02 +00:00
chore: crop video
This commit is contained in:
@@ -1,15 +1,23 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import Slider from 'rc-slider';
|
||||
import 'rc-slider/assets/index.css';
|
||||
import BaseFileInput from './BaseFileInput';
|
||||
import { BaseFileInputProps, formatTime } from './file-input-utils';
|
||||
import Cropper, { MediaSize, Point, Size, Area } from 'react-easy-crop';
|
||||
|
||||
interface VideoFileInputProps extends BaseFileInputProps {
|
||||
showTrimControls?: boolean;
|
||||
onTrimChange?: (trimStart: number, trimEnd: number) => void;
|
||||
trimStart?: number;
|
||||
trimEnd?: number;
|
||||
showCropOverlay?: boolean;
|
||||
cropPosition?: { x: number; y: number };
|
||||
cropSize?: { width: number; height: number };
|
||||
onCropChange?: (
|
||||
position: { x: number; y: number },
|
||||
size: { width: number; height: number }
|
||||
) => void;
|
||||
}
|
||||
|
||||
export default function ToolVideoInput({
|
||||
@@ -17,11 +25,28 @@ export default function ToolVideoInput({
|
||||
onTrimChange,
|
||||
trimStart = 0,
|
||||
trimEnd = 100,
|
||||
showCropOverlay,
|
||||
cropPosition = { x: 0, y: 0 },
|
||||
cropSize = { width: 100, height: 100 },
|
||||
onCropChange,
|
||||
...props
|
||||
}: VideoFileInputProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
let videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [videoDuration, setVideoDuration] = useState(0);
|
||||
const [videoWidth, setVideoWidth] = useState(0);
|
||||
const [videoHeight, setVideoHeight] = useState(0);
|
||||
|
||||
const [crop, setCrop] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}>({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 0
|
||||
});
|
||||
const onVideoLoad = (e: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||
const duration = e.currentTarget.duration;
|
||||
setVideoDuration(duration);
|
||||
@@ -30,13 +55,59 @@ export default function ToolVideoInput({
|
||||
onTrimChange(0, duration);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
if (
|
||||
cropPosition.x !== 0 ||
|
||||
cropPosition.y !== 0 ||
|
||||
cropSize.width !== 100 ||
|
||||
cropSize.height !== 100
|
||||
) {
|
||||
setCrop({ ...cropPosition, ...cropSize });
|
||||
}
|
||||
}, [cropPosition, cropSize]);
|
||||
|
||||
const onCropMediaLoaded = (mediaSize: MediaSize) => {
|
||||
const { width, height } = mediaSize;
|
||||
setVideoWidth(width);
|
||||
setVideoHeight(height);
|
||||
if (!crop.width && !crop.height && onCropChange) {
|
||||
const initialCrop = {
|
||||
x: Math.floor(width / 4),
|
||||
y: Math.floor(height / 4),
|
||||
width: Math.floor(width / 2),
|
||||
height: Math.floor(height / 2)
|
||||
};
|
||||
console.log('initialCrop', initialCrop);
|
||||
setCrop(initialCrop);
|
||||
|
||||
onCropChange(
|
||||
{ x: initialCrop.x, y: initialCrop.y },
|
||||
{ width: initialCrop.width, height: initialCrop.height }
|
||||
);
|
||||
}
|
||||
};
|
||||
const handleTrimChange = (start: number, end: number) => {
|
||||
if (onTrimChange) {
|
||||
onTrimChange(start, end);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCropChange = (newCrop: Point) => {
|
||||
setCrop((prevState) => ({ ...prevState, x: newCrop.x, y: newCrop.y }));
|
||||
};
|
||||
const handleCropSizeChange = (newCrop: Size) => {
|
||||
setCrop((prevState) => ({
|
||||
...prevState,
|
||||
height: newCrop.height,
|
||||
width: newCrop.width
|
||||
}));
|
||||
};
|
||||
|
||||
const handleCropComplete = (crop: Area, croppedAreaPixels: Area) => {
|
||||
if (onCropChange) {
|
||||
onCropChange(croppedAreaPixels, croppedAreaPixels);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<BaseFileInput {...props} type={'video'}>
|
||||
{({ preview }) => (
|
||||
@@ -51,16 +122,38 @@ export default function ToolVideoInput({
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={preview}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: showTrimControls ? 'calc(100% - 50px)' : '100%'
|
||||
}}
|
||||
onLoadedMetadata={onVideoLoad}
|
||||
controls={!showTrimControls}
|
||||
/>
|
||||
{showCropOverlay ? (
|
||||
<Cropper
|
||||
setVideoRef={(ref) => {
|
||||
videoRef = ref;
|
||||
}}
|
||||
video={preview}
|
||||
crop={crop}
|
||||
cropSize={crop}
|
||||
onCropSizeChange={handleCropSizeChange}
|
||||
onCropChange={handleCropChange}
|
||||
onCropComplete={handleCropComplete}
|
||||
onMediaLoaded={onCropMediaLoaded}
|
||||
initialCroppedAreaPercentages={{
|
||||
height: 0.5,
|
||||
width: 0.5,
|
||||
x: 0.5,
|
||||
y: 0.5
|
||||
}}
|
||||
// controls={!showTrimControls}
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={preview}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: showTrimControls ? 'calc(100% - 50px)' : '100%'
|
||||
}}
|
||||
onLoadedMetadata={onVideoLoad}
|
||||
controls={!showTrimControls}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showTrimControls && videoDuration > 0 && (
|
||||
<Box
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cropVideo } from './service';
|
||||
|
||||
// Mock FFmpeg
|
||||
vi.mock('@ffmpeg/ffmpeg', () => {
|
||||
return {
|
||||
FFmpeg: vi.fn().mockImplementation(() => {
|
||||
return {
|
||||
loaded: false,
|
||||
load: vi.fn().mockResolvedValue(undefined),
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
exec: vi.fn().mockResolvedValue(undefined),
|
||||
readFile: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3]))
|
||||
};
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
// Mock fetchFile
|
||||
vi.mock('@ffmpeg/util', () => {
|
||||
return {
|
||||
fetchFile: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3]))
|
||||
};
|
||||
});
|
||||
|
||||
describe('crop-video', () => {
|
||||
let mockFile: File;
|
||||
let mockVideoInfo: { width: number; height: number };
|
||||
|
||||
beforeEach(() => {
|
||||
mockFile = new File(['test'], 'test.mp4', { type: 'video/mp4' });
|
||||
mockVideoInfo = { width: 1280, height: 720 };
|
||||
|
||||
// Reset global File constructor
|
||||
global.File = vi.fn().mockImplementation((bits, name, options) => {
|
||||
return new File(bits, name, options);
|
||||
});
|
||||
});
|
||||
|
||||
it('should crop a video with valid parameters', async () => {
|
||||
const options = {
|
||||
width: 640,
|
||||
height: 360,
|
||||
x: 0,
|
||||
y: 0,
|
||||
maintainAspectRatio: false
|
||||
};
|
||||
|
||||
const result = await cropVideo(mockFile, options, mockVideoInfo);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.name).toContain('_cropped');
|
||||
expect(result.type).toBe('video/mp4');
|
||||
});
|
||||
|
||||
it('should throw error if videoInfo is not provided', async () => {
|
||||
const options = {
|
||||
width: 640,
|
||||
height: 360,
|
||||
x: 0,
|
||||
y: 0,
|
||||
maintainAspectRatio: false
|
||||
};
|
||||
|
||||
await expect(cropVideo(mockFile, options, null)).rejects.toThrow(
|
||||
'Video information is required'
|
||||
);
|
||||
});
|
||||
|
||||
it('should adjust crop dimensions to fit within video bounds', async () => {
|
||||
const options = {
|
||||
width: 2000, // Larger than video width
|
||||
height: 1000, // Larger than video height
|
||||
x: 500,
|
||||
y: 500,
|
||||
maintainAspectRatio: false
|
||||
};
|
||||
|
||||
const result = await cropVideo(mockFile, options, mockVideoInfo);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
// We can't test the actual crop dimensions since FFmpeg is mocked
|
||||
// but we can verify the file was created
|
||||
expect(result.name).toContain('_cropped');
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Box, Checkbox, FormControlLabel } from '@mui/material';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import React, { useCallback, useEffect, 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 { GetGroupsType, UpdateField } from '@components/options/ToolOptions';
|
||||
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
|
||||
import { updateNumberField } from '@utils/string';
|
||||
import { FFmpeg } from '@ffmpeg/ffmpeg';
|
||||
@@ -19,8 +19,7 @@ const initialValues: InitialValuesType = {
|
||||
width: 640,
|
||||
height: 360,
|
||||
x: 0,
|
||||
y: 0,
|
||||
maintainAspectRatio: true
|
||||
y: 0
|
||||
};
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
@@ -46,14 +45,12 @@ export default function CropVideo({ title }: ToolComponentProps) {
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
|
||||
// Get video dimensions when a video is loaded
|
||||
useEffect(() => {
|
||||
if (input) {
|
||||
const video = document.createElement('video');
|
||||
video.onloadedmetadata = () => {
|
||||
console.log('loadedmetadata', video.videoWidth, video.videoHeight);
|
||||
setVideoInfo({
|
||||
width: video.videoWidth,
|
||||
height: video.videoHeight
|
||||
@@ -133,8 +130,7 @@ export default function CropVideo({ title }: ToolComponentProps) {
|
||||
|
||||
const getGroups: GetGroupsType<InitialValuesType> = ({
|
||||
values,
|
||||
updateField,
|
||||
setFieldValue
|
||||
updateField
|
||||
}) => [
|
||||
{
|
||||
title: 'Crop Settings',
|
||||
@@ -172,34 +168,48 @@ export default function CropVideo({ title }: ToolComponentProps) {
|
||||
label={'Y Position (pixels)'}
|
||||
sx={{ mb: 2, backgroundColor: 'background.paper' }}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={values.maintainAspectRatio}
|
||||
onChange={(e) => {
|
||||
setFieldValue('maintainAspectRatio', e.target.checked);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="Maintain aspect ratio"
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const handleCropChange =
|
||||
(values: InitialValuesType, updateField: UpdateField<InitialValuesType>) =>
|
||||
(
|
||||
position: { x: number; y: number },
|
||||
size: { width: number; height: number }
|
||||
) => {
|
||||
console.log('Crop position:', position, size);
|
||||
updateField('x', position.x);
|
||||
updateField('y', position.y);
|
||||
updateField('width', size.width);
|
||||
updateField('height', size.height);
|
||||
};
|
||||
const renderCustomInput = (
|
||||
values: InitialValuesType,
|
||||
updateField: UpdateField<InitialValuesType>
|
||||
) => (
|
||||
<ToolVideoInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
accept={['video/mp4', 'video/webm', 'video/ogg']}
|
||||
title={'Input Video'}
|
||||
showCropOverlay={!!input}
|
||||
cropPosition={{
|
||||
x: parseInt(values.x || '0'),
|
||||
y: parseInt(values.y || '0')
|
||||
}}
|
||||
cropSize={{
|
||||
width: parseInt(values.width || '100'),
|
||||
height: parseInt(values.height || '100')
|
||||
}}
|
||||
onCropChange={handleCropChange(values, updateField)}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<ToolContent
|
||||
title={title}
|
||||
input={input}
|
||||
inputComponent={
|
||||
<ToolVideoInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
accept={['video/mp4', 'video/webm', 'video/ogg']}
|
||||
title={'Input Video'}
|
||||
/>
|
||||
}
|
||||
renderCustomInput={renderCustomInput}
|
||||
resultComponent={
|
||||
<ToolFileResult
|
||||
title={'Cropped Video'}
|
||||
|
||||
@@ -3,5 +3,4 @@ export type InitialValuesType = {
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
maintainAspectRatio: boolean;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user