gongxiangp2/lib/image-processor.ts
2026-07-31 10:40:00 +08:00

242 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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',
})
}
function assertProcessableMetadata(metadata: sharp.Metadata) {
if (!metadata.width || !metadata.height) {
throw new Error('无法获取图片尺寸')
}
if (metadata.width > IMAGE_PROCESSING_MAX_DIMENSION || metadata.height > IMAGE_PROCESSING_MAX_DIMENSION) {
throw new Error('图片尺寸过大')
}
if (Number(metadata.pages || 1) > 1) {
throw new Error('不支持动图或多页图片')
}
}
interface ProcessImageOptions {
maxSizeKB?: number // 最大文件大小(KB)
maxWidth?: number // 最大宽度(像素)
maxHeight?: number // 最大高度(像素)
format?: 'webp' | 'jpeg' | 'png'
}
/**
* 处理图片:压缩、调整尺寸、转换格式
* @param buffer 原始图片buffer
* @param options 处理选项
* @returns 处理后的图片buffer
*/
export async function processImage(
buffer: Buffer,
options: ProcessImageOptions = {}
): Promise<Buffer> {
const {
maxSizeKB = 300, // 默认300KB
maxWidth = 600, // 默认600像素
maxHeight = 800, // 默认800像素(适配300x400显示)
format = 'webp', // 默认webP格式
} = options
return withImageProcessingSlot(async () => {
try {
// 获取原始图片信息
const image = createSharpInput(buffer)
const metadata = await image.metadata()
assertProcessableMetadata(metadata)
// 计算新尺寸(保持比例,同时限制宽度和高度)
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)
* @returns 切割后的图片buffer数组
*/
export async function sliceImage(
buffer: Buffer,
maxHeight: number = 1500,
maxSizeKB: number = 500
): Promise<Buffer[]> {
return withImageProcessingSlot(async () => {
try {
const image = createSharpInput(buffer)
const metadata = await image.metadata()
assertProcessableMetadata(metadata)
const { width, height } = metadata
const slices: Buffer[] = []
const maxBytes = maxSizeKB * 1024
// 如果图片高度不超过maxHeight,直接返回原图(压缩处理)
if (height <= maxHeight) {
let quality = 90
let processedBuffer = await createSharpInput(buffer)
.webp({ quality })
.toBuffer()
// 如果文件还是太大,逐步降低质量
while (processedBuffer.length > maxBytes && quality > 20) {
quality -= 10
processedBuffer = await createSharpInput(buffer)
.webp({ quality })
.toBuffer()
}
console.log(`图片无需切割: ${width}x${height}, 压缩到 ${(processedBuffer.length / 1024).toFixed(2)}KB, 质量: ${quality}`)
return [processedBuffer]
}
// 计算需要切割的片数
const sliceCount = Math.ceil(height / maxHeight)
if (sliceCount > IMAGE_PROCESSING_MAX_SLICES) {
throw new Error(`图片过长,最多支持 ${IMAGE_PROCESSING_MAX_SLICES} 个切片`)
}
console.log(`开始切割图片: ${width}x${height} -> ${sliceCount}个片段,每片限制${maxSizeKB}KB`)
// 切割图片
for (let i = 0; i < sliceCount; i++) {
const top = i * maxHeight
const extractHeight = Math.min(maxHeight, height - top)
// 初始质量
let quality = 90
let sliceBuffer = await createSharpInput(buffer)
.extract({
left: 0,
top: top,
width: width,
height: extractHeight
})
.webp({ quality })
.toBuffer()
// 如果文件还是太大,逐步降低质量
while (sliceBuffer.length > maxBytes && quality > 20) {
quality -= 10
sliceBuffer = await createSharpInput(buffer)
.extract({
left: 0,
top: top,
width: width,
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<Buffer> {
return processImage(buffer, options);
}
/**
* 清理文件名(移除特殊字符)
* @param fileName 原始文件名
* @returns 清理后的文件名
*/
export function sanitizeFileName(fileName: string): string {
// 移除路径分隔符和特殊字符
return fileName
.replace(/[\/\\]/g, '-')
.replace(/[<>:"|?*]/g, '')
.replace(/\s+/g, '-')
.trim();
}