Files
memind/user-image-normalize.mjs

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;