导入、资产管理与缩略图生成
围绕 features/gallery/asset 模块构建的资产核心能力:把磁盘上的照片/视频登记为数据库中的 assets 记录(导入与资产管理),并为每条资产维护一份按文件哈希寻址的 WebP 缩略图缓存,包括生成、缺失修复与启动期全局对账。
目的与范围
本页覆盖 src/features/gallery/asset 目录下的资产模型、缩略图生成管线(thumbnail.hpp / thumbnail.cpp 的全部公开 API 与核心内部流程)、assets 数据表结构,以及缩略图缓存目录的读写与生命周期。
留给兄弟页面的内容(本页只做边界引用,不展开):
- 颜色提取与色彩过滤:
features/gallery/color,见相关目录。 - 资产下载与 ZIP 打包:
features/gallery/download。 - 剪贴板能力:
features/gallery/clipboard。 - 地图视图对缩略图的消费方式:
web/src/features/map。 - 通用图像编解码底座(WIC/WebP 封装):
utils/image。
说明:
asset/service.cpp、asset/repository.cpp、asset/query_support.cpp属于本模块的资产登记与查询实现,本次文档编写只读取了thumbnail的完整实现与assets表的 SQL 投影;对未逐行读取的部分,本文以"实现细节未在本次读取范围内"标注,不做臆测。
概述
SpinningMomo 的图库(gallery)以文件系统中的目录树为真实数据源,以 SQLite(经 core/database 封装)中的 assets 表为索引。导入/资产管理层负责把照片与视频登记为带元数据(尺寸、大小、MIME、哈希、评级、审核标记、描述等)的资产记录;缩略图层则把这些资产投影为一份按内容哈希寻址的磁盘缓存:
- 为什么按 hash 寻址:同一张图片(相同内容)即使出现在多个路径下,也只生成/存储一份缩略图。
thumbnail.cpp中内部结构ExpectedThumbnailEntry的注释明确写道:"一个 hash 可能对应多个源文件路径;修复时只需找到其中任意一个仍存在的源文件。" - 为什么启动时对账:用户可能手动删除缓存目录或移动原图。全局对账同时处理两个方向的偏差:补回缺失(expected − existing)与清理孤儿(existing − expected)。
- 为什么支持局部修复:针对单个 root 目录的局部修复(
root_directory参数)只补图、不删孤儿,避免误伤其他根目录下仍在使用的缓存。
架构
架构要点:
- 单一数据源:
AppState(core/state/app_state.hpp)贯穿所有 API,携带数据库连接与缩略图目录等应用级状态;缩略图模块不持有自己的全局状态,纯函数式地通过参数接收依赖(如utils::image::WICFactory&),便于测试与并发隔离。 - 数据库层无专用仓储:缩略图的候选查询直接走
core::database::query<Asset>(app_state, sql)泛型查询,复用features/gallery/types.hpp中的Asset结构做行映射,避免为一次性对账引入额外仓储类型。 - 错误处理统一为
std::expected:所有公开函数返回std::expected<T, std::string>,错误以带前缀的可读字符串向上传播(如"Failed to query thumbnail candidates: ..."),不使用异常穿越模块边界。 - 前端解耦:渲染端(如地图模块)通过
getThumbnailBaseUrl()(web/src/features/map/domain/defaults.ts第 1 行引入)获得缩略图 HTTP 基地址,按同一套 hash 命名规则拼 URL,C++ 侧与 Web 侧只通过"缓存目录 + 命名规则"这一契约耦合。
主内容:实现走读
资产数据模型(assets 表)
缩略图候选查询 query_thumbnail_candidates 的 SELECT 投影完整暴露了 assets 表的列集合(见下方代码块)。其中与缩略图能力直接相关的三列是 type(仅 'photo' / 'video' 参与)、hash(非空才可作为候选)、path(用于回源重建)。
| 列 | 在缩略图管线中的角色 |
|---|---|
id | 资产主键 |
name, path | 显示名与原始文件路径(修复时回源) |
type | 只处理 'photo' / 'video' |
hash | 内容哈希;缩略图文件名与去重键 |
width, height, size, extension, mime_type | 元数据,供前端展示 |
rating, review_flag, description | 用户标注 |
folder_id, root_id, relative_path | 目录树归属(root_id/relative_path 在该查询中以 NULL 投影) |
file_created_at, file_modified_at, created_at, updated_at | 时间戳 |
1auto query_thumbnail_candidates(core::AppState& app_state)
2 -> std::expected<std::vector<Asset>, std::string> {
3 std::string sql = R"(
4 SELECT id, name, path, type,
5 NULL AS dominant_color_hex,
6 rating, review_flag,
7 description, width, height, size, extension, mime_type, hash,
8 NULL AS root_id, NULL AS relative_path, folder_id,
9 file_created_at, file_modified_at,
10 created_at, updated_at
11 FROM assets
12 WHERE type IN ('photo', 'video')
13 AND hash IS NOT NULL
14 AND hash != ''
15 AND path IS NOT NULL
16 AND path != ''
17 )";
18
19 auto result = core::database::query<Asset>(app_state, sql);
20 if (!result) {
21 return std::unexpected("Failed to query thumbnail candidates: " + result.error());
22 }
23
24 return result.value();
25}Source: thumbnail.cpp
设计意图:过滤条件把"没有哈希"或"没有路径"的行在 SQL 层就排除掉,因为这类资产既无法定位缓存文件、也无法回源重建;NULL AS dominant_color_hex 等占位投影说明该查询刻意复用 Asset 行映射类型,而不需要为对账定义新的轻量 DTO——对账只关心 hash/type/path。
期望集合的构建(按 hash 去重)
collect_expected_thumbnail_entries 把候选行折叠成 hash → ExpectedThumbnailEntry 的映射。每个条目记录该 hash 的类型与全部候选源路径,后续修复时按顺序尝试,直到找到一个仍存在的源文件:
1// “一个缩略图 hash 应该如何被满足”的最小工作单元。
2// 一个 hash 可能对应多个源文件路径;修复时只需找到其中任意一个仍存在的源文件。
3struct ExpectedThumbnailEntry {
4 std::string hash;
5 std::string type;
6 std::vector<std::filesystem::path> source_paths;
7};
8
9// 内部汇总结构:只关注“缺失缩略图补回”这一件事。
10struct MissingThumbnailRepairSummary {
11 int candidate_hashes = 0;
12 int missing_thumbnails = 0;
13 int repaired_thumbnails = 0;
14 int failed_repairs = 0;
15 int skipped_missing_sources = 0;
16};Source: thumbnail.cpp
局部修复时的 root 过滤发生在收集阶段而非 SQL 阶段——SQL 不带目录条件全量拉取,随后用 utils::path::IsPathWithinBase 在 C++ 侧过滤:
1// 局部修复时允许只处理某个 root;全局对账则传 nullopt 表示不过滤。
2auto normalize_thumbnail_root_filter(std::optional<std::filesystem::path> root_directory)
3 -> std::expected<std::optional<std::filesystem::path>, std::string> {
4 if (!root_directory.has_value()) {
5 return std::optional<std::filesystem::path>{std::nullopt};
6 }
7
8 auto normalized_root_result = utils::path::NormalizePath(root_directory.value());
9 if (!normalized_root_result) {
10 return std::unexpected("Failed to normalize thumbnail repair root: " +
11 normalized_root_result.error());
12 }
13
14 return std::optional<std::filesystem::path>{normalized_root_result.value()};
15}Source: thumbnail.cpp
1 std::filesystem::path asset_path(asset.path);
2 if (normalized_root_directory.has_value() &&
3 !utils::path::IsPathWithinBase(asset_path, normalized_root_directory.value())) {
4 continue;
5 }Source: thumbnail.cpp
设计意图:先 NormalizePath 再比较,避免大小写/分隔符差异导致 root 过滤漏判;在 C++ 侧过滤而不是 SQL 侧,使得同一份候选查询可以同时服务局部修复与全局对账两种场景,避免维护两条 SQL。
缩略图生成与落盘
三条落盘路径共享同一套"hash → 缓存目录"的路径规则(ensure_thumbnail_path):
generate_thumbnail(...):从源文件(照片或视频封面帧)解码 → 缩放到short_edge_size(默认 480 短边)→ WebP 编码 → 落盘。save_thumbnail_from_bgra(...):从内存中的 BGRA 位图直接编码落盘。save_thumbnail_data(...):把已编码好的 WebP 字节(如视频封面帧)直接写入,路径规则与generate_thumbnail一致(头文件第 60 行注释)。
编码参数固定为质量 80:
1auto make_thumbnail_webp_options() -> utils::image::WebPEncodeOptions {
2 utils::image::WebPEncodeOptions options;
3 options.quality = 80.0f;
4 return options;
5}Source: thumbnail.cpp
force_overwrite 参数(默认 false)决定了已存在缓存是否被重写;默认跳过已存在的文件,使修复操作天然幂等且增量。
命名与反解
缓存文件名即 hash(.webp)。反向操作由 extract_hash_from_thumbnail 提供:从缩略图文件路径解析出 hash,全局对账在枚举磁盘 .webp 文件时用它把"实际存在集合"折回到 hash 域,与 DB 推导的"期望集合"做集合运算。
核心流程
缺失修复流程(repair_missing_thumbnails)
统计口径(头文件注释):candidate_hashes 是"去重后的候选 hash 数;不是资产条数"——因为多条资产可能共享同一 hash。这正是按内容寻址带来的收益:一次补图覆盖所有重复副本。
启动期全局对账(补缺 + 清孤儿)
两个方向的失败被分别计数(failed_repairs 与 failed_orphan_deletions),调用方可以据此决定是否重试或上报;对账不会因为个别文件失败而中断整体流程。
用法示例
以下片段全部摘自仓库实际源码。
示例 1:生成单张缩略图(公开 API 签名)
1// 缩略图生成
2auto generate_thumbnail(core::AppState& app_state, utils::image::WICFactory& wic_factory,
3 const std::filesystem::path& source_file, const std::string& file_hash,
4 std::uint32_t short_edge_size, bool force_overwrite = false)
5 -> std::expected<std::filesystem::path, std::string>;
6
7auto save_thumbnail_from_bgra(core::AppState& app_state, const std::string& file_hash,
8 const utils::image::BGRABitmapData& bitmap_data,
9 bool force_overwrite = false)
10 -> std::expected<std::filesystem::path, std::string>;
11
12// 落盘内存中的 WebP(视频封面帧等);路径规则与 generate_thumbnail 一致。
13auto save_thumbnail_data(core::AppState& app_state, const std::string& file_hash,
14 const utils::image::WebPEncodedResult& webp_data,
15 bool force_overwrite = false)
16 -> std::expected<std::filesystem::path, std::string>;Source: thumbnail.hpp
三条 API 覆盖了"源文件 → 缩略图"的全部入口形态:generate_thumbnail 处理可解码的源文件;save_thumbnail_from_bgra 处理已在内存中的位图;save_thumbnail_data 处理已经编码完成的 WebP 字节(典型来源是视频封面帧管线,模块内 include 了 utils/media/video_asset.hpp 佐证这一点)。
示例 2:缺失修复与全局对账入口
1auto repair_missing_thumbnails(core::AppState& app_state,
2 std::optional<std::filesystem::path> root_directory = std::nullopt,
3 std::uint32_t short_edge_size = 480)
4 -> std::expected<ThumbnailRepairStats, std::string>;
5
6// 启动后的全局缓存对账:
7// 1. 用 DB 推导“应存在的缩略图集合”
8// 2. 用磁盘枚举“实际存在的缩略图集合”Source: thumbnail.hpp
示例 3:路径管理
1// 路径管理
2auto ensure_thumbnails_directory_exists(core::AppState& app_state)
3 -> std::expected<void, std::string>;
4
5auto ensure_thumbnail_path(core::AppState& app_state, const std::string& file_hash)
6 -> std::expected<std::filesystem::path, std::string>;Source: thumbnail.hpp
ensure_thumbnails_directory_exists 是所有写入前的目录预检;ensure_thumbnail_path 是 hash → 绝对路径的唯一换算点,任何新增的落盘调用都应复用它而非自行拼接路径,以保证命名规则在全模块内一致。
示例 4:对账统计结构(两种场景的差异)
1// 仅用于“补缺失缩略图”场景的统计。
2// 这里不关心孤儿缩略图,因为局部修复不会删除它们。
3struct ThumbnailRepairStats {
4 // 去重后的候选 hash 数;不是资产条数。
5 int candidate_hashes = 0;
6 // 期望存在但当前磁盘上不存在的缩略图数。
7 int missing_thumbnails = 0;
8 // 本次实际补回成功的缩略图数。
9 int repaired_thumbnails = 0;
10 // 生成/写入失败的次数。
11 int failed_repairs = 0;
12 // 期望补图,但找不到任何可用原图源文件的次数。
13 int skipped_missing_sources = 0;
14};
15
16// 用于“全局缓存对账”场景的统计。
17// 启动时会同时关注:缺失缩略图补回 + 孤儿缩略图清理。
18struct ThumbnailCacheReconcileStats {
19 int expected_hashes = 0;
20 int existing_thumbnails = 0;
21 int missing_thumbnails = 0;
22 int repaired_thumbnails = 0;
23 int orphaned_thumbnails = 0;
24 int deleted_orphaned_thumbnails = 0;
25 int failed_repairs = 0;
26 int failed_orphan_deletions = 0;
27 int skipped_missing_sources = 0;
28};Source: thumbnail.hpp
(注:ThumbnailCacheReconcileStats 中各行字段名摘自源文件;为控制篇幅此处对第二结构体省略了逐字段中文注释,原始注释可在源文件第 26-47 行查看。)
配置项
| 项 | 类型 | 默认值 | 说明 | 来源 |
|---|---|---|---|---|
short_edge_size | std::uint32_t | 480 | 缩略图短边像素;repair_missing_thumbnails 的默认参数 | thumbnail.hpp#L75 |
force_overwrite | bool | false | 是否覆盖已存在的缓存文件;默认跳过使操作幂等 | thumbnail.hpp#L52-L64 |
WebP quality | float | 80.0f | make_thumbnail_webp_options() 内固定,编译期常量性质,未暴露为外部配置 | thumbnail.cpp#L41-L45 |
root_directory | std::optional<std::filesystem::path> | std::nullopt | 局部修复的根目录过滤;nullopt 表示全局 | thumbnail.hpp#L74 |
| 候选过滤条件 | SQL | — | type IN ('photo','video') AND hash 非空 AND path 非空 | thumbnail.cpp#L58-L62 |
API 参考
所有函数位于命名空间 features::gallery::asset::thumbnail,错误统一以 std::expected<T, std::string> 返回,不抛异常。
generate_thumbnail(app_state, wic_factory, source_file, file_hash, short_edge_size, force_overwrite) → std::expected<std::filesystem::path, std::string>
参数:
app_state(core::AppState&):应用状态(数据库、缓存目录等)。wic_factory(utils::image::WICFactory&):Windows Imaging Component 工厂,用于解码。source_file(const std::filesystem::path&):原图路径。file_hash(const std::string&):内容哈希,决定缓存文件名。short_edge_size(std::uint32_t):目标短边。force_overwrite(bool, 默认false):是否覆盖已有缓存。
返回: 成功时为写入的 .webp 绝对路径;失败时为可读错误字符串。
save_thumbnail_from_bgra(app_state, file_hash, bitmap_data, force_overwrite) → std::expected<std::filesystem::path, std::string>
从内存 BGRA 位图直接编码为 WebP 并落盘,跳过解码阶段。
save_thumbnail_data(app_state, file_hash, webp_data, force_overwrite) → std::expected<std::filesystem::path, std::string>
把已编码的 utils::image::WebPEncodedResult 直接写入缓存;路径规则与 generate_thumbnail 完全一致。
ensure_thumbnails_directory_exists(app_state) → std::expected<void, std::string>
预检/创建缓存目录;所有写入操作的前置条件。
ensure_thumbnail_path(app_state, file_hash) → std::expected<std::filesystem::path, std::string>
hash → 缓存文件路径的唯一换算点。
extract_hash_from_thumbnail(thumbnail_path) → std::optional<std::string>
从缓存文件路径反解 hash;无法解析时返回 nullopt。
repair_missing_thumbnails(app_state, root_directory = nullopt, short_edge_size = 480) → std::expected<ThumbnailRepairStats, std::string>
补回缺失缩略图(不删除孤儿)。局部模式仅处理 NormalizePath(root) 之下、且通过 IsPathWithinBase 校验的资产。
失败模式、边界与并发
- 错误传播:每个阶段失败都拼接前缀上下文(如
"Failed to normalize thumbnail repair root: ..."),最终以std::unexpected冒泡;不会因单条记录失败而中止整批对账。 - 重复内容去重:多条资产共享一个 hash 时只保留一份缩略图;统计口径
candidate_hashes明确"不是资产条数"。 - 源文件全灭:
source_paths中所有路径都不存在时计入skipped_missing_sources,而不是报错——这是可预期的人工状态(用户删除原图但 DB 记录未清)。 - 局部 vs 全局的删除语义差异:
ThumbnailRepairStats的注释明确"局部修复不会删除孤儿缩略图",避免在只看一个 root 的上下文中误删其他 root 仍在使用的缓存。 - 跨进程脏数据:缓存目录位于
%LocalAppData%\SpinningMomo,卸载器installer/CleanupAppDataRoot.js会在彻底卸载时递归删除整个目录(含 webview2、缩略图),因此缓存必须始终可重建——这正是启动对账存在的根本理由。 - 并发:模块不持有可变全局状态,依赖通过参数注入(
WICFactory&可复用);但仓库内未发现针对该模块的显式加锁代码,多线程并发调用同一 hash 的写路径时依赖force_overwrite=false的存在性检查来收敛,实现细节未在本次读取范围内确认。
性能与运维要点
- 增量幂等:默认
force_overwrite=false+ 存在性检查,使对账可以安全地重复执行而几乎零成本跳过已满足项。 - 固定质量 80 的 WebP:在体积与视觉质量间取常量平衡,避免暴露配置面;若需调整需改
make_thumbnail_webp_options()。 - 回源策略按需解码:只有缺失的 hash 才会触发源文件解码与编码,已满足项不产生 I/O。
- 运维观测:两个统计结构覆盖补图/删除的成功、失败、跳过全部分支,可直接用于启动日志或诊断输出。
扩展点
- 新增资产类型:在
query_thumbnail_candidates的type IN (...)中加入新类型,并在生成路径上提供对应解码器即可;ExpectedThumbnailEntry.type已预留类型字段。 - 自定义编码参数:
make_thumbnail_webp_options()是编码参数的唯一汇聚点,可在此接入运行时配置。 - 新的落盘来源:复用
ensure_thumbnail_path+save_thumbnail_*系列,保证命名一致性。 - 测试性:纯函数式依赖注入(
AppState&、WICFactory&)便于构造隔离测试环境。