perf: improve compression reliability and deployment safety

This commit is contained in:
237899745
2026-07-25 10:29:49 +08:00
parent 06220ca921
commit 9d7668bdee
34 changed files with 1391 additions and 1042 deletions

View File

@@ -1,15 +1,15 @@
use crate::error::{AppError, ErrorCode};
use crate::state::AppState;
use img_parts::{Bytes as ImgBytes, DynImage, ImageEXIF, ImageICC};
use image::codecs::bmp::BmpEncoder;
use image::codecs::gif::{GifDecoder, GifEncoder};
use image::codecs::ico::IcoEncoder;
use image::codecs::jpeg::JpegEncoder;
use image::codecs::png::PngEncoder;
use image::codecs::tiff::TiffEncoder;
use image::{DynamicImage, ExtendedColorType, ImageEncoder};
use image::{AnimationDecoder, GenericImageView};
use image::{DynamicImage, ExtendedColorType, ImageEncoder};
use img_parts::{Bytes as ImgBytes, DynImage, ImageEXIF, ImageICC};
use oxipng::StripChunks;
use rgb::FromSlice;
use std::io::Cursor;
@@ -105,15 +105,12 @@ pub fn parse_level(value: &str) -> Result<CompressionLevel, AppError> {
}
pub fn parse_compression_rate(value: &str) -> Result<u8, AppError> {
let rate: u8 = value
.trim()
.parse()
.map_err(|_| {
AppError::new(
ErrorCode::InvalidRequest,
"compression_rate 需为 1-100 的整数(压缩后体积占比)",
)
})?;
let rate: u8 = value.trim().parse().map_err(|_| {
AppError::new(
ErrorCode::InvalidRequest,
"compression_rate 需为 1-100 的整数(压缩后体积占比)",
)
})?;
if !(1..=100).contains(&rate) {
return Err(AppError::new(
ErrorCode::InvalidRequest,
@@ -199,8 +196,7 @@ pub fn detect_format(bytes: &[u8]) -> Result<ImageFmt, AppError> {
if has_brand(
&brands,
&[
*b"heic", *b"heix", *b"hevc", *b"hevx", *b"heis", *b"heim", *b"mif1",
*b"msf1",
*b"heic", *b"heix", *b"hevc", *b"hevx", *b"heis", *b"heim", *b"mif1", *b"msf1",
],
) {
return Err(AppError::new(
@@ -233,27 +229,69 @@ pub fn detect_format(bytes: &[u8]) -> Result<ImageFmt, AppError> {
))
}
#[allow(clippy::too_many_arguments)]
pub async fn compress_image_bytes(
state: &AppState,
input: &[u8],
input: Vec<u8>,
format_in: ImageFmt,
format_out: ImageFmt,
level: CompressionLevel,
compression_rate: Option<u8>,
target_size_bytes: Option<u64>, // 新增:直接指定目标大小(字节)
target_size_bytes: Option<u64>, // 新增:直接指定目标大小(字节)
max_width: Option<u32>,
max_height: Option<u32>,
preserve_metadata: bool,
) -> Result<Vec<u8>, AppError> {
let max_image_pixels = state.config.max_image_pixels;
let permit = state
.image_processing_semaphore
.clone()
.acquire_owned()
.await
.map_err(|err| {
AppError::new(ErrorCode::Internal, "图片处理并发控制器已关闭").with_source(err)
})?;
tokio::task::spawn_blocking(move || {
let _permit = permit;
compress_image_bytes_sync(
input,
format_in,
format_out,
level,
compression_rate,
target_size_bytes,
max_width,
max_height,
preserve_metadata,
max_image_pixels,
)
})
.await
.map_err(|err| {
AppError::new(ErrorCode::CompressionFailed, "图片处理任务异常退出").with_source(err)
})?
}
#[allow(clippy::too_many_arguments)]
fn compress_image_bytes_sync(
input: Vec<u8>,
format_in: ImageFmt,
format_out: ImageFmt,
level: CompressionLevel,
compression_rate: Option<u8>,
target_size_bytes: Option<u64>,
max_width: Option<u32>,
max_height: Option<u32>,
preserve_metadata: bool,
max_image_pixels: u64,
) -> Result<Vec<u8>, AppError> {
let original_size = input.len() as u64;
if format_in == ImageFmt::Gif {
if is_animated_gif(input)? {
return Err(AppError::new(
ErrorCode::UnsupportedFormat,
"暂不支持动图 GIF",
));
}
if format_in == ImageFmt::Gif && is_animated_gif(&input)? {
return Err(AppError::new(
ErrorCode::UnsupportedFormat,
"暂不支持动图 GIF",
));
}
let retention_rate = effective_rate(compression_rate, level);
@@ -269,14 +307,14 @@ pub async fn compress_image_bytes(
&& max_height.is_none()
{
if preserve_metadata {
return Ok(input.to_vec());
return Ok(input);
}
let stripped = strip_metadata(input).unwrap_or_else(|_| input.to_vec());
let stripped = strip_metadata(&input).unwrap_or_else(|_| input.clone());
return Ok(stripped);
}
let (icc_profile, exif) = if preserve_metadata {
extract_metadata(input)
extract_metadata(&input)
} else {
(None, None)
};
@@ -294,13 +332,23 @@ pub async fn compress_image_bytes(
if !preserve_metadata {
opts.strip = StripChunks::Safe;
}
oxipng::optimize_from_memory(input, &opts)
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "PNG 压缩失败").with_source(err))?
oxipng::optimize_from_memory(&input, &opts).map_err(|err| {
AppError::new(ErrorCode::CompressionFailed, "PNG 压缩失败").with_source(err)
})?
} else {
let image = image::load_from_memory(input)
.map_err(|err| AppError::new(ErrorCode::InvalidImage, "图片解码失败").with_source(err))?;
#[cfg(not(target_os = "linux"))]
if format_in == ImageFmt::Avif {
return Err(AppError::new(
ErrorCode::UnsupportedFormat,
"当前平台构建不支持 AVIF 解码,请转换为 PNG/JPEG/WebP 后重试",
));
}
enforce_pixel_limit(state, &image)?;
let image = image::load_from_memory(&input).map_err(|err| {
AppError::new(ErrorCode::InvalidImage, "图片解码失败").with_source(err)
})?;
enforce_pixel_limit(max_image_pixels, &image)?;
let (image, did_resize) = resize_if_needed(image, max_width, max_height);
resized = did_resize;
@@ -332,23 +380,23 @@ pub async fn compress_image_bytes(
if !resized && output.len() >= input.len() {
if preserve_metadata {
return Ok(input.to_vec());
return Ok(input);
}
let stripped = strip_metadata(input).unwrap_or_else(|_| input.to_vec());
let stripped = strip_metadata(&input).unwrap_or_else(|_| input.clone());
return Ok(if stripped.len() <= input.len() {
stripped
} else {
input.to_vec()
input
});
}
Ok(output)
}
fn enforce_pixel_limit(state: &AppState, image: &DynamicImage) -> Result<(), AppError> {
fn enforce_pixel_limit(max_image_pixels: u64, image: &DynamicImage) -> Result<(), AppError> {
let (w, h) = image.dimensions();
let pixels = (w as u64).saturating_mul(h as u64);
if pixels > state.config.max_image_pixels {
if pixels > max_image_pixels {
return Err(AppError::new(
ErrorCode::TooManyPixels,
format!("图片像素过大({}x{}", w, h),
@@ -394,11 +442,7 @@ fn fit_within(w: u32, h: u32, max_width: Option<u32>, max_height: Option<u32>) -
(nw, nh)
}
fn encode_png(
image: DynamicImage,
rate: u8,
preserve_metadata: bool,
) -> Result<Vec<u8>, AppError> {
fn encode_png(image: DynamicImage, rate: u8, preserve_metadata: bool) -> Result<Vec<u8>, AppError> {
let rgba = image.to_rgba8();
let (w, h) = rgba.dimensions();
let mut out = Vec::new();
@@ -406,7 +450,9 @@ fn encode_png(
let encoder = PngEncoder::new(&mut out);
encoder
.write_image(rgba.as_raw(), w, h, ExtendedColorType::Rgba8)
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "PNG 编码失败").with_source(err))?;
.map_err(|err| {
AppError::new(ErrorCode::CompressionFailed, "PNG 编码失败").with_source(err)
})?;
let preset = png_preset_from_rate(rate);
let mut opts = oxipng::Options::from_preset(preset);
@@ -433,7 +479,9 @@ fn encode_jpeg_raw(raw: &[u8], w: u32, h: u32, quality: u8) -> Result<Vec<u8>, A
let mut encoder = JpegEncoder::new_with_quality(&mut out, quality);
encoder
.encode(raw, w, h, ExtendedColorType::Rgb8)
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "JPEG 编码失败").with_source(err))?;
.map_err(|err| {
AppError::new(ErrorCode::CompressionFailed, "JPEG 编码失败").with_source(err)
})?;
Ok(out)
}
@@ -451,12 +499,6 @@ fn encode_webp(image: DynamicImage, rate: u8) -> Result<Vec<u8>, AppError> {
Ok(bytes.to_vec())
}
fn encode_webp_with_quality(image: DynamicImage, quality: u8) -> Result<Vec<u8>, AppError> {
let rgba = image.to_rgba8();
let (w, h) = rgba.dimensions();
encode_webp_raw(rgba.as_raw(), w, h, quality)
}
fn encode_webp_raw(raw: &[u8], w: u32, h: u32, quality: u8) -> Result<Vec<u8>, AppError> {
let encoder = webp::Encoder::from_rgba(raw, w, h);
Ok(encoder.encode(quality as f32).to_vec())
@@ -473,26 +515,20 @@ fn encode_avif(image: DynamicImage, rate: u8) -> Result<Vec<u8>, AppError> {
let img = ravif::Img::new(pixels, w as usize, h as usize);
let encoder = ravif::Encoder::new().with_quality(quality);
let encoded = encoder
.encode_rgba(img)
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "AVIF 编码失败").with_source(err))?;
let encoded = encoder.encode_rgba(img).map_err(|err| {
AppError::new(ErrorCode::CompressionFailed, "AVIF 编码失败").with_source(err)
})?;
Ok(encoded.avif_file)
}
fn encode_avif_with_quality(image: DynamicImage, quality: u8) -> Result<Vec<u8>, AppError> {
let rgba = image.to_rgba8();
let (w, h) = rgba.dimensions();
encode_avif_raw(rgba.as_raw(), w, h, quality)
}
fn encode_avif_raw(raw: &[u8], w: u32, h: u32, quality: u8) -> Result<Vec<u8>, AppError> {
let pixels = raw.as_rgba();
let img = ravif::Img::new(pixels, w as usize, h as usize);
let encoder = ravif::Encoder::new().with_quality(quality as f32);
let encoded = encoder
.encode_rgba(img)
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "AVIF 编码失败").with_source(err))?;
let encoded = encoder.encode_rgba(img).map_err(|err| {
AppError::new(ErrorCode::CompressionFailed, "AVIF 编码失败").with_source(err)
})?;
Ok(encoded.avif_file)
}
@@ -579,7 +615,8 @@ where
Some((_bytes, best_w, best_h, best_size)) => {
let new_pixels = (new_w as u64).saturating_mul(new_h as u64);
let best_pixels = (*best_w as u64).saturating_mul(*best_h as u64);
new_pixels > best_pixels || (new_pixels == best_pixels && result_size > *best_size)
new_pixels > best_pixels
|| (new_pixels == best_pixels && result_size > *best_size)
}
};
@@ -587,9 +624,7 @@ where
best_under = Some((result, new_w, new_h, result_size));
}
if new_w == orig_w
&& new_h == orig_h
&& target_size.saturating_sub(result_size) <= 1024
if new_w == orig_w && new_h == orig_h && target_size.saturating_sub(result_size) <= 1024
{
break;
}
@@ -645,11 +680,7 @@ where
let mut consider = |bytes: Vec<u8>| {
let size = bytes.len() as u64;
let is_under = size <= target_size;
let diff = if size > target_size {
size - target_size
} else {
target_size - size
};
let diff = size.abs_diff(target_size);
let should_update = match (best_is_under, is_under) {
(false, true) => true,
@@ -689,87 +720,6 @@ where
best.ok_or_else(|| AppError::new(ErrorCode::CompressionFailed, "压缩失败"))
}
fn encode_target_quality<F>(
min_q: u8,
max_q: u8,
target_size: u64,
mut encode: F,
) -> Result<Vec<u8>, AppError>
where
F: FnMut(u8) -> Result<Vec<u8>, AppError>,
{
let mut best: Option<Vec<u8>> = None;
let mut best_diff = u64::MAX;
let mut best_is_under = false; // 记录最佳结果是否小于目标
let mut best_size = 0u64;
// 考虑一个候选结果
let consider = |bytes: Vec<u8>, best: &mut Option<Vec<u8>>, best_diff: &mut u64, best_is_under: &mut bool, best_size: &mut u64| {
let size = bytes.len() as u64;
let is_under = size <= target_size;
let diff = if size > target_size {
size - target_size
} else {
target_size - size
};
// 优先选择不超过目标大小的结果
let should_update = match (*best_is_under, is_under) {
(false, true) => true, // 当前小于目标,之前大于目标 -> 更新
(true, false) => false, // 当前大于目标,之前小于目标 -> 不更新
_ => diff < *best_diff, // 同类情况,选择更接近的
};
if should_update {
*best_diff = diff;
*best_is_under = is_under;
*best_size = size;
*best = Some(bytes);
}
};
// 先尝试两端
let bytes = encode(min_q)?;
consider(bytes, &mut best, &mut best_diff, &mut best_is_under, &mut best_size);
if min_q != max_q {
let bytes = encode(max_q)?;
consider(bytes, &mut best, &mut best_diff, &mut best_is_under, &mut best_size);
}
// 二分查找,增加迭代次数到 12 次以提高精度
let mut low = min_q;
let mut high = max_q;
for _ in 0..12 {
if low > high {
break;
}
let mid = (low + high) / 2;
let bytes = encode(mid)?;
let size = bytes.len() as u64;
consider(bytes, &mut best, &mut best_diff, &mut best_is_under, &mut best_size);
if size > target_size {
high = mid.saturating_sub(1);
} else {
low = mid.saturating_add(1);
}
}
// 精细调整:如果当前结果超出目标太多,尝试更低质量
if best_size > target_size {
let mut q = min_q;
while q <= min_q.saturating_add(5) && q <= max_q {
let bytes = encode(q)?;
consider(bytes, &mut best, &mut best_diff, &mut best_is_under, &mut best_size);
if best_size <= target_size {
break; // 已找到满足条件的结果
}
q = q.saturating_add(1);
}
}
best.ok_or_else(|| AppError::new(ErrorCode::CompressionFailed, "压缩失败"))
}
fn encode_gif(image: DynamicImage, rate: u8) -> Result<Vec<u8>, AppError> {
let rgba = image.to_rgba8();
let (w, h) = rgba.dimensions();
@@ -780,7 +730,9 @@ fn encode_gif(image: DynamicImage, rate: u8) -> Result<Vec<u8>, AppError> {
let mut encoder = GifEncoder::new_with_speed(&mut out, speed);
encoder
.encode(rgba.as_raw(), w, h, ExtendedColorType::Rgba8)
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "GIF 编码失败").with_source(err))?;
.map_err(|err| {
AppError::new(ErrorCode::CompressionFailed, "GIF 编码失败").with_source(err)
})?;
}
Ok(out)
@@ -793,7 +745,9 @@ fn encode_bmp(image: DynamicImage) -> Result<Vec<u8>, AppError> {
let encoder = BmpEncoder::new(&mut out);
encoder
.write_image(rgba.as_raw(), w, h, ExtendedColorType::Rgba8)
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "BMP 编码失败").with_source(err))?;
.map_err(|err| {
AppError::new(ErrorCode::CompressionFailed, "BMP 编码失败").with_source(err)
})?;
Ok(out)
}
@@ -804,7 +758,9 @@ fn encode_tiff(image: DynamicImage) -> Result<Vec<u8>, AppError> {
let encoder = TiffEncoder::new(&mut out);
encoder
.write_image(rgba.as_raw(), w, h, ExtendedColorType::Rgba8)
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "TIFF 编码失败").with_source(err))?;
.map_err(|err| {
AppError::new(ErrorCode::CompressionFailed, "TIFF 编码失败").with_source(err)
})?;
Ok(out.into_inner())
}
@@ -815,7 +771,9 @@ fn encode_ico(image: DynamicImage) -> Result<Vec<u8>, AppError> {
let encoder = IcoEncoder::new(&mut out);
encoder
.write_image(rgba.as_raw(), w, h, ExtendedColorType::Rgba8)
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "ICO 编码失败").with_source(err))?;
.map_err(|err| {
AppError::new(ErrorCode::CompressionFailed, "ICO 编码失败").with_source(err)
})?;
Ok(out)
}
@@ -837,8 +795,9 @@ fn apply_metadata(
}
let out_bytes = ImgBytes::from(output);
let dyn_img = DynImage::from_bytes(out_bytes.clone())
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "解析输出图片元数据失败").with_source(err))?;
let dyn_img = DynImage::from_bytes(out_bytes.clone()).map_err(|err| {
AppError::new(ErrorCode::CompressionFailed, "解析输出图片元数据失败").with_source(err)
})?;
let Some(mut img) = dyn_img else {
return Ok(out_bytes.to_vec());
@@ -848,16 +807,17 @@ fn apply_metadata(
img.set_exif(exif);
let mut buf = Vec::new();
img.encoder()
.write_to(&mut buf)
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "写入图片元数据失败").with_source(err))?;
img.encoder().write_to(&mut buf).map_err(|err| {
AppError::new(ErrorCode::CompressionFailed, "写入图片元数据失败").with_source(err)
})?;
Ok(buf)
}
fn strip_metadata(input: &[u8]) -> Result<Vec<u8>, AppError> {
let bytes = ImgBytes::copy_from_slice(input);
let dyn_img = DynImage::from_bytes(bytes.clone())
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "解析图片元数据失败").with_source(err))?;
let dyn_img = DynImage::from_bytes(bytes.clone()).map_err(|err| {
AppError::new(ErrorCode::CompressionFailed, "解析图片元数据失败").with_source(err)
})?;
let Some(mut img) = dyn_img else {
return Ok(bytes.to_vec());
};
@@ -866,9 +826,9 @@ fn strip_metadata(input: &[u8]) -> Result<Vec<u8>, AppError> {
img.set_exif(None);
let mut buf = Vec::new();
img.encoder()
.write_to(&mut buf)
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "写入图片元数据失败").with_source(err))?;
img.encoder().write_to(&mut buf).map_err(|err| {
AppError::new(ErrorCode::CompressionFailed, "写入图片元数据失败").with_source(err)
})?;
Ok(buf)
}
@@ -927,13 +887,58 @@ fn strength_from_rate(rate: u8) -> u8 {
fn is_animated_gif(input: &[u8]) -> Result<bool, AppError> {
let decoder = GifDecoder::new(Cursor::new(input))
.map_err(|err| AppError::new(ErrorCode::InvalidImage, "GIF 解码失败").with_source(err))?;
let mut frames = decoder.into_frames().into_iter();
let mut frames = decoder.into_frames();
if let Some(frame) = frames.next() {
frame.map_err(|err| AppError::new(ErrorCode::InvalidImage, "GIF 解码失败").with_source(err))?;
frame.map_err(|err| {
AppError::new(ErrorCode::InvalidImage, "GIF 解码失败").with_source(err)
})?;
}
if let Some(frame) = frames.next() {
frame.map_err(|err| AppError::new(ErrorCode::InvalidImage, "GIF 解码失败").with_source(err))?;
frame.map_err(|err| {
AppError::new(ErrorCode::InvalidImage, "GIF 解码失败").with_source(err)
})?;
return Ok(true);
}
Ok(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_supported_formats_from_signatures() {
assert_eq!(detect_format(b"\x89PNG\r\n\x1a\n").unwrap(), ImageFmt::Png);
assert_eq!(detect_format(b"\xff\xd8").unwrap(), ImageFmt::Jpeg);
assert_eq!(
detect_format(b"RIFF\x00\x00\x00\x00WEBP").unwrap(),
ImageFmt::Webp
);
assert_eq!(detect_format(b"GIF89a").unwrap(), ImageFmt::Gif);
assert_eq!(detect_format(b"BM").unwrap(), ImageFmt::Bmp);
}
#[test]
fn detects_avif_and_rejects_heic() {
let avif = b"\x00\x00\x00\x18ftypavif\x00\x00\x00\x00avif";
assert_eq!(detect_format(avif).unwrap(), ImageFmt::Avif);
let heic = b"\x00\x00\x00\x18ftypheic\x00\x00\x00\x00mif1";
let error = detect_format(heic).unwrap_err();
assert_eq!(error.code, ErrorCode::UnsupportedFormat);
}
#[test]
fn fit_within_preserves_aspect_ratio_and_never_upscales() {
assert_eq!(fit_within(4000, 2000, Some(1000), None), (1000, 500));
assert_eq!(fit_within(4000, 2000, None, Some(250)), (500, 250));
assert_eq!(fit_within(400, 200, Some(800), Some(800)), (400, 200));
}
#[test]
fn compression_rate_maps_to_expected_target_size() {
assert_eq!(target_size_from_rate(10_000, 1), 100);
assert_eq!(target_size_from_rate(10_000, 55), 5_500);
assert_eq!(target_size_from_rate(10_000, 100), 10_000);
}
}