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

143
.idea/workspace.xml generated
View File

@@ -4,14 +4,15 @@
<option name="autoReloadType" value="SELECTIVE" /> <option name="autoReloadType" value="SELECTIVE" />
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="b30e2810-c4c1-4aad-b134-794e52cc1c7d" name="Changes" comment="chore: readme img and fix broken link"> <list default="true" id="b30e2810-c4c1-4aad-b134-794e52cc1c7d" name="Changes" comment="feat: crop video init">
<change afterPath="$PROJECT_DIR$/src/pages/tools/video/crop-video/crop-video.service.test.ts" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.github/workflows/ci.yml" beforeDir="false" afterPath="$PROJECT_DIR$/.github/workflows/ci.yml" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/pages/tools/video/crop-video/index.tsx" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/pages/tools/video/crop-video/meta.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/pages/tools/video/crop-video/types.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/components/ToolContent.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/ToolContent.tsx" afterDir="false" /> <change beforePath="$PROJECT_DIR$/package-lock.json" beforeDir="false" afterPath="$PROJECT_DIR$/package-lock.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/video/index.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/video/index.ts" afterDir="false" /> <change beforePath="$PROJECT_DIR$/package.json" beforeDir="false" afterPath="$PROJECT_DIR$/package.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/components/input/ToolVideoInput.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/input/ToolVideoInput.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/video/crop-video/crop-video.service.test.ts" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/video/crop-video/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/video/crop-video/index.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/video/crop-video/types.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/video/crop-video/types.ts" afterDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -163,57 +164,57 @@
<option name="hideEmptyMiddlePackages" value="true" /> <option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" /> <option name="showLibraryContents" value="true" />
</component> </component>
<component name="PropertiesComponent"><![CDATA[{ <component name="PropertiesComponent">{
"keyToString": { &quot;keyToString&quot;: {
"ASKED_ADD_EXTERNAL_FILES": "true", &quot;ASKED_ADD_EXTERNAL_FILES&quot;: &quot;true&quot;,
"ASKED_SHARE_PROJECT_CONFIGURATION_FILES": "true", &quot;ASKED_SHARE_PROJECT_CONFIGURATION_FILES&quot;: &quot;true&quot;,
"Docker.Dockerfile build.executor": "Run", &quot;Docker.Dockerfile build.executor&quot;: &quot;Run&quot;,
"Docker.Dockerfile.executor": "Run", &quot;Docker.Dockerfile.executor&quot;: &quot;Run&quot;,
"Playwright.Create transparent PNG.should make png color transparent.executor": "Run", &quot;Playwright.Create transparent PNG.should make png color transparent.executor&quot;: &quot;Run&quot;,
"Playwright.JoinText Component.executor": "Run", &quot;Playwright.JoinText Component.executor&quot;: &quot;Run&quot;,
"Playwright.JoinText Component.should merge text pieces with specified join character.executor": "Run", &quot;Playwright.JoinText Component.should merge text pieces with specified join character.executor&quot;: &quot;Run&quot;,
"RunOnceActivity.OpenProjectViewOnStart": "true", &quot;RunOnceActivity.OpenProjectViewOnStart&quot;: &quot;true&quot;,
"RunOnceActivity.ShowReadmeOnStart": "true", &quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
"RunOnceActivity.git.unshallow": "true", &quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
"Vitest.compute function (1).executor": "Run", &quot;Vitest.compute function (1).executor&quot;: &quot;Run&quot;,
"Vitest.compute function.executor": "Run", &quot;Vitest.compute function.executor&quot;: &quot;Run&quot;,
"Vitest.crop-video.executor": "Run", &quot;Vitest.crop-video.executor&quot;: &quot;Run&quot;,
"Vitest.mergeText.executor": "Run", &quot;Vitest.mergeText.executor&quot;: &quot;Run&quot;,
"Vitest.mergeText.should merge lines and preserve blank lines when deleteBlankLines is false.executor": "Run", &quot;Vitest.mergeText.should merge lines and preserve blank lines when deleteBlankLines is false.executor&quot;: &quot;Run&quot;,
"Vitest.mergeText.should merge lines, preserve blank lines and trailing spaces when both deleteBlankLines and deleteTrailingSpaces are false.executor": "Run", &quot;Vitest.mergeText.should merge lines, preserve blank lines and trailing spaces when both deleteBlankLines and deleteTrailingSpaces are false.executor&quot;: &quot;Run&quot;,
"Vitest.parsePageRanges.executor": "Run", &quot;Vitest.parsePageRanges.executor&quot;: &quot;Run&quot;,
"Vitest.removeDuplicateLines function.executor": "Run", &quot;Vitest.removeDuplicateLines function.executor&quot;: &quot;Run&quot;,
"Vitest.removeDuplicateLines function.newlines option.executor": "Run", &quot;Vitest.removeDuplicateLines function.newlines option.executor&quot;: &quot;Run&quot;,
"Vitest.removeDuplicateLines function.newlines option.should filter newlines when newlines is set to filter.executor": "Run", &quot;Vitest.removeDuplicateLines function.newlines option.should filter newlines when newlines is set to filter.executor&quot;: &quot;Run&quot;,
"Vitest.replaceText function (regexp mode).should return the original text when passed an invalid regexp.executor": "Run", &quot;Vitest.replaceText function (regexp mode).should return the original text when passed an invalid regexp.executor&quot;: &quot;Run&quot;,
"Vitest.replaceText function.executor": "Run", &quot;Vitest.replaceText function.executor&quot;: &quot;Run&quot;,
"Vitest.timeBetweenDates.executor": "Run", &quot;Vitest.timeBetweenDates.executor&quot;: &quot;Run&quot;,
"git-widget-placeholder": "crop-video", &quot;git-widget-placeholder&quot;: &quot;crop-video&quot;,
"ignore.virus.scanning.warn.message": "true", &quot;ignore.virus.scanning.warn.message&quot;: &quot;true&quot;,
"kotlin-language-version-configured": "true", &quot;kotlin-language-version-configured&quot;: &quot;true&quot;,
"last_opened_file_path": "C:/Users/Ibrahima/IdeaProjects/omni-tools/src", &quot;last_opened_file_path&quot;: &quot;C:/Users/Ibrahima/IdeaProjects/omni-tools/src&quot;,
"node.js.detected.package.eslint": "true", &quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
"node.js.detected.package.tslint": "true", &quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
"node.js.selected.package.eslint": "(autodetect)", &quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
"node.js.selected.package.tslint": "(autodetect)", &quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
"nodejs_package_manager_path": "npm", &quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
"npm.build.executor": "Run", &quot;npm.build.executor&quot;: &quot;Run&quot;,
"npm.dev.executor": "Run", &quot;npm.dev.executor&quot;: &quot;Run&quot;,
"npm.lint.executor": "Run", &quot;npm.lint.executor&quot;: &quot;Run&quot;,
"npm.prebuild.executor": "Run", &quot;npm.prebuild.executor&quot;: &quot;Run&quot;,
"npm.script:create:tool.executor": "Run", &quot;npm.script:create:tool.executor&quot;: &quot;Run&quot;,
"npm.test.executor": "Run", &quot;npm.test.executor&quot;: &quot;Run&quot;,
"npm.test:e2e.executor": "Run", &quot;npm.test:e2e.executor&quot;: &quot;Run&quot;,
"npm.test:e2e:run.executor": "Run", &quot;npm.test:e2e:run.executor&quot;: &quot;Run&quot;,
"prettierjs.PrettierConfiguration.Package": "C:\\Users\\Ibrahima\\IdeaProjects\\omni-tools\\node_modules\\prettier", &quot;prettierjs.PrettierConfiguration.Package&quot;: &quot;C:\\Users\\Ibrahima\\IdeaProjects\\omni-tools\\node_modules\\prettier&quot;,
"project.structure.last.edited": "Problems", &quot;project.structure.last.edited&quot;: &quot;Problems&quot;,
"project.structure.proportion": "0.0", &quot;project.structure.proportion&quot;: &quot;0.0&quot;,
"project.structure.side.proportion": "0.2", &quot;project.structure.side.proportion&quot;: &quot;0.2&quot;,
"settings.editor.selected.configurable": "refactai_advanced_settings", &quot;settings.editor.selected.configurable&quot;: &quot;refactai_advanced_settings&quot;,
"ts.external.directory.path": "C:\\Users\\Ibrahima\\IdeaProjects\\omni-tools\\node_modules\\typescript\\lib", &quot;ts.external.directory.path&quot;: &quot;C:\\Users\\Ibrahima\\IdeaProjects\\omni-tools\\node_modules\\typescript\\lib&quot;,
"vue.rearranger.settings.migration": "true" &quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
} }
}]]></component> }</component>
<component name="ReactDesignerToolWindowState"> <component name="ReactDesignerToolWindowState">
<option name="myId2Visible"> <option name="myId2Visible">
<map> <map>
@@ -239,7 +240,7 @@
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\src\pages\categories" /> <recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\src\pages\categories" />
</key> </key>
</component> </component>
<component name="RunManager" selected="Vitest.crop-video"> <component name="RunManager" selected="npm.dev">
<configuration name="crop-video" type="JavaScriptTestRunnerVitest" temporary="true" nameIsGenerated="true"> <configuration name="crop-video" type="JavaScriptTestRunnerVitest" temporary="true" nameIsGenerated="true">
<node-interpreter value="project" /> <node-interpreter value="project" />
<vitest-package value="$PROJECT_DIR$/node_modules/vitest" /> <vitest-package value="$PROJECT_DIR$/node_modules/vitest" />
@@ -318,8 +319,8 @@
</list> </list>
<recent_temporary> <recent_temporary>
<list> <list>
<item itemvalue="Vitest.crop-video" />
<item itemvalue="npm.dev" /> <item itemvalue="npm.dev" />
<item itemvalue="Vitest.crop-video" />
<item itemvalue="Vitest.replaceText function (regexp mode).should return the original text when passed an invalid regexp" /> <item itemvalue="Vitest.replaceText function (regexp mode).should return the original text when passed an invalid regexp" />
<item itemvalue="Vitest.parsePageRanges" /> <item itemvalue="Vitest.parsePageRanges" />
<item itemvalue="Vitest.timeBetweenDates" /> <item itemvalue="Vitest.timeBetweenDates" />
@@ -417,14 +418,8 @@
<workItem from="1743699386059" duration="11195000" /> <workItem from="1743699386059" duration="11195000" />
<workItem from="1743782726563" duration="2444000" /> <workItem from="1743782726563" duration="2444000" />
<workItem from="1743811558991" duration="1279000" /> <workItem from="1743811558991" duration="1279000" />
</task> <workItem from="1744042155791" duration="11000" />
<task id="LOCAL-00144" summary="feat: stringify json"> <workItem from="1744051596076" duration="361000" />
<option name="closed" value="true" />
<created>1741417920442</created>
<option name="number" value="00144" />
<option name="presentableId" value="LOCAL-00144" />
<option name="project" value="LOCAL" />
<updated>1741417920442</updated>
</task> </task>
<task id="LOCAL-00145" summary="feat: arithmetic sequence"> <task id="LOCAL-00145" summary="feat: arithmetic sequence">
<option name="closed" value="true" /> <option name="closed" value="true" />
@@ -810,7 +805,15 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1743811980098</updated> <updated>1743811980098</updated>
</task> </task>
<option name="localTasksCounter" value="193" /> <task id="LOCAL-00193" summary="feat: crop video init">
<option name="closed" value="true" />
<created>1743986670751</created>
<option name="number" value="00193" />
<option name="presentableId" value="LOCAL-00193" />
<option name="project" value="LOCAL" />
<updated>1743986670751</updated>
</task>
<option name="localTasksCounter" value="194" />
<servers /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
@@ -857,7 +860,6 @@
<option name="CHECK_CODE_SMELLS_BEFORE_PROJECT_COMMIT" value="false" /> <option name="CHECK_CODE_SMELLS_BEFORE_PROJECT_COMMIT" value="false" />
<option name="CHECK_NEW_TODO" value="false" /> <option name="CHECK_NEW_TODO" value="false" />
<option name="ADD_EXTERNAL_FILES_SILENTLY" value="true" /> <option name="ADD_EXTERNAL_FILES_SILENTLY" value="true" />
<MESSAGE value="chore: result file name" />
<MESSAGE value="chore: text result extensions" /> <MESSAGE value="chore: text result extensions" />
<MESSAGE value="chore: show new tools in landing" /> <MESSAGE value="chore: show new tools in landing" />
<MESSAGE value="chore: zoom on hover" /> <MESSAGE value="chore: zoom on hover" />
@@ -882,7 +884,8 @@
<MESSAGE value="feat: image to text" /> <MESSAGE value="feat: image to text" />
<MESSAGE value="chore: hideCopy if video or audio" /> <MESSAGE value="chore: hideCopy if video or audio" />
<MESSAGE value="chore: readme img and fix broken link" /> <MESSAGE value="chore: readme img and fix broken link" />
<option name="LAST_COMMIT_MESSAGE" value="chore: readme img and fix broken link" /> <MESSAGE value="feat: crop video init" />
<option name="LAST_COMMIT_MESSAGE" value="feat: crop video init" />
</component> </component>
<component name="XSLT-Support.FileAssociations.UIState"> <component name="XSLT-Support.FileAssociations.UIState">
<expand /> <expand />

