288 lines
9.1 KiB
TypeScript
288 lines
9.1 KiB
TypeScript
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<Buffer> {
|
||
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<Buffer[]> {
|
||
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<Buffer> {
|
||
return processImage(buffer, options);
|
||
}
|
||
|
||
/**
|
||
* 清理文件名(移除特殊字符)
|
||
* @param fileName 原始文件名
|
||
* @returns 清理后的文件名
|
||
*/
|
||
export function sanitizeFileName(fileName: string): string {
|
||
// 移除路径分隔符和特殊字符
|
||
return fileName
|
||
.replace(/[\/\\]/g, '-')
|
||
.replace(/[<>:"|?*]/g, '')
|
||
.replace(/\s+/g, '-')
|
||
.trim();
|
||
}
|