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

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>