173 lines
5.1 KiB
JavaScript
173 lines
5.1 KiB
JavaScript
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;
|