import sharp from 'sharp' import { withImageProcessingSlot } from '@/lib/image-processing-limiter' import { configureSharpRuntime } from '@/lib/sharp-runtime' configureSharpRuntime() const IMAGE_PROCESSING_MAX_PIXELS = 60_000_000 const IMAGE_PROCESSING_MAX_DIMENSION = 20_000 const IMAGE_PROCESSING_MAX_SLICES = 60 function createSharpInput(buffer: Buffer) { return sharp(buffer, { limitInputPixels: IMAGE_PROCESSING_MAX_PIXELS, sequentialRead: true, failOn: 'error', }) } export function assertProcessableMetadata( metadata: sharp.Metadata, options: { allowAnimatedFirstFrame?: boolean maxInputWidth?: number maxInputHeight?: number maxInputPixels?: number } = {}, ) { if (!metadata.width || !metadata.height) { throw new Error('无法获取图片尺寸') } const maxInputWidth = options.maxInputWidth || IMAGE_PROCESSING_MAX_DIMENSION const maxInputHeight = options.maxInputHeight || IMAGE_PROCESSING_MAX_DIMENSION const maxInputPixels = options.maxInputPixels || IMAGE_PROCESSING_MAX_PIXELS if (metadata.width > maxInputWidth || metadata.height > maxInputHeight) { throw new Error('图片尺寸过大') } if (metadata.width * metadata.height > maxInputPixels) { throw new Error('图片像素总量过大') } if (!options.allowAnimatedFirstFrame && Number(metadata.pages || 1) > 1) { throw new Error('不支持动图或多页图片') } } interface ProcessImageOptions { maxSizeKB?: number // 最大文件大小(KB) maxWidth?: number // 最大宽度(像素) maxHeight?: number // 最大高度(像素) format?: 'webp' | 'jpeg' | 'png' // 仅用于已有独立安全门禁的调用链路,默认仍拒绝动图和超长维度。 allowAnimatedFirstFrame?: boolean maxInputWidth?: number maxInputHeight?: number maxInputPixels?: number } /** * 处理图片:压缩、调整尺寸、转换格式 * @param buffer 原始图片buffer * @param options 处理选项 * @returns 处理后的图片buffer */ export async function processImage( buffer: Buffer, options: ProcessImageOptions = {} ): Promise { const { maxSizeKB = 300, // 默认300KB maxWidth = 600, // 默认600像素 maxHeight = 800, // 默认800像素(适配300x400显示) format = 'webp', // 默认webP格式 allowAnimatedFirstFrame = false, maxInputWidth = IMAGE_PROCESSING_MAX_DIMENSION, maxInputHeight = IMAGE_PROCESSING_MAX_DIMENSION, maxInputPixels = IMAGE_PROCESSING_MAX_PIXELS, } = options return withImageProcessingSlot(async () => { try { // 获取原始图片信息 const image = createSharpInput(buffer) const metadata = await image.metadata() assertProcessableMetadata(metadata, { allowAnimatedFirstFrame, maxInputWidth, maxInputHeight, maxInputPixels, }) // 计算新尺寸(保持比例,同时限制宽度和高度) let width = metadata.width || maxWidth let height = metadata.height || maxHeight // 如果宽度超过限制 if (width > maxWidth) { const ratio = maxWidth / width width = maxWidth height = Math.round(height * ratio) } // 如果高度超过限制,再次调整 if (height > maxHeight) { const ratio = maxHeight / height height = maxHeight width = Math.round(width * ratio) } // 优化:初始质量从80降到70,在保证视觉质量的前提下减少文件大小 let quality = 70 let processedBuffer: Buffer // 第一次处理 processedBuffer = await createSharpInput(buffer) .resize(width, height, { fit: 'inside', withoutEnlargement: true, }) .webp({ quality }) .toBuffer() // 如果文件还是太大,逐步降低质量 const maxBytes = maxSizeKB * 1024 while (processedBuffer.length > maxBytes && quality > 20) { quality -= 10 processedBuffer = await createSharpInput(buffer) .resize(width, height, { fit: 'inside', withoutEnlargement: true, }) .webp({ quality }) .toBuffer() } console.log(`图片处理完成: ${metadata.width}x${metadata.height} -> ${width}x${height}, ${(buffer.length / 1024).toFixed(2)}KB -> ${(processedBuffer.length / 1024).toFixed(2)}KB, 质量: ${quality}`) return processedBuffer } catch (error) { console.error('图片处理失败:', error) throw new Error('图片处理失败') } }) } /** * 获取处理后的文件名(添加.webp扩展名) */ export function getProcessedFileName(originalName: string): string { const nameWithoutExt = originalName.replace(/\.[^/.]+$/, '') return `${nameWithoutExt}.webp` } /** * 切割长图片为多个最高1500px的图片片段,并压缩到指定大小 * @param buffer 原始图片buffer * @param maxHeight 每个片段的最大高度(默认1500px) * @param maxSizeKB 每个切片的最大文件大小(默认500KB) * @param maxWidth 可选的切片最大宽度;会先等比缩放再按输出高度切片 * @returns 切割后的图片buffer数组 */ export async function sliceImage( buffer: Buffer, maxHeight: number = 1500, maxSizeKB: number = 500, maxWidth?: number, options: { allowAnimatedFirstFrame?: boolean maxInputWidth?: number maxInputHeight?: number maxInputPixels?: number } = {}, ): Promise { return withImageProcessingSlot(async () => { try { const image = createSharpInput(buffer) const metadata = await image.metadata() assertProcessableMetadata(metadata, options) const { width, height } = metadata const outputScale = maxWidth && width > maxWidth ? maxWidth / width : 1 const outputWidth = Math.max(1, Math.round(width * outputScale)) const outputHeight = Math.max(1, Math.round(height * outputScale)) const slices: Buffer[] = [] const maxBytes = maxSizeKB * 1024 const createOutputPipeline = () => { const pipeline = createSharpInput(buffer) return outputScale < 1 ? pipeline.resize({ width: outputWidth, withoutEnlargement: true }) : pipeline } // 如果图片高度不超过maxHeight,直接返回原图(压缩处理) if (outputHeight <= maxHeight) { let quality = 90 let processedBuffer = await createOutputPipeline() .webp({ quality }) .toBuffer() // 如果文件还是太大,逐步降低质量 while (processedBuffer.length > maxBytes && quality > 20) { quality -= 10 processedBuffer = await createOutputPipeline() .webp({ quality }) .toBuffer() } console.log(`图片无需切割: ${width}x${height} -> ${outputWidth}x${outputHeight}, 压缩到 ${(processedBuffer.length / 1024).toFixed(2)}KB, 质量: ${quality}`) return [processedBuffer] } // 计算需要切割的片数 const sliceCount = Math.ceil(outputHeight / maxHeight) if (sliceCount > IMAGE_PROCESSING_MAX_SLICES) { throw new Error(`图片过长,最多支持 ${IMAGE_PROCESSING_MAX_SLICES} 个切片`) } console.log(`开始切割图片: ${width}x${height} -> ${outputWidth}x${outputHeight}, ${sliceCount}个片段,每片限制${maxSizeKB}KB`) // 切割图片 for (let i = 0; i < sliceCount; i++) { const top = i * maxHeight const extractHeight = Math.min(maxHeight, outputHeight - top) // 初始质量 let quality = 90 let sliceBuffer = await createOutputPipeline() .extract({ left: 0, top: top, width: outputWidth, height: extractHeight }) .webp({ quality }) .toBuffer() // 如果文件还是太大,逐步降低质量 while (sliceBuffer.length > maxBytes && quality > 20) { quality -= 10 sliceBuffer = await createOutputPipeline() .extract({ left: 0, top: top, width: outputWidth, height: extractHeight }) .webp({ quality }) .toBuffer() } slices.push(sliceBuffer) console.log(`切割片段 ${i + 1}/${sliceCount}: 高度 ${extractHeight}px, 大小 ${(sliceBuffer.length / 1024).toFixed(2)}KB, 质量: ${quality}`) } return slices } catch (error) { console.error('图片切割失败:', error) throw new Error('图片切割失败') } }) } /** * 压缩图片(简化版,用于 API 路由) * @param buffer 原始图片buffer * @param options 压缩选项 * @returns 压缩后的图片buffer */ export async function compressImage( buffer: Buffer, options: ProcessImageOptions = {} ): Promise { return processImage(buffer, options); } /** * 清理文件名(移除特殊字符) * @param fileName 原始文件名 * @returns 清理后的文件名 */ export function sanitizeFileName(fileName: string): string { // 移除路径分隔符和特殊字符 return fileName .replace(/[\/\\]/g, '-') .replace(/[<>:"|?*]/g, '') .replace(/\s+/g, '-') .trim(); }