chore: crop video

This commit is contained in:
Ibrahima G. Coulibaly
2025-04-07 19:55:50 +01:00
parent 45af1b0735
commit 06369568fc
7 changed files with 239 additions and 198 deletions

View File

@@ -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

View File

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

View File

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

View File

@@ -3,5 +3,4 @@ export type InitialValuesType = {
height: number;
x: number;
y: number;
maintainAspectRatio: boolean;
};