Skip to content

Commit 5cddb95

Browse files
committed
fix(td): 偿还 TD-19-F/G 与 TD-28-A/B(暂停图标白像素判定/OCR 检索全库化/质量分滑动窗口/增量合并索引)
1 parent 356def9 commit 5cddb95

5 files changed

Lines changed: 106 additions & 30 deletions

File tree

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

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -128,25 +128,35 @@ pub fn assemble_hybrid_note(
128128
/// @ai-context: 近似口径——调用方传**净化后**块可得精确分;本函数接受原料块
129129
/// (junk/低分块压分 → 门控更严,宁缺毋滥符合 spec 哲学);无
130130
/// 行合并上下文时 merged_lines 取块文本(块即行保守近似)。
131+
/// @ai-context: TD-2026-08-28-A 偿还——原实现每章节全量过滤 blocks(O(C×B))+
132+
/// 每块文本克隆;改为块按时间戳排序一次 + 章节有序滑动窗口
133+
/// (partition_point 定界),整体 O(B log B + C log B),章节间隙块
134+
/// 语义不变(不属于任何窗口——与原 filter 行为一致)。
131135
pub fn chapter_quality_scores(
132136
chapters: &[ChapterBoundary],
133137
blocks: &[SessionOcrBlock],
134138
) -> Vec<f32> {
135139
let mut bounds: Vec<&ChapterBoundary> = chapters.iter().collect();
136140
bounds.sort_by_key(|c| c.time_ms);
141+
if bounds.is_empty() {
142+
return Vec::new();
143+
}
144+
// 画面块按时间戳排序(只一次——窗口定界用;region 过滤与原实现一致)
145+
let mut full: Vec<&SessionOcrBlock> = blocks.iter().filter(|bl| bl.region == "full").collect();
146+
full.sort_by_key(|bl| bl.timestamp_ms);
147+
// 起始游标跳过最早章节之前的块(与原实现 timestamp >= b.time 语义一致)
148+
let mut start = full.partition_point(|bl| bl.timestamp_ms < bounds[0].time_ms);
137149
bounds
138150
.iter()
139151
.enumerate()
140-
.map(|(i, b)| {
152+
.map(|(i, _b)| {
141153
let next_ms = bounds.get(i + 1).map(|n| n.time_ms);
142-
let window: Vec<&SessionOcrBlock> = blocks
143-
.iter()
144-
.filter(|bl| {
145-
bl.region == "full"
146-
&& bl.timestamp_ms >= b.time_ms
147-
&& next_ms.is_none_or(|n| bl.timestamp_ms < n)
148-
})
149-
.collect();
154+
// 窗口内块(时间序连续——章节有序,块有序,滑动定界无需重扫)
155+
let end = full.partition_point(|bl| {
156+
next_ms.is_none_or(|n| bl.timestamp_ms < n)
157+
});
158+
let window = &full[start..end];
159+
start = end;
150160
if window.is_empty() {
151161
return 0.0;
152162
}

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

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,19 +40,23 @@ pub struct OcrBlockHit {
4040
pub screen_last_ms: Option<u64>,
4141
}
4242

43-
/// 会话检索范围上限(与旧实现 list_sessions(None, 500, 0) 同口径)。
44-
const SESSION_SCOPE: i64 = 500;
45-
43+
/// 会话检索范围(TD-2026-08-19-G 偿还:原 500 上限静默截断已移除——全库检索,
44+
/// 结果由 limit 有界;本常量删除,保留占位注释防回归引入)。
45+
///
46+
/// @ai-context: 曾经 const SESSION_SCOPE: i64 = 500;若未来重新加上限,必须
47+
/// 在 UI 暴露"仅搜最近 N 会话"并明示(静默截断是 2026-08-19-G 根因)。
4648
/// 检索会话 OCR 块(REQ-133):关键词 → 命中块 + 会话标题 + 图路径 + 屏区间。
4749
///
4850
/// @ai-context: 只搜画面要点(region=full——字幕区是转写冗余,搜字幕用段搜索);
4951
/// 大小写不敏感(LIKE 对 ASCII 天然忽略大小写,CJK 无大小写概念);
5052
/// 结果有界(100 条防超大 payload)。
51-
/// @ai-context: M2 修复——单条 SQL 语义等价于旧逐会话循环:
52-
/// ① 会话范围 = 最近 500 会话(started_at DESC, id DESC 子查询);
53-
/// ② 屏区间 = 同会话同屏全部块(含字幕块,旧实现不过滤 region)
53+
/// @ai-context: M2 修复——单条 SQL 语义等价于旧逐会话循环(无会话范围上限;
54+
/// 屏区间 = 同会话同屏全部块(含字幕块,旧实现不过滤 region)
5455
/// 的 min/max 时间戳(LEFT JOIN 聚合子查询);
55-
/// ③ 排序 = 会话倒序 + 块时间升序(与旧双层循环一致)。
56+
/// 排序 = 会话倒序 + 块时间升序(与旧双层循环一致)。
57+
/// @ai-context: TD-2026-08-19-G 偿还——原 SESSION_SCOPE=500 静默截断:会话数
58+
/// >500 时旧会话命中被漏(用户"搜不到"无从感知);移除上限改全库
59+
/// 检索(本地 SQLite 万行级 + 结果 LIMIT 100 有界,无性能顾虑)。
5660
/// @param data_dir - 应用数据目录(图存在性校验;None 时 image_path 恒 None)
5761
pub fn search_ocr_blocks(
5862
db: &Db,
@@ -83,13 +87,10 @@ pub fn search_ocr_blocks(
8387
) sr ON sr.session_id = b.session_id AND sr.screen_id = b.screen_id
8488
WHERE b.region = 'full'
8589
AND b.text LIKE ?1 ESCAPE '\\'
86-
AND b.session_id IN (
87-
SELECT id FROM sessions ORDER BY started_at DESC, id DESC LIMIT ?2
88-
)
8990
ORDER BY s.started_at DESC, s.id DESC, b.timestamp_ms ASC
90-
LIMIT ?3",
91+
LIMIT ?2",
9192
)?;
92-
let rows = stmt.query_map(params![pattern, SESSION_SCOPE, limit as i64], |row| {
93+
let rows = stmt.query_map(params![pattern, limit as i64], |row| {
9394
Ok(OcrBlockHit {
9495
ocr_block_id: row.get(0)?,
9596
session_id: row.get(1)?,
@@ -218,6 +219,29 @@ mod tests {
218219
assert_eq!(search_ocr_blocks(&db, None, "关键词", 3).unwrap().len(), 3);
219220
}
220221

222+
#[test]
223+
fn search_beyond_500_sessions_not_silently_truncated() {
224+
// TD-2026-08-19-G 回归:原 SESSION_SCOPE=500 静默截断——第 501+ 会话的
225+
// 命中被漏(用户"搜不到"无感知);移除上限后全库命中
226+
let db = Db::open(":memory:").unwrap();
227+
// Arrange:造 501 会话,命中关键词放在**最早**(第 1 个)会话
228+
let mut hit_sid = 0;
229+
for i in 0..501 {
230+
let s = db.create_session(&NewSession {
231+
title: format!("会话{}", i), source_window: None, profile: None, kind: None,
232+
}).unwrap();
233+
if i == 0 {
234+
add_block(&db, s.id, 1000, "老会话目标词", "full", None);
235+
hit_sid = s.id;
236+
}
237+
}
238+
// Act
239+
let hits = search_ocr_blocks(&db, None, "目标词", 10).unwrap();
240+
// Assert:命中(旧实现此用例必失败——第 1 会话不在最近 500 之内)
241+
assert_eq!(hits.len(), 1);
242+
assert_eq!(hits[0].session_id, hit_sid);
243+
}
244+
221245
#[test]
222246
fn search_returns_screen_range_across_regions() {
223247
// Arrange:同屏含 full 块(2000) 与 subtitle 块(500/3000)——

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,26 @@ fn position_stable(a: &ScreenFrame, b: &ScreenFrame) -> bool {
5252

5353
/// 跨帧增量合并:顺序扫描——后帧包含前帧且位置稳定 → 合并(文本取后帧);
5454
/// 否则新屏(翻页)。输入须按时间序(调用方保证)。
55+
///
56+
/// @ai-context: TD-2026-08-28-B 偿还——原实现每帧 `frames.iter().find(p.id==cur.id)`
57+
/// 线性回查 O(n²);改为预建 id→帧索引(HashMap),回查 O(1),
58+
/// 整体 O(n)。纯函数零副作用(输入不变)。
5559
pub fn merge_incremental(frames: &[ScreenFrame]) -> Vec<MergedScreen> {
5660
let mut out: Vec<MergedScreen> = Vec::new();
61+
// 帧索引(首帧 id → 帧;id 唯一性由调用方保证——同屏帧集)
62+
let by_id: std::collections::HashMap<u64, &ScreenFrame> =
63+
frames.iter().map(|f| (f.id, f)).collect();
5764
for f in frames {
5865
let Some(cur) = out.last_mut() else {
5966
out.push(MergedScreen { id: f.id, text: f.text.clone() });
6067
continue;
6168
};
62-
if text_contains(&cur.text, &f.text) && position_stable(frames.iter().find(|p| p.id == cur.id).unwrap_or(f), f) {
69+
if text_contains(&cur.text, &f.text)
70+
&& position_stable(
71+
by_id.get(&cur.id).copied().unwrap_or(f),
72+
f,
73+
)
74+
{
6375
// 同屏增量:文本取后帧(更完整),屏号保持首帧
6476
cur.text = f.text.clone();
6577
} else {

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

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,12 @@ pub fn detect_player_action(frame: &RgbImage) -> Option<PlayerAction> {
5656
///
5757
/// @ai-context: 播放器暂停态 = 视频暗遮罩 + 中央大图标(白三角/双竖条),
5858
/// 用中央区域颜色统计近似:中央区暗像素占比高(暗遮罩/黑圆盘)
59-
/// 且存在中等占比亮像素(白色图标)→ 判定暂停。
60-
/// @ai-context: 阈值偏严防假信号:暗色视频场景无图标时 bright 占比过低不判;
59+
/// 且存在中等占比**白色**像素(图标是白色,非任意亮色)→ 判定暂停。
60+
/// @ai-context: 阈值偏严防假信号:暗色视频场景无图标时白像素占比过低不判;
6161
/// 全亮画面 dark 不足不判;字幕在画面底部、不落在中央区(不干扰)。
62+
/// @ai-context: TD-2026-08-19-F 偿还——原实现只判"亮像素"(lum>210),暗底+中央
63+
/// 亮色内容(如暗色视频里发光的彩色标题/物体)会误报为暂停;
64+
/// 图标白 = 高亮 + 低饱和(R≈G≈B,±40 容差),彩色亮块不再命中。
6265
/// @ai-context: 输入为 RGB 字节(image::RgbImage.as_raw());w/h 为像素尺寸;
6366
/// 输入异常(尺寸过小/字节不足)→ false(防御)。
6467
pub fn detect_pause_icon(pixels: &[u8], w: u32, h: u32) -> bool {
@@ -71,19 +74,25 @@ pub fn detect_pause_icon(pixels: &[u8], w: u32, h: u32) -> bool {
7174
let x0 = w / 2 - cw / 2;
7275
let y0 = h / 2 - ch / 2;
7376
let mut dark = 0u32;
74-
let mut bright = 0u32;
77+
let mut white = 0u32;
7578
let mut total = 0u32;
7679
for y in y0..(y0 + ch).min(h) {
7780
for x in x0..(x0 + cw).min(w) {
7881
let i = ((y * w + x) * 3) as usize;
82+
let r = pixels[i] as u32;
83+
let g = pixels[i + 1] as u32;
84+
let b = pixels[i + 2] as u32;
7985
// 近似亮度(Rec.601 整数版,0-255)
80-
let lum = (pixels[i] as u32 * 299 + pixels[i + 1] as u32 * 587 + pixels[i + 2] as u32 * 114)
81-
/ 1000;
86+
let lum = (r * 299 + g * 587 + b * 114) / 1000;
8287
if lum < 45 {
8388
dark += 1;
8489
}
90+
// 图标白:高亮 + 低饱和(R≈G≈B ±40——白三角/双竖条;彩色亮块不算)
8591
if lum > 210 {
86-
bright += 1;
92+
let (r, g, b) = (r as i32, g as i32, b as i32);
93+
if (r - g).abs() < 40 && (g - b).abs() < 40 && (r - b).abs() < 40 {
94+
white += 1;
95+
}
8796
}
8897
total += 1;
8998
}
@@ -92,8 +101,8 @@ pub fn detect_pause_icon(pixels: &[u8], w: u32, h: u32) -> bool {
92101
return false;
93102
}
94103
let dark_ratio = dark as f32 / total as f32;
95-
let bright_ratio = bright as f32 / total as f32;
96-
dark_ratio >= 0.55 && (0.02..=0.6).contains(&bright_ratio)
104+
let white_ratio = white as f32 / total as f32;
105+
dark_ratio >= 0.55 && (0.015..=0.35).contains(&white_ratio)
97106
}
98107

99108
/// 采样间隔按播放速率缩放(M17,纯函数)。

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,27 @@ fn tiny_frame_returns_none() {
9191
assert!(detect_player_action(&frame).is_none());
9292
}
9393

94+
#[test]
95+
fn dark_scene_with_colored_glow_not_detected() {
96+
// TD-2026-08-19-F 回归:暗底 + 中央亮**彩色**内容(发光红点/橙标题)——
97+
// 修复前被判为暂停(bright 满足),修复后白色判据拒绝
98+
let frame = RgbImage::from_fn(240, 135, |x, y| {
99+
let cx = 120i32;
100+
let cy = 67i32;
101+
let dx = x as i32 - cx;
102+
let dy = y as i32 - cy;
103+
if dx * dx + dy * dy <= 15 * 15 {
104+
Rgb([255, 40, 40]) // 中央亮红圆(饱和度极高,非白)
105+
} else {
106+
Rgb([30, 30, 30]) // 暗底
107+
}
108+
});
109+
// Act
110+
let action = detect_player_action(&frame);
111+
// Assert:彩色亮内容不是暂停图标 → 不判
112+
assert!(action.is_none());
113+
}
114+
94115
#[test]
95116
fn speed_scaled_interval_boundaries() {
96117
// Assert:1.0x → base;2.0x → base/2;0.5x → base*2(上限保护)

0 commit comments

Comments
 (0)