mirror of
https://github.com/iib0011/omni-tools.git
synced 2025-09-18 05:29:33 +02:00
feat: trim video
This commit is contained in:
40
.idea/workspace.xml
generated
40
.idea/workspace.xml
generated
@@ -4,8 +4,23 @@
|
||||
<option name="autoReloadType" value="SELECTIVE" />
|
||||
</component>
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="b30e2810-c4c1-4aad-b134-794e52cc1c7d" name="Changes" comment="fix: tools by category scroll">
|
||||
<list default="true" id="b30e2810-c4c1-4aad-b134-794e52cc1c7d" name="Changes" comment="fix: missing meta">
|
||||
<change afterPath="$PROJECT_DIR$/src/pages/tools/video/trim/index.tsx" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/src/pages/tools/video/trim/meta.ts" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/package-lock.json" beforeDir="false" afterPath="$PROJECT_DIR$/package-lock.json" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/package.json" beforeDir="false" afterPath="$PROJECT_DIR$/package.json" 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$/src/components/examples/ToolExamples.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/examples/ToolExamples.tsx" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/components/input/ToolFileInput.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/input/ToolFileInput.tsx" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/components/options/ToolOptions.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/options/ToolOptions.tsx" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/components/result/ToolFileResult.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/result/ToolFileResult.tsx" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/pages/tools/list/shuffle/shuffle.service.test.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/list/shuffle/shuffle.service.test.ts" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/pages/tools/string/to-morse/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/string/to-morse/index.tsx" 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$/src/tools/defineTool.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/tools/defineTool.tsx" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/tools/index.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/tools/index.ts" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/vite.config.ts" beforeDir="false" afterPath="$PROJECT_DIR$/vite.config.ts" afterDir="false" />
|
||||
</list>
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
@@ -335,14 +350,7 @@
|
||||
<workItem from="1741537936314" duration="1294000" />
|
||||
<workItem from="1741539602311" duration="4557000" />
|
||||
<workItem from="1741547560596" duration="1671000" />
|
||||
</task>
|
||||
<task id="LOCAL-00112" summary="feat: ui changes">
|
||||
<option name="closed" value="true" />
|
||||
<created>1740464250449</created>
|
||||
<option name="number" value="00112" />
|
||||
<option name="presentableId" value="LOCAL-00112" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1740464250449</updated>
|
||||
<workItem from="1741567442768" duration="12099000" />
|
||||
</task>
|
||||
<task id="LOCAL-00113" summary="fix: tsc">
|
||||
<option name="closed" value="true" />
|
||||
@@ -728,7 +736,15 @@
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1741548044897</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="161" />
|
||||
<task id="LOCAL-00161" summary="fix: missing meta">
|
||||
<option name="closed" value="true" />
|
||||
<created>1741568170877</created>
|
||||
<option name="number" value="00161" />
|
||||
<option name="presentableId" value="LOCAL-00161" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1741568170877</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="162" />
|
||||
<servers />
|
||||
</component>
|
||||
<component name="TypeScriptGeneratedFilesManager">
|
||||
@@ -775,7 +791,6 @@
|
||||
<option name="CHECK_CODE_SMELLS_BEFORE_PROJECT_COMMIT" value="false" />
|
||||
<option name="CHECK_NEW_TODO" value="false" />
|
||||
<option name="ADD_EXTERNAL_FILES_SILENTLY" value="true" />
|
||||
<MESSAGE value="chore: compute only if value" />
|
||||
<MESSAGE value="chore: remove prettify test" />
|
||||
<MESSAGE value="chore: prettify json in home" />
|
||||
<MESSAGE value="feat: jakarta font" />
|
||||
@@ -800,7 +815,8 @@
|
||||
<MESSAGE value="fix: prettify json" />
|
||||
<MESSAGE value="refactor: sum" />
|
||||
<MESSAGE value="fix: tools by category scroll" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="fix: tools by category scroll" />
|
||||
<MESSAGE value="fix: missing meta" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="fix: missing meta" />
|
||||
</component>
|
||||
<component name="XSLT-Support.FileAssociations.UIState">
|
||||
<expand />
|
||||
|
68
package-lock.json
generated
68
package-lock.json
generated
@@ -10,10 +10,14 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.4",
|
||||
"@emotion/styled": "^11.11.5",
|
||||
"@ffmpeg/core": "^0.12.10",
|
||||
"@ffmpeg/ffmpeg": "^0.12.15",
|
||||
"@ffmpeg/util": "^0.12.2",
|
||||
"@jimp/types": "^1.6.0",
|
||||
"@mui/icons-material": "^5.15.20",
|
||||
"@mui/material": "^5.15.20",
|
||||
"@playwright/test": "^1.45.0",
|
||||
"@types/ffmpeg": "^1.0.7",
|
||||
"@types/lodash": "^4.17.5",
|
||||
"@types/morsee": "^1.0.2",
|
||||
"@types/omggif": "^1.0.5",
|
||||
@@ -33,6 +37,7 @@
|
||||
"react-image-crop": "^11.0.7",
|
||||
"react-router-dom": "^6.23.1",
|
||||
"type-fest": "^4.35.0",
|
||||
"use-deep-compare-effect": "^1.8.1",
|
||||
"yup": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1354,6 +1359,45 @@
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ffmpeg/core": {
|
||||
"version": "0.12.10",
|
||||
"resolved": "https://registry.npmjs.org/@ffmpeg/core/-/core-0.12.10.tgz",
|
||||
"integrity": "sha512-dzNplnn2Nxle2c2i2rrDhqcB19q9cglCkWnoMTDN9Q9l3PvdjZWd1HfSPjCNWc/p8Q3CT+Es9fWOR0UhAeYQZA==",
|
||||
"license": "GPL-2.0-or-later",
|
||||
"engines": {
|
||||
"node": ">=16.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@ffmpeg/ffmpeg": {
|
||||
"version": "0.12.15",
|
||||
"resolved": "https://registry.npmjs.org/@ffmpeg/ffmpeg/-/ffmpeg-0.12.15.tgz",
|
||||
"integrity": "sha512-1C8Obr4GsN3xw+/1Ww6PFM84wSQAGsdoTuTWPOj2OizsRDLT4CXTaVjPhkw6ARyDus1B9X/L2LiXHqYYsGnRFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ffmpeg/types": "^0.12.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@ffmpeg/types": {
|
||||
"version": "0.12.4",
|
||||
"resolved": "https://registry.npmjs.org/@ffmpeg/types/-/types-0.12.4.tgz",
|
||||
"integrity": "sha512-k9vJQNBGTxE5AhYDtOYR5rO5fKsspbg51gbcwtbkw2lCdoIILzklulcjJfIDwrtn7XhDeF2M+THwJ2FGrLeV6A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@ffmpeg/util": {
|
||||
"version": "0.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@ffmpeg/util/-/util-0.12.2.tgz",
|
||||
"integrity": "sha512-ouyoW+4JB7WxjeZ2y6KpRvB+dLp7Cp4ro8z0HIVpZVCM7AwFlHa0c4R8Y/a4M3wMqATpYKhC7lSFHQ0T11MEDw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.2.tgz",
|
||||
@@ -2942,6 +2986,12 @@
|
||||
"integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/ffmpeg": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/ffmpeg/-/ffmpeg-1.0.7.tgz",
|
||||
"integrity": "sha512-7Pw61IDG9Tj+gXGNshJ7JIM2fhDe0IrK7/F+b8midmsiljiugWKbW5KoNmhtZS3pPXWVfVHP3wOwquF/wbVxiw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/hoist-non-react-statics": {
|
||||
"version": "3.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz",
|
||||
@@ -4629,7 +4679,6 @@
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
@@ -10140,6 +10189,23 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-deep-compare-effect": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/use-deep-compare-effect/-/use-deep-compare-effect-1.8.1.tgz",
|
||||
"integrity": "sha512-kbeNVZ9Zkc0RFGpfMN3MNfaKNvcLNyxOAAd9O4CBZ+kCBXXscn9s/4I+8ytUER4RDpEYs5+O6Rs4PqiZ+rHr5Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"dequal": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10",
|
||||
"npm": ">=6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.13"
|
||||
}
|
||||
},
|
||||
"node_modules/utif2": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/utif2/-/utif2-4.1.0.tgz",
|
||||
|
@@ -27,10 +27,14 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.4",
|
||||
"@emotion/styled": "^11.11.5",
|
||||
"@ffmpeg/core": "^0.12.10",
|
||||
"@ffmpeg/ffmpeg": "^0.12.15",
|
||||
"@ffmpeg/util": "^0.12.2",
|
||||
"@jimp/types": "^1.6.0",
|
||||
"@mui/icons-material": "^5.15.20",
|
||||
"@mui/material": "^5.15.20",
|
||||
"@playwright/test": "^1.45.0",
|
||||
"@types/ffmpeg": "^1.0.7",
|
||||
"@types/lodash": "^4.17.5",
|
||||
"@types/morsee": "^1.0.2",
|
||||
"@types/omggif": "^1.0.5",
|
||||
@@ -50,6 +54,7 @@
|
||||
"react-image-crop": "^11.0.7",
|
||||
"react-router-dom": "^6.23.1",
|
||||
"type-fest": "^4.35.0",
|
||||
"use-deep-compare-effect": "^1.8.1",
|
||||
"yup": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@@ -1,10 +1,7 @@
|
||||
import React, { useRef, useState, ReactNode, useEffect } from 'react';
|
||||
import React, { useRef, ReactNode, useState } from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import { FormikProps, FormikValues } from 'formik';
|
||||
import ToolOptions, {
|
||||
GetGroupsType,
|
||||
UpdateField
|
||||
} from '@components/options/ToolOptions';
|
||||
import { Formik, FormikProps, FormikValues } from 'formik';
|
||||
import ToolOptions, { GetGroupsType } from '@components/options/ToolOptions';
|
||||
import ToolInputAndResult from '@components/ToolInputAndResult';
|
||||
import ToolInfo from '@components/ToolInfo';
|
||||
import Separator from '@components/Separator';
|
||||
@@ -60,54 +57,53 @@ export default function ToolContent<T extends FormikValues, I>({
|
||||
validationSchema,
|
||||
renderCustomInput
|
||||
}: ToolContentProps<T, I>) {
|
||||
const formRef = useRef<FormikProps<T>>(null);
|
||||
|
||||
const [initialized, forceUpdate] = useState(0);
|
||||
useEffect(() => {
|
||||
if (formRef.current && !initialized) {
|
||||
forceUpdate((n) => n + 1);
|
||||
}
|
||||
}, [initialized]);
|
||||
return (
|
||||
<Box>
|
||||
<ToolInputAndResult
|
||||
input={
|
||||
inputComponent ??
|
||||
(renderCustomInput &&
|
||||
formRef.current &&
|
||||
renderCustomInput(
|
||||
formRef.current.values,
|
||||
formRef.current.setFieldValue
|
||||
))
|
||||
}
|
||||
result={resultComponent}
|
||||
/>
|
||||
|
||||
<ToolOptions
|
||||
formRef={formRef}
|
||||
compute={compute}
|
||||
getGroups={getGroups}
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
input={input}
|
||||
validationSchema={validationSchema}
|
||||
/>
|
||||
onSubmit={() => {}}
|
||||
>
|
||||
{({ values, setFieldValue }) => {
|
||||
return (
|
||||
<>
|
||||
<ToolInputAndResult
|
||||
input={
|
||||
inputComponent ??
|
||||
(renderCustomInput &&
|
||||
renderCustomInput(values, setFieldValue))
|
||||
}
|
||||
result={resultComponent}
|
||||
/>
|
||||
|
||||
{toolInfo && toolInfo.title && toolInfo.description && (
|
||||
<ToolInfo title={toolInfo.title} description={toolInfo.description} />
|
||||
)}
|
||||
<ToolOptions
|
||||
compute={compute}
|
||||
getGroups={getGroups}
|
||||
input={input}
|
||||
/>
|
||||
|
||||
{exampleCards && exampleCards.length > 0 && (
|
||||
<>
|
||||
<Separator backgroundColor="#5581b5" margin="50px" />
|
||||
<ToolExamples
|
||||
title={title}
|
||||
exampleCards={exampleCards}
|
||||
getGroups={getGroups}
|
||||
formRef={formRef}
|
||||
setInput={setInput}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{toolInfo && toolInfo.title && toolInfo.description && (
|
||||
<ToolInfo
|
||||
title={toolInfo.title}
|
||||
description={toolInfo.description}
|
||||
/>
|
||||
)}
|
||||
|
||||
{exampleCards && exampleCards.length > 0 && (
|
||||
<>
|
||||
<Separator backgroundColor="#5581b5" margin="50px" />
|
||||
<ToolExamples
|
||||
title={title}
|
||||
exampleCards={exampleCards}
|
||||
getGroups={getGroups}
|
||||
setInput={setInput}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
@@ -2,7 +2,7 @@ import { Box, Grid, Stack, Typography } from '@mui/material';
|
||||
import ExampleCard, { ExampleCardProps } from './ExampleCard';
|
||||
import React from 'react';
|
||||
import { GetGroupsType } from '@components/options/ToolOptions';
|
||||
import { FormikProps } from 'formik';
|
||||
import { useFormikContext } from 'formik';
|
||||
|
||||
export type CardExampleType<T> = Omit<
|
||||
ExampleCardProps<T>,
|
||||
@@ -14,7 +14,6 @@ export interface ExampleProps<T> {
|
||||
subtitle?: string;
|
||||
exampleCards: CardExampleType<T>[];
|
||||
getGroups: GetGroupsType<T> | null;
|
||||
formRef: React.RefObject<FormikProps<T>>;
|
||||
setInput?: React.Dispatch<React.SetStateAction<any>>;
|
||||
}
|
||||
|
||||
@@ -23,12 +22,13 @@ export default function ToolExamples<T>({
|
||||
subtitle,
|
||||
exampleCards,
|
||||
getGroups,
|
||||
formRef,
|
||||
setInput
|
||||
}: ExampleProps<T>) {
|
||||
const { setValues } = useFormikContext<T>();
|
||||
|
||||
function changeInputResult(newInput: string | undefined, newOptions: T) {
|
||||
setInput?.(newInput);
|
||||
formRef.current?.setValues(newOptions);
|
||||
setValues(newOptions);
|
||||
const toolsElement = document.getElementById('tool');
|
||||
if (toolsElement) {
|
||||
toolsElement.scrollIntoView({ behavior: 'smooth' });
|
||||
|
@@ -22,6 +22,12 @@ interface ToolFileInputProps {
|
||||
position: { x: number; y: number },
|
||||
size: { width: number; height: number }
|
||||
) => void;
|
||||
type?: 'image' | 'video' | 'audio';
|
||||
// Video specific props
|
||||
showTrimControls?: boolean;
|
||||
onTrimChange?: (trimStart: number, trimEnd: number) => void;
|
||||
trimStart?: number;
|
||||
trimEnd?: number;
|
||||
}
|
||||
|
||||
export default function ToolFileInput({
|
||||
@@ -33,15 +39,22 @@ export default function ToolFileInput({
|
||||
cropShape = 'rectangular',
|
||||
cropPosition = { x: 0, y: 0 },
|
||||
cropSize = { width: 100, height: 100 },
|
||||
onCropChange
|
||||
onCropChange,
|
||||
type = 'image',
|
||||
showTrimControls = false,
|
||||
onTrimChange,
|
||||
trimStart = 0,
|
||||
trimEnd = 100
|
||||
}: ToolFileInputProps) {
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const theme = useTheme();
|
||||
const { showSnackBar } = useContext(CustomSnackBarContext);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [imgWidth, setImgWidth] = useState(0);
|
||||
const [imgHeight, setImgHeight] = useState(0);
|
||||
const [videoDuration, setVideoDuration] = useState(0);
|
||||
|
||||
// Convert position and size to crop format used by ReactCrop
|
||||
const [crop, setCrop] = useState<Crop>({
|
||||
@@ -129,6 +142,17 @@ export default function ToolFileInput({
|
||||
}
|
||||
};
|
||||
|
||||
// Handle video load to set duration
|
||||
const onVideoLoad = (e: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||
const duration = e.currentTarget.duration;
|
||||
setVideoDuration(duration);
|
||||
|
||||
// Initialize trim with full duration if needed
|
||||
if (onTrimChange && trimStart === 0 && trimEnd === 100) {
|
||||
onTrimChange(0, duration);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCropChange = (newCrop: Crop) => {
|
||||
setCrop(newCrop);
|
||||
};
|
||||
@@ -145,11 +169,20 @@ export default function ToolFileInput({
|
||||
}
|
||||
};
|
||||
|
||||
const handleTrimChange = (start: number, end: number) => {
|
||||
if (onTrimChange) {
|
||||
onTrimChange(start, end);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
const clipboardItems = event.clipboardData?.items ?? [];
|
||||
const item = clipboardItems[0];
|
||||
if (item && item.type.includes('image')) {
|
||||
if (
|
||||
item &&
|
||||
(item.type.includes('image') || item.type.includes('video'))
|
||||
) {
|
||||
const file = item.getAsFile();
|
||||
if (file) onChange(file);
|
||||
}
|
||||
@@ -161,6 +194,15 @@ export default function ToolFileInput({
|
||||
};
|
||||
}, [onChange]);
|
||||
|
||||
// Format seconds to MM:SS format
|
||||
const formatTime = (seconds: number) => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds
|
||||
.toString()
|
||||
.padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<InputHeader title={title} />
|
||||
@@ -188,14 +230,24 @@ export default function ToolFileInput({
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{showCropOverlay ? (
|
||||
<ReactCrop
|
||||
crop={crop}
|
||||
onChange={handleCropChange}
|
||||
onComplete={handleCropComplete}
|
||||
circularCrop={cropShape === 'circular'}
|
||||
style={{ maxWidth: '100%', maxHeight: globalInputHeight }}
|
||||
>
|
||||
{type === 'image' &&
|
||||
(showCropOverlay ? (
|
||||
<ReactCrop
|
||||
crop={crop}
|
||||
onChange={handleCropChange}
|
||||
onComplete={handleCropComplete}
|
||||
circularCrop={cropShape === 'circular'}
|
||||
style={{ maxWidth: '100%', maxHeight: globalInputHeight }}
|
||||
>
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={preview}
|
||||
alt="Preview"
|
||||
style={{ maxWidth: '100%', maxHeight: globalInputHeight }}
|
||||
onLoad={onImageLoad}
|
||||
/>
|
||||
</ReactCrop>
|
||||
) : (
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={preview}
|
||||
@@ -203,14 +255,98 @@ export default function ToolFileInput({
|
||||
style={{ maxWidth: '100%', maxHeight: globalInputHeight }}
|
||||
onLoad={onImageLoad}
|
||||
/>
|
||||
</ReactCrop>
|
||||
) : (
|
||||
<img
|
||||
ref={imageRef}
|
||||
))}
|
||||
{type === 'video' && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={preview}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: showTrimControls ? 'calc(100% - 50px)' : '100%'
|
||||
}}
|
||||
onLoadedMetadata={onVideoLoad}
|
||||
controls={!showTrimControls}
|
||||
/>
|
||||
|
||||
{showTrimControls && videoDuration > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
padding: '10px 20px',
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption">
|
||||
Start: {formatTime(trimStart || 0)}
|
||||
</Typography>
|
||||
<Typography variant="caption">
|
||||
End: {formatTime(trimEnd || videoDuration)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={videoDuration}
|
||||
step={0.1}
|
||||
value={trimStart || 0}
|
||||
onChange={(e) =>
|
||||
handleTrimChange(
|
||||
parseFloat(e.target.value),
|
||||
trimEnd || videoDuration
|
||||
)
|
||||
}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min={trimStart || 0}
|
||||
max={videoDuration}
|
||||
step={0.1}
|
||||
value={trimEnd || videoDuration}
|
||||
onChange={(e) =>
|
||||
handleTrimChange(
|
||||
trimStart || 0,
|
||||
parseFloat(e.target.value)
|
||||
)
|
||||
}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{type === 'audio' && (
|
||||
<audio
|
||||
src={preview}
|
||||
alt="Preview"
|
||||
style={{ maxWidth: '100%', maxHeight: globalInputHeight }}
|
||||
onLoad={onImageLoad}
|
||||
controls
|
||||
style={{ width: '100%', maxWidth: '500px' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
@@ -228,8 +364,8 @@ export default function ToolFileInput({
|
||||
}}
|
||||
>
|
||||
<Typography color={theme.palette.grey['600']}>
|
||||
Click here to select an image from your device, press Ctrl+V to
|
||||
use an image from your clipboard, drag and drop a file from
|
||||
Click here to select a {type} from your device, press Ctrl+V to
|
||||
use a {type} from your clipboard, drag and drop a file from
|
||||
desktop
|
||||
</Typography>
|
||||
</Box>
|
||||
|
@@ -1,99 +1,62 @@
|
||||
import { Box, Stack, useTheme } from '@mui/material';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import React, { ReactNode, RefObject, useContext, useEffect } from 'react';
|
||||
import { Formik, FormikProps, FormikValues, useFormikContext } from 'formik';
|
||||
import React, { ReactNode, useContext } from 'react';
|
||||
import { FormikProps, FormikValues, useFormikContext } from 'formik';
|
||||
import ToolOptionGroups, { ToolOptionGroup } from './ToolOptionGroups';
|
||||
import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext';
|
||||
|
||||
export type UpdateField<T> = <Y extends keyof T>(field: Y, value: T[Y]) => void;
|
||||
|
||||
const FormikListenerComponent = <T,>({
|
||||
initialValues,
|
||||
input,
|
||||
compute
|
||||
}: {
|
||||
initialValues: T;
|
||||
input: any;
|
||||
compute: (optionsValues: T, input: any) => void;
|
||||
}) => {
|
||||
const { values } = useFormikContext<typeof initialValues>();
|
||||
const { values } = useFormikContext<T>();
|
||||
const { showSnackBar } = useContext(CustomSnackBarContext);
|
||||
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
compute(values, input);
|
||||
} catch (exception: unknown) {
|
||||
if (exception instanceof Error) showSnackBar(exception.message, 'error');
|
||||
else console.error(exception);
|
||||
}
|
||||
}, [values, input]);
|
||||
}, [values, input, showSnackBar]);
|
||||
|
||||
return null; // This component doesn't render anything
|
||||
};
|
||||
|
||||
interface FormikHelperProps<T> {
|
||||
compute: (optionsValues: T, input: any) => void;
|
||||
input: any;
|
||||
children?: ReactNode;
|
||||
getGroups:
|
||||
| null
|
||||
| ((
|
||||
formikProps: FormikProps<T> & { updateField: UpdateField<T> }
|
||||
) => ToolOptionGroup[]);
|
||||
formikProps: FormikProps<T>;
|
||||
}
|
||||
|
||||
const ToolBody = <T,>({
|
||||
compute,
|
||||
input,
|
||||
children,
|
||||
getGroups,
|
||||
formikProps
|
||||
}: FormikHelperProps<T>) => {
|
||||
const { values, setFieldValue } = useFormikContext<T>();
|
||||
|
||||
const updateField: UpdateField<T> = (field, value) => {
|
||||
// @ts-ignore
|
||||
setFieldValue(field, value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack direction={'row'} spacing={2}>
|
||||
<FormikListenerComponent<T>
|
||||
compute={compute}
|
||||
input={input}
|
||||
initialValues={values}
|
||||
/>
|
||||
<ToolOptionGroups
|
||||
groups={getGroups?.({ ...formikProps, updateField }) ?? []}
|
||||
/>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export type GetGroupsType<T> = (
|
||||
formikProps: FormikProps<T> & { updateField: UpdateField<T> }
|
||||
) => ToolOptionGroup[];
|
||||
|
||||
export default function ToolOptions<T extends FormikValues>({
|
||||
children,
|
||||
initialValues,
|
||||
validationSchema,
|
||||
compute,
|
||||
input,
|
||||
getGroups,
|
||||
formRef
|
||||
getGroups
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
initialValues: T;
|
||||
validationSchema?: any | (() => any);
|
||||
compute: (optionsValues: T, input: any) => void;
|
||||
input?: any;
|
||||
getGroups: GetGroupsType<T> | null;
|
||||
formRef?: RefObject<FormikProps<T>>;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const formikContext = useFormikContext<T>();
|
||||
|
||||
// Early return if no groups to display
|
||||
if (!getGroups) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const updateField: UpdateField<T> = (field, value) => {
|
||||
formikContext.setFieldValue(field as string, value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
@@ -101,8 +64,7 @@ export default function ToolOptions<T extends FormikValues>({
|
||||
borderRadius: 2,
|
||||
padding: 2,
|
||||
backgroundColor: theme.palette.background.default,
|
||||
boxShadow: '2',
|
||||
display: getGroups ? 'block' : 'none'
|
||||
boxShadow: '2'
|
||||
}}
|
||||
mt={2}
|
||||
>
|
||||
@@ -111,23 +73,13 @@ export default function ToolOptions<T extends FormikValues>({
|
||||
<Typography fontSize={22}>Tool options</Typography>
|
||||
</Stack>
|
||||
<Box mt={2}>
|
||||
<Formik
|
||||
innerRef={formRef}
|
||||
initialValues={initialValues}
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={() => {}}
|
||||
>
|
||||
{(formikProps) => (
|
||||
<ToolBody
|
||||
compute={compute}
|
||||
input={input}
|
||||
getGroups={getGroups}
|
||||
formikProps={formikProps}
|
||||
>
|
||||
{children}
|
||||
</ToolBody>
|
||||
)}
|
||||
</Formik>
|
||||
<Stack direction={'row'} spacing={2}>
|
||||
<FormikListenerComponent<T> compute={compute} input={input} />
|
||||
<ToolOptionGroups
|
||||
groups={getGroups({ ...formikContext, updateField }) ?? []}
|
||||
/>
|
||||
{children}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
@@ -58,6 +58,18 @@ export default function ToolFileResult({
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
};
|
||||
|
||||
// Determine the file type based on MIME type
|
||||
const getFileType = () => {
|
||||
if (!value) return 'unknown';
|
||||
if (value.type.startsWith('image/')) return 'image';
|
||||
if (value.type.startsWith('video/')) return 'video';
|
||||
if (value.type.startsWith('audio/')) return 'audio';
|
||||
return 'unknown';
|
||||
};
|
||||
|
||||
const fileType = getFileType();
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<InputHeader title={title} />
|
||||
@@ -82,11 +94,32 @@ export default function ToolFileResult({
|
||||
backgroundImage: `url(${greyPattern})`
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={preview}
|
||||
alt="Result"
|
||||
style={{ maxWidth: '100%', maxHeight: globalInputHeight }}
|
||||
/>
|
||||
{fileType === 'image' && (
|
||||
<img
|
||||
src={preview}
|
||||
alt="Result"
|
||||
style={{ maxWidth: '100%', maxHeight: globalInputHeight }}
|
||||
/>
|
||||
)}
|
||||
{fileType === 'video' && (
|
||||
<video
|
||||
src={preview}
|
||||
controls
|
||||
style={{ maxWidth: '100%', maxHeight: globalInputHeight }}
|
||||
/>
|
||||
)}
|
||||
{fileType === 'audio' && (
|
||||
<audio
|
||||
src={preview}
|
||||
controls
|
||||
style={{ width: '100%', maxWidth: '500px' }}
|
||||
/>
|
||||
)}
|
||||
{fileType === 'unknown' && (
|
||||
<Box sx={{ padding: 2, textAlign: 'center' }}>
|
||||
File processed successfully. Click download to save the result.
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
@@ -31,7 +31,6 @@ describe('shuffle function', () => {
|
||||
joinSeparator,
|
||||
length
|
||||
);
|
||||
console.log(result);
|
||||
expect(result.split(joinSeparator).length).toBe(2);
|
||||
});
|
||||
|
||||
@@ -49,7 +48,6 @@ describe('shuffle function', () => {
|
||||
joinSeparator,
|
||||
length
|
||||
);
|
||||
console.log(result);
|
||||
expect(result.split(joinSeparator).length).toBe(4);
|
||||
});
|
||||
|
||||
@@ -66,7 +64,6 @@ describe('shuffle function', () => {
|
||||
joinSeparator,
|
||||
length
|
||||
);
|
||||
console.log(result);
|
||||
expect(result.split(joinSeparator)).toContain('apple');
|
||||
});
|
||||
|
||||
@@ -83,7 +80,6 @@ describe('shuffle function', () => {
|
||||
joinSeparator,
|
||||
length
|
||||
);
|
||||
console.log(result);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
||||
|
@@ -13,7 +13,6 @@ const initialValues = {
|
||||
export default function ToMorse() {
|
||||
const [input, setInput] = useState<string>('');
|
||||
const [result, setResult] = useState<string>('');
|
||||
// const formRef = useRef<FormikProps<typeof initialValues>>(null);
|
||||
const computeOptions = (optionsValues: typeof initialValues, input: any) => {
|
||||
const { dotSymbol, dashSymbol } = optionsValues;
|
||||
setResult(compute(input, dotSymbol, dashSymbol));
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import { gifTools } from './gif';
|
||||
import { tool as trimVideo } from './trim/meta';
|
||||
|
||||
export const videoTools = [...gifTools];
|
||||
export const videoTools = [...gifTools, trimVideo];
|
||||
|
143
src/pages/tools/video/trim/index.tsx
Normal file
143
src/pages/tools/video/trim/index.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { Box } from '@mui/material';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import * as Yup from 'yup';
|
||||
import ToolFileInput from '@components/input/ToolFileInput';
|
||||
import ToolFileResult from '@components/result/ToolFileResult';
|
||||
import ToolContent from '@components/ToolContent';
|
||||
import { ToolComponentProps } from '@tools/defineTool';
|
||||
import { GetGroupsType } from '@components/options/ToolOptions';
|
||||
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
|
||||
import { updateNumberField } from '@utils/string';
|
||||
import { FFmpeg } from '@ffmpeg/ffmpeg';
|
||||
import { fetchFile } from '@ffmpeg/util';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
const ffmpeg = new FFmpeg();
|
||||
|
||||
const initialValues = {
|
||||
trimStart: 0,
|
||||
trimEnd: 100
|
||||
};
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
trimStart: Yup.number().min(0, 'Start time must be positive'),
|
||||
trimEnd: Yup.number().min(
|
||||
Yup.ref('trimStart'),
|
||||
'End time must be greater than start time'
|
||||
)
|
||||
});
|
||||
|
||||
export default function TrimVideo({ title }: ToolComponentProps) {
|
||||
const [input, setInput] = useState<File | null>(null);
|
||||
const [result, setResult] = useState<File | null>(null);
|
||||
|
||||
const compute = async (
|
||||
optionsValues: typeof initialValues,
|
||||
input: File | null
|
||||
) => {
|
||||
console.log('compute', optionsValues, input);
|
||||
if (!input) return;
|
||||
|
||||
const { trimStart, trimEnd } = optionsValues;
|
||||
|
||||
try {
|
||||
if (!ffmpeg.loaded) {
|
||||
await ffmpeg.load();
|
||||
}
|
||||
|
||||
const inputName = 'input.mp4';
|
||||
const outputName = 'output.mp4';
|
||||
// Load file into FFmpeg's virtual filesystem
|
||||
await ffmpeg.writeFile(inputName, await fetchFile(input));
|
||||
// Run FFmpeg command to trim video
|
||||
await ffmpeg.exec([
|
||||
'-i',
|
||||
inputName,
|
||||
'-ss',
|
||||
trimStart.toString(),
|
||||
'-to',
|
||||
trimEnd.toString(),
|
||||
'-c',
|
||||
'copy',
|
||||
outputName
|
||||
]);
|
||||
// Retrieve the processed file
|
||||
const trimmedData = await ffmpeg.readFile(outputName);
|
||||
const trimmedBlob = new Blob([trimmedData], { type: 'video/mp4' });
|
||||
const trimmedFile = new File(
|
||||
[trimmedBlob],
|
||||
`${input.name.replace(/\.[^/.]+$/, '')}_trimmed.mp4`,
|
||||
{
|
||||
type: 'video/mp4'
|
||||
}
|
||||
);
|
||||
|
||||
setResult(trimmedFile);
|
||||
} catch (error) {
|
||||
console.error('Error trimming video:', error);
|
||||
}
|
||||
};
|
||||
const debouncedCompute = useCallback(debounce(compute, 1000), []);
|
||||
const getGroups: GetGroupsType<typeof initialValues> = ({
|
||||
values,
|
||||
updateField
|
||||
}) => [
|
||||
{
|
||||
title: 'Timestamps',
|
||||
component: (
|
||||
<Box>
|
||||
<TextFieldWithDesc
|
||||
onOwnChange={(value) =>
|
||||
updateNumberField(value, 'trimStart', updateField)
|
||||
}
|
||||
value={values.trimStart}
|
||||
label={'Start Time'}
|
||||
/>
|
||||
<TextFieldWithDesc
|
||||
onOwnChange={(value) =>
|
||||
updateNumberField(value, 'trimEnd', updateField)
|
||||
}
|
||||
value={values.trimEnd}
|
||||
label={'End Time'}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
];
|
||||
return (
|
||||
<ToolContent
|
||||
title={title}
|
||||
input={input}
|
||||
renderCustomInput={({ trimStart, trimEnd }, setFieldValue) => {
|
||||
return (
|
||||
<ToolFileInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
accept={['video/mp4', 'video/webm', 'video/ogg']}
|
||||
title={'Input Video'}
|
||||
type="video"
|
||||
showTrimControls={true}
|
||||
onTrimChange={(trimStart, trimEnd) => {
|
||||
setFieldValue('trimStart', trimStart);
|
||||
setFieldValue('trimEnd', trimEnd);
|
||||
}}
|
||||
trimStart={trimStart}
|
||||
trimEnd={trimEnd}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
resultComponent={
|
||||
<ToolFileResult
|
||||
title={'Trimmed Video'}
|
||||
value={result}
|
||||
extension={'webm'}
|
||||
/>
|
||||
}
|
||||
initialValues={initialValues}
|
||||
getGroups={getGroups}
|
||||
compute={debouncedCompute}
|
||||
setInput={setInput}
|
||||
validationSchema={validationSchema}
|
||||
/>
|
||||
);
|
||||
}
|
13
src/pages/tools/video/trim/meta.ts
Normal file
13
src/pages/tools/video/trim/meta.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const tool = defineTool('video', {
|
||||
name: 'Trim Video',
|
||||
path: 'trim',
|
||||
icon: 'mdi:scissors',
|
||||
description:
|
||||
'This online utility lets you trim videos by setting start and end points. You can preview the trimmed section before processing. Supports common video formats like MP4, WebM, and OGG.',
|
||||
shortDescription: 'Trim videos by setting start and end points',
|
||||
keywords: ['trim', 'cut', 'video', 'clip', 'edit'],
|
||||
component: lazy(() => import('./index'))
|
||||
});
|
@@ -18,6 +18,7 @@ export type ToolCategory =
|
||||
| 'png'
|
||||
| 'number'
|
||||
| 'gif'
|
||||
| 'video'
|
||||
| 'list'
|
||||
| 'json'
|
||||
| 'csv';
|
||||
|
@@ -67,6 +67,12 @@ const categoriesConfig: {
|
||||
icon: 'material-symbols-light:csv-outline',
|
||||
value:
|
||||
'Tools for working with CSV files - convert CSV to different formats, manipulate CSV data, validate CSV structure, and process CSV files efficiently.'
|
||||
},
|
||||
{
|
||||
type: 'video',
|
||||
icon: 'lets-icons:video-light',
|
||||
value:
|
||||
'Tools for working with videos – extract frames from videos, create GIFs from videos, convert videos to different formats, and much more.'
|
||||
}
|
||||
];
|
||||
export const filterTools = (
|
||||
|
@@ -6,6 +6,9 @@ import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
// https://vitejs.dev/config https://vitest.dev/config
|
||||
export default defineConfig({
|
||||
plugins: [react(), tsconfigPaths()],
|
||||
optimizeDeps: {
|
||||
exclude: ['@ffmpeg/ffmpeg', '@ffmpeg/util']
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'happy-dom',
|
||||
|
Reference in New Issue
Block a user