mirror of
https://github.com/iib0011/omni-tools.git
synced 2025-11-06 17:04:56 +05:30
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:
parent
fc18dc0dc0
commit
0a33958490
8 changed files with 559 additions and 2 deletions
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
228
src/pages/tools/image/generic/heic-converter/index.tsx
Normal file
228
src/pages/tools/image/generic/heic-converter/index.tsx
Normal 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')
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
24
src/pages/tools/image/generic/heic-converter/meta.ts
Normal file
24
src/pages/tools/image/generic/heic-converter/meta.ts
Normal 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'))
|
||||
});
|
||||
180
src/pages/tools/image/generic/heic-converter/service.ts
Normal file
180
src/pages/tools/image/generic/heic-converter/service.ts
Normal 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];
|
||||
}
|
||||
14
src/pages/tools/image/generic/heic-converter/types.ts
Normal file
14
src/pages/tools/image/generic/heic-converter/types.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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
|
||||
];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue