feat: add HEIC Converter tool for converting HEIC/HEIF images to JPG, PNG, or WebP formats with options for quality and resizing

This commit is contained in:
AshAnand34 2025-07-19 19:11:26 -07:00
commit 0a33958490
8 changed files with 559 additions and 2 deletions

View file

@ -101,5 +101,56 @@
"description": "Rotate an image by a specified angle.",
"shortDescription": "Rotate an image easily.",
"title": "Rotate Image"
},
"heicConverter": {
"title": "HEIC Converter",
"description": "Convert HEIC/HEIF images to JPG, PNG, or WebP formats with customizable quality and resize options.",
"shortDescription": "Convert HEIC/HEIF images to common formats",
"longDescription": "Convert HEIC and HEIF images (commonly used by Apple devices) to widely supported formats like JPG, PNG, or WebP. Features include quality control, resizing options, and file size optimization.",
"input": {
"title": "Upload HEIC/HEIF Image"
},
"options": {
"outputFormat": {
"title": "Output Format",
"jpg": "JPEG (JPG)",
"png": "PNG",
"webp": "WebP"
},
"quality": {
"description": "Quality setting for JPEG output (1-100, higher = better quality)"
},
"resize": {
"title": "Resize Options",
"none": "No resize",
"width": "Set width",
"height": "Set height",
"both": "Set both width and height"
},
"resizeValue": {
"description": "Size in pixels for the selected resize mode"
},
"advanced": {
"title": "Advanced Options"
},
"preserveMetadata": {
"title": "Preserve Metadata",
"description": "Keep EXIF data and other image metadata (when possible)"
}
},
"result": {
"title": "Converted Image",
"originalSize": "Original Size",
"convertedSize": "Converted Size",
"compression": "Compression"
},
"error": {
"invalidFile": "Please upload a valid HEIC or HEIF image file.",
"conversionFailed": "Failed to convert the image. Please try again."
},
"info": {
"title": "What is HEIC Converter?",
"description": "HEIC (High Efficiency Image Container) is Apple's image format that provides better compression than JPEG while maintaining high quality. This tool converts HEIC/HEIF files to widely supported formats for better compatibility across devices and platforms."
}
}
}

View file

