diff --git a/public/locales/en/image.json b/public/locales/en/image.json index 9a03c73..60128ea 100644 --- a/public/locales/en/image.json +++ b/public/locales/en/image.json @@ -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." + } } } diff --git a/src/pages/tools/audio/change-speed/service.ts b/src/pages/tools/audio/change-speed/service.ts index c6e6663..baa4628 100644 --- a/src/pages/tools/audio/change-speed/service.ts +++ b/src/pages/tools/audio/change-speed/service.ts @@ -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') { diff --git a/src/pages/tools/image/generic/heic-converter/heic-converter.service.test.ts b/src/pages/tools/image/generic/heic-converter/heic-converter.service.test.ts new file mode 100644 index 0000000..758cf87 --- /dev/null +++ b/src/pages/tools/image/generic/heic-converter/heic-converter.service.test.ts @@ -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'); + }); + }); +}); diff --git a/src/pages/tools/image/generic/heic-converter/index.tsx b/src/pages/tools/image/generic/heic-converter/index.tsx new file mode 100644 index 0000000..1521634 --- /dev/null +++ b/src/pages/tools/image/generic/heic-converter/index.tsx @@ -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(null); + const [result, setResult] = useState(null); + const [error, setError] = useState(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 | null = ({ + values, + updateField + }) => [ + { + title: t('image:heicConverter.options.outputFormat.title'), + component: ( + + 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' && ( + + updateField('quality', parseInt(value) || 85) + } + description={t('image:heicConverter.options.quality.description')} + inputProps={{ + type: 'number', + min: 1, + max: 100, + 'data-testid': 'quality-input' + }} + /> + )} + + ) + }, + { + title: t('image:heicConverter.options.resize.title'), + component: ( + + 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' && ( + + updateField('resizeValue', parseInt(value) || 1920) + } + description={t( + 'image:heicConverter.options.resizeValue.description' + )} + inputProps={{ + type: 'number', + min: 1, + 'data-testid': 'resize-value-input' + }} + /> + )} + + ) + }, + { + title: t('image:heicConverter.options.advanced.title'), + component: ( + + updateField('preserveMetadata', value)} + description={t( + 'image:heicConverter.options.preserveMetadata.description' + )} + /> + + ) + } + ]; + + return ( + + } + resultComponent={ + + {error && ( + + {error} + + )} + + {result && ( + + + + + + + + + + )} + + } + getGroups={getGroups} + toolInfo={{ + title: t('image:heicConverter.info.title'), + description: + longDescription || t('image:heicConverter.info.description') + }} + /> + ); +} diff --git a/src/pages/tools/image/generic/heic-converter/meta.ts b/src/pages/tools/image/generic/heic-converter/meta.ts new file mode 100644 index 0000000..2cf1caf --- /dev/null +++ b/src/pages/tools/image/generic/heic-converter/meta.ts @@ -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')) +}); diff --git a/src/pages/tools/image/generic/heic-converter/service.ts b/src/pages/tools/image/generic/heic-converter/service.ts new file mode 100644 index 0000000..20128e2 --- /dev/null +++ b/src/pages/tools/image/generic/heic-converter/service.ts @@ -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 { + 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]; +} diff --git a/src/pages/tools/image/generic/heic-converter/types.ts b/src/pages/tools/image/generic/heic-converter/types.ts new file mode 100644 index 0000000..f7a500e --- /dev/null +++ b/src/pages/tools/image/generic/heic-converter/types.ts @@ -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; +}; diff --git a/src/pages/tools/image/generic/index.ts b/src/pages/tools/image/generic/index.ts index d87de31..eae8e84 100644 --- a/src/pages/tools/image/generic/index.ts +++ b/src/pages/tools/image/generic/index.ts @@ -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 ];