diff --git a/.gitignore b/.gitignore index 8896adc..f8834f1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ /uploads /static +/logs /frontend/node_modules /frontend/dist diff --git a/frontend/src/pages/HomePage.vue b/frontend/src/pages/HomePage.vue index 2990ef2..9dda2c4 100644 --- a/frontend/src/pages/HomePage.vue +++ b/frontend/src/pages/HomePage.vue @@ -382,7 +382,7 @@ async function resendVerification() { step="1" class="w-full" /> -
数值越大压缩越强,输出保持原格式。
+
数值越大压缩越强,系统尽量接近目标节省比例。
diff --git a/src/services/compress.rs b/src/services/compress.rs index 457f627..e0314a1 100644 --- a/src/services/compress.rs +++ b/src/services/compress.rs @@ -187,6 +187,7 @@ pub async fn compress_image_bytes( max_height: Option, preserve_metadata: bool, ) -> Result, AppError> { + let original_size = input.len() as u64; if format_in == ImageFmt::Gif { if is_animated_gif(input)? { return Err(AppError::new( @@ -197,6 +198,7 @@ pub async fn compress_image_bytes( } let rate = effective_rate(compression_rate, level); + let target_size = compression_rate.map(|value| target_size_from_rate(original_size, value)); let (icc_profile, exif) = if preserve_metadata { extract_metadata(input) } else { @@ -227,9 +229,18 @@ pub async fn compress_image_bytes( match format_out { ImageFmt::Png => encode_png(image, rate, preserve_metadata)?, - ImageFmt::Jpeg => encode_jpeg(image, rate)?, - ImageFmt::Webp => encode_webp(image, rate)?, - ImageFmt::Avif => encode_avif(image, rate)?, + ImageFmt::Jpeg => match target_size { + Some(target) => encode_jpeg_target(image, target)?, + None => encode_jpeg(image, rate)?, + }, + ImageFmt::Webp => match target_size { + Some(target) => encode_webp_target(image, target)?, + None => encode_webp(image, rate)?, + }, + ImageFmt::Avif => match target_size { + Some(target) => encode_avif_target(image, target)?, + None => encode_avif(image, rate)?, + }, ImageFmt::Gif => encode_gif(image, rate)?, ImageFmt::Bmp => encode_bmp(image)?, ImageFmt::Tiff => encode_tiff(image)?, @@ -329,17 +340,22 @@ fn encode_png( } fn encode_jpeg(image: DynamicImage, rate: u8) -> Result, AppError> { + let quality = jpeg_quality_from_rate(rate); + encode_jpeg_with_quality(image, quality) +} + +fn encode_jpeg_with_quality(image: DynamicImage, quality: u8) -> Result, AppError> { let rgb = image.to_rgb8(); let (w, h) = rgb.dimensions(); + encode_jpeg_raw(rgb.as_raw(), w, h, quality) +} + +fn encode_jpeg_raw(raw: &[u8], w: u32, h: u32, quality: u8) -> Result, AppError> { let mut out = Vec::new(); - - let quality = jpeg_quality_from_rate(rate); - let mut encoder = JpegEncoder::new_with_quality(&mut out, quality); encoder - .encode(rgb.as_raw(), w, h, ExtendedColorType::Rgb8) + .encode(raw, w, h, ExtendedColorType::Rgb8) .map_err(|err| AppError::new(ErrorCode::CompressionFailed, "JPEG 编码失败").with_source(err))?; - Ok(out) } @@ -357,6 +373,17 @@ fn encode_webp(image: DynamicImage, rate: u8) -> Result, AppError> { Ok(bytes.to_vec()) } +fn encode_webp_with_quality(image: DynamicImage, quality: u8) -> Result, 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, AppError> { + let encoder = webp::Encoder::from_rgba(raw, w, h); + Ok(encoder.encode(quality as f32).to_vec()) +} + fn encode_avif(image: DynamicImage, rate: u8) -> Result, AppError> { let rgba = image.to_rgba8(); let (w, h) = rgba.dimensions(); @@ -375,6 +402,93 @@ fn encode_avif(image: DynamicImage, rate: u8) -> Result, AppError> { Ok(encoded.avif_file) } +fn encode_avif_with_quality(image: DynamicImage, quality: u8) -> Result, 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, 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))?; + Ok(encoded.avif_file) +} + +fn encode_jpeg_target(image: DynamicImage, target_size: u64) -> Result, AppError> { + let rgb = image.to_rgb8(); + let (w, h) = rgb.dimensions(); + let raw = rgb.into_raw(); + encode_target_quality(35, 95, target_size, |q| encode_jpeg_raw(&raw, w, h, q)) +} + +fn encode_webp_target(image: DynamicImage, target_size: u64) -> Result, AppError> { + let rgba = image.to_rgba8(); + let (w, h) = rgba.dimensions(); + let raw = rgba.into_raw(); + encode_target_quality(30, 95, target_size, |q| encode_webp_raw(&raw, w, h, q)) +} + +fn encode_avif_target(image: DynamicImage, target_size: u64) -> Result, AppError> { + let rgba = image.to_rgba8(); + let (w, h) = rgba.dimensions(); + let raw = rgba.into_raw(); + encode_target_quality(35, 90, target_size, |q| encode_avif_raw(&raw, w, h, q)) +} + +fn encode_target_quality( + min_q: u8, + max_q: u8, + target_size: u64, + mut encode: F, +) -> Result, AppError> +where + F: FnMut(u8) -> Result, AppError>, +{ + let mut best: Option> = None; + let mut best_diff = u64::MAX; + + let mut consider = |bytes: Vec| { + let size = bytes.len() as u64; + let diff = if size > target_size { + size - target_size + } else { + target_size - size + }; + if diff < best_diff { + best_diff = diff; + best = Some(bytes); + } + }; + + consider(encode(min_q)?); + if min_q != max_q { + consider(encode(max_q)?); + } + + let mut low = min_q; + let mut high = max_q; + for _ in 0..7 { + if low > high { + break; + } + let mid = (low + high) / 2; + let bytes = encode(mid)?; + let size = bytes.len() as u64; + consider(bytes); + if size > target_size { + high = mid.saturating_sub(1); + } else { + low = mid.saturating_add(1); + } + } + + best.ok_or_else(|| AppError::new(ErrorCode::CompressionFailed, "压缩失败")) +} + fn encode_gif(image: DynamicImage, rate: u8) -> Result, AppError> { let rgba = image.to_rgba8(); let (w, h) = rgba.dimensions(); @@ -488,6 +602,13 @@ fn effective_rate(rate: Option, level: CompressionLevel) -> u8 { } } +fn target_size_from_rate(original_size: u64, rate: u8) -> u64 { + let rate = rate.clamp(1, 100) as u64; + let remaining = 100_u64.saturating_sub(rate); + let target = original_size.saturating_mul(remaining) / 100; + target.max(1) +} + fn png_preset_from_rate(rate: u8) -> u8 { (((rate.saturating_sub(1)) as f32 / 99.0) * 6.0).round() as u8 }