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 条(三种格子的副文案、坏像素降级)。
This commit is contained in:
AskaEth committed 2026-11-11 16:33:56 +08:00
1 parent 798bfb279b
commit 843f6d08f9
21 files changed
+1519 -360

No files matched your search

+3 -2
View File
@@ -86,10 +86,11 @@ pub use pack::{
pub use settings::{
find_row, SettingControl, SettingGroupView, SettingRowView, JAVA_REFRESH_ACTION,
};
// M235: 实例存档 / 截图的只读列表
// M235: 实例存档 / 截图的只读列表(M251 加逐张缩略图预览)
pub use saves::{
SavesArea, SavesCommand, SavesFailureKind, SavesFailureOp, SavesFailureView, SavesListView,
SavesOped, SavesSkipKind, SavesSkipView, ScreenshotView, WorldView,
SavesOped, SavesSkipKind, SavesSkipView, ScreenshotPreview, ScreenshotThumbFailure,
ScreenshotThumbKind, ScreenshotView, ThumbnailView, WorldView,
};
// M159: 自更新
pub use update::{
+13 -1
View File
@@ -39,6 +39,12 @@ use serde::{Deserialize, Serialize};
use crate::dto::InstanceId;
mod preview;
pub use preview::{
ScreenshotPreview, ScreenshotThumbFailure, ScreenshotThumbKind, ThumbnailView,
};
/// app → host 的存档指令。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum SavesCommand {
@@ -135,7 +141,7 @@ pub struct WorldView {
pub modified_ms: Option<u64>,
}
/// 一张截图(**只给元数据**,不给像素:解码图片要引依赖,U3 明确不做)。
/// 一张截图(元数据 + **预览**)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScreenshotView {
/// 磁盘文件名(含扩展名)。
@@ -144,6 +150,12 @@ pub struct ScreenshotView {
pub bytes: u64,
/// 文件 mtime(UTC 毫秒;`None` = 取不到)。
pub modified_ms: Option<u64>,
/// 预览(缩略图像素 / 没生成 / 具名失败原因),见 [`ScreenshotPreview`]。
///
/// 像素**不落盘**(D54 第 2 条):解码在 host 的后台线程里做完,随这份快照一次性交给
/// 壳层。`(file, modified_ms, bytes)` 同时也是 host 侧缓存键的三段——三者任一变了就是
/// 另一张图,必须重解。
pub preview: ScreenshotPreview,
}
/// 被跳过的条目来自哪个目录。
+89
View File
@@ -0,0 +1,89 @@
//! 一张截图的**预览**(M251 · SAVESHOTS,D54 第 2/6 条)。
//!
//! ## 像素为什么走 DTO
//!
//! D54 定「**不落盘缩略图**」:写 PNG 要编码器,而编码器只为一份磁盘缓存引入不划算。于是像素
//! 就直接躺在快照里跟着 IPC 面走,由壳层用 `iced::widget::image::Handle::from_rgba` 建句柄。
//! 代价是快照变大(60 张 × 160² × 4B ≈ 6MB),收益是零编码、零临时文件、零清理。
//!
//! ## 三种状态,一种都不许静默
//!
//! | 状态 | 什么时候 | 界面 |
//! |---|---|---|
//! | [`ScreenshotPreview::Ready`] | 解出来了 | 缩略图 |
//! | [`ScreenshotPreview::NotGenerated`] | 本次刷新超出 60 张上限(按 mtime 倒序只解前 60) | 「未生成预览」占位 |
//! | [`ScreenshotPreview::Failed`] | 超过 8 MiB / 读不出来 / 解不出来 | 占位 + **逐条**原因 |
//!
//! **不拼人读文案**(M161):这里只有结构与 [`ScreenshotThumbKind`],句子由 app 层按 i18n key 现求。
//!
//! ## 为什么失败也带 `detail`
//!
//! 「读不出来」有五种完全不同的原因(权限 / 被占用 / 文件已删 / 是目录 / 盘掉了),用户能自己
//! 处理的那几种全靠这句话。原文只进括号,不进主句(与 [`super::SavesFailureView`] 同一条口径)。
use serde::{Deserialize, Serialize};
/// 一张截图的预览状态。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ScreenshotPreview {
/// 还没生成:本次刷新超出上限(按 mtime 倒序只解前 60 张,上限常量在
/// `crate::host::saves::thumbs::MAX_PER_REFRESH`——策略住在解码那一侧,DTO 只表达状态)。
NotGenerated,
/// 解出来了(RGBA8,行主序,`width × height × 4`)。
Ready(ThumbnailView),
/// 没解出来(逐条可见的原因 + 原文)。
Failed(ScreenshotThumbFailure),
}
impl ScreenshotPreview {
/// 这一拍有没有可画的像素。
#[must_use]
pub fn thumbnail(&self) -> Option<&ThumbnailView> {
match self {
Self::Ready(view) => Some(view),
Self::NotGenerated | Self::Failed(_) => None,
}
}
}
/// 一张缩略图的像素(**未落盘**,见模块文档)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ThumbnailView {
/// 宽(像素)。
pub width: u32,
/// 高(像素)。
pub height: u32,
/// RGBA8 字节,行主序,长度必须是 `width × height × 4`。
pub pixels: Vec<u8>,
}
impl ThumbnailView {
/// 像素数与宽高对得上吗(壳层建 `Handle` 之前**必须**成立:`from_rgba` 不校验长度,
/// 长度不对就是一张花屏图或者一次 panic 后的空窗)。
#[must_use]
pub fn is_consistent(&self) -> bool {
let expected = u64::from(self.width) * u64::from(self.height) * 4;
expected == self.pixels.len() as u64
}
}
/// 一张截图的预览为什么没出来(**每一种在 UI 上都有一句话**)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScreenshotThumbFailure {
/// 大类。
pub kind: ScreenshotThumbKind,
/// 底层原文(OS 错误 / 解码器原文);只进括号,不进主句。
pub detail: String,
}
/// 预览失败的大类。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ScreenshotThumbKind {
/// 单张超过解码上限(8 MiB):**不解码**,直接跳过(D54 第 5 条)。
TooLarge,
/// 读不出来(不存在 / 无权限 / 被占用)。
Unreadable,
/// 读到了但解不出来(坏 PNG / 不支持的格式)。
Decode,
}
+66
View File
@@ -18,6 +18,12 @@ fn view() -> SavesListView {
file: "2024-05-01_10.00.00.png".to_string(),
bytes: 1024,
modified_ms: None,
// M251:像素真的能过 IPC(4 字节 = 1×1 RGBA),这是「不落盘直接交给壳层」的前提。
preview: ScreenshotPreview::Ready(ThumbnailView {
width: 1,
height: 1,
pixels: vec![0x2E, 0x7D, 0x4F, 0xFF],
}),
}],
skipped: vec![SavesSkipView {
area: SavesArea::Screenshots,
@@ -121,6 +127,66 @@ fn empty_means_no_worlds_and_no_screenshots() {
file: "a.png".to_string(),
bytes: 0,
modified_ms: None,
preview: ScreenshotPreview::NotGenerated,
});
assert!(!view.is_empty());
}
/// 预览的三种状态都要能过 IPC,且**失败原因逐条带得回来**(M251 · D54 第 6 条)。
#[test]
fn previews_round_trip_in_every_state() {
let failure = ScreenshotThumbFailure {
kind: ScreenshotThumbKind::TooLarge,
detail: "9000000 > 8388608 字节".to_string(),
};
for preview in [
ScreenshotPreview::NotGenerated,
ScreenshotPreview::Ready(ThumbnailView {
width: 2,
height: 1,
pixels: vec![1, 2, 3, 4, 5, 6, 7, 8],
}),
ScreenshotPreview::Failed(failure),
] {
let text = serde_json::to_string(&preview).expect("序列化");
assert_eq!(
serde_json::from_str::<ScreenshotPreview>(&text).expect("反序列化"),
preview
);
}
}
/// 「像素长度与宽高对得上」这条判据本身(壳层建 `Handle` 前要它成立)。
#[test]
fn thumbnail_consistency_catches_a_short_buffer() {
let good = ThumbnailView {
width: 2,
height: 2,
pixels: vec![0; 16],
};
assert!(good.is_consistent());
let short = ThumbnailView {
pixels: vec![0; 15],
..good.clone()
};
assert!(!short.is_consistent(), "少一个字节也必须判不一致");
}
/// 只有 `Ready` 才有像素可画(另外两支都是占位,界面不许把它们当成「黑图」)。
#[test]
fn only_ready_hands_out_pixels() {
assert!(ScreenshotPreview::Ready(ThumbnailView {
width: 1,
height: 1,
pixels: vec![0, 0, 0, 255],
})
.thumbnail()
.is_some());
assert!(ScreenshotPreview::NotGenerated.thumbnail().is_none());
assert!(ScreenshotPreview::Failed(ScreenshotThumbFailure {
kind: ScreenshotThumbKind::Unreadable,
detail: "access denied".to_string(),
})
.thumbnail()
.is_none());
}