From 1aac62f6f0363ec1fe9471cca23995466f56f5fc Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 09:17:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(palette-extract):=20=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E8=A7=A3=E7=A0=81=E9=80=82=E9=85=8D=E5=B1=82=EF=BC=88D40=20?= =?UTF-8?q?=E7=AC=AC=202=20=E5=B1=82=EF=BC=8C=E5=94=AF=E4=B8=80=E5=BC=95?= =?UTF-8?q?=E5=85=A5=20image=20=E7=9A=84=E5=9C=B0=E6=96=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新 crate unlucky-palette-extract:路径/字节 → RGBA → design 的种子 - image 只开 png + jpeg(关 default-features,不拖 rayon/avif/webp) - 解码前按 Limits 拒超大尺寸(解压炸弹),解码后最长边缩到 256 - input(&Result) 把失败翻成 ImageInput::Undecodable(回落链靠它区分「没设」与「坏了」) - 11 个单测覆盖纯色/JPEG/抄错扩展名/垃圾字节/截断/缺文件/1px/灰图/大图/超限/零拷贝 --- Cargo.lock | 8 ++ Cargo.toml | 7 + crates/palette-extract/Cargo.toml | 26 ++++ crates/palette-extract/src/lib.rs | 215 ++++++++++++++++++++++++++++ crates/palette-extract/src/tests.rs | 154 ++++++++++++++++++++ 5 files changed, 410 insertions(+) create mode 100644 crates/palette-extract/Cargo.toml create mode 100644 crates/palette-extract/src/lib.rs create mode 100644 crates/palette-extract/src/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 1e3da14..90dd0b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6942,6 +6942,14 @@ dependencies = [ name = "unlucky-design" version = "0.1.0" +[[package]] +name = "unlucky-palette-extract" +version = "0.1.0" +dependencies = [ + "image", + "unlucky-design", +] + [[package]] name = "unlucky-presenter" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 1caed83..ab81a1b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,9 @@ members = [ # P4(D26 iced 重写)第一步:与 Slint 版 `crates/app` 并存的新 UI crate。 # bin 名 `unlucky-launcher-iced`,翻牌时才改名(见 DevDocs/SubAgentsTalk/app-iced-骨架与翻牌.md)。 "crates/app-iced", + # M87(D40 第 2 层):配色取色的解码适配层。**唯一**允许引入 `image` 类依赖的地方, + # 使 `crates/design` 能继续宣称「零第三方依赖」。 + "crates/palette-extract", ] [workspace.package] @@ -61,6 +64,10 @@ zip = { version = "9", default-features = false, features = ["deflate-flate2-zli flate2 = "1" tar = "0.4" +# M87(D40):配色取色的解码适配层。版本与 `crates/app-iced` 的截图通道对齐(同一份 +# `image` 只编译一次);编解码器由各 crate 自己按需开 feature,这里只钉版本。 +image = { version = "0.25", default-features = false } + # UI 层唯一入口(D2:Slint,RF 2.0 轨道)。 # 默认特性已含 backend-default + renderer-femtovg(GL)+ renderer-software(CPU 软渲染兜底, # Win7 老机无可用 GPU 时的降级路径,见 DevDocs/PlanDoc/01c-native-gui-options.md §6)。 diff --git a/crates/palette-extract/Cargo.toml b/crates/palette-extract/Cargo.toml new file mode 100644 index 0000000..d70a98f --- /dev/null +++ b/crates/palette-extract/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "unlucky-palette-extract" +description = "配色取色的解码适配层(D40 第 2 层):图片路径/字节 → RGBA 像素 → unlucky-design 的种子" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +rust-version.workspace = true +publish.workspace = true + +# 分层纪律(D40):`crates/design` 保持零第三方依赖,**图片解码只能在这里**。 +# 本 crate 的职责边界很窄:解码 + 限尺寸 + 把像素交给 design,不碰 UI、不碰设置、不碰壁纸探测。 +[dependencies] +unlucky-design = { path = "../design", version = "0.1" } + +# 只开两个编解码器(MIT OR Apache-2.0,deny.toml 白名单内): +# - png:启动器背景图最常见的无损格式,也是截图通道用的格式; +# - jpeg:Windows 壁纸与用户照片的绝对主力(不引它等于「取色对一半用户不可用」)。 +# 不开 `default-features`(默认会拖进 rayon + avif/ravif + webp + tiff + gif + exr + qoi +# 等一整套编码器,体积与审计面都翻倍);不开 webp/tiff/avif 是因为 M73 的壁纸探测拿到的 +# 就是 png/jpg/bmp 三类,等真有人需要再单开一个 feature。 +image = { workspace = true, default-features = false, features = ["png", "jpeg"] } + +[lints] +workspace = true diff --git a/crates/palette-extract/src/lib.rs b/crates/palette-extract/src/lib.rs new file mode 100644 index 0000000..232e2cc --- /dev/null +++ b/crates/palette-extract/src/lib.rs @@ -0,0 +1,215 @@ +//! # 配色取色的解码适配层(D40 三层里的第 2 层) +//! +//! 本 crate 只做一件事:**图片路径 / 字节 → RGBA 像素 → `unlucky_design` 的种子色**。 +//! +//! ```no_run +//! use unlucky_palette_extract::{input, load}; +//! use unlucky_design::palette::{resolve_source, CardKey}; +//! use unlucky_design::color::SchemeFlavor; +//! +//! // ① 读用户设的启动器背景图(失败不 panic,失败状态会原样进 Provenance) +//! let background = load("D:/Pictures/bg.png"); +//! let (scheme, provenance) = resolve_source( +//! Some(input(&background)), +//! None, // ② 桌面壁纸:本次不启用 +//! Some(CardKey::Sky), // ③ 用户选中的色卡 = 回落目标 +//! SchemeFlavor::Light, +//! ); +//! println!("{:?} / {:?}", provenance.level, provenance.hint_key()); +//! ``` +//! +//! ## 为什么解码要单独成 crate(D40) +//! +//! `crates/design` 的卖点是「零第三方依赖」(`cargo tree -p unlucky-design` 单行),而图片解码 +//! 必然拉依赖。把 `image` 塞进 design 会让那句卖点变成假话,所以: +//! +//! | 层 | crate | 依赖 | 职责 | +//! |---|---|---|---| +//! | 1 纯数学 | `unlucky-design` | 无 | 像素/种子 → 49 角色方案、色卡、回落决策、对比度收敛 | +//! | 2 解码 | **本 crate** | `image`(png + jpeg) | 路径/字节 → RGBA 像素;限尺寸;错误分类 | +//! | 3 调用侧 | 后续 appearance/settings mission | UI 框架 | 拿路径、把 `Provenance` 变文案 | +//! +//! ## 为什么选 `image`(而不是 `png` + `zune-jpeg` 各接一个) +//! +//! 1. **仓库里已经有它**:`crates/app-iced` 的截图通道用 `image`(png)。同一份依赖只编译一次, +//! 版本由 workspace 的 `image = "0.25"` 统一钉住,`cargo deny` 的许可面不新增条目。 +//! 2. **格式嗅探现成**:`ImageReader::with_guessed_format()` 按魔数判格式,用户把 `.jpg` +//! 改成 `.png` 也能读(壁纸文件被改名的概率不低)。 +//! 3. **有 `Limits`**:可以在解码前拒绝「头里写着 40000×40000」的 PNG(压缩炸弹), +//! 单接 `png` crate 也要自己写这层判断。 +//! +//! 许可:`image` 是 `MIT OR Apache-2.0`,在 `deny.toml` 白名单内(`[licenses].allow`)。 +//! +//! ## 体积影响(`image` 只开 png + jpeg,关掉 default-features) +//! +//! 拉进的传递依赖就这几个:`png`(+ `fdeflate`/`miniz_oxide`/`crc32fast`/`flate2` 已在树里)、 +//! `zune-jpeg` + `zune-core`、`byteorder-lite`、`num-traits`、`moxcms`/`pxfm`(色彩转换)。 +//! 关掉的默认特性里有 `rayon`(省一个线程池)、`avif`/`ravif`(最大的一个,编解码器整套)、 +//! `webp`/`tiff`/`gif`/`exr`/`qoi`/`hdr`/`dds`/`ff`。实测增量见 M87 报告(`cargo tree` 前后对比)。 +//! +//! ## 两条硬纪律 +//! +//! 1. **解码后才交出去**:送进 `unlucky_design` 的像素最多 [`MAX_EDGE`] 边长(默认 256)。 +//! 取色只需要色彩分布,不需要分辨率;一张 4K 壁纸缩到 256×144 后量化结果与全分辨率 +//! 在肉眼层面一致,内存却从 33MB 掉到 147KB。`crates/design` 里还有第二道块平均降采样 +//! (防御式:直接调 design 的调用方也能拿到有界开销)。 +//! 2. **失败不 panic、不 return 默认色**:解码失败只变成 [`ExtractError`], +//! [`input`] 把它翻成 `ImageInput::Undecodable` 交给 `resolve_source`, +//! 由后者回落色卡并在 `Provenance` 里留下原因。**不许**在这里「读不到就给个蓝色」。 + +use std::io::Cursor; +use std::path::Path; + +use unlucky_design::palette::{self, ImageInput, ImageSource, Seed}; + +/// 交给 `unlucky_design` 前允许的最大边长(像素)。 +/// +/// 256 的依据:量化器自己只采 65536 个点(≈256²),再大的图在 `crates/design` 里也会被 +/// 块平均降采样;提前缩小只是省内存与解码时间。可预测、可测试(见 `tests.rs`)。 +pub const MAX_EDGE: u32 = 256; + +/// 解码**之前**允许的图片头尺寸上限。 +/// +/// 4K 壁纸是 3840×2160,8K 是 7680×4320;16384 留出 2 倍余量,同时挡住「头里写着 +/// 40000×40000」的解压炸弹(否则解码器会先按头分配几十 GB)。 +const MAX_SOURCE_EDGE: u32 = 16_384; + +/// 解码后的 RGBA8 图像(行主序,`width × height × 4` 字节)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Rgba8 { + width: u32, + height: u32, + pixels: Vec, +} + +impl Rgba8 { + /// 宽(像素)。 + pub fn width(&self) -> u32 { + self.width + } + + /// 高(像素)。 + pub fn height(&self) -> u32 { + self.height + } + + /// 原始 RGBA8 字节(`width × height × 4`)。 + pub fn pixels(&self) -> &[u8] { + &self.pixels + } + + /// 包成 `unlucky_design` 的借用视图(零拷贝,供 `resolve_source` 用)。 + pub fn view(&self) -> ImageSource<'_> { + ImageSource { + pixels: &self.pixels, + width: self.width, + height: self.height, + } + } +} + +/// 解码/取色失败的原因。 +#[derive(Debug)] +pub enum ExtractError { + /// 读文件失败(不存在、无权限、被占用)。 + Io(std::io::Error), + /// 解码失败(格式不认识、文件截断、超出 [`MAX_SOURCE_EDGE`] 限制)。 + Decode(image::ImageError), + /// 解码成功但取色失败(图太小、全透明、彩度不足……原因在 `unlucky_design`)。 + Palette(palette::Failure), +} + +impl std::fmt::Display for ExtractError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ExtractError::Io(error) => write!(f, "读取图片失败:{error}"), + ExtractError::Decode(error) => write!(f, "解码图片失败:{error}"), + ExtractError::Palette(failure) => { + write!(f, "取色失败({}):{failure:?}", failure.code()) + } + } + } +} + +impl std::error::Error for ExtractError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + ExtractError::Io(error) => Some(error), + ExtractError::Decode(error) => Some(error), + ExtractError::Palette(_) => None, + } + } +} + +/// 从内存字节解码(格式按魔数嗅探,不看扩展名)。 +pub fn decode_bytes(bytes: &[u8]) -> Result { + let reader = image::ImageReader::new(Cursor::new(bytes)) + .with_guessed_format() + .map_err(ExtractError::Io)?; + finish(reader) +} + +/// 从文件路径解码(格式按魔数嗅探,不看扩展名)。 +pub fn load(path: impl AsRef) -> Result { + let reader = image::ImageReader::open(path).map_err(ExtractError::Io)?; + let reader = reader.with_guessed_format().map_err(ExtractError::Io)?; + finish(reader) +} + +/// 路径 → 种子色(解码 + 量化一步到位;只需要 bool「能不能取」时用 [`input`])。 +pub fn extract_seed(path: impl AsRef) -> Result { + seed_from_rgba8(&load(path)?) +} + +/// 字节 → 种子色(与 [`extract_seed`] 同一条路径,测试与「内存里的图」用)。 +pub fn seed_from_bytes(bytes: &[u8]) -> Result { + seed_from_rgba8(&decode_bytes(bytes)?) +} + +fn seed_from_rgba8(image: &Rgba8) -> Result { + palette::analyze_rgba(image.pixels(), image.width(), image.height()) + .map_err(ExtractError::Palette) +} + +/// 把「读到了 / 没读到」翻成 `resolve_source` 的输入。 +/// +/// 这是本 crate 与编排层之间**唯一**的接线点:`Err` → `ImageInput::Undecodable` +/// (回落链会据此写 `Skip::Undecodable` 而不是 `Skip::Absent`——两者对用户的含义不同: +/// 「你没设背景图」 vs 「你的背景图坏了」)。 +pub fn input<'a>(loaded: &'a Result) -> ImageInput<'a> { + match loaded { + Ok(image) => ImageInput::Pixels(image.view()), + Err(_) => ImageInput::Undecodable, + } +} + +fn finish( + 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(); + 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 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 { + width: scaled.width(), + height: scaled.height(), + pixels: scaled.into_raw(), + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/palette-extract/src/tests.rs b/crates/palette-extract/src/tests.rs new file mode 100644 index 0000000..7ba9755 --- /dev/null +++ b/crates/palette-extract/src/tests.rs @@ -0,0 +1,154 @@ +//! 解码适配层的单测:真 PNG / 真 JPEG 字节进,种子或**具体错误**出。 +//! +//! 这里刻意不 mock 解码器:本 crate 存在的意义就是「真解码」,mock 掉它等于不测。 +//! 测试用内存里的字节(`encode_*` 现造),不依赖任何外部素材文件。 + +use std::io::Cursor; + +use super::*; +use image::{DynamicImage, ImageFormat, Rgba, RgbaImage}; + +fn solid_image(width: u32, height: u32, rgb: [u8; 3]) -> RgbaImage { + RgbaImage::from_pixel(width, height, Rgba([rgb[0], rgb[1], rgb[2], 0xFF])) +} + +fn encode_png(image: &RgbaImage) -> Vec { + let mut bytes = Cursor::new(Vec::new()); + DynamicImage::ImageRgba8(image.clone()) + .write_to(&mut bytes, ImageFormat::Png) + .expect("PNG 编码应当成功"); + bytes.into_inner() +} + +fn encode_jpeg(image: &RgbaImage, quality: u8) -> Vec { + let mut bytes = Cursor::new(Vec::new()); + let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, quality); + encoder + .encode_image(&DynamicImage::ImageRgba8(image.clone())) + .expect("JPEG 编码应当成功"); + bytes.into_inner() +} + +#[test] +fn solid_png_keeps_the_exact_seed_colour() { + let png = encode_png(&solid_image(32, 32, [0xC0, 0x39, 0x2B])); + let seed = seed_from_bytes(&png).expect("砖红纯色图应当有种子"); + // 32×32 不触发缩放;design 侧对纯色图做桶内原值平均 → 逐通道相等。 + assert_eq!(seed.color.r, 0xC0); + assert_eq!(seed.color.g, 0x39); + assert_eq!(seed.color.b, 0x2B); + assert!(seed.chroma > 8.0); +} + +#[test] +fn jpeg_decoding_is_wired_up() { + // 只验证「jpeg feature 真的接上了」:JPEG 会动色,只要求色相别跑偏太多。 + let jpeg = encode_jpeg(&solid_image(48, 48, [0xC0, 0x39, 0x2B]), 92); + let seed = seed_from_bytes(&jpeg).expect("JPEG 应当能解码并取色"); + assert!(seed.chroma > 8.0, "彩度 {}", seed.chroma); + assert!( + (seed.hue - 30.0).abs() < 25.0, + "砖红的 CAM16 色相应当在 30° 附近,实际 {}", + seed.hue + ); +} + +#[test] +fn extension_is_ignored_and_format_is_sniffed() { + // 用户把 .jpg 改成 .png 的情况很常见:按魔数判断,不按扩展名。 + let jpeg = encode_jpeg(&solid_image(16, 16, [0x2E, 0x7D, 0x4F]), 95); + let decoded = decode_bytes(&jpeg).expect("魔数嗅探应当认得 JPEG"); + assert_eq!((decoded.width(), decoded.height()), (16, 16)); +} + +#[test] +fn garbage_bytes_fail_and_map_to_undecodable() { + let loaded = decode_bytes(b"this is definitely not an image"); + assert!( + matches!( + loaded, + Err(ExtractError::Decode(_)) | Err(ExtractError::Io(_)) + ), + "非图片字节应当解码失败" + ); + assert_eq!(input(&loaded), ImageInput::Undecodable); +} + +#[test] +fn truncated_png_fails_instead_of_returning_half_an_image() { + let png = encode_png(&solid_image(64, 64, [0x3C, 0x8D, 0xBC])); + let truncated = &png[..png.len() / 2]; + let loaded = decode_bytes(truncated); + assert!(loaded.is_err(), "截断的 PNG 不许当成有效图"); + assert_eq!(input(&loaded), ImageInput::Undecodable); +} + +#[test] +fn missing_file_is_an_io_error() { + let loaded = load("Z:/definitely/not/here/background.png"); + match loaded { + Err(ExtractError::Io(_)) => {} + other => panic!("应当报 IO 错误,实际 {other:?}"), + } +} + +#[test] +fn one_pixel_image_is_reported_as_too_small() { + let png = encode_png(&solid_image(1, 1, [0xC0, 0x39, 0x2B])); + match seed_from_bytes(&png) { + Err(ExtractError::Palette(palette::Failure::TooSmall { width, height })) => { + assert_eq!((width, height), (1, 1)); + } + other => panic!("1×1 图应当取色失败,实际 {other:?}"), + } +} + +#[test] +fn gray_png_falls_back_instead_of_inventing_a_colour() { + // D40 的反证:灰白图**不许**变成青色。这里必须报低彩度,而不是返回一个种子。 + let png = encode_png(&solid_image(24, 24, [0xEE, 0xEE, 0xEC])); + match seed_from_bytes(&png) { + Err(ExtractError::Palette(palette::Failure::LowChroma { chroma, .. })) => { + assert!(chroma < 8.0, "彩度 {chroma}"); + } + other => panic!("灰白图应当报低彩度,实际 {other:?}"), + } +} + +#[test] +fn large_image_is_scaled_to_the_max_edge() { + let png = encode_png(&solid_image(1024, 512, [0xB2, 0x6A, 0x00])); + let decoded = decode_bytes(&png).expect("大图应当解码成功"); + assert_eq!(decoded.width(), MAX_EDGE); + assert_eq!(decoded.height(), MAX_EDGE / 2); + assert_eq!( + decoded.pixels().len(), + (MAX_EDGE * MAX_EDGE / 2 * 4) as usize + ); + // 缩放后仍是那个橙色:取色的结论不该被「缩了多少」改变。 + let seed = seed_from_rgba8(&decoded).expect("琥珀橙有种子"); + assert!(seed.chroma > 8.0); +} + +#[test] +fn absurd_dimensions_are_rejected_before_allocating() { + // 头里写着 20000 宽的 PNG:超过 MAX_SOURCE_EDGE,必须在解码前拒掉。 + let png = encode_png(&solid_image(20_000, 1, [0x3C, 0x8D, 0xBC])); + match decode_bytes(&png) { + Err(ExtractError::Decode(_)) => {} + other => panic!("超限尺寸应当被拒,实际 {other:?}"), + } +} + +#[test] +fn input_borrows_without_copying() { + let loaded = decode_bytes(&encode_png(&solid_image(4, 4, [0x67, 0x50, 0xA4]))); + match input(&loaded) { + ImageInput::Pixels(source) => { + assert_eq!((source.width, source.height), (4, 4)); + // 零拷贝:视图指向的就是 `Rgba8` 自己的缓冲。 + assert_eq!(source.pixels, loaded.as_ref().expect("解码成功").pixels()); + } + ImageInput::Undecodable => panic!("刚解码成功却报了 undecodable"), + } +}