Repository Wiki
ChanIok/SpinningMomo

导入、资产管理与缩略图生成

围绕 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 参数)只补图、不删孤儿,避免误伤其他根目录下仍在使用的缓存。

架构

Loading diagram...

架构要点:

  • 单一数据源: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时间戳
cpp
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 的类型与全部候选源路径,后续修复时按顺序尝试,直到找到一个仍存在的源文件:

cpp
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++ 侧过滤:

cpp
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

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:

cpp
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)

Loading diagram...

统计口径(头文件注释):candidate_hashes 是"去重后的候选 hash 数;不是资产条数"——因为多条资产可能共享同一 hash。这正是按内容寻址带来的收益:一次补图覆盖所有重复副本。

启动期全局对账(补缺 + 清孤儿)

Loading diagram...

两个方向的失败被分别计数(failed_repairs 与 failed_orphan_deletions),调用方可以据此决定是否重试或上报;对账不会因为个别文件失败而中断整体流程。

用法示例

以下片段全部摘自仓库实际源码。

示例 1:生成单张缩略图(公开 API 签名)

cpp
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:缺失修复与全局对账入口

cpp
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:路径管理

cpp
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:对账统计结构(两种场景的差异)

cpp
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_sizestd::uint32_t480缩略图短边像素;repair_missing_thumbnails 的默认参数thumbnail.hpp#L75
force_overwriteboolfalse是否覆盖已存在的缓存文件;默认跳过使操作幂等thumbnail.hpp#L52-L64
WebP qualityfloat80.0fmake_thumbnail_webp_options() 内固定,编译期常量性质,未暴露为外部配置thumbnail.cpp#L41-L45
root_directorystd::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&)便于构造隔离测试环境。

相关链接

Sources

(3 files)
src/features/gallery/asset
src/features/gallery/download