Repository Wiki
ChanIok/SpinningMomo

照片提取:参数解析与用户记录(NUAN5)

照片提取(extensions::infinity_nikki::photo_extract)是 SpinningMomo 面向《无限暖暖》(Infinity Nikki)照片资产的相机参数提取子系统:它把本地扫描得到的照片候选资产批量提交给 NUAN5.PRO 解析服务,解析出相机参数后按 UID 分组落库,形成"用户记录 + 参数记录",供摄影面板与数据统计消费。

Purpose and Scope

本页覆盖 src/extensions/infinity_nikki/photo_extract/ 目录下的完整提取机制,包括:

  • 两个对外入口:手动任务 extract_photo_params 与静默增量 extract_photo_params_silent_incremental;
  • 共享的四阶段流水线 extract_photo_params_from_candidates(候选加载 → WorkerPool 并发准备 → 分批远端解析 → 按 UID 批量落库);
  • 进度上报协议(stage/percent/message 与节流策略);
  • 与 infra 层(候选查询、NUAN5 批量解析、upsert_photo_params_batch 落库)和 scan 层(prepare_photo_extract_entry)的协作边界;
  • 结果统计语义(saved / skipped / failed / clothes_rows_written)与错误收集上限。

不在本页范围(留给兄弟页面):

  • 候选照片如何被扫描/发现、prepare_photo_extract_entry 内部如何从图片字节提取载荷 —— 属于照片扫描与准备层(photo_extract/scan.* 与资产扫描流程),本页仅引用其接口;
  • NUAN5 字典(相机参数字典 spinning-momo-camera.json)的下载与缓存 —— 见 metadata_dict 相关页面;
  • NUAN5 角色 API(https://nuan5.pro/api/role/)与角色档案 —— 见角色档案(role_profile)页面;
  • 摄影面板 UI 如何展示结果 —— 见摄影面板(photography panel)相关页面;
  • 数据库迁移 003_infinity_nikki_params_nuan5_columns.sql 的具体 DDL —— 见数据库迁移页面(本页只说明其对参数列的影响)。

Overview

解决的问题

《无限暖暖》的照片文件内嵌了拍摄时的相机参数(镜头、焦距、姿态、服装等)。SpinningMomo 本地不实现解码,而是把这些照片数据交给第三方服务 NUAN5.PRO 解析(README 中明确致谢:"感谢 NUAN5.PRO 提供照片数据解析服务支持")。照片提取子系统负责:

  1. 选候选:从本地数据库挑出需要解析的照片资产(手动模式按 folder_id/only_missing 过滤;静默增量模式按变更集给出的 candidate_asset_ids 直查)。
  2. 并发准备:把候选资产在 core::worker_pool 中并发执行 scan::prepare_photo_extract_entry,产出带 uid 与 asset_id 的 PreparedPhotoExtractEntry。
  3. 批量解析:按固定批量(kExtractBatchSize = 50,且一批可跨多个 UID)调用 infra::extract_batch_photo_params,由其请求 NUAN5 并返回逐条记录。
  4. 按 UID 落库:解析成功的记录按 UID 分组,调用 infra::upsert_photo_params_batch 一次性批量 upsert,并统计写入的服装行数(clothes_rows_written)。

两种运行形态

形态入口函数候选来源UID 来源典型触发方
手动任务extract_photo_paramsinfra::load_candidate_assets(request)(folder_id + only_missing,默认 only_missing=true)资产自带 UID,或 uid_override 强制覆盖用户在 UI 主动发起
静默增量extract_photo_params_silent_incrementalinfra::load_candidate_assets_by_ids(request.candidate_asset_ids)仅资产自带 UID(uid_override 为 std::nullopt)后台监测到照片变更后自动补解析

源码注释明确了两者的边界语义:

// 手动任务保留原语义:候选由 folder_id/only_missing 决定。 // 静默增量明确只解析本次变更集映射出的资产,不混入历史 missing。

执行层(extract_photo_params_from_candidates)只关心"候选集合",不关心候选如何产生 —— 这是把入口差异收敛到单一流水线的关键设计。

Architecture

Loading diagram...

设计要点:

  • 入口薄、流水线厚:两个入口各自只做参数校验与候选加载(manual_task / silent_incremental 作为 mode_tag 透传给日志),重逻辑全部下沉到 extract_photo_params_from_candidates,避免两套并行的提取路径。
  • CPU 与 IO 分离:prepare(本地字节处理)丢进 WorkerPool 并发执行;远端请求与落库留在协程里按序、按批执行。协程通过 slot_ready 原子数组按候选原始顺序"收割"结果,从而保证批量请求顺序稳定、进度可预测。
  • 批量边界统一:网络请求批量上限与 DB 落库批量都围绕 ParsedPhotoParamsBatchItem 组织,apply_batch_result 再按 UID 重新分组调用 upsert_photo_params_batch,使一次网络往返可覆盖多用户记录。

Core Flow:四阶段流水线详解

extract_photo_params_from_candidates 是整个子系统的核心,它的完整控制流如下(含并发与节流细节):

Loading diagram...

阶段 1:候选加载与前置校验

两个入口先做硬校验,再进入流水线:

  • !app_state.database → std::unexpected("Database is not initialized");
  • 手动模式:only_missing = request.only_missing.value_or(true);uid_override 若存在但为空串 → std::unexpected("UID override is empty");
  • 候选加载失败直接把 load_candidate_assets* 的错误原样返回;
  • 候选为空时跳过全部阶段,直接 stage="completed", percent=100.0, message="No candidate assets",返回零计数的 InfinityNikkiExtractPhotoParamsResult。

阶段 2:WorkerPool 并发准备

这是最精巧的一段并发设计。实现里为每个候选维护两个共享状态:

  • outcomes(shared_ptr<vector<PrepareTaskOutcome>>):每个槽位写入 entry 或 error;
  • slot_ready(unique_ptr<atomic<bool>[]>):每槽完成标志,store(true, memory_order_release);
  • completed_prepare(shared_ptr<atomic<size_t>>):全局已完成计数,用于把 scanned_count 映射到进度。
cpp
1auto* slot_ready_ptr = slot_ready.get(); 2for (std::size_t index = 0; index < candidate_count; ++index) { 3 auto submitted = core::worker_pool::submit_task( 4 app_state, [outcomes, completed_prepare, slot_ready_ptr, candidate = candidates[index], 5 uid_override, index]() mutable { 6 auto prepared_result = scan::prepare_photo_extract_entry(candidate, uid_override); 7 if (prepared_result) { 8 (*outcomes)[index].entry = std::move(prepared_result.value()); 9 } else { 10 (*outcomes)[index].error = prepared_result.error(); 11 } 12 13 slot_ready_ptr[index].store(true, std::memory_order_release); 14 completed_prepare->fetch_add(1, std::memory_order_relaxed); 15 }); 16 17 if (!submitted) { 18 (*outcomes)[index].error = "Failed to submit prepare task to worker pool"; 19 slot_ready_ptr[index].store(true, std::memory_order_release); 20 completed_prepare->fetch_add(1, std::memory_order_relaxed); 21 } 22}

Source: photo_extract.cpp

要点:任务提交失败不会中断整轮提取,而是把该候选记为 error 并立即置位 slot_ready,让收割循环照常推进。流水线开始前还会检查 core::worker_pool::is_running(app_state),未运行则整体失败返回 "Worker pool is not available for photo extract preparation"。

收割循环:协程按候选原始顺序 wait_for_slot_ready,用 asio::steady_timer 每 kPollIntervalMillis = 50ms 轮询,同时把 completed_prepare 的值刷进 scanned_count 并节流上报进度。为什么用轮询而不是每任务完成回调?因为这里需要"顺序语义"(第 i 个槽必须先于 i+1 被消费,批量分组才稳定),轮询 + release/acquire 标志是最简单且无锁的实现。

阶段 3:分批发送远端

攒满 kExtractBatchSize = 50 条就 send_extract_batch:

cpp
1auto send_extract_batch( 2 core::AppState& app_state, std::vector<scan::PreparedPhotoExtractEntry> batch, 3 InfinityNikkiExtractPhotoParamsResult& result, ExtractProgressState& progress, 4 const std::function<void(const InfinityNikkiExtractPhotoParamsProgress&)>& progress_callback) 5 -> asio::awaitable<void> { 6 if (batch.empty()) { 7 co_return; 8 } 9 std::unordered_set<std::string> uid_set; 10 for (const auto& entry : batch) { 11 uid_set.insert(entry.uid); 12 } 13 Logger().debug("extract_photo_params: sending batch (unique_uids={}, count={})", uid_set.size(), 14 batch.size()); 15 BatchExtractOutcome batch_outcome; 16 batch_outcome.result = co_await infra::extract_batch_photo_params(app_state, batch); 17 apply_batch_result(app_state, batch, batch_outcome, result, progress, progress_callback); 18}

Source: photo_extract.cpp

日志刻意打印 unique_uids 与 count 两个数:一批可同时包含多个 UID 的照片,这是"按用户记录"组织数据的前提。

阶段 4:消费结果与按 UID 落库

apply_batch_result 是失败语义的核心:

cpp
1if (!batch_outcome.result) { 2 Logger().error("apply_batch_result: extract batch failed: {}", batch_outcome.result.error()); 3 fail_all(batch_outcome.result.error()); 4 return; 5} 6 7std::unordered_map<std::string, std::vector<infra::ParsedPhotoParamsBatchItem>> items_by_uid; 8 9auto& records = batch_outcome.result.value(); 10for (std::size_t index = 0; index < entries.size(); ++index) { 11 const auto& entry = entries[index]; 12 if (!records[index].record.has_value()) { 13 auto reason = records[index].error_message.value_or("photo params unrecognized"); 14 mark_candidate_failed(result, progress, entry.asset_id, 15 std::format("API returned null ({})", reason)); 16 continue; 17 } 18 19 items_by_uid[entry.uid].push_back(infra::ParsedPhotoParamsBatchItem{ 20 .asset_id = entry.asset_id, 21 .record = std::move(*records[index].record), 22 }); 23}

Source: photo_extract.cpp

分三层失败粒度:

层级触发条件结果处理
整批失败extract_batch_photo_params 返回 errorfail_all:批内全部候选 mark_candidate_failed,跳过落库
单条失败records[index].record 为空该 asset_id 记 failed,原因 "API returned null (…)",其余照常
落库失败upsert_photo_params_batch(uid, items) 失败该 UID 组内全部条目记 failed 并提前 return(放弃本批剩余 UID 组)

落库成功后统计累加:

cpp
1std::size_t total_saved = 0; 2std::int32_t total_clothes = 0; 3for (const auto& [uid, items] : items_by_uid) { 4 if (items.empty()) { 5 continue; 6 } 7 auto save_result = infra::upsert_photo_params_batch(app_state, uid, items); 8 if (!save_result) { /* …该组全部 failed,return… */ } 9 total_saved += items.size(); 10 total_clothes += save_result.value(); 11}

Source: photo_extract.cpp

upsert_photo_params_batch 返回 std::expected<std::int32_t, std::string>,value() 即本组写入的服装参数行数,汇入 result.clothes_rows_written。注意"用户记录"的形态:items_by_uid 的键就是记录分组键,每个 UID 一次 upsert,天然对应"用户记录 + 该用户的多条照片参数"。

数据契约(infra 层)

流水线依赖的三个核心类型定义在 photo_extract/infra.hpp:

cpp
1struct ParsedPhotoParamsRecord { 2 std::optional<std::string> camera_params; 3 // …(NUAN5 解析出的其余参数字段) 4}; 5 6struct ParsedPhotoParamsBatchItem { 7 std::int64_t asset_id; 8 ParsedPhotoParamsRecord record; 9}; 10 11struct ExtractBatchPhotoParamsRecord { 12 std::optional<ParsedPhotoParamsRecord> record; 13 std::optional<std::string> error_message; 14};

Source: infra.hpp

infra.cpp 内部还有 NUAN5 原始 JSON 到 ParsedPhotoParamsRecord 的映射层(如 struct Nuan5Light、struct Nuan5RawTime、to_parsed_record(const Nuan5DecodedPhoto&)),把可选字段规整成统一记录后再进入落库。数据库侧,src/migrations/003_infinity_nikki_params_nuan5_columns.sql(迁移注册名 "Add nuan5 Infinity Nikki extract columns",版本 2.0.8.0)为参数列建表/加列;随后 2.0.9.0 迁移 "Rebuild Infinity Nikki user record as key-value" 将用户记录重建为键值形态 —— 这解释了为什么落库以 UID 为键、以 ParsedPhotoParamsBatchItem 列表为值。

进度上报协议

进度回调签名:const std::function<void(const InfinityNikkiExtractPhotoParamsProgress&)>&,结构包含 stage、current、total、percent(optional<double>)、message。上报由三个函数协作:

  • report_extract_progress:统一出口。percent 未提供且 total>0 时自动按 current/total 计算,并 clamp(0,100);回调为空则直接返回(允许无 UI 场景静默运行)。
  • calculate_processing_percent:处理阶段把 scanned_ratio 与 finalized_ratio 各取一半加权,映射到 [5.0, 99.0] 区间(kProcessingStartPercent/kProcessingEndPercent)。为什么是两段各占 50%? 因为流水线里"准备完成"与"解析落库完成"是两个独立推进的计数(scanned_count 由 WorkerPool 推进、finalized_count 由结果消费推进),取平均能单调且真实地反映整体进度,不会在某一段卡住时假死。
  • report_processing_progress:节流器。仅当 floor(percent) 比上次上报值严格更大,且距上次上报 ≥ kMinProgressReportIntervalMillis = 200ms 才上报(force=true 绕过两者,用于阶段切换和终态)。

阶段字符串与百分比语义:

stagepercent 区间含义
preparing固定 2.0正在加载候选(入口发出)
processing5.0 → 99.0并发准备 + 分批解析落库
completed固定 100.0结束,message 为 Done: saved=…, skipped=…, failed=…

处理阶段的 message 由 build_processing_message 生成:Scanned {s} / {t}, finalized {f} / {t}, saved {…}, skipped {…}, failed {…}。

结果统计语义

InfinityNikkiExtractPhotoParamsResult(定义于 extensions/infinity_nikki/types.hpp)的字段由三个计数器函数维护:

cpp
1auto mark_candidate_skipped(InfinityNikkiExtractPhotoParamsResult& result, 2 ExtractProgressState& progress, std::int64_t asset_id, 3 const std::string& reason) -> void { 4 result.skipped_count++; 5 result.processed_count++; 6 progress.finalized_count++; 7 add_error(result.errors, std::format("asset_id {} skipped: {}", asset_id, reason)); 8}

Source: photo_extract.cpp

计数器递增场景
candidate_count候选总数,流水线开始时一次性设置
saved_count / processed_count / clothes_rows_written仅在 upsert_photo_params_batch 成功后按组累加(mark_candidates_saved)
skipped_countprepare 阶段失败(mark_candidate_skipped),例如 payload 提取失败或任务提交失败
failed_countAPI 返回 null / 整批失败 / 落库失败(mark_candidate_failed)
errorsskipped/failed 的原因文本,上限 kMaxErrorMessages = 50(add_error 超限静默丢弃)

processed_count = saved + skipped + failed 是恒等式;最终日志以 mode={}, completed. candidates=… 汇总,mode 为 manual_task 或 silent_incremental。

常量与配置

本模块的调参全部是编译期常量,无运行时配置项(运行时开关由上层调用者/请求字段承担):

常量值作用
kMaxErrorMessages50result.errors 收集上限,防止海量失败撑爆结果
kExtractBatchSize50每次远端批量解析的最大条数(可跨多 UID)
kMinProgressReportIntervalMillis200进度上报最小间隔(毫秒)
kPollIntervalMillis50wait_for_slot_ready 轮询间隔
kPreparingPercent2.0preparing 阶段固定百分比
kProcessingStartPercent / kProcessingEndPercent5.0 / 99.0处理阶段百分比映射区间

请求侧可配置项(InfinityNikkiExtractPhotoParamsRequest):folder_id、only_missing(默认 true,即默认只补解析缺失参数的照片)、uid_override(覆盖候选 UID,用于纠错归档);InfinityNikkiSilentExtractPhotoParamsRequest 仅含 candidate_asset_ids。

API Reference

extract_photo_params(app_state, request, progress_callback) -> asio::awaitable<std::expected<InfinityNikkiExtractPhotoParamsResult, std::string>>

参数:

  • app_state (core::AppState&):全局状态,必须已初始化 database,且 WorkerPool 处于运行态。
  • request (const InfinityNikkiExtractPhotoParamsRequest&):folder_id + only_missing + 可选 uid_override。
  • progress_callback:进度回调,可为空。

返回: 成功时返回完整统计的 Result;失败时 std::unexpected,错误串可能是:Database is not initialized、UID override is empty、Worker pool is not available for photo extract preparation、候选加载错误。

extract_photo_params_silent_incremental(app_state, request, progress_callback) -> 同上

静默增量入口,按 candidate_asset_ids 精确解析,不接受 uid_override。注意语义差异:它不会把历史 missing 混入本批(源码注释明确),因此适合作为"变更即解析"的后台任务。

内部协作接口(infra 层)

  • infra::load_candidate_assets(app_state, request) -> std::expected<std::vector<scan::CandidateAssetRow>, std::string>
  • infra::load_candidate_assets_by_ids(app_state, ids) -> 同上
  • infra::extract_batch_photo_params(app_state, batch) -> asio::awaitable<std::expected<std::vector<ExtractBatchPhotoParamsRecord>, std::string>>(与入参 batch 下标一一对应)
  • infra::upsert_photo_params_batch(app_state, uid, items) -> std::expected<std::int32_t, std::string>(返回写入服装行数)
  • scan::prepare_photo_extract_entry(candidate, uid_override) -> std::expected<PreparedPhotoExtractEntry, std::string>

Failure Modes, Edge Cases & Concurrency

失败模式分级(详见 Core Flow 阶段 4 表格):整批失败 / 单条失败 / UID 组落库失败三级;任一失败都不会终止整个提取任务 —— 除落库失败会放弃当前批剩余 UID 组(提前 return)外,后续批照常发送。这是"尽力而为 + 明细可查"的取舍:错误逐条进入 result.errors(带上 asset_id),供 UI 呈现。

并发要点:

  • slot_ready 用 store(true, release) / load(acquire) 建立跨线程的 happens-before,保证协程读到 true 时 outcomes[index] 已完整可见;
  • completed_prepare 仅用于进度展示,用 relaxed 序即可;
  • 收割循环按候选原顺序进行,因此批量内容与发送顺序是确定性的(便于复现问题与日志比对);
  • WorkerPool 任务提交失败被降级为该候选的 skipped,不抛异常、不中断。

边界情况:

  • 空候选集合:立即 completed,零副作用;
  • candidate_count 很大时,slot_ready 是按候选数分配的 atomic<bool> 数组(unique_ptr 持有),内存开销与候选数线性;
  • 进度百分比在 total<=0 时直接给 100.0(calculate_processing_percent 的防御分支),避免除零。

对第三方的节制:项目对 NUAN5 的请求保持节流策略(角色 API 侧源码注释写明"与现有 NUAN5.PRO 照片解析请求保持同等节制"),照片解析侧通过 50 条/批的聚合降低请求次数。

Performance & Operational Notes

  • 吞吐瓶颈在网络往返:本地 prepare 在 WorkerPool 并行,流水线实际按批串行等待远端响应;如需提速,方向是批内并行度或增大 kExtractBatchSize(当前为编译期常量)。
  • 进度节流成本可控:200ms 最小间隔 + 百分比去重,保证 UI 在上万候选时也不会被回调淹没。
  • 可观测性:Logger 输出模式级日志 —— 开始(mode=…, found N candidate assets)、每批 debug(unique_uids=…, count=…)、整批失败 warn、单条/落库失败 error、完成汇总 info。排障时按 mode 与 asset_id 过滤即可。
  • 数据一致性:每 UID 一次 upsert_photo_params_batch 是最小落库单元,组内失败整组标记 failed,不会出现"半组写入但仍计 saved"的情况。

Extension Points

  • 新增运行形态:仿照 extract_photo_params_silent_incremental,写一个新的候选加载入口 + 复用 extract_photo_params_from_candidates,即可接入任意来源的候选(如云端同步集、过滤器)。
  • 更换解析后端:infra::extract_batch_photo_params 是唯一远端触点,返回与入参下标对齐的 ExtractBatchPhotoParamsRecord 向量;替换实现只需保持这一契约。
  • 扩展统计维度:ParsedPhotoParamsRecord(infra.hpp)是 NUAN5 字段的统一落点,新增字段需同步迁移(参考 003_infinity_nikki_params_nuan5_columns.sql 及后续 key-value 用户记录重建迁移)与 to_parsed_record 映射。
  • 候选扫描与本地准备:src/extensions/infinity_nikki/photo_extract/scan.*
  • NUAN5 基础设施与落库:src/extensions/infinity_nikki/photo_extract/infra.cpp / infra.hpp
  • NUAN5 相机字典下载与缓存:src/extensions/infinity_nikki/metadata_dict.cpp
  • NUAN5 角色 API:src/extensions/infinity_nikki/role_profile.cpp
  • 数据库迁移(nuan5 列 / 用户记录 key-value 化):src/core/migration/scripts/scripts.cpp、src/migrations/003_infinity_nikki_params_nuan5_columns.sql
  • UI 展示层:src/ui/photography_panel/photography_panel.cpp