diff --git a/src/main/java/com/yunhai/userbase/utils/ImageCompressUtil.java b/src/main/java/com/yunhai/userbase/utils/ImageCompressUtil.java new file mode 100644 index 0000000..9579e2a --- /dev/null +++ b/src/main/java/com/yunhai/userbase/utils/ImageCompressUtil.java @@ -0,0 +1,103 @@ +package com.yunhai.userbase.utils; + +import cn.hutool.core.img.Img; +import cn.hutool.core.img.ImgUtil; +import com.yunhai.platform.utils.exception.DException; + +import javax.imageio.ImageIO; +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; + +/** + * 头像图片压缩工具(基于 hutool-core 的 Img,无额外依赖)。
+ *
+ * 压缩规则(按原图字节数分支): + * + */ +public final class ImageCompressUtil { + + /** 超过该字节数走高压缩分支(5MB) */ + private static final int BIG_THRESHOLD = 5 * 1024 * 1024; + /** 高压缩分支的最大长边(px) */ + private static final int BIG_MAX_SIDE = 2000; + /** 近无损质量 */ + private static final float QUALITY_NEAR_LOSSLESS = 0.95f; + /** 高压缩质量 */ + private static final float QUALITY_HIGH = 0.75f; + + private ImageCompressUtil() { + } + + /** + * 压缩头像,失败/无收益时一律返回原字节,保证调用方总能拿到可用数据。 + * + * @param srcBytes 原图字节(非空) + * @param ext 小写扩展名(不含点),用于判定格式分支 + * @return 压缩后的字节;无需/无法压缩时为原字节 + */ + public static byte[] compressAvatar(byte[] srcBytes, String ext) { + if (srcBytes == null || srcBytes.length == 0) { + throw new DException("头像内容为空"); + } + String type = ext == null ? "" : ext.toLowerCase().replaceFirst("^\\.", ""); + // 动图重编码丢帧,原样返回 + if ("gif".equals(type)) { + return srcBytes; + } + BufferedImage image; + try { + image = ImageIO.read(new ByteArrayInputStream(srcBytes)); + } catch (Exception e) { + image = null; + } + // webp/heic/CMYK jpg 等解不动:原样返回(前置已校验大小上限) + if (image == null) { + return srcBytes; + } + + boolean big = srcBytes.length > BIG_THRESHOLD; + float quality = big ? QUALITY_HIGH : QUALITY_NEAR_LOSSLESS; + int maxSide = big ? BIG_MAX_SIDE : Integer.MAX_VALUE; + boolean lossyFormat = "jpg".equals(type) || "jpeg".equals(type); + int w = image.getWidth(); + int h = image.getHeight(); + + // png/bmp 无质量参数:不需要缩放就不重编码(重编码无收益甚至变大) + if (!lossyFormat && Math.max(w, h) <= maxSide) { + return srcBytes; + } + try { + String targetType = lossyFormat ? ImgUtil.IMAGE_TYPE_JPEG + : ("bmp".equals(type) ? ImgUtil.IMAGE_TYPE_BMP : ImgUtil.IMAGE_TYPE_PNG); + Img img = Img.from(image) + .setTargetImageType(targetType) + .setQuality(quality); + if (lossyFormat) { + // JPEG 无透明通道,透明区域统一铺白底 + img.setBackgroundColor(Color.WHITE); + } + if (Math.max(w, h) > maxSide) { + double ratio = maxSide / (double) Math.max(w, h); + img.scale(Math.max(1, (int) Math.round(w * ratio)), + Math.max(1, (int) Math.round(h * ratio))); + } + ByteArrayOutputStream out = new ByteArrayOutputStream(); + img.write(out); + byte[] compressed = out.toByteArray(); + // 无收益(压完更大)时保留原字节 + return (compressed.length == 0 || compressed.length >= srcBytes.length) ? srcBytes : compressed; + } catch (Exception e) { + return srcBytes; + } + } +}