Skip to content

Commit dd5f9b2

Browse files
committed
feat(capture): v0.19.2 窗口选择增强与开始即录门控(REQ-271~273)
1 parent 802a370 commit dd5f9b2

6 files changed

Lines changed: 208 additions & 26 deletions

File tree

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,9 @@ impl LiveSessionManager {
6767

6868
// P3:尝试交接预备线程
6969
if let Some(p) = self.prepared.lock().expect("prepared lock poisoned").take() {
70-
match wait_prepared_ready(&p, std::time::Duration::from_secs(5)) {
70+
// v0.19.2(用户实测):就绪等待放宽到 15s(CUDA 冷载可能 >5s——
71+
// 原 5s 常超时走"取消+内联"双引擎重载,浪费且开头不齐)
72+
match wait_prepared_ready(&p, std::time::Duration::from_secs(15)) {
7173
Ok(()) => {
7274
let handoff = StartHandoff {
7375
params,
@@ -107,7 +109,7 @@ impl LiveSessionManager {
107109
}
108110
}
109111
Err(reason) => {
110-
// 加载失败/等待超时:取消预备线程(防双引擎内存翻倍),回退内联
112+
// 加载失败:取消预备线程(防双引擎内存翻倍),回退内联加载
111113
let _ = p.tx.send(PrepareMsg::Cancel);
112114
let cancel_deadline =
113115
std::time::Instant::now() + std::time::Duration::from_millis(1000);
@@ -117,6 +119,15 @@ impl LiveSessionManager {
117119
if !p.thread.is_finished() {
118120
eprintln!("[LiveSession] 预备线程取消超时,已 detach");
119121
}
122+
if reason == "等待超时" {
123+
// v0.19.2(用户实测):>15s 仍在加载 → 不再走内联双引擎
124+
// 重载(两个模型实例并存数秒、内存翻倍且开头不齐),
125+
// 明确失败让用户重试;本会话行标记失败防孤儿 recording
126+
let _ = params.db.mark_session_failed(session_id);
127+
return Err(AppError::Io(
128+
"引擎预热超时(模型加载过慢)——已取消,请稍候重试".to_string(),
129+
));
130+
}
120131
eprintln!("[LiveSession] 预热{},回退内联加载", reason);
121132
}
122133
}

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

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,38 @@ pub fn has_capturable_size(width: i32, height: i32) -> bool {
5454
width > 0 && height > 0
5555
}
5656

57+
/// 系统/工具类进程白名单(v0.19.2 用户实测:终端/记事本/文件资源管理器等
58+
/// 系统窗口出现在选择列表属噪声)。
59+
///
60+
/// @ai-context: 判定只用**进程名**(不含扩展名、小写比较)——标题易变不可靠
61+
/// (资源管理器窗口标题=目录名);浏览器/播放器/办公软件不在
62+
/// 名单(编程教学可能录编辑器/终端以外的窗口——见"彻底过滤"
63+
/// 的用户裁决:本名单是保守最小集,新增进程在此登记)。
64+
/// 前端默认隐藏 systemWindow,可开关找回(能力不丢失)。
65+
const SYSTEM_PROCESSES: &[&str] = &[
66+
// 文件管理与桌面
67+
"explorer",
68+
// 文本与终端
69+
"notepad",
70+
"cmd",
71+
"powershell",
72+
"pwsh",
73+
"windowsterminal",
74+
// 任务与系统设置(UWP 宿主窗口一并覆盖)
75+
"taskmgr",
76+
"applicationframehost",
77+
"systemsettings",
78+
"shellExperienceHost",
79+
"searchapp",
80+
"dwm",
81+
];
82+
83+
/// 判定是否系统/工具窗口(纯函数;进程名空 → false 保留兜底选择)。
84+
pub fn is_system_window(_title: &str, process_name: &str) -> bool {
85+
let p = process_name.trim().to_lowercase();
86+
!p.is_empty() && SYSTEM_PROCESSES.iter().any(|s| *s == p)
87+
}
88+
5789
#[cfg(test)]
5890
mod tests {
5991
use super::*;
@@ -109,4 +141,28 @@ mod tests {
109141
assert!(!has_capturable_size(0, 0));
110142
assert!(has_capturable_size(1920, 1080));
111143
}
144+
145+
#[test]
146+
fn system_processes_detected() {
147+
// 终端/记事本/资源管理器/任务管理器——进程名小写无扩展名判定
148+
assert!(is_system_window("随便什么标题", "explorer"));
149+
assert!(is_system_window("", "Notepad"));
150+
assert!(is_system_window("C:\\dev", "cmd"));
151+
assert!(is_system_window("", "WindowsTerminal"));
152+
assert!(is_system_window("", "pwsh"));
153+
assert!(is_system_window("", "Taskmgr"));
154+
assert!(is_system_window("", "ApplicationFrameHost"));
155+
}
156+
157+
#[test]
158+
fn media_and_office_not_system() {
159+
// 浏览器/播放器/办公/无进程名——不得误判为系统窗口
160+
assert!(!is_system_window("", "chrome"));
161+
assert!(!is_system_window("", "msedge"));
162+
assert!(!is_system_window("", "potplayer"));
163+
assert!(!is_system_window("", "Code"));
164+
assert!(!is_system_window("", "WeChat"));
165+
assert!(!is_system_window("", ""), "进程名空保留兜底(不猜不滤)");
166+
assert!(!is_system_window("bilibili 播放窗口", "chrome"));
167+
}
112168
}

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

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ pub struct CaptureWindow {
3636
/// 站点首页标记(2026-08 用户需求:B站首页等无视频内容落地页,
3737
/// 前端显示"首页"标签且不进入推荐;仍可手动选择兜底)
3838
pub is_homepage: bool,
39+
/// v0.19.2:枚举 z 序(EnumWindows 自上而下,0=最顶)。推荐区同级按此
40+
/// 稳定排序——"播放中/当前活跃窗口靠前"的零成本近似(真播放检测需
41+
/// OCR/音频轮询,成本高,登记观察项)。
42+
pub z_order: u32,
43+
/// v0.19.2:系统/工具窗口(终端/资源管理器/记事本等——前端默认过滤,
44+
/// 可开关找回;判定见 window_filter::is_system_window)
45+
pub system_window: bool,
3946
}
4047

4148
/// 标题关键词评分表:(关键词, 权重, 原因)。
@@ -54,6 +61,10 @@ const TITLE_KEYWORDS: &[(&str, u32, &str)] = &[
5461
("网课", 80, "网课关键词"),
5562
("zoom", 70, "Zoom 会议"),
5663
("钉钉", 60, "钉钉"),
64+
// v0.19.2(用户实测):抖音/快手桌面端标题固定为平台名——补表进推荐区
65+
("抖音", 80, "抖音视频"),
66+
("douyin", 80, "抖音视频"),
67+
("快手", 60, "快手视频"),
5768
("课程", 50, "课程关键词"),
5869
("教程", 50, "教程关键词"),
5970
("学习", 30, "学习关键词"),
@@ -66,6 +77,10 @@ const PROCESS_KEYWORDS: &[(&str, u32, &str)] = &[
6677
("mpv", 60, "mpv 播放器"),
6778
("kmplayer", 60, "KMPlayer 播放器"),
6879
("wmplayer", 50, "Windows 播放器"),
80+
// v0.19.2(用户实测):抖音/快手/独立 B站客户端进程名(窗口标题不可靠时兜底)
81+
("douyin", 80, "抖音客户端"),
82+
("kuaishou", 60, "快手客户端"),
83+
("bilibili", 90, "B站客户端"),
6984
("chrome", 40, "Chrome 浏览器"),
7085
("msedge", 40, "Edge 浏览器"),
7186
("firefox", 40, "Firefox 浏览器"),
@@ -209,18 +224,35 @@ pub fn list_capture_windows() -> Vec<CaptureWindow> {
209224
let ok = unsafe { GetWindowRect(HWND(*id as *mut core::ffi::c_void), &mut rect) };
210225
ok.is_ok() && crate::window_filter::has_capturable_size(rect.right - rect.left, rect.bottom - rect.top)
211226
})
212-
.map(|(id, title, pid)| {
227+
// enumerate 序号即 z 序(EnumWindows 自上而下)——0=最顶窗口
228+
.enumerate()
229+
.map(|(z_order, (id, title, pid))| {
213230
let process_name = unsafe { process_name_of(pid) };
214231
let (score, reasons) = score_window(&title, &process_name);
215232
let is_homepage = crate::window_filter::is_site_homepage(&title);
216233
// 站点首页降权:移出推荐(评分清零 + 原因标注),仍保留可手动选择
217234
let (score, reasons) =
218235
crate::window_filter::demote_homepage(score, reasons, &title);
219-
CaptureWindow { id, title, process_name, pid, score, reasons, is_homepage }
236+
// v0.19.2:系统/工具窗口标记(终端/资源管理器等——前端默认过滤
237+
// 可开关找回;不影响其余字段,供 UI 灰显与计数)
238+
let system_window =
239+
crate::window_filter::is_system_window(&title, &process_name);
240+
CaptureWindow {
241+
id,
242+
title,
243+
process_name,
244+
pid,
245+
score,
246+
reasons,
247+
is_homepage,
248+
z_order: z_order as u32,
249+
system_window,
250+
}
220251
})
221252
.collect();
222-
// 推荐窗口(高分)在前,同级按标题稳定排序
223-
windows.sort_by(|a, b| b.score.cmp(&a.score).then_with(|| a.title.cmp(&b.title)));
253+
// 推荐在前(score 降序);同级按 z 序(越靠顶越新活跃——"播放中置顶"的
254+
// 零成本近似;标题排序会让活跃窗口埋没在同分浏览器标签海里)
255+
windows.sort_by(|a, b| b.score.cmp(&a.score).then_with(|| a.z_order.cmp(&b.z_order)));
224256
windows
225257
}
226258

@@ -292,4 +324,19 @@ mod tests {
292324
// Assert
293325
assert_eq!(upper, lower);
294326
}
327+
328+
#[test]
329+
fn douyin_titles_and_process_score_high() {
330+
// v0.19.2(用户实测):抖音标题固定为"抖音"/进程 douyin——进推荐区
331+
let (t, tr) = score_window("抖音", "douyin");
332+
assert!(t >= 80, "标题命中: {}", t);
333+
assert!(tr.iter().any(|r| r.contains("抖音")));
334+
let (p, pr) = score_window("视频", "douyin");
335+
assert!(p >= 80, "进程命中: {}", p);
336+
assert!(pr.iter().any(|r| r.contains("抖音")));
337+
// 标题不含平台词但进程为 B站客户端 → 仍可推荐
338+
let (b, br) = score_window("一些课程名称", "bilibili");
339+
assert!(b >= 90);
340+
assert!(br.iter().any(|r| r.contains("B站")));
341+
}
295342
}

‎app/src/components/WindowSelectCard.tsx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,15 @@ function WindowRow({ win, isSelected, onClick }: { win: WindowInfo; isSelected:
5353
🏠 首页
5454
</span>
5555
)}
56+
{/* v0.19.2:系统窗口标记(默认过滤——勾选"显示系统窗口"后可见) */}
57+
{win.systemWindow && (
58+
<span
59+
style={{ marginLeft: 6, fontSize: 10, color: "#9ca3af", background: "#f3f4f6", borderRadius: 4, padding: "0 4px" }}
60+
title="系统/工具窗口(终端/资源管理器等),默认隐藏"
61+
>
62+
🖥 系统
63+
</span>
64+
)}
5665
</div>
5766
<div style={{ fontSize: 11, color: "#6b7280" }}>
5867
进程: {win.processName || "未知"} (PID {win.pid})

0 commit comments

Comments
 (0)