Skip to content

Commit b13ead0

Browse files
committed
feat(ocr): 结构图命令层——批量/手动/列表/删除+停止后自动捕获
1 parent 0ca4fbe commit b13ead0

3 files changed

Lines changed: 227 additions & 0 deletions

File tree

‎app/src-tauri/src/commands_live.rs‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use std::sync::Arc;
1212
use std::time::Instant;
1313

1414
use serde::Serialize;
15+
use tauri::Emitter;
1516
use tauri::State;
1617

1718
use crate::commands::AppState;
@@ -100,10 +101,44 @@ pub async fn stop_live_session(state: State<'_, AppState>) -> Result<Option<i64>
100101
// 无结构区域 → 内部降级跳过——run_refine 已有完整降级链)
101102
if let Some(sid) = stopped {
102103
trigger_auto_refine(&state, sid);
104+
// v0.7.7(REQ-182):停止后自动结构图捕获(非线性结构"图像即产物";
105+
// 无屏/无图/版面空 → 内部降级跳过;去重幂等可重跑)
106+
trigger_auto_capture_structures(&state, sid);
103107
}
104108
Ok(stopped)
105109
}
106110

111+
/// 停止后自动结构图捕获触发(v0.7.7 REQ-182)。
112+
///
113+
/// @ai-context: 与 trigger_auto_refine 并列的停止后批处理——纯本地规则管线
114+
/// (版面分析 + 启发式过滤),无模型依赖;失败仅留日志不阻断
115+
/// (捕获是增强非主链路)。
116+
fn trigger_auto_capture_structures(state: &AppState, session_id: i64) {
117+
let state: AppState = (*state).clone();
118+
tauri::async_runtime::spawn_blocking(move || {
119+
let now = std::time::SystemTime::now()
120+
.duration_since(std::time::UNIX_EPOCH)
121+
.map(|d| d.as_millis() as u64)
122+
.unwrap_or(0);
123+
match crate::structure_capture::capture_session_structures(
124+
&state.db,
125+
&state.data_dir,
126+
session_id,
127+
now,
128+
) {
129+
Ok(summary) if summary.captured > 0 => {
130+
eprintln!(
131+
"[Structures] 会话 {} 停止:自动捕获 {} 张结构图(扫描 {} 屏)",
132+
session_id, summary.captured, summary.screens_scanned
133+
);
134+
let _ = state.app.emit("session:structures-updated", &summary);
135+
}
136+
Ok(_) => {} // 无结构区域/无图:静默跳过(常态)
137+
Err(e) => eprintln!("[Structures] 会话 {} 自动捕获失败: {}", session_id, e),
138+
}
139+
});
140+
}
141+
107142
/// 停止后自动结构精修触发(v0.7.3 REQ-159)。
108143
///
109144
/// @ai-context: 前置检查:会话存在 region_kind=table/formula 的 OCR 块才触发
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
//! 结构图命令层(REQ-182/183/184 / v0.7.7):批量捕获 / 手动框选 / 列表 / 删除。
2+
//!
3+
//! @ai-context: IPC 安全边界:全部入参校验(session_id>0、归一化坐标 0-1、
4+
//! 最小尺寸);文件访问限定会话图片目录(data_dir/session-images/<id>,
5+
//! AGENTS.md 安全红线);事件 session:structures-updated 驱动前端图库刷新。
6+
//! @ai-context: 停止后自动触发与手动重跑共用同一管线(capture_session_structures)——
7+
//! 去重幂等,重跑不重复入库。
8+
9+
use tauri::Emitter;
10+
use tauri::State;
11+
12+
use crate::commands::AppState;
13+
use crate::db_structures::StructureImageRecord;
14+
use crate::structure_capture::CaptureSummary;
15+
16+
/// 会话图片目录(与 commands_images 同约定)。
17+
fn session_images_dir(data_dir: &std::path::Path, session_id: i64) -> std::path::PathBuf {
18+
data_dir.join("session-images").join(session_id.to_string())
19+
}
20+
21+
/// 批量捕获结构图(停止后自动触发 / 图库「重新捕获」重跑;幂等)。
22+
#[tauri::command]
23+
pub async fn capture_session_structures(
24+
state: State<'_, AppState>,
25+
session_id: i64,
26+
) -> Result<CaptureSummary, String> {
27+
if session_id <= 0 {
28+
return Err("无效的会话 id".to_string());
29+
}
30+
let state: AppState = (*state).clone();
31+
let summary = tauri::async_runtime::spawn_blocking(move || {
32+
let now = std::time::SystemTime::now()
33+
.duration_since(std::time::UNIX_EPOCH)
34+
.map(|d| d.as_millis() as u64)
35+
.unwrap_or(0);
36+
crate::structure_capture::capture_session_structures(
37+
&state.db,
38+
&state.data_dir,
39+
session_id,
40+
now,
41+
)
42+
})
43+
.await
44+
.map_err(|e| format!("任务调度失败: {}", e))?
45+
.map_err(|e| e.to_string())?;
46+
// 图库刷新事件(前端 ImageGallery 监听)
47+
let _ = state.app.emit("session:structures-updated", &summary);
48+
Ok(summary)
49+
}
50+
51+
/// 手动框选截取(REQ-184):屏卡全帧图归一化坐标 → 裁剪入库。
52+
///
53+
/// @ai-context: 入参归一化 0-1(前端框选与图同坐标系);像素换算按 image_ref
54+
/// 解码帧的宽高;越界钳制;<32×32 拒绝(误触防护);原样裁剪
55+
/// (不做白边——用户意图优先);手动不设预算上限。
56+
#[tauri::command]
57+
pub async fn capture_structure_manual(
58+
state: State<'_, AppState>,
59+
session_id: i64,
60+
screen_id: Option<i64>,
61+
x: f32,
62+
y: f32,
63+
w: f32,
64+
h: f32,
65+
) -> Result<StructureImageRecord, String> {
66+
if session_id <= 0 {
67+
return Err("无效的会话 id".to_string());
68+
}
69+
// 归一化坐标校验(防负数/越界/空框)
70+
if !(0.0..=1.0).contains(&x) || !(0.0..=1.0).contains(&y) || !(0.0..=1.0).contains(&w) || !(0.0..=1.0).contains(&h) {
71+
return Err("框选坐标无效(须 0-1 归一化)".to_string());
72+
}
73+
let state: AppState = (*state).clone();
74+
tauri::async_runtime::spawn_blocking(move || {
75+
manual_capture_inner(&state, session_id, screen_id, x, y, w, h)
76+
})
77+
.await
78+
.map_err(|e| format!("任务调度失败: {}", e))?
79+
}
80+
81+
/// 手动捕获核心(IO;独立函数便于单测)。
82+
fn manual_capture_inner(
83+
state: &AppState,
84+
session_id: i64,
85+
screen_id: Option<i64>,
86+
x: f32,
87+
y: f32,
88+
w: f32,
89+
h: f32,
90+
) -> std::result::Result<StructureImageRecord, String> {
91+
// ① 定位屏卡 image_ref(复用屏卡体系;无图 → 明确错误,前端禁用按钮)
92+
let blocks = state.db.list_ocr_blocks(session_id).map_err(|e| e.to_string())?;
93+
let images_dir = session_images_dir(&state.data_dir, session_id);
94+
let screens = crate::screens::build_screens(&blocks, Some(&images_dir));
95+
let screen = screens
96+
.iter()
97+
.find(|s| s.screen_id == screen_id)
98+
.ok_or_else(|| "未找到对应屏卡".to_string())?;
99+
let image_ref = screen
100+
.image_ref
101+
.clone()
102+
.ok_or_else(|| "该屏无图像,无法框选截取".to_string())?;
103+
// ② 解码全帧图
104+
let frame = image::open(images_dir.join(&image_ref))
105+
.map_err(|e| format!("屏图解码失败: {}", e))?
106+
.to_rgb8();
107+
let (fw, fh) = (frame.width(), frame.height());
108+
// ③ 归一化 → 像素坐标(钳制越界;最小尺寸拒绝)
109+
let px = |v: f32, dim: u32| (v * dim as f32).round().min(dim as f32) as u32;
110+
let (cx, cy) = (px(x, fw), px(y, fh));
111+
let (cw, ch) = (px(w, fw).min(fw - cx), px(h, fh).min(fh - cy));
112+
if cw < 32 || ch < 32 {
113+
return Err("框选区域过小(≥32×32 像素)".to_string());
114+
}
115+
let crop = image::imageops::crop_imm(&frame, cx, cy, cw, ch).to_image();
116+
// ④ 手动入库(不设预算/不去重)+ 记录
117+
let mut store =
118+
crate::structure_store::StructureImageStore::new(images_dir.clone()).map_err(|e| e.to_string())?;
119+
let now = std::time::SystemTime::now()
120+
.duration_since(std::time::UNIX_EPOCH)
121+
.map(|d| d.as_millis() as u64)
122+
.unwrap_or(0);
123+
let rel = store
124+
.save_manual(now, &rgb_to_bgra(&crop), crop.width(), crop.height())
125+
.map_err(|e| e.to_string())?;
126+
let rec = StructureImageRecord {
127+
id: 0,
128+
session_id,
129+
screen_id: screen.screen_id,
130+
kind: "manual".to_string(),
131+
bbox: format!(r#"{{"x":{},"y":{},"w":{},"h":{}}}"#, cx, cy, cw, ch),
132+
source_ts_ms: screen.first_seen_ms,
133+
crop_path: rel,
134+
source: "manual".to_string(),
135+
created_at: now,
136+
};
137+
let id = crate::db_structures::insert_structure_image(&state.db, &rec).map_err(|e| e.to_string())?;
138+
let mut saved = rec;
139+
saved.id = id;
140+
let _ = state.app.emit("session:structures-updated", ());
141+
Ok(saved)
142+
}
143+
144+
/// RGB → BGRA(结构图存储契约;与 structure_capture 内部同实现——命令层
145+
/// 独立小函数,避免跨模块依赖私有工具)。
146+
fn rgb_to_bgra(rgb: &image::RgbImage) -> Vec<u8> {
147+
let mut out = Vec::with_capacity((rgb.width() * rgb.height() * 4) as usize);
148+
for p in rgb.pixels() {
149+
out.extend_from_slice(&[p[2], p[1], p[0], 255]);
150+
}
151+
out
152+
}
153+
154+
/// 会话结构图列表(图库数据源;按入库时间升序)。
155+
#[tauri::command]
156+
pub fn list_session_structure_images(
157+
state: State<'_, AppState>,
158+
session_id: i64,
159+
) -> Result<Vec<StructureImageRecord>, String> {
160+
if session_id <= 0 {
161+
return Err("无效的会话 id".to_string());
162+
}
163+
crate::db_structures::list_structure_images(&state.db, session_id).map_err(|e| e.to_string())
164+
}
165+
166+
/// 删除结构图(记录驱动:删记录 + 删文件;文件仅被本表引用可安全删)。
167+
#[tauri::command]
168+
pub fn delete_structure_image(
169+
state: State<'_, AppState>,
170+
id: i64,
171+
) -> Result<bool, String> {
172+
if id <= 0 {
173+
return Err("无效的记录 id".to_string());
174+
}
175+
let rec = crate::db_structures::delete_structure_image(&state.db, id)
176+
.map_err(|e| e.to_string())?;
177+
if let Some(r) = rec {
178+
let images_dir = session_images_dir(&state.data_dir, r.session_id);
179+
let store = crate::structure_store::StructureImageStore::new(images_dir)
180+
.map_err(|e| e.to_string())?;
181+
store.delete_image(&r.crop_path).map_err(|e| e.to_string())?;
182+
}
183+
let _ = state.app.emit("session:structures-updated", ());
184+
Ok(true)
185+
}

‎app/src-tauri/src/lib.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ mod commands_refine_inner;
5555
mod commands_session;
5656
// v0.7.6 审查硬拆:会话 → 笔记转换管线(原料装载/结构渲染/单条转换/批量编排/预览)
5757
mod commands_session_note;
58+
// v0.7.7(REQ-182/183/184):结构图命令层——批量捕获/手动框选/列表/删除
59+
mod commands_structures;
5860
mod commands_streaming;
5961
mod commands_vocab;
6062
mod commands_video;
@@ -341,6 +343,11 @@ pub fn run() {
341343
commands_images::delete_session_images_all,
342344
commands_images::save_user_screenshot,
343345
commands_images::session_images_base_url,
346+
// 结构图(REQ-182/183/184,v0.7.7:非线性结构图像捕获持久化 + 图库)
347+
commands_structures::capture_session_structures,
348+
commands_structures::capture_structure_manual,
349+
commands_structures::list_session_structure_images,
350+
commands_structures::delete_structure_image,
344351
// 会话音频落盘(REQ-068,v0.6.0 M4:状态/清理——M6 清理 UI 消费)
345352
commands_audio::session_audio_status,
346353
commands_audio::session_audio_cleanup,

0 commit comments

Comments
 (0)