21
package-lock.json generated
View File

@@ -37,6 +37,7 @@
"rc-slider": "^11.1.8", "rc-slider": "^11.1.8",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-easy-crop": "^5.4.1",
"react-helmet": "^6.1.0", "react-helmet": "^6.1.0",
"react-image-crop": "^11.0.7", "react-image-crop": "^11.0.7",
"react-router-dom": "^6.23.1", "react-router-dom": "^6.23.1",
@@ -7876,6 +7877,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/normalize-wheel": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz",
"integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==",
"license": "BSD-3-Clause"
},
"node_modules/notistack": { "node_modules/notistack": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/notistack/-/notistack-3.0.1.tgz", "resolved": "https://registry.npmjs.org/notistack/-/notistack-3.0.1.tgz",
@@ -8884,6 +8891,20 @@
"react": "^18.3.1" "react": "^18.3.1"
} }
}, },
"node_modules/react-easy-crop": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-5.4.1.tgz",
"integrity": "sha512-Djtsi7bWO75vkKYkVxNRrJWY69pXLahIAkUN0mmt9cXNnaq2tpG59ctSY6P7ipJgBc7COJDRMRuwb2lYwtACNQ==",
"license": "MIT",
"dependencies": {
"normalize-wheel": "^1.0.1",
"tslib": "^2.0.1"
},
"peerDependencies": {
"react": ">=16.4.0",
"react-dom": ">=16.4.0"
}
},
"node_modules/react-fast-compare": { "node_modules/react-fast-compare": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz",

View File

@@ -54,6 +54,7 @@
"rc-slider": "^11.1.8", "rc-slider": "^11.1.8",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-easy-crop": "^5.4.1",
"react-helmet": "^6.1.0", "react-helmet": "^6.1.0",
"react-image-crop": "^11.0.7", "react-image-crop": "^11.0.7",
"react-router-dom": "^6.23.1", "react-router-dom": "^6.23.1",

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 { Box, Typography } from '@mui/material';
import Slider from 'rc-slider'; import Slider from 'rc-slider';
import 'rc-slider/assets/index.css'; import 'rc-slider/assets/index.css';
import BaseFileInput from './BaseFileInput'; import BaseFileInput from './BaseFileInput';
import { BaseFileInputProps, formatTime } from './file-input-utils'; import { BaseFileInputProps, formatTime } from './file-input-utils';
import Cropper, { MediaSize, Point, Size, Area } from 'react-easy-crop';
interface VideoFileInputProps extends BaseFileInputProps { interface VideoFileInputProps extends BaseFileInputProps {
showTrimControls?: boolean; showTrimControls?: boolean;
onTrimChange?: (trimStart: number, trimEnd: number) => void; onTrimChange?: (trimStart: number, trimEnd: number) => void;
trimStart?: number; trimStart?: number;
trimEnd?: 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({ export default function ToolVideoInput({
@@ -17,11 +25,28 @@ export default function ToolVideoInput({
onTrimChange, onTrimChange,
trimStart = 0, trimStart = 0,
trimEnd = 100, trimEnd = 100,
showCropOverlay,
cropPosition = { x: 0, y: 0 },
cropSize = { width: 100, height: 100 },
onCropChange,
...props ...props
}: VideoFileInputProps) { }: VideoFileInputProps) {
const videoRef = useRef<HTMLVideoElement>(null); let videoRef = useRef<HTMLVideoElement>(null);
const [videoDuration, setVideoDuration] = useState(0); 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 onVideoLoad = (e: React.SyntheticEvent<HTMLVideoElement>) => {
const duration = e.currentTarget.duration; const duration = e.currentTarget.duration;
setVideoDuration(duration); setVideoDuration(duration);
@@ -30,13 +55,59 @@ export default function ToolVideoInput({
onTrimChange(0, duration); 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) => { const handleTrimChange = (start: number, end: number) => {
if (onTrimChange) { if (onTrimChange) {
onTrimChange(start, end); 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 ( return (
<BaseFileInput {...props} type={'video'}> <BaseFileInput {...props} type={'video'}>
{({ preview }) => ( {({ preview }) => (
@@ -51,16 +122,38 @@ export default function ToolVideoInput({
justifyContent: 'center' justifyContent: 'center'
}} }}
> >
<video {showCropOverlay ? (
ref={videoRef} <Cropper
src={preview} setVideoRef={(ref) => {
style={{ videoRef = ref;
maxWidth: '100%', }}
maxHeight: showTrimControls ? 'calc(100% - 50px)' : '100%' video={preview}
}} crop={crop}
onLoadedMetadata={onVideoLoad} cropSize={crop}
controls={!showTrimControls} 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 && ( {showTrimControls && videoDuration > 0 && (
<Box <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 { Box } from '@mui/material';
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import * as Yup from 'yup'; import * as Yup from 'yup';
import ToolFileResult from '@components/result/ToolFileResult'; import ToolFileResult from '@components/result/ToolFileResult';
import ToolContent from '@components/ToolContent'; import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool'; 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 TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import { updateNumberField } from '@utils/string'; import { updateNumberField } from '@utils/string';
import { FFmpeg } from '@ffmpeg/ffmpeg'; import { FFmpeg } from '@ffmpeg/ffmpeg';
@@ -19,8 +19,7 @@ const initialValues: InitialValuesType = {
width: 640, width: 640,
height: 360, height: 360,
x: 0, x: 0,
y: 0, y: 0
maintainAspectRatio: true
}; };
const validationSchema = Yup.object({ const validationSchema = Yup.object({
@@ -46,14 +45,12 @@ export default function CropVideo({ title }: ToolComponentProps) {
width: number; width: number;
height: number; height: number;
} | null>(null); } | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
// Get video dimensions when a video is loaded // Get video dimensions when a video is loaded
useEffect(() => { useEffect(() => {
if (input) { if (input) {
const video = document.createElement('video'); const video = document.createElement('video');
video.onloadedmetadata = () => { video.onloadedmetadata = () => {
console.log('loadedmetadata', video.videoWidth, video.videoHeight);
setVideoInfo({ setVideoInfo({
width: video.videoWidth, width: video.videoWidth,
height: video.videoHeight height: video.videoHeight
@@ -133,8 +130,7 @@ export default function CropVideo({ title }: ToolComponentProps) {
const getGroups: GetGroupsType<InitialValuesType> = ({ const getGroups: GetGroupsType<InitialValuesType> = ({
values, values,
updateField, updateField
setFieldValue
}) => [ }) => [
{ {
title: 'Crop Settings', title: 'Crop Settings',
@@ -172,34 +168,48 @@ export default function CropVideo({ title }: ToolComponentProps) {
label={'Y Position (pixels)'} label={'Y Position (pixels)'}
sx={{ mb: 2, backgroundColor: 'background.paper' }} sx={{ mb: 2, backgroundColor: 'background.paper' }}
/> />
<FormControlLabel
control={
<Checkbox
checked={values.maintainAspectRatio}
onChange={(e) => {
setFieldValue('maintainAspectRatio', e.target.checked);
}}
/>
}
label="Maintain aspect ratio"
/>
</Box> </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 ( return (
<ToolContent <ToolContent
title={title} title={title}
input={input} input={input}
inputComponent={ renderCustomInput={renderCustomInput}
<ToolVideoInput
value={input}
onChange={setInput}
accept={['video/mp4', 'video/webm', 'video/ogg']}
title={'Input Video'}
/>
}
resultComponent={ resultComponent={
<ToolFileResult <ToolFileResult
title={'Cropped Video'} title={'Cropped Video'}

View File

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