@ -41,7 +41,7 @@ export async function changeAudioSpeed(
const outputName = `output.${outputFormat}`;
await ffmpeg.writeFile(fileName, await fetchFile(input));
const audioFilter = computeAudioFilter(newSpeed);
let args = ['-i', fileName, '-filter:a', audioFilter];
const args = ['-i', fileName, '-filter:a', audioFilter];
if (outputFormat === 'mp3') {
args.push('-b:a', '192k', '-f', 'mp3', outputName);
} else if (outputFormat === 'aac') {

View file

@ -0,0 +1,57 @@
import { expect, describe, it } from 'vitest';
import { isHeicFile, formatFileSize } from './service';
// Mock File constructor
const createMockFile = (name: string, type: string): File => {
return new File(['mock content'], name, { type });
};
describe('HEIC Converter Service', () => {
describe('isHeicFile', () => {
it('should return true for HEIC files', () => {
const heicFile = createMockFile('test.heic', 'image/heic');
expect(isHeicFile(heicFile)).toBe(true);
});
it('should return true for HEIF files', () => {
const heifFile = createMockFile('test.heif', 'image/heif');
expect(isHeicFile(heifFile)).toBe(true);
});
it('should return true for files with .heic extension', () => {
const heicFile = createMockFile('test.heic', 'application/octet-stream');
expect(isHeicFile(heicFile)).toBe(true);
});
it('should return true for files with .heif extension', () => {
const heifFile = createMockFile('test.heif', 'application/octet-stream');
expect(isHeicFile(heifFile)).toBe(true);
});
it('should return false for non-HEIC files', () => {
const jpgFile = createMockFile('test.jpg', 'image/jpeg');
const pngFile = createMockFile('test.png', 'image/png');
expect(isHeicFile(jpgFile)).toBe(false);
expect(isHeicFile(pngFile)).toBe(false);
});
});
describe('formatFileSize', () => {
it('should format bytes correctly', () => {
expect(formatFileSize(0)).toBe('0 Bytes');
expect(formatFileSize(1024)).toBe('1 KB');
expect(formatFileSize(1048576)).toBe('1 MB');
expect(formatFileSize(1073741824)).toBe('1 GB');
});
it('should handle decimal values', () => {
expect(formatFileSize(1536)).toBe('1.5 KB');
expect(formatFileSize(1572864)).toBe('1.5 MB');
});
it('should handle large file sizes', () => {
expect(formatFileSize(2147483648)).toBe('2 GB');
});
});
});

View file

@ -0,0 +1,228 @@
import { Box, Alert, Chip } from '@mui/material';
import React, { useState } from 'react';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import ToolImageInput from '@components/input/ToolImageInput';
import ToolFileResult from '@components/result/ToolFileResult';
import { GetGroupsType } from '@components/options/ToolOptions';
import { convertHeicImage, isHeicFile, formatFileSize } from './service';
import { InitialValuesType, ConversionResult } from './types';
import { useTranslation } from 'react-i18next';
import RadioWithTextField from '@components/options/RadioWithTextField';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import CheckboxWithDesc from '@components/options/CheckboxWithDesc';
const initialValues: InitialValuesType = {
outputFormat: 'jpg',
quality: 85,
preserveMetadata: false,
resizeMode: 'none',
resizeValue: 1920
};
export default function HeicConverter({
title,
longDescription
}: ToolComponentProps) {
const { t } = useTranslation();
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<ConversionResult | null>(null);
const [error, setError] = useState<string | null>(null);
const compute = async (values: InitialValuesType, input: File | null) => {
if (!input) return;
if (!isHeicFile(input)) {
setError(t('image:heicConverter.error.invalidFile'));
return;
}
try {
setError(null);
setResult(null);
const conversionResult = await convertHeicImage(input, values);
setResult(conversionResult);
} catch (err) {
console.error('HEIC conversion failed:', err);
setError(t('image:heicConverter.error.conversionFailed'));
}
};
const getGroups: GetGroupsType<InitialValuesType> | null = ({
values,
updateField
}) => [
{
title: t('image:heicConverter.options.outputFormat.title'),
component: (
<Box>
<RadioWithTextField
value={values.outputFormat}
onTextChange={(value: any) => updateField('outputFormat', value)}
options={[
{
value: 'jpg',
label: t('image:heicConverter.options.outputFormat.jpg')
},
{
value: 'png',
label: t('image:heicConverter.options.outputFormat.png')
},
{
value: 'webp',
label: t('image:heicConverter.options.outputFormat.webp')
}
]}
/>
{values.outputFormat === 'jpg' && (
<TextFieldWithDesc
value={values.quality.toString()}
onOwnChange={(value) =>
updateField('quality', parseInt(value) || 85)
}
description={t('image:heicConverter.options.quality.description')}
inputProps={{
type: 'number',
min: 1,
max: 100,
'data-testid': 'quality-input'
}}
/>
)}
</Box>
)
},
{
title: t('image:heicConverter.options.resize.title'),
component: (
<Box>
<RadioWithTextField
value={values.resizeMode}
onTextChange={(value: any) => updateField('resizeMode', value)}
options={[
{
value: 'none',
label: t('image:heicConverter.options.resize.none')
},
{
value: 'width',
label: t('image:heicConverter.options.resize.width')
},
{
value: 'height',
label: t('image:heicConverter.options.resize.height')
},
{
value: 'both',
label: t('image:heicConverter.options.resize.both')
}
]}
/>
{values.resizeMode !== 'none' && (
<TextFieldWithDesc
value={values.resizeValue.toString()}
onOwnChange={(value) =>
updateField('resizeValue', parseInt(value) || 1920)
}
description={t(
'image:heicConverter.options.resizeValue.description'
)}
inputProps={{
type: 'number',
min: 1,
'data-testid': 'resize-value-input'
}}
/>
)}
</Box>
)
},
{
title: t('image:heicConverter.options.advanced.title'),
component: (
<Box>
<CheckboxWithDesc
title={t('image:heicConverter.options.preserveMetadata.title')}
checked={values.preserveMetadata}
onChange={(value) => updateField('preserveMetadata', value)}
description={t(
'image:heicConverter.options.preserveMetadata.description'
)}
/>
</Box>
)
}
];
return (
<ToolContent
title={title}
input={input}
setInput={setInput}
initialValues={initialValues}
compute={compute}
inputComponent={
<ToolImageInput
value={input}
onChange={setInput}
accept={['image/heic', 'image/heif']}
title={t('image:heicConverter.input.title')}
/>
}
resultComponent={
<Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{result && (
<Box>
<ToolFileResult
title={t('image:heicConverter.result.title')}
value={result.file}
extension={result.format}
/>
<Box sx={{ mt: 2, display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<Chip
label={`${t(
'image:heicConverter.result.originalSize'
)}: ${formatFileSize(result.originalSize)}`}
variant="outlined"
color="primary"
/>
<Chip
label={`${t(
'image:heicConverter.result.convertedSize'
)}: ${formatFileSize(result.convertedSize)}`}
variant="outlined"
color="secondary"
/>
<Chip
label={`${t(
'image:heicConverter.result.compression'
)}: ${Math.round(
(1 - result.convertedSize / result.originalSize) * 100
)}%`}
variant="outlined"
color="success"
/>
</Box>
</Box>
)}
</Box>
}
getGroups={getGroups}
toolInfo={{
title: t('image:heicConverter.info.title'),
description:
longDescription || t('image:heicConverter.info.description')
}}
/>
);
}

View file

@ -0,0 +1,24 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('image-generic', {
i18n: {
name: 'image:heicConverter.title',
description: 'image:heicConverter.description',
shortDescription: 'image:heicConverter.shortDescription',
longDescription: 'image:heicConverter.longDescription'
},
path: 'heic-converter',
icon: 'mdi:image-multiple-outline',
keywords: [
'heic',
'heif',
'converter',
'image',
'format',
'convert',
'iphone',
'apple'
],
component: lazy(() => import('./index'))
});

View file

@ -0,0 +1,180 @@
import { InitialValuesType, ConversionResult } from './types';
/**
* Converts HEIC/HEIF images to other formats
* Uses browser's native capabilities and canvas for conversion
*/
export async function convertHeicImage(
file: File,
options: InitialValuesType
): Promise<ConversionResult> {
return new Promise((resolve, reject) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Canvas context not available'));
return;
}
const img = new Image();
img.onload = () => {
try {
// Calculate dimensions based on resize mode
let { width, height } = calculateDimensions(
img.width,
img.height,
options.resizeMode,
options.resizeValue
);
// Set canvas dimensions
canvas.width = width;
canvas.height = height;
// Draw image with white background (for transparency handling)
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, height);
ctx.drawImage(img, 0, 0, width, height);
// Convert to desired format
const mimeType = getMimeType(options.outputFormat);
const quality =
options.outputFormat === 'jpg' ? options.quality / 100 : 1;
canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error('Failed to convert image'));
return;
}
const fileName = generateFileName(file.name, options.outputFormat);
const convertedFile = new File([blob], fileName, {
type: mimeType
});
const result: ConversionResult = {
file: convertedFile,
originalSize: file.size,
convertedSize: convertedFile.size,
format: options.outputFormat
};
resolve(result);
},
mimeType,
quality
);
} catch (error) {
reject(error);
}
};
img.onerror = () => {
reject(new Error('Failed to load HEIC image'));
};
// Create object URL for the image
const objectUrl = URL.createObjectURL(file);
img.src = objectUrl;
// Clean up object URL after loading
img.onload = () => {
URL.revokeObjectURL(objectUrl);
};
});
}
/**
* Calculate new dimensions based on resize mode
*/
function calculateDimensions(
originalWidth: number,
originalHeight: number,
resizeMode: InitialValuesType['resizeMode'],
resizeValue: number
): { width: number; height: number } {
switch (resizeMode) {
case 'width': {
const aspectRatio = originalWidth / originalHeight;
return {
width: resizeValue,
height: Math.round(resizeValue / aspectRatio)
};
}
case 'height': {
const aspectRatio2 = originalWidth / originalHeight;
return {
width: Math.round(resizeValue * aspectRatio2),
height: resizeValue
};
}
case 'both': {
return {
width: resizeValue,
height: resizeValue
};
}
default: {
return {
width: originalWidth,
height: originalHeight
};
}
}
}
/**
* Get MIME type for output format
*/
function getMimeType(format: InitialValuesType['outputFormat']): string {
switch (format) {
case 'jpg':
return 'image/jpeg';
case 'png':
return 'image/png';
case 'webp':
return 'image/webp';
default:
return 'image/jpeg';
}
}
/**
* Generate output filename with correct extension
*/
function generateFileName(
originalName: string,
format: InitialValuesType['outputFormat']
): string {
const baseName = originalName.replace(/\.[^/.]+$/, '');
const extension = format === 'jpg' ? 'jpg' : format;
return `${baseName}.${extension}`;
}
/**
* Validate if file is a HEIC/HEIF image
*/
export function isHeicFile(file: File): boolean {
const heicTypes = ['image/heic', 'image/heif'];
return (
heicTypes.includes(file.type) ||
file.name.toLowerCase().endsWith('.heic') ||
file.name.toLowerCase().endsWith('.heif')
);
}
/**
* Get file size in human readable format
*/
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}

View file

@ -0,0 +1,14 @@
export type InitialValuesType = {
outputFormat: 'jpg' | 'png' | 'webp';
quality: number;
preserveMetadata: boolean;
resizeMode: 'none' | 'width' | 'height' | 'both';
resizeValue: number;
};
export type ConversionResult = {
file: File;
originalSize: number;
convertedSize: number;
format: string;
};

View file

@ -1,3 +1,4 @@
import { tool as heicConverter } from './heic-converter/meta';
import { tool as resizeImage } from './resize/meta';
import { tool as compressImage } from './compress/meta';
import { tool as changeColors } from './change-colors/meta';
@ -10,6 +11,7 @@ import { tool as qrCodeGenerator } from './qr-code/meta';
import { tool as rotateImage } from './rotate/meta';
import { tool as convertToJpg } from './convert-to-jpg/meta';
import { tool as imageEditor } from './editor/meta';
export const imageGenericTools = [
imageEditor,
resizeImage,
@ -22,5 +24,6 @@ export const imageGenericTools = [
imageToText,
qrCodeGenerator,
rotateImage,
convertToJpg
convertToJpg,
heicConverter
];