feat: optimize compression and production deployment
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user