feat: 实现图片压缩功能

This commit is contained in:
2026-01-25 22:37:40 +08:00
parent 081e2058bf
commit 54009163b1
18 changed files with 1603 additions and 167 deletions

289
src/lib/image-processor.ts Normal file
View File

@@ -0,0 +1,289 @@
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 };
}