Merge pull request #339 from iib0011/batch-compressing-images

feature: Batch compressing images
This commit is contained in:
Chesterkxng 2026-03-20 18:07:52 +01:00 committed by GitHub
commit d318dd3f26
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 252 additions and 35 deletions

View file

@ -10,18 +10,20 @@
"title": "Change image Opacity"
},
"compress": {
"compressedSize": "Compressed Size",
"compressedSize": "Total compressed size",
"compressionOptions": "Compression options",
"description": "Reduce image file size while maintaining quality.",
"failedToCompress": "Failed to compress image. Please try again.",
"compressing": "Compressing images...",
"description": "Reduce the file size of one or multiple images while preserving visual quality. Upload a batch of images and download them all at once as a ZIP archive.",
"failedToCompress": "Image(s) compression failed. Please try again.",
"someFailedToCompress": "Some images could not be compressed and were skipped.",
"fileSizes": "File sizes",
"inputTitle": "Input image",
"maxFileSizeDescription": "Maximum file size in megabytes",
"originalSize": "Original Size",
"qualityDescription": "Image quality percentage (lower means smaller file size)",
"resultTitle": "Compressed image",
"shortDescription": "Compress images to reduce file size while maintaining reasonable quality.",
"title": "Compress Image"
"inputTitle": "Images Input",
"maxFileSizeDescription": "Maximum output file size per image in megabytes",
"originalSize": "Total original size",
"qualityDescription": "Image quality percentage — lower value means smaller file size",
"resultTitle": "Compressed images",
"shortDescription": "Compress one or multiple images at once. Download results individually or as a ZIP archive.",
"title": "Compress Images"
},
"compressPng": {
"description": "This is a program that compresses PNG pictures. As soon as you paste your PNG picture in the input area, the program will compress it and show the result in the output area. In the options, you can adjust the compression level, as well as find the old and new picture file sizes.",

View file

@ -260,6 +260,10 @@
"loading": "Loading... This may take a moment.",
"result": "Result"
},
"toolMultipleImageInput": {
"inputTitle": "Images Input",
"noFilesSelected": "No files selected"
},
"userTypes": {
"developers": "Developers",
"generalUsers": "General users"

View file

@ -0,0 +1,169 @@
import React, { useRef } from 'react';
import { Box } from '@mui/material';
import Typography from '@mui/material/Typography';
import InputHeader from '../InputHeader';
import InputFooter from './InputFooter';
import ImageIcon from '@mui/icons-material/Image';
import { useTranslation } from 'react-i18next';
interface MultiImageInputComponentProps {
accept: string[];
title?: string;
type: 'image';
value: MultiImageInput[];
onChange: (files: MultiImageInput[]) => void;
}
export interface MultiImageInput {
file: File;
order: number;
preview?: string;
}
export default function ToolMultiImageInput({
value,
onChange,
accept,
title,
type
}: MultiImageInputComponentProps) {
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (files) {
const newFiles: MultiImageInput[] = Array.from(files).map((file) => ({
file,
order: value.length,
preview: URL.createObjectURL(file)
}));
onChange([...value, ...newFiles]);
}
};
const handleImportClick = () => {
fileInputRef.current?.click();
};
function handleClear() {
onChange([]);
}
function fileNameTruncate(fileName: string) {
const maxLength = 10;
if (fileName.length > maxLength) {
return fileName.slice(0, maxLength) + '...';
}
return fileName;
}
return (
<Box>
<InputHeader
title={
title ||
t('toolMultipleImageInput.inputTitle', {
type: type.charAt(0).toUpperCase() + type.slice(1)
})
}
/>
<Box
sx={{
width: '100%',
height: '300px',
border: value?.length ? 0 : 1,
borderRadius: 2,
boxShadow: '5',
bgcolor: 'background.paper',
position: 'relative'
}}
>
<Box
width="100%"
height="100%"
sx={{
overflow: 'auto',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexWrap: 'wrap',
position: 'relative'
}}
>
{value?.length ? (
value.map((file, index) => (
<Box
key={index}
sx={{
margin: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'space-between',
width: '120px',
border: 1,
borderRadius: 1,
padding: 1,
position: 'relative'
}}
>
{file.preview ? (
<Box
component="img"
src={file.preview}
alt={file.file.name}
sx={{
width: '100%',
height: '80px',
objectFit: 'cover',
borderRadius: 1
}}
/>
) : (
<ImageIcon sx={{ fontSize: 40 }} />
)}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
mt: 0.5
}}
>
<Typography variant="caption">
{fileNameTruncate(file.file.name)}
</Typography>
<Box
sx={{ cursor: 'pointer' }}
onClick={() => {
const updatedFiles = value.filter((_, i) => i !== index);
onChange(updatedFiles);
}}
>
</Box>
</Box>
</Box>
))
) : (
<Typography variant="body2" color="text.secondary">
{t('toolMultipleImageInput.noFilesSelected')}
</Typography>
)}
</Box>
</Box>
<InputFooter handleImport={handleImportClick} handleClear={handleClear} />
<input
ref={fileInputRef}
style={{ display: 'none' }}
type="file"
accept={accept.join(',')}
onChange={handleFileChange}
multiple={true}
/>
</Box>
);
}

