Files
Unlucky-Launcher/crates/palette-extract/src/lib.rs
T
AskaEth 1aac62f6f0 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/灰图/大图/超限/零拷贝
2026-11-02 11:04:52 +08:00

216 lines
9.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! # 配色取色的解码适配层(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;