Files
Unlucky-Launcher/crates/presenter/src/host/saves/thumbs.rs
T
AskaEth 843f6d08f9 feat(saves): 截图缩略图墙(M251 · D54 全套:解码/缓存/上限/可见失败)
按 D54 落地,解码在 palette-extract、缓存在 presenter、像素随快照走、壳层只画。

palette-extract(上一提交)→ presenter:
- dto/saves/preview.rs:ScreenshotPreview{NotGenerated,Ready,Failed} + ThumbnailView
  (带 is_consistent:长度 ≠ w×h×4 的像素不许进渲染器)+ ScreenshotThumbKind;
  ScreenshotView 新增 preview 字段(像素不落盘,随 IPC 面走);
- host/saves/thumbs.rs:attach() 在后台线程(spawn_list 同一条线程)按 mtime 倒序解前
  60 张、单张 >8 MiB 先查大小不进解码器、失败也逐条带原因;进程级 memo 键 =
  (路径, mtime, 大小),覆盖/替换都会重解;超过 240 条只留本拍碰到的键;
- presenter 新增依赖 unlucky-palette-extract(塔已批,方向 presenter → palette-extract)。

app-iced:
- saves.rs 拆成 model / rows / wall(原文件 385 行,加完墙必破 400):model 里建一次
  iced Handle(渲染回调零解码、零重复上传),wall 3 列排格子,没像素的格子给
  160×90 占位框 + 逐条原因;text.rs 拆出 text/saves.rs 承载存档/截图一族文案;
- i18n 加 4 条「这一格为什么没有图」(未生成/太大/读不出/解不出)+ 金表同步;
- 出图夹具(saves_shot)合成两种场景的像素,并摆出四种格子(真图×2/未生成/超限失败)。

用例:presenter 9 条(含缓存命中不重解、mtime 变了必重解、第 61 张不解、
>8MiB 不进解码器、缓存有界)+ app-iced 3 条(三种格子的副文案、坏像素降级)。
2026-11-11 16:33:56 +08:00

