feat: trim video

This commit is contained in:
Ibrahima G. Coulibaly
2025-03-10 04:13:10 +00:00
parent e2c6d02fe6
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" />
</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
View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
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'
| 'number'
| 'gif'
| 'video'
| 'list'
| 'json'
| 'csv';

View File

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

View File

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