feat(palette-extract): 图片解码适配层(D40 第 2 层,唯一引入 image 的地方)

- 新 crate unlucky-palette-extract:路径/字节 → RGBA → design 的种子
- image 只开 png + jpeg(关 default-features,不拖 rayon/avif/webp)
- 解码前按 Limits 拒超大尺寸(解压炸弹),解码后最长边缩到 256
- input(&Result<Rgba8, _>) 把失败翻成 ImageInput::Undecodable(回落链靠它区分「没设」与「坏了」)
- 11 个单测覆盖纯色/JPEG/抄错扩展名/垃圾字节/截断/缺文件/1px/灰图/大图/超限/零拷贝
This commit is contained in:
AskaEth committed 2026-11-02 11:04:52 +08:00
1 parent 18327fb038
commit 1aac62f6f0
5 files changed
+410

No files matched your search

Generated
+8
View File
@@ -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"
+7
View File
@@ -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)。
+26
View File
@@ -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
+215
View File
@@ -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<u8>,
}
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<Rgba8, ExtractError> {
let reader = image::ImageReader::new(Cursor::new(bytes))
.with_guessed_format()
.map_err(ExtractError::Io)?;
finish(reader)
}
/// 从文件路径解码(格式按魔数嗅探,不看扩展名)。
pub fn load(path: impl AsRef<Path>) -> Result<Rgba8, ExtractError> {
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<Path>) -> Result<Seed, ExtractError> {
seed_from_rgba8(&load(path)?)
}
/// 字节 → 种子色(与 [`extract_seed`] 同一条路径,测试与「内存里的图」用)。
pub fn seed_from_bytes(bytes: &[u8]) -> Result<Seed, ExtractError> {
seed_from_rgba8(&decode_bytes(bytes)?)
}
fn seed_from_rgba8(image: &Rgba8) -> Result<Seed, ExtractError> {
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<Rgba8, ExtractError>) -> ImageInput<'a> {
match loaded {
Ok(image) => ImageInput::Pixels(image.view()),
Err(_) => ImageInput::Undecodable,
}
}
fn finish<R: std::io::BufRead + std::io::Seek>(
mut reader: image::ImageReader<R>,
) -> Result<Rgba8, 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();
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;
+154
View File
@@ -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<u8> {
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<u8> {
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"),
}
}