176 lines
7.8 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.
//! 截图的**后台解码 + 进程级 memo**(M251 · SAVESHOTS,落地 D54 第 3/4/5/6 条)。
//!
//! ## 为什么解码在后台线程
//!
//! 一屏 60 张 PNG 解码是几百毫秒量级的工作。D54 明写「**不许**在渲染回调里解码」:把这段放进
//! `iced` 的 `view()` 会直接把帧率打穿。所以解码发生在 [`super::spawn_list`] 那条后台线程里
//! (`job::spawn`,与 `host/saves.rs` 同一模式),UI 那一侧只收一份**带像素的快照**。
//!
//! ## 缓存键为什么必须是三段
//!
//! `(路径, mtime, 大小)`——**只按路径**是错的,而且是那种「测试不会红」的错:
//!
//! | 场景 | 只按路径的后果 |
//! |---|---|
//! | 游戏又存了一张同名截图(覆盖) | 永远显示上一张的缩略图 |
//! | 用户拿同名文件替换(修图后放回) | 同上,且没有任何日志 |
//!
//! 带上 mtime 与大小之后,覆盖 ⇒ 键变了 ⇒ 重解。这条由
//! `tests::a_changed_file_is_decoded_again` 钉住(把 mtime 从键里去掉,它必红)。
//!
//! ## 为什么是**进程级** memo 而不是状态字段
//!
//! 解码发生在 `spawn_list` 起的线程里,而那条线程拿到的东西只有 `HostEnv` + `tx`(见
//! `host/saves.rs`)。把缓存挂到 `CoreHost` 上要改 `host/mod.rs`(贴着 400 行)与
//! `host/dispatch.rs`(M251 的 scope 外),换来的语义与这里**完全一样**:键是全路径,多实例
//! 共享一份不会串味;它也不是业务状态,只是一份「同样的键不再重解」的记忆。
//!
//! ## 两条硬上限(D54 第 5 条)
//!
//! | 上限 | 值 | 为什么 |
//! |---|---|---|
//! | 单张字节数 | [`MAX_SOURCE_BYTES`] = 8 MiB | 一张 200MB 的 PNG 解码要几 GB 内存;**解码前**查大小,超了就不进解码器,并留一条可见原因 |
//! | 每次刷新张数 | [`MAX_PER_REFRESH`] = 60 | 按 mtime 倒序只解前 60 张(最近玩的那几屏最有用),其余给「未生成预览」占位 |
//!
//! 上限是**逐条可见**的:跳过的那张在快照里带 [`ScreenshotPreview::Failed`] 或
//! [`ScreenshotPreview::NotGenerated`],不是静默少一张(D54 第 6 条)。
//!
//! ## 缓存不会无限长
//!
//! 键里有 mtime,覆盖一次就多一个键。所以超过 [`MAX_CACHE_ENTRIES`] 时只保留**这一次刷新碰到
//! 的键**(换目录、删截图之后旧键一起掉)。代价是极端情况下重解一次,收益是常驻内存有界。
use std::collections::HashMap;
use std::path::Path;
use std::sync::{Mutex, OnceLock};
use unlucky_palette_extract::{thumbnail, ExtractError, THUMBNAIL_MAX_EDGE};
use crate::dto::{
ScreenshotPreview, ScreenshotThumbFailure, ScreenshotThumbKind, ScreenshotView, ThumbnailView,
};
/// 单张截图的解码上限(字节)。超过就**不解码**(D54 第 5 条)。
pub const MAX_SOURCE_BYTES: u64 = 8 * 1024 * 1024;
/// 每次刷新最多解几张(按 mtime 倒序取前这么多)。
pub const MAX_PER_REFRESH: usize = 60;
/// 缓存的条目上限(超过就只留这一拍碰到的键)。
pub const MAX_CACHE_ENTRIES: usize = 240;
/// 缓存键:**(完整路径, mtime 毫秒, 字节数)**。
///
/// 三段缺一不可,理由见模块文档的表格。`mtime` 是 `Option`:取不到时间的文件用 `None` 占位
/// (那种文件本来就没有「变了」的可靠判据,大小仍然兜住一部分)。
type Key = (String, Option<u64>, u64);
/// 缓存里存的是**结局**(成功或失败)。
///
/// 失败也缓存:一张坏 PNG 不该每次刷新都重解一遍(那是「滚动一次卡一次」的另一种来源)。
/// 键里带 mtime ⇒ 用户修好文件(mtime 变)之后自然会重解。
type Cache = HashMap<Key, Result<ThumbnailView, ScreenshotThumbFailure>>;
fn cache() -> &'static Mutex<Cache> {
static CACHE: OnceLock<Mutex<Cache>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
/// 给一批截图挂上预览(**在后台线程里调**,`super::view` 的唯一调用点)。
pub(super) fn attach(dir: &Path, views: &mut [ScreenshotView]) {
attach_with(dir, views, &mut decode);
}
/// [`attach`] 的可测内核:解码器是一个参数,于是用例能数「到底进了几次解码器」。
///
/// 这类断言正是 D54 要求的那条「**缓存命中不重复解码**」:数的是解码器的调用次数,不是
/// 「界面上看起来一样」。
fn attach_with(
dir: &Path,
views: &mut [ScreenshotView],
decode: &mut impl FnMut(&Path, u64) -> Result<ThumbnailView, ScreenshotThumbFailure>,
) {
if views.is_empty() {
return;
}
// ① 排序:mtime 倒序(取不到的排最后),同刻按文件名倒序兜底(两次刷新之间不抖)。
let mut order: Vec<usize> = (0..views.len()).collect();
order.sort_by(|&left, &right| {
views[right]
.modified_ms
.cmp(&views[left].modified_ms)
.then_with(|| views[right].file.cmp(&views[left].file))
});
let mut store = cache().lock().unwrap_or_else(|poison| poison.into_inner());
let mut touched: Vec<Key> = Vec::with_capacity(order.len().min(MAX_PER_REFRESH));
for (rank, &index) in order.iter().enumerate() {
// ② 超出这一拍的解码预算:占位,不碰盘、不进解码器。
if rank >= MAX_PER_REFRESH {
views[index].preview = ScreenshotPreview::NotGenerated;
continue;
}
let (file, bytes, modified) = {
let shot = &views[index];
(shot.file.clone(), shot.bytes, shot.modified_ms)
};
// ③ 单张太大:**解码前**就拒,理由逐条可见。
if bytes > MAX_SOURCE_BYTES {
views[index].preview = ScreenshotPreview::Failed(ScreenshotThumbFailure {
kind: ScreenshotThumbKind::TooLarge,
detail: format!("{bytes} > {MAX_SOURCE_BYTES} 字节"),
});
continue;
}
// ④ 缓存 → 解码 → 回填。
let path = dir.join(&file);
let key: Key = (path.display().to_string(), modified, bytes);
let outcome = match store.get(&key) {
Some(cached) => cached.clone(),
None => {
let computed = decode(&path, bytes);
store.insert(key.clone(), computed.clone());
computed
}
};
touched.push(key);
views[index].preview = match outcome {
Ok(view) => ScreenshotPreview::Ready(view),
Err(failure) => ScreenshotPreview::Failed(failure),
};
}
// ⑤ 有界:只留这一拍碰到的键(旧的一律掉)。
if store.len() > MAX_CACHE_ENTRIES {
store.retain(|key, _| touched.contains(key));
}
}
/// 真解码(唯一碰 `image` 的地方;错都在 `palette-extract` 里分好类了)。
fn decode(path: &Path, _bytes: u64) -> Result<ThumbnailView, ScreenshotThumbFailure> {
match thumbnail(path, THUMBNAIL_MAX_EDGE) {
Ok(image) => Ok(ThumbnailView {
width: image.width(),
height: image.height(),
pixels: image.pixels().to_vec(),
}),
Err(error) => Err(failure(&error)),
}
}
/// `palette-extract` 的错误 → 预览失败的大类 + 原文(不翻译,翻译在 app 层)。
fn failure(error: &ExtractError) -> ScreenshotThumbFailure {
let kind = match error {
ExtractError::Io(_) => ScreenshotThumbKind::Unreadable,
// 取色失败那条支在本路径上不可能出现(`thumbnail` 不做量化),但穷尽 match 比
// 通配兜底诚实:真出现了也算「解不出来」,不会被当成读到了。
ExtractError::Decode(_) | ExtractError::Palette(_) => ScreenshotThumbKind::Decode,
};
ScreenshotThumbFailure {
kind,
detail: error.to_string(),
}
}
#[cfg(test)]
mod tests;