290 lines
7.7 KiB
TypeScript
290 lines
7.7 KiB
TypeScript
import sharp from "sharp";
|
|
import type { ImageCompressConfig } from "@/types";
|
|
|
|
/**
|
|
* Image processing service using Sharp
|
|
* Handles compression, format conversion, and resizing
|
|
*/
|
|
|
|
export interface ProcessedImageResult {
|
|
buffer: Buffer;
|
|
format: string;
|
|
width: number;
|
|
height: number;
|
|
originalSize: number;
|
|
compressedSize: number;
|
|
compressionRatio: number;
|
|
}
|
|
|
|
export interface ImageMetadata {
|
|
format: string;
|
|
width: number;
|
|
height: number;
|
|
size: number;
|
|
}
|
|
|
|
// Supported output formats for compression
|
|
const SUPPORTED_OUTPUT_FORMATS = ["jpeg", "jpg", "png", "webp", "gif", "tiff", "tif"] as const;
|
|
type SupportedFormat = (typeof SUPPORTED_OUTPUT_FORMATS)[number];
|
|
|
|
/**
|
|
* Get image metadata without loading the full image
|
|
*/
|
|
export async function getImageMetadata(buffer: Buffer): Promise<ImageMetadata> {
|
|
const metadata = await sharp(buffer).metadata();
|
|
|
|
return {
|
|
format: metadata.format || "unknown",
|
|
width: metadata.width || 0,
|
|
height: metadata.height || 0,
|
|
size: buffer.length,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Validate image buffer using Sharp
|
|
* Checks if the buffer contains a valid image
|
|
*/
|
|
export async function validateImageBuffer(buffer: Buffer): Promise<boolean> {
|
|
try {
|
|
const metadata = await sharp(buffer).metadata();
|
|
return (
|
|
metadata.format !== undefined &&
|
|
metadata.width !== undefined &&
|
|
metadata.width > 0 &&
|
|
metadata.height !== undefined &&
|
|
metadata.height > 0
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if format is supported for output
|
|
*/
|
|
function isSupportedFormat(format: string): format is SupportedFormat {
|
|
return SUPPORTED_OUTPUT_FORMATS.includes(format as SupportedFormat);
|
|
}
|
|
|
|
/**
|
|
* Compress and/or convert image
|
|
*/
|
|
export async function compressImage(
|
|
buffer: Buffer,
|
|
config: ImageCompressConfig
|
|
): Promise<ProcessedImageResult> {
|
|
// Validate input buffer
|
|
const isValid = await validateImageBuffer(buffer);
|
|
if (!isValid) {
|
|
throw new Error("Invalid image data");
|
|
}
|
|
|
|
// Get original metadata
|
|
const originalMetadata = await getImageMetadata(buffer);
|
|
|
|
// Create Sharp instance
|
|
let pipeline = sharp(buffer, {
|
|
// Limit input pixels to prevent DoS attacks
|
|
limitInputPixels: 268402689, // ~16384x16384
|
|
// Enforce memory limits
|
|
unlimited: false,
|
|
});
|
|
|
|
// Apply resizing if configured
|
|
if (config.resize) {
|
|
const { width, height, fit } = config.resize;
|
|
|
|
if (width || height) {
|
|
pipeline = pipeline.resize(width || null, height || null, {
|
|
fit: fit === "contain" ? "inside" : fit === "cover" ? "cover" : "fill",
|
|
// Don't enlarge images
|
|
withoutEnlargement: fit !== "fill",
|
|
});
|
|
}
|
|
}
|
|
|
|
// Determine output format
|
|
let outputFormat = config.format === "original" ? originalMetadata.format : config.format;
|
|
|
|
// For BMP input without format conversion, use JPEG as output
|
|
// since Sharp doesn't support BMP output
|
|
if (outputFormat === "bmp") {
|
|
outputFormat = "jpeg";
|
|
}
|
|
|
|
// Validate format is supported
|
|
if (!isSupportedFormat(outputFormat)) {
|
|
outputFormat = "jpeg";
|
|
}
|
|
|
|
// Apply format-specific compression
|
|
switch (outputFormat) {
|
|
case "jpeg":
|
|
case "jpg":
|
|
pipeline = pipeline.jpeg({
|
|
quality: config.quality,
|
|
mozjpeg: true, // Use MozJPEG for better compression
|
|
progressive: true, // Progressive loading
|
|
});
|
|
break;
|
|
|
|
case "png":
|
|
// PNG compression is lossless, quality affects compression level
|
|
// Map 1-100 to 0-9 compression level (inverted)
|
|
const compressionLevel = Math.floor(((100 - config.quality) / 100) * 9);
|
|
pipeline = pipeline.png({
|
|
compressionLevel,
|
|
adaptiveFiltering: true,
|
|
palette: false, // Keep true color
|
|
});
|
|
break;
|
|
|
|
case "webp":
|
|
pipeline = pipeline.webp({
|
|
quality: config.quality,
|
|
effort: 6, // Compression effort (0-6, 6 is highest)
|
|
});
|
|
break;
|
|
|
|
case "gif":
|
|
// GIF doesn't support quality parameter in the same way
|
|
// We'll use near-lossless for better quality
|
|
pipeline = pipeline.gif({
|
|
dither: 1.0,
|
|
});
|
|
break;
|
|
|
|
case "tiff":
|
|
case "tif":
|
|
pipeline = pipeline.tiff({
|
|
quality: config.quality,
|
|
compression: "jpeg",
|
|
});
|
|
break;
|
|
|
|
default:
|
|
// Default to JPEG
|
|
pipeline = pipeline.jpeg({
|
|
quality: config.quality,
|
|
mozjpeg: true,
|
|
});
|
|
}
|
|
|
|
// Get metadata before compression
|
|
const metadata = await pipeline.metadata();
|
|
|
|
// Process image
|
|
const compressedBuffer = await pipeline.toBuffer();
|
|
|
|
// Calculate compression ratio
|
|
const compressionRatio = Math.round(
|
|
((buffer.length - compressedBuffer.length) / buffer.length) * 100
|
|
);
|
|
|
|
return {
|
|
buffer: compressedBuffer,
|
|
format: outputFormat,
|
|
width: metadata.width || 0,
|
|
height: metadata.height || 0,
|
|
originalSize: buffer.length,
|
|
compressedSize: compressedBuffer.length,
|
|
compressionRatio,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Batch compress multiple images
|
|
*/
|
|
export async function batchCompressImages(
|
|
files: Array<{ buffer: Buffer; name: string }>,
|
|
config: ImageCompressConfig,
|
|
onProgress?: (current: number, total: number) => void
|
|
): Promise<Array<{ result: ProcessedImageResult; name: string }>> {
|
|
const results: Array<{ result: ProcessedImageResult; name: string }> = [];
|
|
|
|
for (let i = 0; i < files.length; i++) {
|
|
const result = await compressImage(files[i].buffer, config);
|
|
results.push({ result, name: files[i].name });
|
|
|
|
if (onProgress) {
|
|
onProgress(i + 1, files.length);
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Calculate recommended quality based on desired compression ratio
|
|
*/
|
|
export function calculateQualityForTargetRatio(
|
|
targetRatio: number,
|
|
currentRatio?: number,
|
|
currentQuality?: number
|
|
): number {
|
|
// If we have current data, adjust based on difference
|
|
if (currentRatio !== undefined && currentQuality !== undefined) {
|
|
const difference = targetRatio - currentRatio;
|
|
const adjustment = difference * 2; // Adjust by 2x the difference
|
|
return Math.max(1, Math.min(100, Math.round(currentQuality + adjustment)));
|
|
}
|
|
|
|
// Default heuristic: higher target ratio = lower quality
|
|
return Math.max(1, Math.min(100, Math.round(100 - targetRatio * 1.5)));
|
|
}
|
|
|
|
/**
|
|
* Validate ImageCompressConfig
|
|
*/
|
|
export function validateCompressConfig(config: ImageCompressConfig): {
|
|
valid: boolean;
|
|
error?: string;
|
|
} {
|
|
if (!config.quality || typeof config.quality !== "number") {
|
|
return { valid: false, error: "Quality is required and must be a number" };
|
|
}
|
|
|
|
if (config.quality < 1 || config.quality > 100) {
|
|
return { valid: false, error: "Quality must be between 1 and 100" };
|
|
}
|
|
|
|
const validFormats = ["original", "jpeg", "jpg", "png", "webp", "gif", "bmp", "tiff", "tif"];
|
|
if (!validFormats.includes(config.format)) {
|
|
return { valid: false, error: `Invalid format. Allowed: ${validFormats.join(", ")}` };
|
|
}
|
|
|
|
if (config.resize) {
|
|
if (config.resize.width !== undefined) {
|
|
if (
|
|
typeof config.resize.width !== "number" ||
|
|
config.resize.width < 1 ||
|
|
config.resize.width > 16384
|
|
) {
|
|
return { valid: false, error: "Width must be between 1 and 16384" };
|
|
}
|
|
}
|
|
|
|
if (config.resize.height !== undefined) {
|
|
if (
|
|
typeof config.resize.height !== "number" ||
|
|
config.resize.height < 1 ||
|
|
config.resize.height > 16384
|
|
) {
|
|
return { valid: false, error: "Height must be between 1 and 16384" };
|
|
}
|
|
}
|
|
|
|
if (!config.resize.width && !config.resize.height) {
|
|
return { valid: false, error: "At least one of width or height must be specified" };
|
|
}
|
|
|
|
const validFits = ["contain", "cover", "fill"];
|
|
if (!validFits.includes(config.resize.fit)) {
|
|
return { valid: false, error: `Invalid fit. Allowed: ${validFits.join(", ")}` };
|
|
}
|
|
}
|
|
|
|
return { valid: true };
|
|
}
|