View file

@ -1,11 +1,13 @@
import React, { useContext, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { InitialValuesType } from './types';
import { compressImage } from './service';
import { compressImages } from './service';
import ToolContent from '@components/ToolContent';
import ToolImageInput from '@components/input/ToolImageInput';
import ToolMultipleImageInput, {
MultiImageInput
} from '@components/input/ToolMultipleImageInput';
import { ToolComponentProps } from '@tools/defineTool';
import ToolFileResult from '@components/result/ToolFileResult';
import ToolMultiFileResult from '@components/result/ToolMultiFileResult';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import { Box } from '@mui/material';
import Typography from '@mui/material/Typography';
@ -19,28 +21,42 @@ const initialValues: InitialValuesType = {
export default function CompressImage({ title }: ToolComponentProps) {
const { t } = useTranslation('image');
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
const [input, setInput] = useState<MultiImageInput[]>([]);
const [results, setResults] = useState<File[]>([]);
const [zipFile, setZipFile] = useState<File | null>(null);
const [isProcessing, setIsProcessing] = useState<boolean>(false);
const [originalSize, setOriginalSize] = useState<number | null>(null); // Store original file size
const [compressedSize, setCompressedSize] = useState<number | null>(null); // Store compressed file size
const { showSnackBar } = useContext(CustomSnackBarContext);
const compute = async (values: InitialValuesType, input: File | null) => {
if (!input) return;
const compute = async (
values: InitialValuesType,
input: MultiImageInput[]
) => {
if (!input.length) return;
setOriginalSize(input.reduce((acc, img) => acc + img.file.size, 0));
setOriginalSize(input.size);
try {
setIsProcessing(true);
const compressed = await compressImage(input, values);
const output = await compressImages(
input.map((img) => img.file),
values
);
if (compressed) {
setResult(compressed);
setCompressedSize(compressed.size);
} else {
if (!output) {
showSnackBar(t('compress.failedToCompress'), 'error');
return;
}
if (output.results.length < input.length) {
showSnackBar(t('compress.failedToCompress'), 'error');
}
setResults(output.results);
setZipFile(output.zipFile);
setCompressedSize(output.results.reduce((acc, f) => acc + f.size, 0));
} catch (err) {
console.error('Error in compression:', err);
} finally {
@ -53,17 +69,19 @@ export default function CompressImage({ title }: ToolComponentProps) {
title={title}
input={input}
inputComponent={
<ToolImageInput
<ToolMultipleImageInput
value={input}
type={'image'}
onChange={setInput}
accept={['image/*']}
title={t('compress.inputTitle')}
/>
}
resultComponent={
<ToolFileResult
<ToolMultiFileResult
title={t('compress.resultTitle')}
value={result}
value={results}
zipFile={zipFile}
loading={isProcessing}
/>
}

View file

@ -1,10 +1,11 @@
import { InitialValuesType } from './types';
import imageCompression from 'browser-image-compression';
import JSZip from 'jszip';
export const compressImage = async (
file: File,
export const compressImages = async (
files: File[],
options: InitialValuesType
): Promise<File | null> => {
): Promise<{ results: File[]; zipFile: File } | null> => {
try {
const { maxFileSizeInMB, quality } = options;
@ -16,15 +17,38 @@ export const compressImage = async (
initialQuality: quality / 100 // Convert percentage to decimal
};
// Compress the image
const compressedFile = await imageCompression(file, compressionOptions);
// Compress the given images
const compressed = await Promise.all(
files.map(async (file) => {
try {
const compressedFile = await imageCompression(
file,
compressionOptions
);
return new File([compressedFile], file.name, {
type: compressedFile.type
});
} catch (error) {
console.error(`Error compressing ${file.name}:`, error);
return null;
}
})
);
// Create a new file with the original name
return new File([compressedFile], file.name, {
type: compressedFile.type
const results = compressed.filter((f): f is File => f !== null);
if (results.length === 0) return null;
const zip = new JSZip();
results.forEach((file) => zip.file(file.name, file));
const zipBlob = await zip.generateAsync({ type: 'blob' });
const zipFile = new File([zipBlob], 'compressed-images.zip', {
type: 'application/zip'
});
return { results, zipFile };
} catch (error) {
console.error('Error compressing image:', error);
console.error('Error compressing images:', error);
return null;
}
};