mirror of
https://github.com/iib0011/omni-tools.git
synced 2025-11-05 08:24:56 +05:30
feat: trim video
This commit is contained in:
parent
e2c6d02fe6
commit
d76abec8c0
16 changed files with 535 additions and 169 deletions
40
.idea/workspace.xml
generated
40
.idea/workspace.xml
generated
|
|
@ -4,8 +4,23 @@
|
||||||
<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="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$/.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>
|
</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" />
|
||||||
|
|
@ -335,14 +350,7 @@
|
||||||
<workItem from="1741537936314" duration="1294000" />
|
<workItem from="1741537936314" duration="1294000" />
|
||||||
<workItem from="1741539602311" duration="4557000" />
|
<workItem from="1741539602311" duration="4557000" />
|
||||||
<workItem from="1741547560596" duration="1671000" />
|
<workItem from="1741547560596" duration="1671000" />
|
||||||
</task>
|
<workItem from="1741567442768" duration="12099000" />
|
||||||
<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>
|
|
||||||
</task>
|
</task>
|
||||||
<task id="LOCAL-00113" summary="fix: tsc">
|
<task id="LOCAL-00113" summary="fix: tsc">
|
||||||
<option name="closed" value="true" />
|
<option name="closed" value="true" />
|
||||||
|
|
@ -728,7 +736,15 @@
|
||||||
<option name="project" value="LOCAL" />
|
<option name="project" value="LOCAL" />
|
||||||
<updated>1741548044897</updated>
|
<updated>1741548044897</updated>
|
||||||
</task>
|
</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 />
|
<servers />
|
||||||
</component>
|
</component>
|
||||||
<component name="TypeScriptGeneratedFilesManager">
|
<component name="TypeScriptGeneratedFilesManager">
|
||||||
|
|
@ -775,7 +791,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: compute only if value" />
|
|
||||||
<MESSAGE value="chore: remove prettify test" />
|
<MESSAGE value="chore: remove prettify test" />
|
||||||
<MESSAGE value="chore: prettify json in home" />
|
<MESSAGE value="chore: prettify json in home" />
|
||||||
<MESSAGE value="feat: jakarta font" />
|
<MESSAGE value="feat: jakarta font" />
|
||||||
|
|
@ -800,7 +815,8 @@
|
||||||
<MESSAGE value="fix: prettify json" />
|
<MESSAGE value="fix: prettify json" />
|
||||||
<MESSAGE value="refactor: sum" />
|
<MESSAGE value="refactor: sum" />
|
||||||
<MESSAGE value="fix: tools by category scroll" />
|
<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>
|
||||||
<component name="XSLT-Support.FileAssociations.UIState">
|
<component name="XSLT-Support.FileAssociations.UIState">
|
||||||
<expand />
|
<expand />
|
||||||
|
|
|
||||||
68
package-lock.json
generated
68
package-lock.json
generated
|
|
@ -10,10 +10,14 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.11.4",
|
"@emotion/react": "^11.11.4",
|
||||||
"@emotion/styled": "^11.11.5",
|
"@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",
|
"@jimp/types": "^1.6.0",
|
||||||
"@mui/icons-material": "^5.15.20",
|
"@mui/icons-material": "^5.15.20",
|
||||||
"@mui/material": "^5.15.20",
|
"@mui/material": "^5.15.20",
|
||||||
"@playwright/test": "^1.45.0",
|
"@playwright/test": "^1.45.0",
|
||||||
|
"@types/ffmpeg": "^1.0.7",
|
||||||
"@types/lodash": "^4.17.5",
|
"@types/lodash": "^4.17.5",
|
||||||
"@types/morsee": "^1.0.2",
|
"@types/morsee": "^1.0.2",
|
||||||
"@types/omggif": "^1.0.5",
|
"@types/omggif": "^1.0.5",
|
||||||
|
|
@ -33,6 +37,7 @@
|
||||||
"react-image-crop": "^11.0.7",
|
"react-image-crop": "^11.0.7",
|
||||||
"react-router-dom": "^6.23.1",
|
"react-router-dom": "^6.23.1",
|
||||||
"type-fest": "^4.35.0",
|
"type-fest": "^4.35.0",
|
||||||
|
"use-deep-compare-effect": "^1.8.1",
|
||||||
"yup": "^1.4.0"
|
"yup": "^1.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -1354,6 +1359,45 @@
|
||||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
"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": {
|
"node_modules/@floating-ui/core": {
|
||||||
"version": "1.6.2",
|
"version": "1.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.2.tgz",
|
||||||
|
|
@ -2942,6 +2986,12 @@
|
||||||
"integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==",
|
"integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/@types/hoist-non-react-statics": {
|
||||||
"version": "3.3.5",
|
"version": "3.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz",
|
"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",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
|
|
@ -10140,6 +10189,23 @@
|
||||||
"punycode": "^2.1.0"
|
"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": {
|
"node_modules/utif2": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/utif2/-/utif2-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/utif2/-/utif2-4.1.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,14 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.11.4",
|
"@emotion/react": "^11.11.4",
|
||||||
"@emotion/styled": "^11.11.5",
|
"@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",
|
"@jimp/types": "^1.6.0",
|
||||||
"@mui/icons-material": "^5.15.20",
|
"@mui/icons-material": "^5.15.20",
|
||||||
"@mui/material": "^5.15.20",
|
"@mui/material": "^5.15.20",
|
||||||
"@playwright/test": "^1.45.0",
|
"@playwright/test": "^1.45.0",
|
||||||
|
"@types/ffmpeg": "^1.0.7",
|
||||||
"@types/lodash": "^4.17.5",
|
"@types/lodash": "^4.17.5",
|
||||||
"@types/morsee": "^1.0.2",
|
"@types/morsee": "^1.0.2",
|
||||||
"@types/omggif": "^1.0.5",
|
"@types/omggif": "^1.0.5",
|
||||||
|
|
@ -50,6 +54,7 @@
|
||||||
"react-image-crop": "^11.0.7",
|
"react-image-crop": "^11.0.7",
|
||||||
"react-router-dom": "^6.23.1",
|
"react-router-dom": "^6.23.1",
|
||||||
"type-fest": "^4.35.0",
|
"type-fest": "^4.35.0",
|
||||||
|
"use-deep-compare-effect": "^1.8.1",
|
||||||
"yup": "^1.4.0"
|
"yup": "^1.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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 { Box } from '@mui/material';
|
||||||
import { FormikProps, FormikValues } from 'formik';
|
import { Formik, FormikProps, FormikValues } from 'formik';
|
||||||
import ToolOptions, {
|
import ToolOptions, { GetGroupsType } from '@components/options/ToolOptions';
|
||||||
GetGroupsType,
|
|
||||||
UpdateField
|
|
||||||
} from '@components/options/ToolOptions';
|
|
||||||
import ToolInputAndResult from '@components/ToolInputAndResult';
|
import ToolInputAndResult from '@components/ToolInputAndResult';
|
||||||
import ToolInfo from '@components/ToolInfo';
|
import ToolInfo from '@components/ToolInfo';
|
||||||
import Separator from '@components/Separator';
|
import Separator from '@components/Separator';
|
||||||
|
|
@ -60,54 +57,53 @@ export default function ToolContent<T extends FormikValues, I>({
|
||||||
validationSchema,
|
validationSchema,
|
||||||
renderCustomInput
|
renderCustomInput
|
||||||
}: ToolContentProps<T, I>) {
|
}: 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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<ToolInputAndResult
|
<Formik
|
||||||
input={
|
|
||||||
inputComponent ??
|
|
||||||
(renderCustomInput &&
|
|
||||||
formRef.current &&
|
|
||||||
renderCustomInput(
|
|
||||||
formRef.current.values,
|
|
||||||
formRef.current.setFieldValue
|
|
||||||
))
|
|
||||||
}
|
|
||||||
result={resultComponent}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ToolOptions
|
|
||||||
formRef={formRef}
|
|
||||||
compute={compute}
|
|
||||||
getGroups={getGroups}
|
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
input={input}
|
|
||||||
validationSchema={validationSchema}
|
validationSchema={validationSchema}
|
||||||
/>
|
onSubmit={() => {}}
|
||||||
|
>
|
||||||
|
{({ values, setFieldValue }) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ToolInputAndResult
|
||||||
|
input={
|
||||||
|
inputComponent ??
|
||||||
|
(renderCustomInput &&
|
||||||
|
renderCustomInput(values, setFieldValue))
|
||||||
|
}
|
||||||
|
result={resultComponent}
|
||||||
|
/>
|
||||||
|
|
||||||
{toolInfo && toolInfo.title && toolInfo.description && (
|
<ToolOptions
|
||||||
<ToolInfo title={toolInfo.title} description={toolInfo.description} />
|
compute={compute}
|
||||||
)}
|
getGroups={getGroups}
|
||||||
|
input={input}
|
||||||
|
/>
|
||||||
|
|
||||||
{exampleCards && exampleCards.length > 0 && (
|
{toolInfo && toolInfo.title && toolInfo.description && (
|
||||||
<>
|
<ToolInfo
|
||||||
<Separator backgroundColor="#5581b5" margin="50px" />
|
title={toolInfo.title}
|
||||||
<ToolExamples
|
description={toolInfo.description}
|
||||||
title={title}
|
/>
|
||||||
exampleCards={exampleCards}
|
)}
|
||||||
getGroups={getGroups}
|
|
||||||
formRef={formRef}
|
{exampleCards && exampleCards.length > 0 && (
|
||||||
setInput={setInput}
|
<>
|
||||||
/>
|
<Separator backgroundColor="#5581b5" margin="50px" />
|
||||||
</>
|
<ToolExamples
|
||||||
)}
|
title={title}
|
||||||
|
exampleCards={exampleCards}
|
||||||
|
getGroups={getGroups}
|
||||||
|
setInput={setInput}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Formik>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { Box, Grid, Stack, Typography } from '@mui/material';
|
||||||
import ExampleCard, { ExampleCardProps } from './ExampleCard';
|
import ExampleCard, { ExampleCardProps } from './ExampleCard';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { GetGroupsType } from '@components/options/ToolOptions';
|
import { GetGroupsType } from '@components/options/ToolOptions';
|
||||||
import { FormikProps } from 'formik';
|
import { useFormikContext } from 'formik';
|
||||||
|
|
||||||
export type CardExampleType<T> = Omit<
|
export type CardExampleType<T> = Omit<
|
||||||
ExampleCardProps<T>,
|
ExampleCardProps<T>,
|
||||||
|
|
@ -14,7 +14,6 @@ export interface ExampleProps<T> {
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
exampleCards: CardExampleType<T>[];
|
exampleCards: CardExampleType<T>[];
|
||||||
getGroups: GetGroupsType<T> | null;
|
getGroups: GetGroupsType<T> | null;
|
||||||
formRef: React.RefObject<FormikProps<T>>;
|
|
||||||
setInput?: React.Dispatch<React.SetStateAction<any>>;
|
setInput?: React.Dispatch<React.SetStateAction<any>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -23,12 +22,13 @@ export default function ToolExamples<T>({
|
||||||
subtitle,
|
subtitle,
|
||||||
exampleCards,
|
exampleCards,
|
||||||
getGroups,
|
getGroups,
|
||||||
formRef,
|
|
||||||
setInput
|
setInput
|
||||||
}: ExampleProps<T>) {
|
}: ExampleProps<T>) {
|
||||||
|
const { setValues } = useFormikContext<T>();
|
||||||
|
|
||||||
function changeInputResult(newInput: string | undefined, newOptions: T) {
|
function changeInputResult(newInput: string | undefined, newOptions: T) {
|
||||||
setInput?.(newInput);
|
setInput?.(newInput);
|
||||||
formRef.current?.setValues(newOptions);
|
setValues(newOptions);
|
||||||
const toolsElement = document.getElementById('tool');
|
const toolsElement = document.getElementById('tool');
|
||||||
if (toolsElement) {
|
if (toolsElement) {
|
||||||
toolsElement.scrollIntoView({ behavior: 'smooth' });
|
toolsElement.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,12 @@ interface ToolFileInputProps {
|
||||||
position: { x: number; y: number },
|
position: { x: number; y: number },
|
||||||
size: { width: number; height: number }
|
size: { width: number; height: number }
|
||||||
) => void;
|
) => void;
|
||||||
|
type?: 'image' | 'video' | 'audio';
|
||||||
|
// Video specific props
|
||||||
|
showTrimControls?: boolean;
|
||||||
|
onTrimChange?: (trimStart: number, trimEnd: number) => void;
|
||||||
|
trimStart?: number;
|
||||||
|
trimEnd?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ToolFileInput({
|
export default function ToolFileInput({
|
||||||
|
|
@ -33,15 +39,22 @@ export default function ToolFileInput({
|
||||||
cropShape = 'rectangular',
|
cropShape = 'rectangular',
|
||||||
cropPosition = { x: 0, y: 0 },
|
cropPosition = { x: 0, y: 0 },
|
||||||
cropSize = { width: 100, height: 100 },
|
cropSize = { width: 100, height: 100 },
|
||||||
onCropChange
|
onCropChange,
|
||||||
|
type = 'image',
|
||||||
|
showTrimControls = false,
|
||||||
|
onTrimChange,
|
||||||
|
trimStart = 0,
|
||||||
|
trimEnd = 100
|
||||||
}: ToolFileInputProps) {
|
}: ToolFileInputProps) {
|
||||||
const [preview, setPreview] = useState<string | null>(null);
|
const [preview, setPreview] = useState<string | null>(null);
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { showSnackBar } = useContext(CustomSnackBarContext);
|
const { showSnackBar } = useContext(CustomSnackBarContext);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const imageRef = useRef<HTMLImageElement>(null);
|
const imageRef = useRef<HTMLImageElement>(null);
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const [imgWidth, setImgWidth] = useState(0);
|
const [imgWidth, setImgWidth] = useState(0);
|
||||||
const [imgHeight, setImgHeight] = useState(0);
|
const [imgHeight, setImgHeight] = useState(0);
|
||||||
|
const [videoDuration, setVideoDuration] = useState(0);
|
||||||
|
|
||||||
// Convert position and size to crop format used by ReactCrop
|
// Convert position and size to crop format used by ReactCrop
|
||||||
const [crop, setCrop] = useState<Crop>({
|
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) => {
|
const handleCropChange = (newCrop: Crop) => {
|
||||||
setCrop(newCrop);
|
setCrop(newCrop);
|
||||||
};
|
};
|
||||||
|
|
@ -145,11 +169,20 @@ export default function ToolFileInput({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTrimChange = (start: number, end: number) => {
|
||||||
|
if (onTrimChange) {
|
||||||
|
onTrimChange(start, end);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePaste = (event: ClipboardEvent) => {
|
const handlePaste = (event: ClipboardEvent) => {
|
||||||
const clipboardItems = event.clipboardData?.items ?? [];
|
const clipboardItems = event.clipboardData?.items ?? [];
|
||||||
const item = clipboardItems[0];
|
const item = clipboardItems[0];
|
||||||
if (item && item.type.includes('image')) {
|
if (
|
||||||
|
item &&
|
||||||
|
(item.type.includes('image') || item.type.includes('video'))
|
||||||
|
) {
|
||||||
const file = item.getAsFile();
|
const file = item.getAsFile();
|
||||||
if (file) onChange(file);
|
if (file) onChange(file);
|
||||||
}
|
}
|
||||||
|
|
@ -161,6 +194,15 @@ export default function ToolFileInput({
|
||||||
};
|
};
|
||||||
}, [onChange]);
|
}, [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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<InputHeader title={title} />
|
<InputHeader title={title} />
|
||||||
|
|
@ -188,14 +230,24 @@ export default function ToolFileInput({
|
||||||
overflow: 'hidden'
|
overflow: 'hidden'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{showCropOverlay ? (
|
{type === 'image' &&
|
||||||
<ReactCrop
|
(showCropOverlay ? (
|
||||||
crop={crop}
|
<ReactCrop
|
||||||
onChange={handleCropChange}
|
crop={crop}
|
||||||
onComplete={handleCropComplete}
|
onChange={handleCropChange}
|
||||||
circularCrop={cropShape === 'circular'}
|
onComplete={handleCropComplete}
|
||||||
style={{ maxWidth: '100%', maxHeight: globalInputHeight }}
|
circularCrop={cropShape === 'circular'}
|
||||||
>
|
style={{ maxWidth: '100%', maxHeight: globalInputHeight }}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
ref={imageRef}
|
||||||
|
src={preview}
|
||||||
|
alt="Preview"
|
||||||
|
style={{ maxWidth: '100%', maxHeight: globalInputHeight }}
|
||||||
|
onLoad={onImageLoad}
|
||||||
|
/>
|
||||||
|
</ReactCrop>
|
||||||
|
) : (
|
||||||
<img
|
<img
|
||||||
ref={imageRef}
|
ref={imageRef}
|
||||||
src={preview}
|
src={preview}
|
||||||
|
|
@ -203,14 +255,98 @@ export default function ToolFileInput({
|
||||||
style={{ maxWidth: '100%', maxHeight: globalInputHeight }}
|
style={{ maxWidth: '100%', maxHeight: globalInputHeight }}
|
||||||
onLoad={onImageLoad}
|
onLoad={onImageLoad}
|
||||||
/>
|
/>
|
||||||
</ReactCrop>
|
))}
|
||||||
) : (
|
{type === 'video' && (
|
||||||
<img
|
<Box
|
||||||
ref={imageRef}
|
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}
|
src={preview}
|
||||||
alt="Preview"
|
controls
|
||||||
style={{ maxWidth: '100%', maxHeight: globalInputHeight }}
|
style={{ width: '100%', maxWidth: '500px' }}
|
||||||
onLoad={onImageLoad}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -228,8 +364,8 @@ export default function ToolFileInput({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography color={theme.palette.grey['600']}>
|
<Typography color={theme.palette.grey['600']}>
|
||||||
Click here to select an image from your device, press Ctrl+V to
|
Click here to select a {type} from your device, press Ctrl+V to
|
||||||
use an image from your clipboard, drag and drop a file from
|
use a {type} from your clipboard, drag and drop a file from
|
||||||
desktop
|
desktop
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -1,99 +1,62 @@
|
||||||
import { Box, Stack, useTheme } from '@mui/material';
|
import { Box, Stack, useTheme } from '@mui/material';
|
||||||
import SettingsIcon from '@mui/icons-material/Settings';
|
import SettingsIcon from '@mui/icons-material/Settings';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import React, { ReactNode, RefObject, useContext, useEffect } from 'react';
|
import React, { ReactNode, useContext } from 'react';
|
||||||
import { Formik, FormikProps, FormikValues, useFormikContext } from 'formik';
|
import { FormikProps, FormikValues, useFormikContext } from 'formik';
|
||||||
import ToolOptionGroups, { ToolOptionGroup } from './ToolOptionGroups';
|
import ToolOptionGroups, { ToolOptionGroup } from './ToolOptionGroups';
|
||||||
import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext';
|
import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext';
|
||||||
|
|
||||||
export type UpdateField<T> = <Y extends keyof T>(field: Y, value: T[Y]) => void;
|
export type UpdateField<T> = <Y extends keyof T>(field: Y, value: T[Y]) => void;
|
||||||
|
|
||||||
const FormikListenerComponent = <T,>({
|
const FormikListenerComponent = <T,>({
|
||||||
initialValues,
|
|
||||||
input,
|
input,
|
||||||
compute
|
compute
|
||||||
}: {
|
}: {
|
||||||
initialValues: T;
|
|
||||||
input: any;
|
input: any;
|
||||||
compute: (optionsValues: T, input: any) => void;
|
compute: (optionsValues: T, input: any) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const { values } = useFormikContext<typeof initialValues>();
|
const { values } = useFormikContext<T>();
|
||||||
const { showSnackBar } = useContext(CustomSnackBarContext);
|
const { showSnackBar } = useContext(CustomSnackBarContext);
|
||||||
|
|
||||||
useEffect(() => {
|
React.useEffect(() => {
|
||||||
try {
|
try {
|
||||||
compute(values, input);
|
compute(values, input);
|
||||||
} catch (exception: unknown) {
|
} catch (exception: unknown) {
|
||||||
if (exception instanceof Error) showSnackBar(exception.message, 'error');
|
if (exception instanceof Error) showSnackBar(exception.message, 'error');
|
||||||
else console.error(exception);
|
else console.error(exception);
|
||||||
}
|
}
|
||||||
}, [values, input]);
|
}, [values, input, showSnackBar]);
|
||||||
|
|
||||||
return null; // This component doesn't render anything
|
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> = (
|
export type GetGroupsType<T> = (
|
||||||
formikProps: FormikProps<T> & { updateField: UpdateField<T> }
|
formikProps: FormikProps<T> & { updateField: UpdateField<T> }
|
||||||
) => ToolOptionGroup[];
|
) => ToolOptionGroup[];
|
||||||
|
|
||||||
export default function ToolOptions<T extends FormikValues>({
|
export default function ToolOptions<T extends FormikValues>({
|
||||||
children,
|
children,
|
||||||
initialValues,
|
|
||||||
validationSchema,
|
|
||||||
compute,
|
compute,
|
||||||
input,
|
input,
|
||||||
getGroups,
|
getGroups
|
||||||
formRef
|
|
||||||
}: {
|
}: {
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
initialValues: T;
|
|
||||||
validationSchema?: any | (() => any);
|
|
||||||
compute: (optionsValues: T, input: any) => void;
|
compute: (optionsValues: T, input: any) => void;
|
||||||
input?: any;
|
input?: any;
|
||||||
getGroups: GetGroupsType<T> | null;
|
getGroups: GetGroupsType<T> | null;
|
||||||
formRef?: RefObject<FormikProps<T>>;
|
|
||||||
}) {
|
}) {
|
||||||
const theme = useTheme();
|
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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
|
|
@ -101,8 +64,7 @@ export default function ToolOptions<T extends FormikValues>({
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
padding: 2,
|
padding: 2,
|
||||||
backgroundColor: theme.palette.background.default,
|
backgroundColor: theme.palette.background.default,
|
||||||
boxShadow: '2',
|
boxShadow: '2'
|
||||||
display: getGroups ? 'block' : 'none'
|
|
||||||
}}
|
}}
|
||||||
mt={2}
|
mt={2}
|
||||||
>
|
>
|
||||||
|
|
@ -111,23 +73,13 @@ export default function ToolOptions<T extends FormikValues>({
|
||||||
<Typography fontSize={22}>Tool options</Typography>
|
<Typography fontSize={22}>Tool options</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Box mt={2}>
|
<Box mt={2}>
|
||||||
<Formik
|
<Stack direction={'row'} spacing={2}>
|
||||||
innerRef={formRef}
|
<FormikListenerComponent<T> compute={compute} input={input} />
|
||||||
initialValues={initialValues}
|
<ToolOptionGroups
|
||||||
validationSchema={validationSchema}
|
groups={getGroups({ ...formikContext, updateField }) ?? []}
|
||||||
onSubmit={() => {}}
|
/>
|
||||||
>
|
{children}
|
||||||
{(formikProps) => (
|
</Stack>
|
||||||
<ToolBody
|
|
||||||
compute={compute}
|
|
||||||
input={input}
|
|
||||||
getGroups={getGroups}
|
|
||||||
formikProps={formikProps}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</ToolBody>
|
|
||||||
)}
|
|
||||||
</Formik>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,18 @@ export default function ToolFileResult({
|
||||||
window.URL.revokeObjectURL(url);
|
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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<InputHeader title={title} />
|
<InputHeader title={title} />
|
||||||
|
|
@ -82,11 +94,32 @@ export default function ToolFileResult({
|
||||||
backgroundImage: `url(${greyPattern})`
|
backgroundImage: `url(${greyPattern})`
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
{fileType === 'image' && (
|
||||||
src={preview}
|
<img
|
||||||
alt="Result"
|
src={preview}
|
||||||
style={{ maxWidth: '100%', maxHeight: globalInputHeight }}
|
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>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,6 @@ describe('shuffle function', () => {
|
||||||
joinSeparator,
|
joinSeparator,
|
||||||
length
|
length
|
||||||
);
|
);
|
||||||
console.log(result);
|
|
||||||
expect(result.split(joinSeparator).length).toBe(2);
|
expect(result.split(joinSeparator).length).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -49,7 +48,6 @@ describe('shuffle function', () => {
|
||||||
joinSeparator,
|
joinSeparator,
|
||||||
length
|
length
|
||||||
);
|
);
|
||||||
console.log(result);
|
|
||||||
expect(result.split(joinSeparator).length).toBe(4);
|
expect(result.split(joinSeparator).length).toBe(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -66,7 +64,6 @@ describe('shuffle function', () => {
|
||||||
joinSeparator,
|
joinSeparator,
|
||||||
length
|
length
|
||||||
);
|
);
|
||||||
console.log(result);
|
|
||||||
expect(result.split(joinSeparator)).toContain('apple');
|
expect(result.split(joinSeparator)).toContain('apple');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -83,7 +80,6 @@ describe('shuffle function', () => {
|
||||||
joinSeparator,
|
joinSeparator,
|
||||||
length
|
length
|
||||||
);
|
);
|
||||||
console.log(result);
|
|
||||||
expect(result).toBe('');
|
expect(result).toBe('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ const initialValues = {
|
||||||
export default function ToMorse() {
|
export default function ToMorse() {
|
||||||
const [input, setInput] = useState<string>('');
|
const [input, setInput] = useState<string>('');
|
||||||
const [result, setResult] = useState<string>('');
|
const [result, setResult] = useState<string>('');
|
||||||
// const formRef = useRef<FormikProps<typeof initialValues>>(null);
|
|
||||||
const computeOptions = (optionsValues: typeof initialValues, input: any) => {
|
const computeOptions = (optionsValues: typeof initialValues, input: any) => {
|
||||||
const { dotSymbol, dashSymbol } = optionsValues;
|
const { dotSymbol, dashSymbol } = optionsValues;
|
||||||
setResult(compute(input, dotSymbol, dashSymbol));
|
setResult(compute(input, dotSymbol, dashSymbol));
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
import { gifTools } from './gif';
|
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'
|
| 'png'
|
||||||
| 'number'
|
| 'number'
|
||||||
| 'gif'
|
| 'gif'
|
||||||
|
| 'video'
|
||||||
| 'list'
|
| 'list'
|
||||||
| 'json'
|
| 'json'
|
||||||
| 'csv';
|
| 'csv';
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,12 @@ const categoriesConfig: {
|
||||||
icon: 'material-symbols-light:csv-outline',
|
icon: 'material-symbols-light:csv-outline',
|
||||||
value:
|
value:
|
||||||
'Tools for working with CSV files - convert CSV to different formats, manipulate CSV data, validate CSV structure, and process CSV files efficiently.'
|
'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 = (
|
export const filterTools = (
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@ import tsconfigPaths from 'vite-tsconfig-paths';
|
||||||
// https://vitejs.dev/config https://vitest.dev/config
|
// https://vitejs.dev/config https://vitest.dev/config
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tsconfigPaths()],
|
plugins: [react(), tsconfigPaths()],
|
||||||
|
optimizeDeps: {
|
||||||
|
exclude: ['@ffmpeg/ffmpeg', '@ffmpeg/util']
|
||||||
|
},
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: 'happy-dom',
|
environment: 'happy-dom',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue