Adjust compression_rate semantics and no-charge at 100%
This commit is contained in:
@@ -272,7 +272,7 @@ Idempotency-Key: <key> # 建议
|
|||||||
| 字段 | 类型 | 必填 | 说明 |
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|---|---|---:|---|
|
|---|---|---:|---|
|
||||||
| `file` | File | 是 | 图片文件 |
|
| `file` | File | 是 | 图片文件 |
|
||||||
| `compression_rate` | Integer | 否 | 压缩率 1-100(数值越大压缩越强),优先级高于 `level` |
|
| `compression_rate` | Integer | 否 | 压缩率 1-100(压缩后体积占原图比例,数值越小压缩越强,100 表示不压缩),优先级高于 `level` |
|
||||||
| `level` | String | 否 | `high` / `medium` / `low`(兼容参数,默认 `medium`) |
|
| `level` | String | 否 | `high` / `medium` / `low`(兼容参数,默认 `medium`) |
|
||||||
| `output_format` | String | 否 | 已停用,仅支持保持原格式 |
|
| `output_format` | String | 否 | 已停用,仅支持保持原格式 |
|
||||||
| `max_width` | Integer | 否 | 最大宽度(等比缩放) |
|
| `max_width` | Integer | 否 | 最大宽度(等比缩放) |
|
||||||
@@ -337,7 +337,7 @@ Idempotency-Key: <key> # 建议
|
|||||||
| 字段 | 类型 | 必填 | 说明 |
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|---|---|---:|---|
|
|---|---|---:|---|
|
||||||
| `files[]` | File[] | 是 | 图片文件数组(上限由套餐决定) |
|
| `files[]` | File[] | 是 | 图片文件数组(上限由套餐决定) |
|
||||||
| `compression_rate` | Integer | 否 | 压缩率 1-100(数值越大压缩越强),优先级高于 `level` |
|
| `compression_rate` | Integer | 否 | 压缩率 1-100(压缩后体积占原图比例,数值越小压缩越强,100 表示不压缩),优先级高于 `level` |
|
||||||
| `level` | String | 否 | `high` / `medium` / `low`(兼容参数) |
|
| `level` | String | 否 | `high` / `medium` / `low`(兼容参数) |
|
||||||
| `output_format` | String | 否 | 已停用,仅支持保持原格式 |
|
| `output_format` | String | 否 | 已停用,仅支持保持原格式 |
|
||||||
| `preserve_metadata` | Boolean | 否 | 是否保留元数据(默认 `false`) |
|
| `preserve_metadata` | Boolean | 否 | 是否保留元数据(默认 `false`) |
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
### 3.1 网站压缩(Web)
|
### 3.1 网站压缩(Web)
|
||||||
- 拖拽/选择图片(单次多文件)。
|
- 拖拽/选择图片(单次多文件)。
|
||||||
- 压缩参数:
|
- 压缩参数:
|
||||||
- 压缩率:1-100(数值越大压缩越强)。
|
- 压缩率:1-100(压缩后体积占原图比例,数值越小压缩越强,100 表示不压缩)。
|
||||||
- 输出格式:保持原格式。
|
- 输出格式:保持原格式。
|
||||||
- 可选:限制宽高(等比缩放)。
|
- 可选:限制宽高(等比缩放)。
|
||||||
- 可选:是否保留元数据(默认不保留)。
|
- 可选:是否保留元数据(默认不保留)。
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
核心组件:
|
核心组件:
|
||||||
- 顶栏:Logo + `/pricing` `/docs` + 登录/注册(登录后显示头像菜单)
|
- 顶栏:Logo + `/pricing` `/docs` + 登录/注册(登录后显示头像菜单)
|
||||||
- 上传区:拖拽 + 点击选择 + 限制提示(格式/大小/数量)
|
- 上传区:拖拽 + 点击选择 + 限制提示(格式/大小/数量)
|
||||||
- 参数区:压缩率、尺寸限制、元数据开关(输出格式保持原图)
|
- 参数区:压缩率(压缩后体积占比)、尺寸限制、元数据开关(输出格式保持原图)
|
||||||
- 结果区:文件列表(缩略图、大小、节省%、状态、下载/重试)
|
- 结果区:文件列表(缩略图、大小、节省%、状态、下载/重试)
|
||||||
- 汇总区:总节省、下载 ZIP、清空
|
- 汇总区:总节省、下载 ZIP、清空
|
||||||
- 信任区:隐私说明(默认去 EXIF)、保留期说明、状态页/联系入口
|
- 信任区:隐私说明(默认去 EXIF)、保留期说明、状态页/联系入口
|
||||||
|
|||||||
@@ -382,7 +382,7 @@ async function resendVerification() {
|
|||||||
step="1"
|
step="1"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
<div class="text-xs text-slate-500">数值越大压缩越强,系统尽量接近目标节省比例。</div>
|
<div class="text-xs text-slate-500">数值越小压缩越强,目标为压缩后体积占原图比例(100% 为不压缩)。</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
|||||||
@@ -260,7 +260,8 @@ async fn compress_json(
|
|||||||
} else {
|
} else {
|
||||||
(saved_bytes as f64) * 100.0 / (original_size as f64)
|
(saved_bytes as f64) * 100.0 / (original_size as f64)
|
||||||
};
|
};
|
||||||
let charge_units = compressed_size < original_size;
|
let skip_charge = req.compression_rate == Some(100);
|
||||||
|
let charge_units = !skip_charge && compressed_size < original_size;
|
||||||
|
|
||||||
if charge_units {
|
if charge_units {
|
||||||
if let QuotaContext::Anonymous { session_id, ip } = "a_ctx {
|
if let QuotaContext::Anonymous { session_id, ip } = "a_ctx {
|
||||||
@@ -616,7 +617,8 @@ async fn compress_direct(
|
|||||||
} else {
|
} else {
|
||||||
(saved_bytes as f64) * 100.0 / (original_size as f64)
|
(saved_bytes as f64) * 100.0 / (original_size as f64)
|
||||||
};
|
};
|
||||||
let charge_units = compressed_size < original_size;
|
let skip_charge = req.compression_rate == Some(100);
|
||||||
|
let charge_units = !skip_charge && compressed_size < original_size;
|
||||||
|
|
||||||
if state.config.storage_type.to_ascii_lowercase() != "local" {
|
if state.config.storage_type.to_ascii_lowercase() != "local" {
|
||||||
return Err(AppError::new(
|
return Err(AppError::new(
|
||||||
|
|||||||
@@ -100,11 +100,16 @@ pub fn parse_compression_rate(value: &str) -> Result<u8, AppError> {
|
|||||||
let rate: u8 = value
|
let rate: u8 = value
|
||||||
.trim()
|
.trim()
|
||||||
.parse()
|
.parse()
|
||||||
.map_err(|_| AppError::new(ErrorCode::InvalidRequest, "compression_rate 需为 1-100 的整数"))?;
|
.map_err(|_| {
|
||||||
|
AppError::new(
|
||||||
|
ErrorCode::InvalidRequest,
|
||||||
|
"compression_rate 需为 1-100 的整数(压缩后体积占比)",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
if !(1..=100).contains(&rate) {
|
if !(1..=100).contains(&rate) {
|
||||||
return Err(AppError::new(
|
return Err(AppError::new(
|
||||||
ErrorCode::InvalidRequest,
|
ErrorCode::InvalidRequest,
|
||||||
"compression_rate 需在 1-100 之间",
|
"compression_rate 需在 1-100 之间(压缩后体积占比)",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(rate)
|
Ok(rate)
|
||||||
@@ -112,9 +117,9 @@ pub fn parse_compression_rate(value: &str) -> Result<u8, AppError> {
|
|||||||
|
|
||||||
pub fn rate_to_level(rate: u8) -> CompressionLevel {
|
pub fn rate_to_level(rate: u8) -> CompressionLevel {
|
||||||
match rate {
|
match rate {
|
||||||
1..=33 => CompressionLevel::Low,
|
1..=33 => CompressionLevel::High,
|
||||||
34..=66 => CompressionLevel::Medium,
|
34..=66 => CompressionLevel::Medium,
|
||||||
_ => CompressionLevel::High,
|
_ => CompressionLevel::Low,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,21 +202,36 @@ pub async fn compress_image_bytes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let rate = effective_rate(compression_rate, level);
|
let retention_rate = effective_rate(compression_rate, level);
|
||||||
let target_size = compression_rate.map(|value| target_size_from_rate(original_size, value));
|
let target_size = compression_rate.map(|value| target_size_from_rate(original_size, value));
|
||||||
|
|
||||||
|
if compression_rate == Some(100)
|
||||||
|
&& format_in == format_out
|
||||||
|
&& max_width.is_none()
|
||||||
|
&& max_height.is_none()
|
||||||
|
{
|
||||||
|
if preserve_metadata {
|
||||||
|
return Ok(input.to_vec());
|
||||||
|
}
|
||||||
|
let stripped = strip_metadata(input).unwrap_or_else(|_| input.to_vec());
|
||||||
|
return Ok(stripped);
|
||||||
|
}
|
||||||
|
|
||||||
let (icc_profile, exif) = if preserve_metadata {
|
let (icc_profile, exif) = if preserve_metadata {
|
||||||
extract_metadata(input)
|
extract_metadata(input)
|
||||||
} else {
|
} else {
|
||||||
(None, None)
|
(None, None)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let strength_rate = strength_from_rate(retention_rate);
|
||||||
|
|
||||||
let mut resized = false;
|
let mut resized = false;
|
||||||
let mut output = if format_in == ImageFmt::Png
|
let mut output = if format_in == ImageFmt::Png
|
||||||
&& format_out == ImageFmt::Png
|
&& format_out == ImageFmt::Png
|
||||||
&& max_width.is_none()
|
&& max_width.is_none()
|
||||||
&& max_height.is_none()
|
&& max_height.is_none()
|
||||||
{
|
{
|
||||||
let preset = png_preset_from_rate(rate);
|
let preset = png_preset_from_rate(strength_rate);
|
||||||
let mut opts = oxipng::Options::from_preset(preset);
|
let mut opts = oxipng::Options::from_preset(preset);
|
||||||
if !preserve_metadata {
|
if !preserve_metadata {
|
||||||
opts.strip = StripChunks::Safe;
|
opts.strip = StripChunks::Safe;
|
||||||
@@ -228,20 +248,20 @@ pub async fn compress_image_bytes(
|
|||||||
resized = did_resize;
|
resized = did_resize;
|
||||||
|
|
||||||
match format_out {
|
match format_out {
|
||||||
ImageFmt::Png => encode_png(image, rate, preserve_metadata)?,
|
ImageFmt::Png => encode_png(image, strength_rate, preserve_metadata)?,
|
||||||
ImageFmt::Jpeg => match target_size {
|
ImageFmt::Jpeg => match target_size {
|
||||||
Some(target) => encode_jpeg_target(image, target)?,
|
Some(target) => encode_jpeg_target(image, target)?,
|
||||||
None => encode_jpeg(image, rate)?,
|
None => encode_jpeg(image, strength_rate)?,
|
||||||
},
|
},
|
||||||
ImageFmt::Webp => match target_size {
|
ImageFmt::Webp => match target_size {
|
||||||
Some(target) => encode_webp_target(image, target)?,
|
Some(target) => encode_webp_target(image, target)?,
|
||||||
None => encode_webp(image, rate)?,
|
None => encode_webp(image, strength_rate)?,
|
||||||
},
|
},
|
||||||
ImageFmt::Avif => match target_size {
|
ImageFmt::Avif => match target_size {
|
||||||
Some(target) => encode_avif_target(image, target)?,
|
Some(target) => encode_avif_target(image, target)?,
|
||||||
None => encode_avif(image, rate)?,
|
None => encode_avif(image, strength_rate)?,
|
||||||
},
|
},
|
||||||
ImageFmt::Gif => encode_gif(image, rate)?,
|
ImageFmt::Gif => encode_gif(image, strength_rate)?,
|
||||||
ImageFmt::Bmp => encode_bmp(image)?,
|
ImageFmt::Bmp => encode_bmp(image)?,
|
||||||
ImageFmt::Tiff => encode_tiff(image)?,
|
ImageFmt::Tiff => encode_tiff(image)?,
|
||||||
ImageFmt::Ico => encode_ico(image)?,
|
ImageFmt::Ico => encode_ico(image)?,
|
||||||
@@ -595,17 +615,16 @@ fn effective_rate(rate: Option<u8>, level: CompressionLevel) -> u8 {
|
|||||||
match rate {
|
match rate {
|
||||||
Some(value) => value.clamp(1, 100),
|
Some(value) => value.clamp(1, 100),
|
||||||
None => match level {
|
None => match level {
|
||||||
CompressionLevel::Low => 25,
|
CompressionLevel::Low => 80,
|
||||||
CompressionLevel::Medium => 55,
|
CompressionLevel::Medium => 55,
|
||||||
CompressionLevel::High => 80,
|
CompressionLevel::High => 30,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn target_size_from_rate(original_size: u64, rate: u8) -> u64 {
|
fn target_size_from_rate(original_size: u64, rate: u8) -> u64 {
|
||||||
let rate = rate.clamp(1, 100) as u64;
|
let rate = rate.clamp(1, 100) as u64;
|
||||||
let remaining = 100_u64.saturating_sub(rate);
|
let target = original_size.saturating_mul(rate) / 100;
|
||||||
let target = original_size.saturating_mul(remaining) / 100;
|
|
||||||
target.max(1)
|
target.max(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -639,6 +658,11 @@ fn gif_speed_from_rate(rate: u8) -> i32 {
|
|||||||
1 + ((rate - 1) * 29 / 99)
|
1 + ((rate - 1) * 29 / 99)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn strength_from_rate(rate: u8) -> u8 {
|
||||||
|
let rate = rate.clamp(1, 100);
|
||||||
|
101_u8.saturating_sub(rate)
|
||||||
|
}
|
||||||
|
|
||||||
fn is_animated_gif(input: &[u8]) -> Result<bool, AppError> {
|
fn is_animated_gif(input: &[u8]) -> Result<bool, AppError> {
|
||||||
let decoder = GifDecoder::new(Cursor::new(input))
|
let decoder = GifDecoder::new(Cursor::new(input))
|
||||||
.map_err(|err| AppError::new(ErrorCode::InvalidImage, "GIF 解码失败").with_source(err))?;
|
.map_err(|err| AppError::new(ErrorCode::InvalidImage, "GIF 解码失败").with_source(err))?;
|
||||||
|
|||||||
@@ -310,7 +310,8 @@ async fn process_task(state: &AppState, task_id: Uuid) -> Result<(), AppError> {
|
|||||||
} else {
|
} else {
|
||||||
(original_size.saturating_sub(compressed_size) as f64) * 100.0 / (original_size as f64)
|
(original_size.saturating_sub(compressed_size) as f64) * 100.0 / (original_size as f64)
|
||||||
};
|
};
|
||||||
let charge_units = compressed_size < original_size;
|
let skip_charge = compression_rate == Some(100);
|
||||||
|
let charge_units = !skip_charge && compressed_size < original_size;
|
||||||
|
|
||||||
// Anonymous quota enforcement requires session_id + client_ip.
|
// Anonymous quota enforcement requires session_id + client_ip.
|
||||||
if task.user_id.is_none() && charge_units {
|
if task.user_id.is_none() && charge_units {
|
||||||
|
|||||||
Reference in New Issue
Block a user