mirror of
https://github.com/iib0011/omni-tools.git
synced 2025-09-19 05:59:34 +02:00
feat: trim video
This commit is contained in:
@@ -1,10 +1,7 @@
|
||||
import React, { useRef, useState, ReactNode, useEffect } from 'react';
|
||||
import React, { useRef, ReactNode, useState } from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import { FormikProps, FormikValues } from 'formik';
|
||||
import ToolOptions, {
|
||||
GetGroupsType,
|
||||
UpdateField
|
||||
} from '@components/options/ToolOptions';
|
||||
import { Formik, FormikProps, FormikValues } from 'formik';
|
||||
import ToolOptions, { GetGroupsType } from '@components/options/ToolOptions';
|
||||
import ToolInputAndResult from '@components/ToolInputAndResult';
|
||||
import ToolInfo from '@components/ToolInfo';
|
||||
import Separator from '@components/Separator';
|
||||
@@ -60,54 +57,53 @@ export default function ToolContent<T extends FormikValues, I>({
|
||||
validationSchema,
|
||||
renderCustomInput
|
||||
}: ToolContentProps<T, I>) {
|
||||
const formRef = useRef<FormikProps<T>>(null);
|
||||
|
||||
const [initialized, forceUpdate] = useState(0);
|
||||
useEffect(() => {
|
||||
if (formRef.current && !initialized) {
|
||||
forceUpdate((n) => n + 1);
|
||||
}
|
||||
}, [initialized]);
|
||||
return (
|
||||
<Box>
|
||||
<ToolInputAndResult
|
||||
input={
|
||||
inputComponent ??
|
||||
(renderCustomInput &&
|
||||
formRef.current &&
|
||||
renderCustomInput(
|
||||
formRef.current.values,
|
||||
formRef.current.setFieldValue
|
||||
))
|
||||
}
|
||||
result={resultComponent}
|
||||
/>
|
||||
|
||||
<ToolOptions
|
||||
formRef={formRef}
|
||||
compute={compute}
|
||||
getGroups={getGroups}
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
input={input}
|
||||
validationSchema={validationSchema}
|
||||
/>
|
||||
onSubmit={() => {}}
|
||||
>
|
||||
{({ values, setFieldValue }) => {
|
||||
return (
|
||||
<>
|
||||
<ToolInputAndResult
|
||||
input={
|
||||
inputComponent ??
|
||||
(renderCustomInput &&
|
||||
renderCustomInput(values, setFieldValue))
|
||||
}
|
||||
result={resultComponent}
|
||||
/>
|
||||
|
||||
{toolInfo && toolInfo.title && toolInfo.description && (
|
||||
<ToolInfo title={toolInfo.title} description={toolInfo.description} />
|
||||
)}
|
||||
<ToolOptions
|
||||
compute={compute}
|
||||
getGroups={getGroups}
|
||||
input={input}
|
||||
/>
|
||||
|
||||
{exampleCards && exampleCards.length > 0 && (
|
||||
<>
|
||||
<Separator backgroundColor="#5581b5" margin="50px" />
|
||||
<ToolExamples
|
||||
title={title}
|
||||
exampleCards={exampleCards}
|
||||
getGroups={getGroups}
|
||||
formRef={formRef}
|
||||
setInput={setInput}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{toolInfo && toolInfo.title && toolInfo.description && (
|
||||
<ToolInfo
|
||||
title={toolInfo.title}
|
||||
description={toolInfo.description}
|
||||
/>
|
||||
)}
|
||||
|
||||
{exampleCards && exampleCards.length > 0 && (
|
||||
<>
|
||||
<Separator backgroundColor="#5581b5" margin="50px" />
|
||||
<ToolExamples
|
||||
title={title}
|
||||
exampleCards={exampleCards}
|
||||
getGroups={getGroups}
|
||||
setInput={setInput}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
@@ -2,7 +2,7 @@ import { Box, Grid, Stack, Typography } from '@mui/material';
|
||||
import ExampleCard, { ExampleCardProps } from './ExampleCard';
|
||||
import React from 'react';
|
||||
import { GetGroupsType } from '@components/options/ToolOptions';
|
||||
import { FormikProps } from 'formik';
|
||||
import { useFormikContext } from 'formik';
|
||||
|
||||
export type CardExampleType<T> = Omit<
|
||||
ExampleCardProps<T>,
|
||||
@@ -14,7 +14,6 @@ export interface ExampleProps<T> {
|
||||
subtitle?: string;
|
||||
exampleCards: CardExampleType<T>[];
|
||||
getGroups: GetGroupsType<T> | null;
|
||||
formRef: React.RefObject<FormikProps<T>>;
|
||||
setInput?: React.Dispatch<React.SetStateAction<any>>;
|
||||
}
|
||||
|
||||
@@ -23,12 +22,13 @@ export default function ToolExamples<T>({
|
||||
subtitle,
|
||||
exampleCards,
|
||||
getGroups,
|
||||
formRef,
|
||||
setInput
|
||||
}: ExampleProps<T>) {
|
||||
const { setValues } = useFormikContext<T>();
|
||||
|
||||
function changeInputResult(newInput: string | undefined, newOptions: T) {
|
||||
setInput?.(newInput);
|
||||
formRef.current?.setValues(newOptions);
|
||||
setValues(newOptions);
|
||||
const toolsElement = document.getElementById('tool');
|
||||
if (toolsElement) {
|
||||
toolsElement.scrollIntoView({ behavior: 'smooth' });
|
||||
|
@@ -22,6 +22,12 @@ interface ToolFileInputProps {
|
||||
position: { x: number; y: number },
|
||||
size: { width: number; height: number }
|
||||
) => void;
|
||||
type?: 'image' | 'video' | 'audio';
|
||||
// Video specific props
|
||||
showTrimControls?: boolean;
|
||||
onTrimChange?: (trimStart: number, trimEnd: number) => void;
|
||||
trimStart?: number;
|
||||
trimEnd?: number;
|
||||
}
|
||||
|
||||
export default function ToolFileInput({
|
||||
@@ -33,15 +39,22 @@ export default function ToolFileInput({
|
||||
cropShape = 'rectangular',
|
||||
cropPosition = { x: 0, y: 0 },
|
||||
cropSize = { width: 100, height: 100 },
|
||||
onCropChange
|
||||
onCropChange,
|
||||
type = 'image',
|
||||
showTrimControls = false,
|
||||
onTrimChange,
|
||||
trimStart = 0,
|
||||
trimEnd = 100
|
||||
}: ToolFileInputProps) {
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const theme = useTheme();
|
||||
const { showSnackBar } = useContext(CustomSnackBarContext);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [imgWidth, setImgWidth] = useState(0);
|
||||
const [imgHeight, setImgHeight] = useState(0);
|
||||
const [videoDuration, setVideoDuration] = useState(0);
|
||||
|
||||
// Convert position and size to crop format used by ReactCrop
|
||||
const [crop, setCrop] = useState<Crop>({
|
||||
@@ -129,6 +142,17 @@ export default function ToolFileInput({
|
||||
}
|
||||
};
|
||||
|
||||
// Handle video load to set duration
|
||||
const onVideoLoad = (e: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||
const duration = e.currentTarget.duration;
|
||||
setVideoDuration(duration);
|
||||
|
||||
// Initialize trim with full duration if needed
|
||||
if (onTrimChange && trimStart === 0 && trimEnd === 100) {
|
||||
onTrimChange(0, duration);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCropChange = (newCrop: Crop) => {
|
||||
setCrop(newCrop);
|
||||
};
|
||||
@@ -145,11 +169,20 @@ export default function ToolFileInput({
|
||||
}
|
||||
};
|
||||
|
||||
const handleTrimChange = (start: number, end: number) => {
|
||||
if (onTrimChange) {
|
||||
onTrimChange(start, end);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
const clipboardItems = event.clipboardData?.items ?? [];
|
||||
const item = clipboardItems[0];
|
||||
if (item && item.type.includes('image')) {
|
||||
if (
|
||||
item &&
|
||||
(item.type.includes('image') || item.type.includes('video'))
|
||||
) {
|
||||
const file = item.getAsFile();
|
||||
if (file) onChange(file);
|
||||
}
|
||||
@@ -161,6 +194,15 @@ export default function ToolFileInput({
|
||||
};
|
||||
}, [onChange]);
|
||||
|
||||
// Format seconds to MM:SS format
|
||||
const formatTime = (seconds: number) => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds
|
||||
.toString()
|
||||
.padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<InputHeader title={title} />
|
||||
@@ -188,14 +230,24 @@ export default function ToolFileInput({
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{showCropOverlay ? (
|
||||
<ReactCrop
|
||||
crop={crop}
|
||||
onChange={handleCropChange}
|
||||
onComplete={handleCropComplete}
|
||||
circularCrop={cropShape === 'circular'}
|
||||
style={{ maxWidth: '100%', maxHeight: globalInputHeight }}
|
||||
>
|
||||
{type === 'image' &&
|
||||
(showCropOverlay ? (
|
||||
<ReactCrop
|
||||
crop={crop}
|
||||
onChange={handleCropChange}
|
||||
onComplete={handleCropComplete}
|
||||
circularCrop={cropShape === 'circular'}
|
||||
style={{ maxWidth: '100%', maxHeight: globalInputHeight }}
|
||||
>
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={preview}
|
||||
alt="Preview"
|
||||
style={{ maxWidth: '100%', maxHeight: globalInputHeight }}
|
||||
onLoad={onImageLoad}
|
||||
/>
|
||||
</ReactCrop>
|
||||
) : (
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={preview}
|
||||
@@ -203,14 +255,98 @@ export default function ToolFileInput({
|
||||
style={{ maxWidth: '100%', maxHeight: globalInputHeight }}
|
||||
onLoad={onImageLoad}
|
||||
/>
|
||||
</ReactCrop>
|
||||
) : (
|
||||
<img
|
||||
ref={imageRef}
|
||||
))}
|
||||
{type === 'video' && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={preview}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: showTrimControls ? 'calc(100% - 50px)' : '100%'
|
||||
}}
|
||||
onLoadedMetadata={onVideoLoad}
|
||||
controls={!showTrimControls}
|
||||
/>
|
||||
|
||||
{showTrimControls && videoDuration > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
padding: '10px 20px',
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption">
|
||||
Start: {formatTime(trimStart || 0)}
|
||||
</Typography>
|
||||
<Typography variant="caption">
|
||||
End: {formatTime(trimEnd || videoDuration)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={videoDuration}
|
||||
step={0.1}
|
||||
value={trimStart || 0}
|
||||
onChange={(e) =>
|
||||
handleTrimChange(
|
||||
parseFloat(e.target.value),
|
||||
trimEnd || videoDuration
|
||||
)
|
||||
}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min={trimStart || 0}
|
||||
max={videoDuration}
|
||||
step={0.1}
|
||||
value={trimEnd || videoDuration}
|
||||
onChange={(e) =>
|
||||
handleTrimChange(
|
||||
trimStart || 0,
|
||||
parseFloat(e.target.value)
|
||||
)
|
||||
}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{type === 'audio' && (
|
||||
<audio
|
||||
src={preview}
|
||||
alt="Preview"
|
||||
style={{ maxWidth: '100%', maxHeight: globalInputHeight }}
|
||||
onLoad={onImageLoad}
|
||||
controls
|
||||
style={{ width: '100%', maxWidth: '500px' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
@@ -228,8 +364,8 @@ export default function ToolFileInput({
|
||||
}}
|
||||
>
|
||||
<Typography color={theme.palette.grey['600']}>
|
||||
Click here to select an image from your device, press Ctrl+V to
|
||||
use an image from your clipboard, drag and drop a file from
|
||||
Click here to select a {type} from your device, press Ctrl+V to
|
||||
use a {type} from your clipboard, drag and drop a file from
|
||||
desktop
|
||||
</Typography>
|
||||
</Box>
|
||||
|
@@ -1,99 +1,62 @@
|
||||
import { Box, Stack, useTheme } from '@mui/material';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import React, { ReactNode, RefObject, useContext, useEffect } from 'react';
|
||||
import { Formik, FormikProps, FormikValues, useFormikContext } from 'formik';
|
||||
import React, { ReactNode, useContext } from 'react';
|
||||
import { FormikProps, FormikValues, useFormikContext } from 'formik';
|
||||
import ToolOptionGroups, { ToolOptionGroup } from './ToolOptionGroups';
|
||||
import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext';
|
||||
|
||||
export type UpdateField<T> = <Y extends keyof T>(field: Y, value: T[Y]) => void;
|
||||
|
||||
const FormikListenerComponent = <T,>({
|
||||
initialValues,
|
||||
input,
|
||||
compute
|
||||
}: {
|
||||
initialValues: T;
|
||||
input: any;
|
||||
compute: (optionsValues: T, input: any) => void;
|
||||
}) => {
|
||||
const { values } = useFormikContext<typeof initialValues>();
|
||||
const { values } = useFormikContext<T>();
|
||||
const { showSnackBar } = useContext(CustomSnackBarContext);
|
||||
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
compute(values, input);
|
||||
} catch (exception: unknown) {
|
||||
if (exception instanceof Error) showSnackBar(exception.message, 'error');
|
||||
else console.error(exception);
|
||||
}
|
||||
}, [values, input]);
|
||||
}, [values, input, showSnackBar]);
|
||||
|
||||
return null; // This component doesn't render anything
|
||||
};
|
||||
|
||||
interface FormikHelperProps<T> {
|
||||
compute: (optionsValues: T, input: any) => void;
|
||||
input: any;
|
||||
children?: ReactNode;
|
||||
getGroups:
|
||||
| null
|
||||
| ((
|
||||
formikProps: FormikProps<T> & { updateField: UpdateField<T> }
|
||||
) => ToolOptionGroup[]);
|
||||
formikProps: FormikProps<T>;
|
||||
}
|
||||
|
||||
const ToolBody = <T,>({
|
||||
compute,
|
||||
input,
|
||||
children,
|
||||
getGroups,
|
||||
formikProps
|
||||
}: FormikHelperProps<T>) => {
|
||||
const { values, setFieldValue } = useFormikContext<T>();
|
||||
|
||||
const updateField: UpdateField<T> = (field, value) => {
|
||||
// @ts-ignore
|
||||
setFieldValue(field, value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack direction={'row'} spacing={2}>
|
||||
<FormikListenerComponent<T>
|
||||
compute={compute}
|
||||
input={input}
|
||||
initialValues={values}
|
||||
/>
|
||||
<ToolOptionGroups
|
||||
groups={getGroups?.({ ...formikProps, updateField }) ?? []}
|
||||
/>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export type GetGroupsType<T> = (
|
||||
formikProps: FormikProps<T> & { updateField: UpdateField<T> }
|
||||
) => ToolOptionGroup[];
|
||||
|
||||
export default function ToolOptions<T extends FormikValues>({
|
||||
children,
|
||||
initialValues,
|
||||
validationSchema,
|
||||
compute,
|
||||
input,
|
||||
getGroups,
|
||||
formRef
|
||||
getGroups
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
initialValues: T;
|
||||
validationSchema?: any | (() => any);
|
||||
compute: (optionsValues: T, input: any) => void;
|
||||
input?: any;
|
||||
getGroups: GetGroupsType<T> | null;
|
||||
formRef?: RefObject<FormikProps<T>>;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const formikContext = useFormikContext<T>();
|
||||
|
||||
// Early return if no groups to display
|
||||
if (!getGroups) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const updateField: UpdateField<T> = (field, value) => {
|
||||
formikContext.setFieldValue(field as string, value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
@@ -101,8 +64,7 @@ export default function ToolOptions<T extends FormikValues>({
|
||||
borderRadius: 2,
|
||||
padding: 2,
|
||||
backgroundColor: theme.palette.background.default,
|
||||
boxShadow: '2',
|
||||
display: getGroups ? 'block' : 'none'
|
||||
boxShadow: '2'
|
||||
}}
|
||||
mt={2}
|
||||
>
|
||||
@@ -111,23 +73,13 @@ export default function ToolOptions<T extends FormikValues>({
|
||||
<Typography fontSize={22}>Tool options</Typography>
|
||||
</Stack>
|
||||
<Box mt={2}>
|
||||
<Formik
|
||||
innerRef={formRef}
|
||||
initialValues={initialValues}
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={() => {}}
|
||||
>
|
||||
{(formikProps) => (
|
||||
<ToolBody
|
||||
compute={compute}
|
||||
input={input}
|
||||
getGroups={getGroups}
|
||||
formikProps={formikProps}
|
||||
>
|
||||
{children}
|
||||
</ToolBody>
|
||||
)}
|
||||
</Formik>
|
||||
<Stack direction={'row'} spacing={2}>
|
||||
<FormikListenerComponent<T> compute={compute} input={input} />
|
||||
<ToolOptionGroups
|
||||
groups={getGroups({ ...formikContext, updateField }) ?? []}
|
||||
/>
|
||||
{children}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
@@ -58,6 +58,18 @@ export default function ToolFileResult({
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
};
|
||||
|
||||
// Determine the file type based on MIME type
|
||||
const getFileType = () => {
|
||||
if (!value) return 'unknown';
|
||||
if (value.type.startsWith('image/')) return 'image';
|
||||
if (value.type.startsWith('video/')) return 'video';
|
||||
if (value.type.startsWith('audio/')) return 'audio';
|
||||
return 'unknown';
|
||||
};
|
||||
|
||||
const fileType = getFileType();
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<InputHeader title={title} />
|
||||
@@ -82,11 +94,32 @@ export default function ToolFileResult({
|
||||
backgroundImage: `url(${greyPattern})`
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={preview}
|
||||
alt="Result"
|
||||
style={{ maxWidth: '100%', maxHeight: globalInputHeight }}
|
||||
/>
|
||||
{fileType === 'image' && (
|
||||
<img
|
||||
src={preview}
|
||||
alt="Result"
|
||||
style={{ maxWidth: '100%', maxHeight: globalInputHeight }}
|
||||
/>
|
||||
)}
|
||||
{fileType === 'video' && (
|
||||
<video
|
||||
src={preview}
|
||||
controls
|
||||
style={{ maxWidth: '100%', maxHeight: globalInputHeight }}
|
||||
/>
|
||||
)}
|
||||
{fileType === 'audio' && (
|
||||
<audio
|
||||
src={preview}
|
||||
controls
|
||||
style={{ width: '100%', maxWidth: '500px' }}
|
||||
/>
|
||||
)}
|
||||
{fileType === 'unknown' && (
|
||||
<Box sx={{ padding: 2, textAlign: 'center' }}>
|
||||
File processed successfully. Click download to save the result.
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
@@ -31,7 +31,6 @@ describe('shuffle function', () => {
|
||||
joinSeparator,
|
||||
length
|
||||
);
|
||||
console.log(result);
|
||||
expect(result.split(joinSeparator).length).toBe(2);
|
||||
});
|
||||
|
||||
@@ -49,7 +48,6 @@ describe('shuffle function', () => {
|
||||
joinSeparator,
|
||||
length
|
||||
);
|
||||
console.log(result);
|
||||
expect(result.split(joinSeparator).length).toBe(4);
|
||||
});
|
||||
|
||||
@@ -66,7 +64,6 @@ describe('shuffle function', () => {
|
||||
joinSeparator,
|
||||
length
|
||||
);
|
||||
console.log(result);
|
||||
expect(result.split(joinSeparator)).toContain('apple');
|
||||
});
|
||||
|
||||
@@ -83,7 +80,6 @@ describe('shuffle function', () => {
|
||||
joinSeparator,
|
||||
length
|
||||
);
|
||||
console.log(result);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
||||
|
@@ -13,7 +13,6 @@ const initialValues = {
|
||||
export default function ToMorse() {
|
||||
const [input, setInput] = useState<string>('');
|
||||
const [result, setResult] = useState<string>('');
|
||||
// const formRef = useRef<FormikProps<typeof initialValues>>(null);
|
||||
const computeOptions = (optionsValues: typeof initialValues, input: any) => {
|
||||
const { dotSymbol, dashSymbol } = optionsValues;
|
||||
setResult(compute(input, dotSymbol, dashSymbol));
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import { gifTools } from './gif';
|
||||
import { tool as trimVideo } from './trim/meta';
|
||||
|
||||
export const videoTools = [...gifTools];
|
||||
export const videoTools = [...gifTools, trimVideo];
|
||||
|
143
src/pages/tools/video/trim/index.tsx
Normal file
143
src/pages/tools/video/trim/index.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { Box } from '@mui/material';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import * as Yup from 'yup';
|
||||
import ToolFileInput from '@components/input/ToolFileInput';
|
||||
import ToolFileResult from '@components/result/ToolFileResult';
|
||||
import ToolContent from '@components/ToolContent';
|
||||
import { ToolComponentProps } from '@tools/defineTool';
|
||||
import { GetGroupsType } from '@components/options/ToolOptions';
|
||||
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
|
||||
import { updateNumberField } from '@utils/string';
|
||||
import { FFmpeg } from '@ffmpeg/ffmpeg';
|
||||
import { fetchFile } from '@ffmpeg/util';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
const ffmpeg = new FFmpeg();
|
||||
|
||||
const initialValues = {
|
||||
trimStart: 0,
|
||||
trimEnd: 100
|
||||
};
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
trimStart: Yup.number().min(0, 'Start time must be positive'),
|
||||
trimEnd: Yup.number().min(
|
||||
Yup.ref('trimStart'),
|
||||
'End time must be greater than start time'
|
||||
)
|
||||
});
|
||||
|
||||
export default function TrimVideo({ title }: ToolComponentProps) {
|
||||
const [input, setInput] = useState<File | null>(null);
|
||||
const [result, setResult] = useState<File | null>(null);
|
||||
|
||||
const compute = async (
|
||||
optionsValues: typeof initialValues,
|
||||
input: File | null
|
||||
) => {
|
||||
console.log('compute', optionsValues, input);
|
||||
if (!input) return;
|
||||
|
||||
const { trimStart, trimEnd } = optionsValues;
|
||||
|
||||
try {
|
||||
if (!ffmpeg.loaded) {
|
||||
await ffmpeg.load();
|
||||
}
|
||||
|
||||
const inputName = 'input.mp4';
|
||||
const outputName = 'output.mp4';
|
||||
// Load file into FFmpeg's virtual filesystem
|
||||
await ffmpeg.writeFile(inputName, await fetchFile(input));
|
||||
// Run FFmpeg command to trim video
|
||||
await ffmpeg.exec([
|
||||
'-i',
|
||||
inputName,
|
||||
'-ss',
|
||||
trimStart.toString(),
|
||||
'-to',
|
||||
trimEnd.toString(),
|
||||
'-c',
|
||||
'copy',
|
||||
outputName
|
||||
]);
|
||||
// Retrieve the processed file
|
||||
const trimmedData = await ffmpeg.readFile(outputName);
|
||||
const trimmedBlob = new Blob([trimmedData], { type: 'video/mp4' });
|
||||
const trimmedFile = new File(
|
||||
[trimmedBlob],
|
||||
`${input.name.replace(/\.[^/.]+$/, '')}_trimmed.mp4`,
|
||||
{
|
||||
type: 'video/mp4'
|
||||
}
|
||||
);
|
||||
|
||||
setResult(trimmedFile);
|
||||
} catch (error) {
|
||||
console.error('Error trimming video:', error);
|
||||
}
|
||||
};
|
||||
const debouncedCompute = useCallback(debounce(compute, 1000), []);
|
||||
const getGroups: GetGroupsType<typeof initialValues> = ({
|
||||
values,
|
||||
updateField
|
||||
}) => [
|
||||
{
|
||||
title: 'Timestamps',
|
||||
component: (
|
||||
<Box>
|
||||
<TextFieldWithDesc
|
||||
onOwnChange={(value) =>
|
||||
updateNumberField(value, 'trimStart', updateField)
|
||||
}
|
||||
value={values.trimStart}
|
||||
label={'Start Time'}
|
||||
/>
|
||||
<TextFieldWithDesc
|
||||
onOwnChange={(value) =>
|
||||
updateNumberField(value, 'trimEnd', updateField)
|
||||
}
|
||||
value={values.trimEnd}
|
||||
label={'End Time'}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
];
|
||||
return (
|
||||
<ToolContent
|
||||
title={title}
|
||||
input={input}
|
||||
renderCustomInput={({ trimStart, trimEnd }, setFieldValue) => {
|
||||
return (
|
||||
<ToolFileInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
accept={['video/mp4', 'video/webm', 'video/ogg']}
|
||||
title={'Input Video'}
|
||||
type="video"
|
||||
showTrimControls={true}
|
||||
onTrimChange={(trimStart, trimEnd) => {
|
||||
setFieldValue('trimStart', trimStart);
|
||||
setFieldValue('trimEnd', trimEnd);
|
||||
}}
|
||||
trimStart={trimStart}
|
||||
trimEnd={trimEnd}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
resultComponent={
|
||||
<ToolFileResult
|
||||
title={'Trimmed Video'}
|
||||
value={result}
|
||||
extension={'webm'}
|
||||
/>
|
||||
}
|
||||
initialValues={initialValues}
|
||||
getGroups={getGroups}
|
||||
compute={debouncedCompute}
|
||||
setInput={setInput}
|
||||
validationSchema={validationSchema}
|
||||
/>
|
||||
);
|
||||
}
|
13
src/pages/tools/video/trim/meta.ts
Normal file
13
src/pages/tools/video/trim/meta.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const tool = defineTool('video', {
|
||||
name: 'Trim Video',
|
||||
path: 'trim',
|
||||
icon: 'mdi:scissors',
|
||||
description:
|
||||
'This online utility lets you trim videos by setting start and end points. You can preview the trimmed section before processing. Supports common video formats like MP4, WebM, and OGG.',
|
||||
shortDescription: 'Trim videos by setting start and end points',
|
||||
keywords: ['trim', 'cut', 'video', 'clip', 'edit'],
|
||||
component: lazy(() => import('./index'))
|
||||
});
|
@@ -18,6 +18,7 @@ export type ToolCategory =
|
||||
| 'png'
|
||||
| 'number'
|
||||
| 'gif'
|
||||
| 'video'
|
||||
| 'list'
|
||||
| 'json'
|
||||
| 'csv';
|
||||
|
@@ -67,6 +67,12 @@ const categoriesConfig: {
|
||||
icon: 'material-symbols-light:csv-outline',
|
||||
value:
|
||||
'Tools for working with CSV files - convert CSV to different formats, manipulate CSV data, validate CSV structure, and process CSV files efficiently.'
|
||||
},
|
||||
{
|
||||
type: 'video',
|
||||
icon: 'lets-icons:video-light',
|
||||
value:
|
||||
'Tools for working with videos – extract frames from videos, create GIFs from videos, convert videos to different formats, and much more.'
|
||||
}
|
||||
];
|
||||
export const filterTools = (
|
||||
|
Reference in New Issue
Block a user