feat(palette-extract): 加 thumbnail(path, max_edge) 出限尺寸缩略图(M251 · D54)
D54 第 1 条:解码与缩放留在 palette-extract(截图是 PNG,现有 png/jpeg/bmp 解码 feature 已覆盖,零新依赖、零新 crate、零新 feature)。 - 把 finish() 拆成 decode_with_limits()(读 + 嗅探 + 卡解压炸弹)与 fit()(等比缩、 不放大),取色与缩略图共用同一段解码,只是目标边长不同; - 新增 thumbnail(path, max_edge) -> Result<Rgba8, ExtractError>,失败给结构化错误 (Io / Decode),不 panic、不返回灰图;max_edge=0 按 1 处理; - THUMBNAIL_MAX_EDGE = 160(D54 第 5 条)。 用例 5 条:真 PNG 等比缩到给定长边、小图不放大、坏 PNG 给 Decode、缺文件给 Io、 0 边按 1 处理。
This commit is contained in:
1 parent
5487f661d9
commit
798bfb279b
3 files changed
+174
-8
No files matched your search
@@ -64,6 +64,10 @@ use std::path::Path;
|
||||
|
||||
use unlucky_design::palette::{self, ImageInput, ImageSource, Seed};
|
||||
|
||||
mod thumbnail;
|
||||
|
||||
pub use thumbnail::{thumbnail, THUMBNAIL_MAX_EDGE};
|
||||
|
||||
/// 交给 `unlucky_design` 前允许的最大边长(像素)。
|
||||
///
|
||||
/// 256 的依据:量化器自己只采 65536 个点(≈256²),再大的图在 `crates/design` 里也会被
|
||||
@@ -186,31 +190,46 @@ pub fn input<'a>(loaded: &'a Result<Rgba8, ExtractError>) -> ImageInput<'a> {
|
||||
}
|
||||
|
||||
fn finish<R: std::io::BufRead + std::io::Seek>(
|
||||
mut reader: image::ImageReader<R>,
|
||||
reader: image::ImageReader<R>,
|
||||
) -> Result<Rgba8, ExtractError> {
|
||||
Ok(fit(decode_with_limits(reader)?, MAX_EDGE))
|
||||
}
|
||||
|
||||
/// 解码一个 reader(带 [`MAX_SOURCE_EDGE`] 头尺寸上限),**不缩放**。
|
||||
///
|
||||
/// 从 [`finish`] 里拆出来只有一个理由:取色([`load`])与缩略图([`thumbnail`])要**不同的
|
||||
/// 目标边长**,而「读文件 + 嗅探格式 + 卡解压炸弹」这段必须只有一份(两份就会有一份忘了卡上限)。
|
||||
pub(crate) fn decode_with_limits<R: std::io::BufRead + std::io::Seek>(
|
||||
mut reader: image::ImageReader<R>,
|
||||
) -> Result<image::RgbaImage, ExtractError> {
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(MAX_SOURCE_EDGE);
|
||||
limits.max_image_height = Some(MAX_SOURCE_EDGE);
|
||||
reader.limits(limits);
|
||||
|
||||
let decoded = reader.decode().map_err(ExtractError::Decode)?;
|
||||
let rgba = decoded.to_rgba8();
|
||||
Ok(decoded.to_rgba8())
|
||||
}
|
||||
|
||||
/// 把解码结果等比缩到**长边 ≤ `max_edge`**(不放大:本来就小的图原样交付)。
|
||||
///
|
||||
/// `thumbnail` 是整数盒式采样:对取色(只关心色彩分布)与缩略图(160px 的墙)都够,
|
||||
/// 且比 Lanczos 快一个量级。
|
||||
pub(crate) fn fit(rgba: image::RgbaImage, max_edge: u32) -> Rgba8 {
|
||||
let (width, height) = (rgba.width(), rgba.height());
|
||||
let scaled = if width.max(height) > MAX_EDGE {
|
||||
// 等比缩到长边 = MAX_EDGE。`thumbnail` 是整数快速采样,对取色(只关心色彩分布)
|
||||
// 足够,且比 Lanczos 快一个量级。
|
||||
let factor = f64::from(MAX_EDGE) / f64::from(width.max(height));
|
||||
let scaled = if width.max(height) > max_edge {
|
||||
let factor = f64::from(max_edge) / f64::from(width.max(height));
|
||||
let target_width = ((f64::from(width) * factor).round() as u32).max(1);
|
||||
let target_height = ((f64::from(height) * factor).round() as u32).max(1);
|
||||
image::imageops::thumbnail(&rgba, target_width, target_height)
|
||||
} else {
|
||||
rgba
|
||||
};
|
||||
Ok(Rgba8 {
|
||||
Rgba8 {
|
||||
width: scaled.width(),
|
||||
height: scaled.height(),
|
||||
pixels: scaled.into_raw(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -187,3 +187,94 @@ fn input_borrows_without_copying() {
|
||||
ImageInput::Undecodable => panic!("刚解码成功却报了 undecodable"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 缩略图(M251 · D54)
|
||||
|
||||
/// 一次性临时目录(本 crate 不引第三方 tempfile:整仓都没有)。
|
||||
struct TempDir(std::path::PathBuf);
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// 写一个真 PNG 到临时目录,返回 `(目录守卫, 文件路径)`。
|
||||
fn png_on_disk(tag: &str, image: &RgbaImage) -> (TempDir, std::path::PathBuf) {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!("palette-thumb-{tag}-{}-{seq}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("建临时目录");
|
||||
let path = dir.join("shot.png");
|
||||
std::fs::write(&path, encode_png(image)).expect("写 PNG");
|
||||
(TempDir(dir), path)
|
||||
}
|
||||
|
||||
/// 真 PNG → 等比缩到给定长边。
|
||||
///
|
||||
/// 抓得住:`thumbnail` 拿错目标边长(例如误用取色那条路的 `MAX_EDGE = 256`)、宽高比被拉平、
|
||||
/// 缩放后字节数对不上 `width × height × 4`。
|
||||
/// 抓不住:缩放**画质**(盒式采样的观感只能靠出图看);像素内容是否正确(只查尺寸与不透明通道)。
|
||||
#[test]
|
||||
fn thumbnail_scales_a_real_png_to_the_asked_edge() {
|
||||
let (_dir, path) = png_on_disk("scale", &solid_image(40, 20, [0x2E, 0x7D, 0x4F]));
|
||||
let thumb = thumbnail(&path, 10).expect("真 PNG 应当出缩略图");
|
||||
assert_eq!((thumb.width(), thumb.height()), (10, 5), "等比缩到长边 10");
|
||||
assert_eq!(thumb.pixels().len(), 10 * 5 * 4);
|
||||
// 纯色图缩完还是那个色(盒式平均不该把纯色抹成别的色)。
|
||||
assert_eq!(&thumb.pixels()[..4], &[0x2E, 0x7D, 0x4F, 0xFF]);
|
||||
}
|
||||
|
||||
/// 小图**不放大**:图本身比墙上的格子小就原样交付(放大只会糊)。
|
||||
///
|
||||
/// 抓得住:有人在 `fit` 里把「长边超限才缩」改成无条件缩(那会把 8×6 的图拉成 160×120)。
|
||||
#[test]
|
||||
fn thumbnail_never_upscales() {
|
||||
let (_dir, path) = png_on_disk("small", &solid_image(8, 6, [0x67, 0x50, 0xA4]));
|
||||
let thumb = thumbnail(&path, THUMBNAIL_MAX_EDGE).expect("小图也应当成功");
|
||||
assert_eq!((thumb.width(), thumb.height()), (8, 6));
|
||||
}
|
||||
|
||||
/// 坏文件给**结构化错误**,不 panic、不返回灰图。
|
||||
///
|
||||
/// 抓得住:把 `?` 换成 `unwrap`/`unwrap_or_default`(前者 panic 让用例挂,后者返回一张图让
|
||||
/// `expect_err` 挂)。
|
||||
/// 抓不住:错误文案的措辞(那是 `image` 的原文,只进括号)。
|
||||
#[test]
|
||||
fn a_broken_png_is_a_decode_error_not_a_panic() {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!("palette-thumb-bad-{}-{seq}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("建临时目录");
|
||||
let guard = TempDir(dir.clone());
|
||||
let path = dir.join("2024-05-01_10.00.00.png");
|
||||
std::fs::write(&path, b"\x89PNG\r\n\x1a\n truncated garbage").expect("写坏文件");
|
||||
|
||||
match thumbnail(&path, THUMBNAIL_MAX_EDGE) {
|
||||
Err(ExtractError::Decode(_)) => {}
|
||||
other => panic!("坏 PNG 应当给 Decode 错误,实际 {other:?}"),
|
||||
}
|
||||
drop(guard);
|
||||
}
|
||||
|
||||
/// 文件不存在 ⇒ `Io`(与「解不出来」分开:一个是没读到,一个是读到了但读不懂)。
|
||||
#[test]
|
||||
fn a_missing_file_is_an_io_error() {
|
||||
let path = std::env::temp_dir().join("palette-thumb-does-not-exist.png");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
match thumbnail(&path, THUMBNAIL_MAX_EDGE) {
|
||||
Err(ExtractError::Io(_)) => {}
|
||||
other => panic!("不存在的文件应当给 Io 错误,实际 {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `max_edge = 0` 按 1 处理(防调用方传 0 得到 0×0 的图,墙上一格空着还查不出原因)。
|
||||
#[test]
|
||||
fn a_zero_edge_is_treated_as_one() {
|
||||
let (_dir, path) = png_on_disk("zero", &solid_image(40, 20, [0x3C, 0x8D, 0xBC]));
|
||||
let thumb = thumbnail(&path, 0).expect("0 也应当能出图");
|
||||
assert_eq!((thumb.width(), thumb.height()), (1, 1));
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! 缩略图:路径 → 限尺寸的 [`Rgba8`](D54 第 1 条,M251 · SAVESHOTS)。
|
||||
//!
|
||||
//! ## 为什么落在本 crate
|
||||
//!
|
||||
//! D54 定的分层没有变:图片解码**只在本 crate**(`crates/design` 零第三方依赖的代价就是把解码
|
||||
//! 摊到这里)。截图是 PNG,本 crate 的 `image` 已经开了 `png`/`jpeg`/`bmp` 解码 feature,
|
||||
//! 所以加这条路**零新依赖、零新 crate、零新 feature**——只是把「解码 + 限尺寸」这段已有的能力
|
||||
//! 换个目标边长([`THUMBNAIL_MAX_EDGE`])暴露一次。
|
||||
//!
|
||||
//! ## 不落盘
|
||||
//!
|
||||
//! 这里**只返回像素**,不写任何缓存文件(D54 第 2 条):写 PNG 需要编码器,而编码器只为一份
|
||||
//! 磁盘缓存引入不划算;内存缓存归调用方(presenter 的 `host::saves::thumbs`)。
|
||||
//!
|
||||
//! ## 失败不 panic
|
||||
//!
|
||||
//! 读不到 / 不是图片 / 头里写着 40000×40000,全都变成 [`ExtractError`](与取色那条路同一族
|
||||
//! 错误)。调用方据此给「逐条可见」的失败原因——**不许**在这里「读不到就画个灰块」。
|
||||
//!
|
||||
//! 与 [`crate::load`] 的区别只有一条:目标边长。`load` 钉 `MAX_EDGE = 256`(取色只需要色彩分布),
|
||||
//! 这里由调用方给(缩略图墙用 160)。
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use unlucky_palette_extract::{thumbnail, THUMBNAIL_MAX_EDGE};
|
||||
//!
|
||||
//! match thumbnail("screenshots/2024-05-01_10.00.00.png", THUMBNAIL_MAX_EDGE) {
|
||||
//! Ok(thumb) => println!("{}×{}", thumb.width(), thumb.height()),
|
||||
//! Err(error) => eprintln!("这张图没有缩略图:{error}"),
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::{decode_with_limits, fit, ExtractError, Rgba8};
|
||||
|
||||
/// 缩略图的最大边(像素)。D54 定 160。
|
||||
///
|
||||
/// 依据:一屏缩略图墙按 4 列排、详情列宽约 700px ⇒ 每格 ≈ 160px。再大只是多占内存
|
||||
/// (160² × 4B ≈ 100KB/张,60 张 ≈ 6MB),肉眼在墙上看不出差别。
|
||||
pub const THUMBNAIL_MAX_EDGE: u32 = 160;
|
||||
|
||||
/// 从文件路径解码并缩到**长边 ≤ `max_edge`**(等比、不放大),返回 RGBA8 像素。
|
||||
///
|
||||
/// 格式按**魔数**嗅探,不看扩展名(与 [`crate::load`] 同一条路:用户把 `.jpg` 改名成 `.png`
|
||||
/// 也照样读得出来)。`max_edge = 0` 按 1 处理(防调用方传 0 得到 0×0 的图)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - [`ExtractError::Io`]:文件不存在 / 无权限 / 被占用 / 打不开;
|
||||
/// - [`ExtractError::Decode`]:不是认得的图片、文件截断、或头尺寸超过 16384(解压炸弹)。
|
||||
pub fn thumbnail(path: impl AsRef<Path>, max_edge: u32) -> Result<Rgba8, ExtractError> {
|
||||
let max_edge = max_edge.max(1);
|
||||
let reader = image::ImageReader::open(path).map_err(ExtractError::Io)?;
|
||||
let reader = reader.with_guessed_format().map_err(ExtractError::Io)?;
|
||||
Ok(fit(decode_with_limits(reader)?, max_edge))
|
||||
}
|
||||
Reference in new issue
Block a user