feat: 实现图片压缩功能
This commit is contained in:
289
src/lib/image-processor.ts
Normal file
289
src/lib/image-processor.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user