import path from 'node:path'; import sharp from 'sharp'; export const DEFAULT_IMAGE_UPLOAD_MAX_BYTES = 10 * 1024 * 1024; export const DEFAULT_IMAGE_MASTER_MAX_BYTES = 6 * 1024 * 1024; export const DEFAULT_IMAGE_MASTER_MAX_SIDE = 2560; export const DEFAULT_IMAGE_INPUT_MAX_PIXELS = 40_000_000; const JPEG_MIN_QUALITY = 62; const WEBP_MIN_QUALITY = 64; const MIN_SIDE = 1280; function fitDimensions(width, height, maxSide, maxPixels) { if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { return { width: maxSide, height: maxSide }; } if (width <= maxSide && height <= maxSide && width * height <= maxPixels) { return { width, height }; } const scale = Math.min(maxSide / width, maxSide / height, Math.sqrt(maxPixels / (width * height))); return { width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)), }; } function buildOutputProfile(mimeType) { if (mimeType === 'image/png') { return { extension: '.png', mimeType: 'image/png', baseQuality: 90, minQuality: 70, encode: (pipeline, quality) => pipeline.png({ compressionLevel: 9, adaptiveFiltering: true, palette: true, quality, effort: 8, }), }; } if (mimeType === 'image/webp') { return { extension: '.webp', mimeType: 'image/webp', baseQuality: 84, minQuality: WEBP_MIN_QUALITY, encode: (pipeline, quality) => pipeline.webp({ quality, alphaQuality: Math.min(100, quality + 8), effort: 5, }), }; } return { extension: '.jpg', mimeType: 'image/jpeg', baseQuality: 84, minQuality: JPEG_MIN_QUALITY, encode: (pipeline, quality) => pipeline.jpeg({ quality, mozjpeg: true, chromaSubsampling: '4:4:4', }), }; } function deriveOutputFilename(filename, extension) { const raw = path.basename(String(filename ?? ''), path.extname(String(filename ?? ''))).trim() || 'image'; return `${raw}${extension}`; } async function renderVariant(buffer, metadata, profile, { width, height, quality, maxPixels }) { const pipeline = sharp(buffer, { failOn: 'error', limitInputPixels: maxPixels, sequentialRead: true, }) .rotate() .resize({ width, height, fit: 'inside', withoutEnlargement: true, }); const encoded = await profile.encode(pipeline, quality).toBuffer(); return encoded; } export async function normalizeImageForStorage({ buffer, mimeType, filename, maxUploadBytes = DEFAULT_IMAGE_UPLOAD_MAX_BYTES, maxOutputBytes = DEFAULT_IMAGE_MASTER_MAX_BYTES, maxSide = DEFAULT_IMAGE_MASTER_MAX_SIDE, maxPixels = DEFAULT_IMAGE_INPUT_MAX_PIXELS, } = {}) { if (!Buffer.isBuffer(buffer) || buffer.length === 0) { throw Object.assign(new Error('图片内容为空'), { code: 'invalid_file_size' }); } if (!mimeType?.startsWith('image/')) { throw Object.assign(new Error('只支持图片文件'), { code: 'unsupported_file_type' }); } if (buffer.length > maxUploadBytes) { throw Object.assign(new Error('图片文件超过单文件大小限制'), { code: 'file_too_large' }); } const metadata = await sharp(buffer, { failOn: 'error', limitInputPixels: maxPixels, sequentialRead: true, }).metadata(); if (!metadata.width || !metadata.height) { throw Object.assign(new Error('无法识别图片尺寸'), { code: 'image_metadata_invalid' }); } if (metadata.pages && metadata.pages > 1) { throw Object.assign(new Error('暂不支持动态图像上传'), { code: 'unsupported_animated_image' }); } const profile = buildOutputProfile(mimeType); const initial = fitDimensions(metadata.width, metadata.height, maxSide, maxPixels); let width = initial.width; let height = initial.height; let quality = profile.baseQuality; let candidate = await renderVariant(buffer, metadata, profile, { width, height, quality, maxPixels }); while (candidate.length > maxOutputBytes) { if (quality > profile.minQuality) { quality = Math.max(profile.minQuality, quality - 6); } else if (Math.max(width, height) > MIN_SIDE) { width = Math.max(1, Math.round(width * 0.85)); height = Math.max(1, Math.round(height * 0.85)); } else { break; } candidate = await renderVariant(buffer, metadata, profile, { width, height, quality, maxPixels }); } const outputFilename = deriveOutputFilename(filename, profile.extension); const output = { buffer: candidate, mimeType: profile.mimeType, filename: outputFilename, width, height, }; const preservesFormat = profile.mimeType === mimeType; const alreadyWithinBounds = metadata.width <= maxSide && metadata.height <= maxSide && metadata.width * metadata.height <= maxPixels && buffer.length <= maxOutputBytes; if (preservesFormat && alreadyWithinBounds && candidate.length >= buffer.length) { return { buffer, mimeType, filename, width: metadata.width, height: metadata.height, }; } return output; } export default normalizeImageForStorage;