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:
1 parent
798bfb279b
commit
843f6d08f9
21 files changed
+1519
-360
No files matched your search
@@ -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::{
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
/// 被跳过的条目来自哪个目录。
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
Reference in new issue
Block a user