feat: trim video

This commit is contained in:
Ibrahima G. Coulibaly 2025-03-10 04:13:10 +00:00
commit d76abec8c0
16 changed files with 535 additions and 169 deletions

40
.idea/workspace.xml generated
View file

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

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

View file

@ -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": {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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}
/>
);
}

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

View file

@ -18,6 +18,7 @@ export type ToolCategory =
| 'png' | 'png'
| 'number' | 'number'
| 'gif' | 'gif'
| 'video'
| 'list' | 'list'
| 'json' | 'json'
| 'csv'; | 'csv';

View file

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

View file

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