|
| 1 | +# v0.11.7 设计:图文会话——截屏导入 + 跨链路采集体验同步 |
| 2 | + |
| 3 | +> 状态:已批准(用户确认方案 A + kind 字段 + 改进采纳,2026-08-22) |
| 4 | +> 依据:课堂助手现有采集动线(实时捕获 / 视频文件导入 / 学习素材)· ADR-004(会话数据模型)· ADR-008(文件导入)· ADR-015(屏卡体系) |
| 5 | +> 痛点:图文类内容(网页/PDF/图文教程)无采集路径——视频导入要求视频文件、实时捕获面向视频窗口,图文场景只能靠"选图片"素材走无会话流水线,无法形成会话、无法事后复盘 |
| 6 | +> 范围:v0.11.7(排期于 v0.11.6 AI 平台化之后) |
| 7 | +> 版本规划:本 spec 配套 [v0.11.7 版本文档](../../versions/v0.11.7.md) 与 [v0.11 系列总览](../../versions/v0.11.md) 更新 |
| 8 | +
|
| 9 | +## 一、总览 |
| 10 | + |
| 11 | +课堂助手新增第三采集动线「图文采集」:用户连续框选截屏 → 组成一个 `kind=photo` 的图文会话(无音频)→ OCR 全图识别 → 会话列表可见、可转笔记。同步两项跨链路采集体验改进到视频导入(完成后直达会话页 + OCR 进度携带识别块数)。 |
| 12 | + |
| 13 | +``` |
| 14 | +图文采集面板(课堂助手左栏第三面板) |
| 15 | + ▶ 开始图文采集 → start_photo_session(创建 recording 会话) |
| 16 | + 📷 截屏 → capture_screen_snapshot(全屏快照 base64) |
| 17 | + → ScreenSelectOverlay 全屏遮罩框选 → canvas 裁剪 → PNG base64 |
| 18 | + → save_photo_capture(存图 + OCR + 落库,返回识别预览) |
| 19 | + ✅ 完成采集 → finish_photo_session(零截图删会话;否则 finish) |
| 20 | +``` |
| 21 | + |
| 22 | +硬约束(写入每层实现): |
| 23 | + |
| 24 | +- **数据主权**:截屏内容全部本地处理(GDI 捕获 → 本地 OCR → 本地存储),不出本机 |
| 25 | +- **三链路 OCR 口径统一**:图文链路与实时/导入链路同口径——OCR 输入缩至 960px 宽、bbox 反算回原图坐标系、低分过滤(score<0.5) |
| 26 | +- **截图不过 UI 垃圾黑名单**:用户显式框选的内容即意图(与自动采帧链路不同),`is_ui_junk` 源头过滤不应用于图文截图 |
| 27 | +- **互斥**:图文采集与实时捕获互斥(共享屏幕内容动线,避免状态混乱) |
| 28 | +- **不留空壳**:完成采集时零截图 → 删除会话;崩溃残留(recording 超 24h)→ 列表加载时清扫为 failed |
| 29 | + |
| 30 | +## 二、数据模型(kind 字段) |
| 31 | + |
| 32 | +`sessions` 表新增 `kind` 列,标识会话类型: |
| 33 | + |
| 34 | +| 值 | 含义 | |
| 35 | +|----|------| |
| 36 | +| `NULL` | 旧数据 / 实时捕获 / 视频导入(默认,零回归) | |
| 37 | +| `'photo'` | 图文会话(截屏采集) | |
| 38 | + |
| 39 | +- **迁移**:`ensure_column(conn, "sessions", "kind", "ALTER TABLE sessions ADD COLUMN kind TEXT")`(幂等,`profile` 列同模式);建表 SQL 同步加列 |
| 40 | +- **类型穿透**:Rust `Session.kind: Option<String>` / `NewSession.kind`;前端 `Session.kind: string | null` |
| 41 | +- `create_session` 签名扩展:`kind: Option<&str>`(既有调用方传 `None`,零回归) |
| 42 | + |
| 43 | +## 三、后端命令(commands_photo.rs + photo_capture.rs) |
| 44 | + |
| 45 | +| 命令 | 职责 | 关键逻辑 | |
| 46 | +|------|------|----------| |
| 47 | +| `start_photo_session(title?)` | 创建图文会话 → 返回 session_id | 互斥检查(`live_session.active` / 已有图文采集中 → Err);默认标题「图文会话 MM-DD HH:mm」 | |
| 48 | +| `capture_screen_snapshot()` | 全屏快照 → base64 | `capture::gdi_capture(None)`(虚拟屏合并区域,多显示器支持)→ BGRA → RGB → JPEG q0.85 编码 → `{ base64, width, height }` | |
| 49 | +| `save_photo_capture(session_id, image_b64)` | 截图落库 + OCR | 解码校验 → `SessionImageStore.save_frame`(full+thumb,双指纹去重,命中返回"重复")→ 全图 OCR(缩 960px 有界等待)→ OCR 块落库(region=full,screen_id=None 走视图层聚类)→ 返回 `{ duplicated, block_count, preview: [String; ≤3] }` | |
| 50 | +| `finish_photo_session(session_id)` | 完成采集 | 零截图 → 删除会话(含图片库);否则 `finish_session` | |
| 51 | +| `discard_photo_session(session_id)` | 放弃采集 | 删除会话(级联子表 + 清理图片库目录) | |
| 52 | + |
| 53 | +### 3.1 关键实现点 |
| 54 | + |
| 55 | +- **OCR 输入**:保存原图尺寸到 `full/<ts>.webp`(屏卡显示清晰);OCR 用缩至 960px 的副本(复用 `import_frame::crop_and_scale` 同款缩放开销);bbox 按缩放比反算回原图坐标系(TD-046 同思路) |
| 56 | +- **OCR 失败降级**:不阻断截图保存——图已落盘,返回 `block_count=0` + 提示;预览取识别文本前 3 条(按置信度排序,去空) |
| 57 | +- **重复截图**:`save_frame` 双指纹命中 → 不落新 OCR 块,返回 `duplicated=true` 提示用户(防误触) |
| 58 | +- **残留清扫**:会话列表加载时 `UPDATE sessions SET status='failed' WHERE kind='photo' AND status='recording' AND started_at < now-24h`(24h 保证不误伤进行中的采集);清扫函数注入 `&Db` 可单测 |
| 59 | +- **互斥注册**:`AppState` 增 `photo_session: Arc<Mutex<Option<i64>>>`;`start_photo_session` 检查并占用;`finish/discard` 释放;实时捕获 `start_live` 增加图文占用检查 |
| 60 | + |
| 61 | +## 四、前端交互 |
| 62 | + |
| 63 | +### 4.1 图文采集面板(新组件 `PhotoCapturePanel.tsx`,课堂助手左栏) |
| 64 | + |
| 65 | +三态动线(仿实时捕获面板结构): |
| 66 | + |
| 67 | +``` |
| 68 | +空闲态: [▶ 开始图文采集] |
| 69 | +采集中: 会话 #12 采集中 · 已截 3 张 |
| 70 | + [🖼 最近截图缩略图](convertFileSrc 预览 full/<ts>.webp) |
| 71 | + [📷 截屏] [✅ 完成采集] [放弃] |
| 72 | +完成态: ✅ 已保存会话 #12(3 张截图) |
| 73 | + [去会话页查看](复用 onOpenSessions prop) |
| 74 | +``` |
| 75 | + |
| 76 | +- 状态机:`idle → collecting → done`;互斥时开始按钮禁用 + 原因提示 |
| 77 | +- 截屏后显示识别反馈:「已识别 8 块:预览文本…」;`duplicated=true` 显示「与上一张内容相同,未重复保存」 |
| 78 | +- 完成采集后 `onStatus` 上抛 + 「去会话页」直达 |
| 79 | + |
| 80 | +### 4.2 全屏框选遮罩(新组件 `ScreenSelectOverlay.tsx`) |
| 81 | + |
| 82 | +复用 `BoxSelectOverlay` 交互范式(拖框 + 确认浮层 + CSS 放大镜预览): |
| 83 | + |
| 84 | +- 快照图 **letterbox 缩放适配**显示(窗口内 fixed 覆盖,第一版不做真实全屏窗口——YAGNI) |
| 85 | +- **DPI 换算**:归一化坐标按**图像物理像素**换算(Windows 125%/150% 缩放下 GDI 捕获为物理像素、CSS 显示为逻辑像素——`selectedPixels = normalized × imagePhysicalSize`,不经 window.devicePixelRatio) |
| 86 | +- 确认 → canvas 裁剪(物理像素矩形)→ **PNG 编码**(无损,OCR 友好;快照显示用 JPEG 仅省流量)→ base64 → `save_photo_capture` |
| 87 | +- Esc / ✕ 取消关闭;保存中禁用交互防重复提交 |
| 88 | + |
| 89 | +## 五、列表/详情/转笔记适配 |
| 90 | + |
| 91 | +| 位置 | 改动 | |
| 92 | +|------|------| |
| 93 | +| `SessionListPanel` | 列表项类型徽标:`kind === "photo"` → 「📷 图文」(`Session.kind` 数据源) | |
| 94 | +| `SessionDetailPanel` | 空 segments 空态文案:「本会话无讲述内容(图文采集)」;屏卡流/画面要点/结构图**零改动**(`build_screens` 聚类 + `attach_images` 配图对导入链路已生效,图文会话同路径) | |
| 95 | +| 转笔记 | `session_to_note` / `preview_session_note` **零改动**(status=finished 即可,纯 OCR 拼接);实现期补 `filter_note` 空 segments 测试验证(不 panic、产出画面要点笔记) | |
| 96 | + |
| 97 | +## 六、跨链路采集体验同步(视频导入) |
| 98 | + |
| 99 | +| # | 同步项 | 改动 | |
| 100 | +|---|--------|------| |
| 101 | +| 1 | 导入完成后「去会话页」直达 | `VideoImportPanel` 增加 `onOpenSessions` prop;完成态文案旁加直达按钮(与图文面板共用交互) | |
| 102 | +| 2 | OCR 进度携带识别块数 | `import.rs` ocr 阶段进度消息「画面识别 x/y」→「画面识别 x/y · 已识别 N 块文字」(累计块数经 `&mut` 计数传入 `ocr_keyframes` 回调) | |
| 103 | + |
| 104 | +明确**不同步**(核实结论): |
| 105 | + |
| 106 | +- PNG 无损:实时链路原始帧直喂 OCR、视频导入 ffmpeg 抽帧 PNG——天然无损,仅图文链路(canvas 裁剪环节)需要 |
| 107 | +- 960px 缩放:三链路已具备(`live_frame_process.rs MAX_OCR_WIDTH=960` / `import_frame.rs crop_and_scale 960`)——图文链路遵循既有口径即可 |
| 108 | +- 重复提示:自动链路静默去重(双指纹/帧间文本去重)与手动链路(提示用户)交互性质不同 |
| 109 | +- DPI 换算:实时链路窗口矩形已用物理像素、屏卡框选为图像坐标——各链路已正确 |
| 110 | + |
| 111 | +## 七、错误处理与降级 |
| 112 | + |
| 113 | +| 场景 | 行为 | |
| 114 | +|------|------| |
| 115 | +| 截屏失败(GDI 不可用/远程桌面) | 遮罩不打开,状态条显示原因,会话不受影响 | |
| 116 | +| 保存失败(解码失败/超预算) | 提示重试;不破坏会话状态 | |
| 117 | +| OCR 失败/超时 | 图已落盘,`block_count=0` + 提示(下张截图重试语义) | |
| 118 | +| base64 解码失败 | 前置拒绝(可诊断错误,不落盘垃圾——`capture_fragment` 同模式) | |
| 119 | +| 应用崩溃残留 recording | 24h 清扫 → failed(列表加载时) | |
| 120 | +| 采集完成零截图 | 删除会话(不留空壳) | |
| 121 | + |
| 122 | +## 八、测试计划 |
| 123 | + |
| 124 | +**Rust(内存库 + 临时目录端到端,仿 `commands_refine_inner_tests` 范式)** |
| 125 | + |
| 126 | +| # | 用例 | |
| 127 | +|---|------| |
| 128 | +| 1 | `save_photo_capture` 端到端:base64 → 存图(full+thumb)→ OCR 块落库 → `build_screens` 聚类出屏卡 | |
| 129 | +| 2 | 零截图 `finish_photo_session` → 会话删除(含图片目录清理) | |
| 130 | +| 3 | 互斥:live active 时 `start_photo_session` 拒绝;图文占用时 `start_live` 拒绝;`finish/discard` 释放占用 | |
| 131 | +| 4 | 残留清扫:kind=photo+recording+超 24h → failed;未超时/非 photo 不误伤 | |
| 132 | +| 5 | `filter_note` 空 segments:纯 OCR 会话产出画面要点笔记(不 panic、非空) | |
| 133 | +| 6 | `create_session` kind 往返 + `ensure_column` 幂等(旧库迁移零回归) | |
| 134 | +| 7 | `save_photo_capture` 重复截图(同内容双指纹)→ duplicated=true,不重复落块 | |
| 135 | + |
| 136 | +**前端(Vitest)** |
| 137 | + |
| 138 | +- `PhotoCapturePanel` 状态机(idle/collecting/done + 互斥禁用) |
| 139 | +- 坐标换算纯函数(归一化 → 物理像素,含非整数 DPI 边界) |
| 140 | + |
| 141 | +## 九、文件清单 |
| 142 | + |
| 143 | +**新增** |
| 144 | + |
| 145 | +- `app/src-tauri/src/photo_capture.rs` — 截图保存 + OCR + 落库编排(业务模块,纯逻辑可测) |
| 146 | +- `app/src-tauri/src/commands_photo.rs` — 5 命令 + 互斥 + 残留清扫 |
| 147 | +- `app/src/components/PhotoCapturePanel.tsx` — 图文采集面板 |
| 148 | +- `app/src/components/ScreenSelectOverlay.tsx` — 全屏框选遮罩 |
| 149 | + |
| 150 | +**修改** |
| 151 | + |
| 152 | +- `app/src-tauri/src/db_migrations.rs` — sessions 加 kind 列(建表 + ensure_column) |
| 153 | +- `app/src-tauri/src/types.rs` / `db_sessions.rs` — Session.kind / NewSession.kind / create_session 扩展 |
| 154 | +- `app/src-tauri/src/lib.rs` — 注册 commands_photo |
| 155 | +- `app/src-tauri/src/commands_live.rs` — `start_live_session` 增加图文占用互斥检查 |
| 156 | +- `app/src-tauri/src/import.rs` — OCR 进度消息携带块数 |
| 157 | +- `app/src/types/session.ts` — Session.kind |
| 158 | +- `app/src/pages/ClassroomPage.tsx` — 挂载 PhotoCapturePanel |
| 159 | +- `app/src/components/VideoImportPanel.tsx` — onOpenSessions prop + 直达按钮 |
| 160 | +- `app/src/components/SessionListPanel.tsx` — 图文徽标 |
| 161 | +- `app/src/components/SessionDetailPanel.tsx` — 空转写空态(视检查结果) |
| 162 | + |
| 163 | +**文档** |
| 164 | + |
| 165 | +- `docs/versions/v0.11.7.md`(版本文档)+ `docs/versions/README.md` 索引 + `docs/versions/v0.11.md` 系列总览追加 |
| 166 | +- ADR:会话类型字段(扩展 ADR-004)——随实现提交 |
| 167 | + |
| 168 | +## 十、YAGNI 清单(明确不做) |
| 169 | + |
| 170 | +- 单张截图删除/回看网格 |
| 171 | +- 截图内容类型标注(表格/代码/公式——结构图体系后续可接线) |
| 172 | +- OCR 结果在会话内编辑 |
| 173 | +- 真实全屏窗口模式(letterbox 缩放第一版足够) |
| 174 | +- 图文会话音频/转写(图文场景无音频,segments 恒空) |
| 175 | + |
| 176 | +## 关联 |
| 177 | + |
| 178 | +- [v0.11.7 版本文档](../../versions/v0.11.7.md) · [v0.11 系列总览](../../versions/v0.11.md) · |
| 179 | +- 复用资产:`capture/gdi_capture.rs`(全屏捕获)· `capture/frame_diff.rs`(裁剪/downscale)· `import_frame.rs`(960px 口径)· `image_store.rs`(full/thumb + 双指纹)· `engine.rs`(OCR 有界等待)· `screens.rs`(屏卡聚类)· `BoxSelectOverlay.tsx`(框选范式)· `capture_fragment`(base64 落盘范式) |
0 commit comments