feat: optimize compression and production deployment

This commit is contained in:
237899745
2026-07-25 11:20:45 +08:00
parent 9d7668bdee
commit 0ff9eae56d
15 changed files with 882 additions and 771 deletions

View File

@@ -4,7 +4,6 @@ use crate::state::AppState;
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::{AnimationDecoder, GenericImageView};
@@ -14,12 +13,13 @@ use oxipng::StripChunks;
use rgb::FromSlice;
use std::io::Cursor;
const TARGET_MIN_DIMENSION: u32 = 640;
const TARGET_MIN_LONG_EDGE: u32 = 640;
const TARGET_MIN_SCALE: f64 = 0.55;
const TARGET_RESIZE_ATTEMPTS: usize = 5;
const TARGET_SCALE_REFINEMENT_ATTEMPTS: usize = 3;
const JPEG_TARGET_MIN_QUALITY: u8 = 40;
const WEBP_TARGET_MIN_QUALITY: u8 = 42;
const JPEG_TARGET_MIN_QUALITY: u8 = 25;
const WEBP_TARGET_MIN_QUALITY: u8 = 30;
const AVIF_TARGET_MIN_QUALITY: u8 = 38;
#[derive(Debug, Clone, Copy)]
@@ -378,7 +378,7 @@ fn compress_image_bytes_sync(
output = apply_metadata(output, icc_profile, exif)?;
}
if !resized && output.len() >= input.len() {
if format_in == format_out && !resized && output.len() >= input.len() {
if preserve_metadata {
return Ok(input);
}
@@ -475,10 +475,16 @@ fn encode_jpeg_with_quality(image: DynamicImage, quality: u8) -> Result<Vec<u8>,
}
fn encode_jpeg_raw(raw: &[u8], w: u32, h: u32, quality: u8) -> Result<Vec<u8>, AppError> {
let width = u16::try_from(w)
.map_err(|_| AppError::new(ErrorCode::InvalidImage, "JPEG 宽度不能超过 65535 像素"))?;
let height = u16::try_from(h)
.map_err(|_| AppError::new(ErrorCode::InvalidImage, "JPEG 高度不能超过 65535 像素"))?;
let mut out = Vec::new();
let mut encoder = JpegEncoder::new_with_quality(&mut out, quality);
let mut encoder = jpeg_encoder::Encoder::new(&mut out, quality);
encoder.set_optimized_huffman_tables(true);
encoder.set_progressive(true);
encoder
.encode(raw, w, h, ExtendedColorType::Rgb8)
.encode(raw, width, height, jpeg_encoder::ColorType::Rgb)
.map_err(|err| {
AppError::new(ErrorCode::CompressionFailed, "JPEG 编码失败").with_source(err)
})?;
@@ -573,36 +579,33 @@ where
F: FnMut(&DynamicImage, u8) -> Result<Vec<u8>, AppError>,
{
let (orig_w, orig_h) = image.dimensions();
let min_w = ((orig_w as f64 * TARGET_MIN_SCALE).round() as u32)
.max(TARGET_MIN_DIMENSION.min(orig_w))
.max(1);
let min_h = ((orig_h as f64 * TARGET_MIN_SCALE).round() as u32)
.max(TARGET_MIN_DIMENSION.min(orig_h))
.max(1);
let long_edge = orig_w.max(orig_h);
let long_edge_floor = TARGET_MIN_LONG_EDGE.min(long_edge) as f64 / long_edge as f64;
let min_scale = TARGET_MIN_SCALE.max(long_edge_floor).min(1.0);
let mut scales = Vec::with_capacity(TARGET_RESIZE_ATTEMPTS + 1);
scales.push(1.0);
for step in 1..=TARGET_RESIZE_ATTEMPTS {
let ratio = step as f64 / TARGET_RESIZE_ATTEMPTS as f64;
let scale = 1.0 - (1.0 - TARGET_MIN_SCALE) * ratio;
scales.push(scale.max(TARGET_MIN_SCALE));
scales.push(1.0 - (1.0 - min_scale) * ratio);
}
let mut best_under: Option<(Vec<u8>, u32, u32, u64)> = None;
let mut best_over: Option<(Vec<u8>, u32, u32, u64)> = None;
let mut best_over: Option<(Vec<u8>, u64, u64)> = None;
let mut previous_over_scale = 1.0;
let mut last_dimensions: Option<(u32, u32)> = None;
for scale in scales {
let new_w = ((orig_w as f64 * scale).round() as u32).clamp(1, orig_w);
let new_h = ((orig_h as f64 * scale).round() as u32).clamp(1, orig_h);
if new_w < min_w || new_h < min_h {
if last_dimensions == Some((new_w, new_h)) {
continue;
}
last_dimensions = Some((new_w, new_h));
let resized = if new_w == orig_w && new_h == orig_h {
image.clone()
} else {
image.resize(new_w, new_h, image::imageops::FilterType::Lanczos3)
image.resize_exact(new_w, new_h, image::imageops::FilterType::Lanczos3)
};
let result =
@@ -610,52 +613,69 @@ where
let result_size = result.len() as u64;
if result_size <= target_size {
let should_update = match &best_under {
None => true,
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)
if new_w == orig_w && new_h == orig_h {
return Ok(result);
}
// The first passing coarse scale has the highest resolution. Refine the
// boundary between it and the preceding failing scale before returning.
let mut best_under = result;
let mut under_scale = scale;
let mut over_scale = previous_over_scale;
let mut under_dimensions = (new_w, new_h);
for _ in 0..TARGET_SCALE_REFINEMENT_ATTEMPTS {
let candidate_scale = (under_scale + over_scale) / 2.0;
let candidate_w =
((orig_w as f64 * candidate_scale).round() as u32).clamp(1, orig_w);
let candidate_h =
((orig_h as f64 * candidate_scale).round() as u32).clamp(1, orig_h);
if (candidate_w, candidate_h) == under_dimensions {
break;
}
};
if should_update {
best_under = Some((result, new_w, new_h, result_size));
let candidate = image.resize_exact(
candidate_w,
candidate_h,
image::imageops::FilterType::Lanczos3,
);
let candidate_result = encode_target_quality_with_image(
&candidate,
min_q,
max_q,
target_size,
&mut encode_fn,
)?;
if candidate_result.len() as u64 <= target_size {
best_under = candidate_result;
under_scale = candidate_scale;
under_dimensions = (candidate_w, candidate_h);
} else {
over_scale = candidate_scale;
}
}
if new_w == orig_w && new_h == orig_h && target_size.saturating_sub(result_size) <= 1024
{
break;
}
return Ok(best_under);
} else {
let should_update = match &best_over {
None => true,
Some((_bytes, best_w, best_h, best_size)) => {
Some((_bytes, best_size, best_pixels)) => {
let over = result_size.saturating_sub(target_size);
let best_over_by = best_size.saturating_sub(target_size);
if over < best_over_by {
true
} else if over == best_over_by {
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
} else {
false
}
let new_pixels = (new_w as u64).saturating_mul(new_h as u64);
over < best_over_by || (over == best_over_by && new_pixels > *best_pixels)
}
};
if should_update {
best_over = Some((result, new_w, new_h, result_size));
let pixels = (new_w as u64).saturating_mul(new_h as u64);
best_over = Some((result, result_size, pixels));
}
previous_over_scale = scale;
}
}
if let Some((bytes, _, _, _)) = best_under {
return Ok(bytes);
}
if let Some((bytes, _, _, _)) = best_over {
if let Some((bytes, _, _)) = best_over {
return Ok(bytes);
}
@@ -673,51 +693,34 @@ fn encode_target_quality_with_image<F>(
where
F: FnMut(&DynamicImage, 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 consider = |bytes: Vec<u8>| {
let size = bytes.len() as u64;
let is_under = size <= target_size;
let diff = size.abs_diff(target_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 = Some(bytes);
}
};
consider(encode_fn(image, min_q)?);
if min_q != max_q {
consider(encode_fn(image, max_q)?);
// Start with the highest quality. If it already fits, no lower-quality
// encodes can improve the result.
let max_quality = encode_fn(image, max_q)?;
if max_quality.len() as u64 <= target_size || min_q == max_q {
return Ok(max_quality);
}
let mut low = min_q;
let mut high = max_q;
for _ in 0..12 {
if low > high {
break;
}
let min_quality = encode_fn(image, min_q)?;
if min_quality.len() as u64 > target_size {
return Ok(min_quality);
}
let mut best_under = min_quality;
let mut low = min_q.saturating_add(1);
let mut high = max_q.saturating_sub(1);
while low <= high {
let mid = (low + high) / 2;
let bytes = encode_fn(image, mid)?;
let size = bytes.len() as u64;
consider(bytes);
if size > target_size {
high = mid.saturating_sub(1);
} else {
best_under = bytes;
low = mid.saturating_add(1);
}
}
best.ok_or_else(|| AppError::new(ErrorCode::CompressionFailed, "压缩失败"))
Ok(best_under)
}
fn encode_gif(image: DynamicImage, rate: u8) -> Result<Vec<u8>, AppError> {
@@ -765,6 +768,8 @@ fn encode_tiff(image: DynamicImage) -> Result<Vec<u8>, AppError> {
}
fn encode_ico(image: DynamicImage) -> Result<Vec<u8>, AppError> {
// A single ICO directory entry can represent at most 256x256 pixels.
let (image, _) = resize_if_needed(image, Some(256), Some(256));
let rgba = image.to_rgba8();
let (w, h) = rgba.dimensions();
let mut out = Vec::new();
@@ -941,4 +946,61 @@ mod tests {
assert_eq!(target_size_from_rate(10_000, 55), 5_500);
assert_eq!(target_size_from_rate(10_000, 100), 10_000);
}
#[test]
fn target_encoder_stops_when_full_resolution_meets_target() {
use std::cell::Cell;
let calls = Cell::new(0);
let image = DynamicImage::new_rgb8(800, 600);
let result = encode_with_auto_resize(image, 100, 40, 95, |_image, quality| {
calls.set(calls.get() + 1);
Ok(vec![0; quality as usize])
})
.unwrap();
assert_eq!(result.len(), 95);
assert_eq!(calls.get(), 1);
}
#[test]
fn target_encoder_can_reduce_a_landscape_at_the_long_edge_floor() {
let image = DynamicImage::new_rgb8(960, 640);
let result = encode_with_auto_resize(image, 40_000, 40, 40, |image, _quality| {
let (width, height) = image.dimensions();
Ok(vec![0; (width as usize * height as usize) / 10])
})
.unwrap();
assert!(result.len() <= 40_000);
}
#[test]
fn format_conversion_never_returns_the_original_encoding() {
let input = encode_png(DynamicImage::new_rgba8(10, 10), 100, false).unwrap();
let output = compress_image_bytes_sync(
input,
ImageFmt::Png,
ImageFmt::Bmp,
CompressionLevel::Medium,
None,
None,
None,
None,
false,
1_000_000,
)
.unwrap();
assert!(output.starts_with(b"BM"));
}
#[test]
fn ico_encoder_fits_large_images_within_the_format_limit() {
let output = encode_ico(DynamicImage::new_rgba8(960, 640)).unwrap();
let decoded = image::load_from_memory(&output).unwrap();
assert!(output.starts_with(b"\x00\x00\x01\x00"));
assert_eq!(decoded.dimensions(), (256, 171));
}
}