Implement compression quota refunds and admin manual subscription
This commit is contained in:
533
src/services/compress.rs
Normal file
533
src/services/compress.rs
Normal file
@@ -0,0 +1,533 @@
|
||||
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 oxipng::StripChunks;
|
||||
use rgb::FromSlice;
|
||||
use std::io::Cursor;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum CompressionLevel {
|
||||
High,
|
||||
Medium,
|
||||
Low,
|
||||
}
|
||||
|
||||
impl CompressionLevel {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::High => "high",
|
||||
Self::Medium => "medium",
|
||||
Self::Low => "low",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ImageFmt {
|
||||
Png,
|
||||
Jpeg,
|
||||
Webp,
|
||||
Avif,
|
||||
Gif,
|
||||
Bmp,
|
||||
Tiff,
|
||||
Ico,
|
||||
}
|
||||
|
||||
impl ImageFmt {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Png => "png",
|
||||
Self::Jpeg => "jpeg",
|
||||
Self::Webp => "webp",
|
||||
Self::Avif => "avif",
|
||||
Self::Gif => "gif",
|
||||
Self::Bmp => "bmp",
|
||||
Self::Tiff => "tiff",
|
||||
Self::Ico => "ico",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extension(self) -> &'static str {
|
||||
match self {
|
||||
Self::Png => "png",
|
||||
Self::Jpeg => "jpg",
|
||||
Self::Webp => "webp",
|
||||
Self::Avif => "avif",
|
||||
Self::Gif => "gif",
|
||||
Self::Bmp => "bmp",
|
||||
Self::Tiff => "tiff",
|
||||
Self::Ico => "ico",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn content_type(self) -> &'static str {
|
||||
match self {
|
||||
Self::Png => "image/png",
|
||||
Self::Jpeg => "image/jpeg",
|
||||
Self::Webp => "image/webp",
|
||||
Self::Avif => "image/avif",
|
||||
Self::Gif => "image/gif",
|
||||
Self::Bmp => "image/bmp",
|
||||
Self::Tiff => "image/tiff",
|
||||
Self::Ico => "image/x-icon",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_level(value: &str) -> Result<CompressionLevel, AppError> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"" | "medium" => Ok(CompressionLevel::Medium),
|
||||
"high" => Ok(CompressionLevel::High),
|
||||
"low" => Ok(CompressionLevel::Low),
|
||||
_ => Err(AppError::new(
|
||||
ErrorCode::InvalidRequest,
|
||||
"level 仅支持 high/medium/low",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
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 的整数"))?;
|
||||
if !(1..=100).contains(&rate) {
|
||||
return Err(AppError::new(
|
||||
ErrorCode::InvalidRequest,
|
||||
"compression_rate 需在 1-100 之间",
|
||||
));
|
||||
}
|
||||
Ok(rate)
|
||||
}
|
||||
|
||||
pub fn rate_to_level(rate: u8) -> CompressionLevel {
|
||||
match rate {
|
||||
1..=33 => CompressionLevel::Low,
|
||||
34..=66 => CompressionLevel::Medium,
|
||||
_ => CompressionLevel::High,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_output_format(value: &str) -> Result<ImageFmt, AppError> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"png" => Ok(ImageFmt::Png),
|
||||
"jpeg" | "jpg" => Ok(ImageFmt::Jpeg),
|
||||
"webp" => Ok(ImageFmt::Webp),
|
||||
"avif" => Ok(ImageFmt::Avif),
|
||||
"gif" => Ok(ImageFmt::Gif),
|
||||
"bmp" => Ok(ImageFmt::Bmp),
|
||||
"tif" | "tiff" => Ok(ImageFmt::Tiff),
|
||||
"ico" => Ok(ImageFmt::Ico),
|
||||
_ => Err(AppError::new(
|
||||
ErrorCode::InvalidRequest,
|
||||
"output_format 仅支持 png/jpeg/webp/avif/gif/bmp/tiff/ico",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn detect_format(bytes: &[u8]) -> Result<ImageFmt, AppError> {
|
||||
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
return Ok(ImageFmt::Png);
|
||||
}
|
||||
if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xD8 {
|
||||
return Ok(ImageFmt::Jpeg);
|
||||
}
|
||||
if bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
|
||||
return Ok(ImageFmt::Webp);
|
||||
}
|
||||
if bytes.len() >= 12 && &bytes[4..8] == b"ftyp" {
|
||||
if bytes[8..12].eq_ignore_ascii_case(b"avif")
|
||||
|| bytes[8..12].eq_ignore_ascii_case(b"avis")
|
||||
{
|
||||
return Ok(ImageFmt::Avif);
|
||||
}
|
||||
}
|
||||
if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
|
||||
return Ok(ImageFmt::Gif);
|
||||
}
|
||||
if bytes.len() >= 2 && bytes[0] == 0x42 && bytes[1] == 0x4D {
|
||||
return Ok(ImageFmt::Bmp);
|
||||
}
|
||||
if bytes.len() >= 4 {
|
||||
if &bytes[0..4] == b"II*\x00" || &bytes[0..4] == b"MM\x00*" {
|
||||
return Ok(ImageFmt::Tiff);
|
||||
}
|
||||
if bytes[0] == 0x00
|
||||
&& bytes[1] == 0x00
|
||||
&& (bytes[2] == 0x01 || bytes[2] == 0x02)
|
||||
&& bytes[3] == 0x00
|
||||
{
|
||||
return Ok(ImageFmt::Ico);
|
||||
}
|
||||
}
|
||||
Err(AppError::new(
|
||||
ErrorCode::UnsupportedFormat,
|
||||
"不支持的图片格式",
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn compress_image_bytes(
|
||||
state: &AppState,
|
||||
input: &[u8],
|
||||
format_in: ImageFmt,
|
||||
format_out: ImageFmt,
|
||||
level: CompressionLevel,
|
||||
compression_rate: Option<u8>,
|
||||
max_width: Option<u32>,
|
||||
max_height: Option<u32>,
|
||||
preserve_metadata: bool,
|
||||
) -> Result<Vec<u8>, AppError> {
|
||||
if format_in == ImageFmt::Gif {
|
||||
if is_animated_gif(input)? {
|
||||
return Err(AppError::new(
|
||||
ErrorCode::UnsupportedFormat,
|
||||
"暂不支持动图 GIF",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let rate = effective_rate(compression_rate, level);
|
||||
let (icc_profile, exif) = if preserve_metadata {
|
||||
extract_metadata(input)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
let mut resized = false;
|
||||
let mut output = if format_in == ImageFmt::Png
|
||||
&& format_out == ImageFmt::Png
|
||||
&& max_width.is_none()
|
||||
&& max_height.is_none()
|
||||
{
|
||||
let preset = png_preset_from_rate(rate);
|
||||
let mut opts = oxipng::Options::from_preset(preset);
|
||||
if !preserve_metadata {
|
||||
opts.strip = StripChunks::Safe;
|
||||
}
|
||||
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))?;
|
||||
|
||||
enforce_pixel_limit(state, &image)?;
|
||||
|
||||
let (image, did_resize) = resize_if_needed(image, max_width, max_height);
|
||||
resized = did_resize;
|
||||
|
||||
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::Gif => encode_gif(image, rate)?,
|
||||
ImageFmt::Bmp => encode_bmp(image)?,
|
||||
ImageFmt::Tiff => encode_tiff(image)?,
|
||||
ImageFmt::Ico => encode_ico(image)?,
|
||||
}
|
||||
};
|
||||
|
||||
if preserve_metadata {
|
||||
output = apply_metadata(output, icc_profile, exif)?;
|
||||
}
|
||||
|
||||
if !resized && output.len() >= input.len() {
|
||||
if preserve_metadata {
|
||||
return Ok(input.to_vec());
|
||||
}
|
||||
let stripped = strip_metadata(input).unwrap_or_else(|_| input.to_vec());
|
||||
return Ok(if stripped.len() <= input.len() {
|
||||
stripped
|
||||
} else {
|
||||
input.to_vec()
|
||||
});
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn enforce_pixel_limit(state: &AppState, 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 {
|
||||
return Err(AppError::new(
|
||||
ErrorCode::TooManyPixels,
|
||||
format!("图片像素过大({}x{})", w, h),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resize_if_needed(
|
||||
image: DynamicImage,
|
||||
max_width: Option<u32>,
|
||||
max_height: Option<u32>,
|
||||
) -> (DynamicImage, bool) {
|
||||
if max_width.is_none() && max_height.is_none() {
|
||||
return (image, false);
|
||||
}
|
||||
|
||||
let (w, h) = image.dimensions();
|
||||
let (target_w, target_h) = fit_within(w, h, max_width, max_height);
|
||||
if target_w == w && target_h == h {
|
||||
return (image, false);
|
||||
}
|
||||
|
||||
(
|
||||
image.resize(target_w, target_h, image::imageops::FilterType::Lanczos3),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
fn fit_within(w: u32, h: u32, max_width: Option<u32>, max_height: Option<u32>) -> (u32, u32) {
|
||||
let mut scale = 1.0_f64;
|
||||
if let Some(mw) = max_width.filter(|v| *v > 0) {
|
||||
scale = scale.min(mw as f64 / w as f64);
|
||||
}
|
||||
if let Some(mh) = max_height.filter(|v| *v > 0) {
|
||||
scale = scale.min(mh as f64 / h as f64);
|
||||
}
|
||||
if scale >= 1.0 {
|
||||
return (w, h);
|
||||
}
|
||||
let nw = (w as f64 * scale).round().max(1.0) as u32;
|
||||
let nh = (h as f64 * scale).round().max(1.0) as u32;
|
||||
(nw, nh)
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
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))?;
|
||||
|
||||
let preset = png_preset_from_rate(rate);
|
||||
let mut opts = oxipng::Options::from_preset(preset);
|
||||
if !preserve_metadata {
|
||||
opts.strip = StripChunks::Safe;
|
||||
}
|
||||
oxipng::optimize_from_memory(&out, &opts)
|
||||
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "PNG 优化失败").with_source(err))
|
||||
}
|
||||
|
||||
fn encode_jpeg(image: DynamicImage, rate: u8) -> Result<Vec<u8>, AppError> {
|
||||
let rgb = image.to_rgb8();
|
||||
let (w, h) = rgb.dimensions();
|
||||
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)
|
||||
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "JPEG 编码失败").with_source(err))?;
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn encode_webp(image: DynamicImage, rate: u8) -> Result<Vec<u8>, AppError> {
|
||||
let rgba = image.to_rgba8();
|
||||
let (w, h) = rgba.dimensions();
|
||||
let encoder = webp::Encoder::from_rgba(rgba.as_raw(), w, h);
|
||||
|
||||
let bytes = if rate <= 10 {
|
||||
encoder.encode_lossless()
|
||||
} else {
|
||||
encoder.encode(webp_quality_from_rate(rate))
|
||||
};
|
||||
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
|
||||
fn encode_avif(image: DynamicImage, rate: u8) -> Result<Vec<u8>, AppError> {
|
||||
let rgba = image.to_rgba8();
|
||||
let (w, h) = rgba.dimensions();
|
||||
|
||||
let quality = avif_quality_from_rate(rate);
|
||||
|
||||
let raw = rgba.into_raw();
|
||||
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);
|
||||
let encoded = encoder
|
||||
.encode_rgba(img)
|
||||
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "AVIF 编码失败").with_source(err))?;
|
||||
|
||||
Ok(encoded.avif_file)
|
||||
}
|
||||
|
||||
fn encode_gif(image: DynamicImage, rate: u8) -> Result<Vec<u8>, AppError> {
|
||||
let rgba = image.to_rgba8();
|
||||
let (w, h) = rgba.dimensions();
|
||||
let mut out = Vec::new();
|
||||
|
||||
let speed = gif_speed_from_rate(rate);
|
||||
{
|
||||
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))?;
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn encode_bmp(image: DynamicImage) -> Result<Vec<u8>, AppError> {
|
||||
let rgba = image.to_rgba8();
|
||||
let (w, h) = rgba.dimensions();
|
||||
let mut out = Vec::new();
|
||||
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))?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn encode_tiff(image: DynamicImage) -> Result<Vec<u8>, AppError> {
|
||||
let rgba = image.to_rgba8();
|
||||
let (w, h) = rgba.dimensions();
|
||||
let mut out = Cursor::new(Vec::new());
|
||||
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))?;
|
||||
Ok(out.into_inner())
|
||||
}
|
||||
|
||||
fn encode_ico(image: DynamicImage) -> Result<Vec<u8>, AppError> {
|
||||
let rgba = image.to_rgba8();
|
||||
let (w, h) = rgba.dimensions();
|
||||
let mut out = Vec::new();
|
||||
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))?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn extract_metadata(input: &[u8]) -> (Option<ImgBytes>, Option<ImgBytes>) {
|
||||
let bytes = ImgBytes::copy_from_slice(input);
|
||||
match DynImage::from_bytes(bytes) {
|
||||
Ok(Some(img)) => (img.icc_profile(), img.exif()),
|
||||
_ => (None, None),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_metadata(
|
||||
output: Vec<u8>,
|
||||
icc_profile: Option<ImgBytes>,
|
||||
exif: Option<ImgBytes>,
|
||||
) -> Result<Vec<u8>, AppError> {
|
||||
if icc_profile.is_none() && exif.is_none() {
|
||||
return Ok(output);
|
||||
}
|
||||
|
||||
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 Some(mut img) = dyn_img else {
|
||||
return Ok(out_bytes.to_vec());
|
||||
};
|
||||
|
||||
img.set_icc_profile(icc_profile);
|
||||
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))?;
|
||||
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 Some(mut img) = dyn_img else {
|
||||
return Ok(bytes.to_vec());
|
||||
};
|
||||
|
||||
img.set_icc_profile(None);
|
||||
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))?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
fn effective_rate(rate: Option<u8>, level: CompressionLevel) -> u8 {
|
||||
match rate {
|
||||
Some(value) => value.clamp(1, 100),
|
||||
None => match level {
|
||||
CompressionLevel::Low => 25,
|
||||
CompressionLevel::Medium => 55,
|
||||
CompressionLevel::High => 80,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn png_preset_from_rate(rate: u8) -> u8 {
|
||||
(((rate.saturating_sub(1)) as f32 / 99.0) * 6.0).round() as u8
|
||||
}
|
||||
|
||||
fn jpeg_quality_from_rate(rate: u8) -> u8 {
|
||||
quality_from_rate(rate, 35, 95)
|
||||
}
|
||||
|
||||
fn webp_quality_from_rate(rate: u8) -> f32 {
|
||||
quality_from_rate(rate, 40, 92) as f32
|
||||
}
|
||||
|
||||
fn avif_quality_from_rate(rate: u8) -> f32 {
|
||||
quality_from_rate(rate, 35, 90) as f32
|
||||
}
|
||||
|
||||
fn quality_from_rate(rate: u8, min_quality: u8, max_quality: u8) -> u8 {
|
||||
let min_q = min_quality as i32;
|
||||
let max_q = max_quality as i32;
|
||||
let rate = rate.clamp(1, 100) as i32;
|
||||
let span = max_q - min_q;
|
||||
let q = max_q - ((rate - 1) * span / 99);
|
||||
q.clamp(min_q, max_q) as u8
|
||||
}
|
||||
|
||||
fn gif_speed_from_rate(rate: u8) -> i32 {
|
||||
let rate = rate.clamp(1, 100) as i32;
|
||||
1 + ((rate - 1) * 29 / 99)
|
||||
}
|
||||
|
||||
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();
|
||||
if let Some(frame) = frames.next() {
|
||||
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))?;
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
Reference in New Issue
Block a user