diff --git a/crates/palette-extract/src/lib.rs b/crates/palette-extract/src/lib.rs index ab38838..c996cb5 100644 --- a/crates/palette-extract/src/lib.rs +++ b/crates/palette-extract/src/lib.rs @@ -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) -> ImageInput<'a> { } fn finish( - mut reader: image::ImageReader, + reader: image::ImageReader, ) -> Result { + Ok(fit(decode_with_limits(reader)?, MAX_EDGE)) +} + +/// 解码一个 reader(带 [`MAX_SOURCE_EDGE`] 头尺寸上限),**不缩放**。 +/// +/// 从 [`finish`] 里拆出来只有一个理由:取色([`load`])与缩略图([`thumbnail`])要**不同的 +/// 目标边长**,而「读文件 + 嗅探格式 + 卡解压炸弹」这段必须只有一份(两份就会有一份忘了卡上限)。 +pub(crate) fn decode_with_limits( + mut reader: image::ImageReader, +) -> Result { 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)] diff --git a/crates/palette-extract/src/tests.rs b/crates/palette-extract/src/tests.rs index dc17050..8049b8c 100644 --- a/crates/palette-extract/src/tests.rs +++ b/crates/palette-extract/src/tests.rs @@ -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)); +} diff --git a/crates/palette-extract/src/thumbnail.rs b/crates/palette-extract/src/thumbnail.rs new file mode 100644 index 0000000..cc59b2b --- /dev/null +++ b/crates/palette-extract/src/thumbnail.rs @@ -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, max_edge: u32) -> Result { + 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)) +}