/**
* Image format conversion utilities using TinyPNG API.
* This module provides functions to convert images between different formats
* such as PNG, JPEG, WebP, and AVIF using the TinyPNG converting API.
*/
import * as fs from 'fs'
import * as path from 'path'
import tinify from 'tinify'
import { type ConvertOptions, type ConvertResult, type ImageFormat } from './types.js'
/**
* Mapping of common file extensions to MIME types.
*/
const EXTENSION_TO_MIME: Record<string, ImageFormat> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.avif': 'image/avif',
}
/**
* Mapping of MIME types to file extensions.
*/
const MIME_TO_EXTENSION: Record<ImageFormat, string> = {
'image/png': '.png',
'image/jpeg': '.jpg',
'image/webp': '.webp',
'image/avif': '.avif',
}
/**
* Get the MIME type of an image file based on its extension.
*
* @param filePath Path to the image file.
* @returns The MIME type string or null if not supported.
*/
function getImageMimeType(filePath: string): ImageFormat | null {
const ext = path.extname(filePath).toLowerCase()
return EXTENSION_TO_MIME[ext] || null
}
/**
* Generate output path for converted image based on the target format.
*
* @param originalPath Original image file path.
* @param targetFormat Target image format.
* @param outputPath Optional custom output path.
* @returns The output file path with correct extension.
*/
function generateOutputPath(originalPath: string, targetFormat: ImageFormat, outputPath?: string): string {
if (outputPath) {
return outputPath
}
const dir = path.dirname(originalPath)
const basename = path.basename(originalPath, path.extname(originalPath))
const ext = MIME_TO_EXTENSION[targetFormat]
return path.join(dir, `${basename}${ext}`)
}
/**
* Convert an image to one or more target formats using TinyPNG API.
* If multiple formats are specified, the smallest result will be returned.
*
* @param imagePath Path to the source image file.
* @param options Conversion options including target formats and background color.
* @param outputPath Optional output path for the converted image.
* @returns Promise resolving to conversion result details.
* @throws Error if the conversion fails or input is invalid.
*/
export async function convertImage(
imagePath: string,
options: ConvertOptions,
outputPath?: string,
): Promise<ConvertResult> {
// Validate input file exists
if (!fs.existsSync(imagePath)) {
throw new Error(`Source image file does not exist: ${imagePath}`)
}
const stats = await fs.promises.stat(imagePath)
if (!stats.isFile()) {
throw new Error(`Path is not a file: ${imagePath}`)
}
// Get original format
const originalFormat = getImageMimeType(imagePath)
if (!originalFormat) {
throw new Error(`Unsupported image format: ${path.extname(imagePath)}`)
}
const originalSize = stats.size
// Prepare conversion options
const { targetFormats, backgroundColor } = options
const formats = Array.isArray(targetFormats) ? targetFormats : [targetFormats]
// Validate target formats
const validFormats: ImageFormat[] = ['image/avif', 'image/webp', 'image/jpeg', 'image/png']
for (const format of formats) {
if (!validFormats.includes(format)) {
throw new Error(`Unsupported target format: ${format}`)
}
}
try {
// Create TinyPNG source from file
const source = tinify.fromFile(imagePath)
// Configure conversion
let converted: any
if (backgroundColor && formats.includes('image/jpeg')) {
// If converting to JPEG (no transparency support) and background is specified
converted = source.convert({ type: formats }).transform({ background: backgroundColor })
} else {
converted = source.convert({ type: formats })
}
// Get the actual format that was chosen (smallest one)
const extension = await converted.result().extension()
const convertedFormat = EXTENSION_TO_MIME[`.${extension}`] || formats[0]
// Generate output path
const finalOutputPath = generateOutputPath(imagePath, convertedFormat, outputPath)
// Save the converted image
await converted.toFile(finalOutputPath)
// Get converted file stats
const convertedStats = await fs.promises.stat(finalOutputPath)
const convertedSize = convertedStats.size
const savings = parseFloat(((originalSize - convertedSize) / originalSize * 100).toFixed(1))
return {
outputPath: finalOutputPath,
originalFormat,
convertedFormat,
originalSize,
convertedSize,
savings,
}
} catch (error) {
if (error instanceof tinify.AccountError) {
throw new Error(`TinyPNG account error: ${error.message}`)
} else if (error instanceof tinify.ClientError) {
throw new Error(`TinyPNG client error: ${error.message}`)
} else if (error instanceof tinify.ServerError) {
throw new Error(`TinyPNG server error: ${error.message}`)
} else if (error instanceof tinify.ConnectionError) {
throw new Error(`TinyPNG connection error: ${error.message}`)
} else {
throw new Error(`Conversion failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
}
}
/**
* Convert multiple images in batch to the specified format(s).
*
* @param imagePaths Array of image file paths to convert.
* @param options Conversion options.
* @returns Promise resolving to array of conversion results.
*/
export async function convertImageBatch(
imagePaths: string[],
options: ConvertOptions,
): Promise<ConvertResult[]> {
const results: ConvertResult[] = []
for (const imagePath of imagePaths) {
try {
const result = await convertImage(imagePath, options)
results.push(result)
} catch (error) {
// For batch operations, we collect errors but continue processing
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
throw new Error(`Failed to convert ${imagePath}: ${errorMessage}`)
}
}
return results
}