Skip to content

Commit 49d8619

Browse files
committed
feat(capture): 画面停更检测与 WGC 会话自愈(REQ-281)
1 parent 4c7dad0 commit 49d8619

6 files changed

Lines changed: 348 additions & 2 deletions

File tree

‎app/src-tauri/src/capture/dxgi_capture.rs‎

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
//! (行数豁免登记拆分计划,TD-033 实施时执行);本文件只保留采样器编排。
1010
1111
use windows::Win32::Foundation::{HWND, RECT};
12-
use windows::Win32::UI::WindowsAndMessaging::GetWindowRect;
12+
use windows::Win32::UI::WindowsAndMessaging::{GetWindowRect, IsIconic, IsWindowVisible};
1313

1414
use super::dxgi_state::{point_in_output, window_center, DxgiState};
1515
use super::frame_diff::{crop_frame, Rect};
@@ -123,6 +123,47 @@ impl ScreenCaptureSampler {
123123
self.pending_event.take()
124124
}
125125

126+
// ── REQ-281(v0.19.6):帧心跳与 WGC 自愈辅助 ──
127+
128+
/// 目标窗口是否可见且非最小化(watchdog 判停依据——业界口径:暂停只看
129+
/// 最小化/不可见,失焦不停;全屏捕获无目标窗口视为恒可见)。
130+
pub fn target_visible(&self) -> bool {
131+
let Some(hwnd) = self.hwnd else { return true };
132+
unsafe { IsWindowVisible(hwnd).as_bool() && !IsIconic(hwnd).as_bool() }
133+
}
134+
135+
/// WGC 会话自愈:仅主路径(WGC)重建;重建失败降级 DXGI/GDI(与既有
136+
/// capture() 错误路径同语义)。返回是否仍处于 WGC 主路径。
137+
///
138+
/// @ai-context: WGC 内容驱动出帧,静默失活(会话未报错但不再有新帧)无
139+
/// 错误信号可捕——watchdog 按"可见且长时间无帧"触发本方法。
140+
pub fn revive_wgc(&mut self) -> bool {
141+
if self.backend != CaptureBackend::Wgc {
142+
return false;
143+
}
144+
let Some(hwnd) = self.hwnd else { return false };
145+
match WgcState::create(hwnd) {
146+
Ok(state) => {
147+
eprintln!("[ScreenCapture] WGC 已重建(REQ-281 watchdog)");
148+
self.wgc = Some(state);
149+
true
150+
}
151+
Err(e) => {
152+
// 重建失败(窗口最小化/遮挡等)→ 一次性降级 DXGI(沿用既有
153+
// 降级语义;DXGI 周期重建逻辑保留,WGC 不回切——YAGNI)
154+
eprintln!("[ScreenCapture] WGC 重建失败,降级 DXGI(REQ-281): {}", e);
155+
self.wgc = None;
156+
self.dxgi = DxgiState::create(self.hwnd).ok();
157+
self.backend = if self.dxgi.is_some() {
158+
CaptureBackend::Dxgi
159+
} else {
160+
CaptureBackend::Gdi
161+
};
162+
false
163+
}
164+
}
165+
}
166+
126167
/// 刷新目标窗口矩形(ADR-007):窗口移动/缩放/分辨率变化后裁剪跟随;
127168
/// GetWindowRect 失败 = 窗口已关闭 → 回退全屏并记录 WindowLost 事件。
128169
fn refresh_window_rect(&mut self) {
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
//! 画面停更监测(REQ-281,v0.19.6)——WGC 静默失活 watchdog + 心跳节拍纯逻辑。
2+
//!
3+
//! @ai-context: 用户双屏复现(视频本身在动但画面停更)指向我方 WGC 会话静默
4+
//! 失活——WGC 内容驱动出帧,失活不报错、只表现为"无新帧"。
5+
//! 本模块把判定做成可单测的纯状态机(时间/帧到达/后端/可见性),
6+
//! 副作用(emit/重建)由调用方(屏幕 worker)执行。
7+
//!
8+
//! @ai-context: 判停口径与业界一致:只认"最小化/不可见/长时间无帧",不认失焦;
9+
//! idle 降频探针(5s 一次)与暂停态由调用方 gate(不喂本模块
10+
//! allow_stall=false),防静止画面/静音学习段误报误重建。
11+
12+
use std::time::{Duration, Instant};
13+
14+
/// 无新帧持续超过该值视为"疑似停更"(视频正常播放帧率远高于此)。
15+
pub const STALL_SECS: u64 = 5;
16+
/// WGC 重建最小间隔(重建本身有成本;配合 DXGI_RECREATE_INTERVAL=30s 节奏)。
17+
pub const RECREATE_MIN_INTERVAL: Duration = Duration::from_secs(30);
18+
/// 帧心跳事件节拍(帧是否到达/静默秒数/后端/可见性上报前端)。
19+
pub const HEARTBEAT_EVERY: Duration = Duration::from_secs(2);
20+
/// 采样新鲜度窗口:距上次真实采样 > 该值(idle/暂停)不判停更。
21+
pub const SAMPLE_FRESH_MS: Duration = Duration::from_millis(1500);
22+
23+
/// 停更监测状态(单采样线程持有,方法均为纯逻辑——时间由调用方注入)。
24+
#[derive(Debug, Clone)]
25+
pub struct FrameLiveness {
26+
/// 最近一次真实采样的墙钟时刻(判定新鲜度 gate)
27+
last_sample_at: Option<Instant>,
28+
/// 无新帧持续起点(有新帧即清 None)
29+
stall_since: Option<Instant>,
30+
/// 停更提示已上抛(边沿去重——恢复才清)
31+
pub stalled: bool,
32+
/// 上次 WGC 重建时刻(节流)
33+
last_recreate_at: Option<Instant>,
34+
/// 上次心跳时刻
35+
last_heartbeat_at: Option<Instant>,
36+
}
37+
38+
/// 一次采样结论(喂入后由调用方读 stall_since/stalled 执行副作用)。
39+
impl FrameLiveness {
40+
pub fn new() -> Self {
41+
Self {
42+
last_sample_at: None,
43+
stall_since: None,
44+
stalled: false,
45+
last_recreate_at: None,
46+
last_heartbeat_at: None,
47+
}
48+
}
49+
50+
/// 采样结果入账:got_frame=true → 清停更窗口;false → 续记停更起点。
51+
///
52+
/// @param now 墙钟(Instant::now)
53+
pub fn observe(&mut self, now: Instant, got_frame: bool) {
54+
self.last_sample_at = Some(now);
55+
if got_frame {
56+
self.stall_since = None;
57+
} else {
58+
self.stall_since = self.stall_since.or(Some(now));
59+
}
60+
}
61+
62+
/// 距上次真实采样是否仍"新鲜"(> SAMPLE_FRESH_MS 的静默不判停更——idle/
63+
/// 暂停段由调用方 gate,此处兜底防御)。
64+
pub fn fresh(&self, now: Instant) -> bool {
65+
self.last_sample_at
66+
.is_some_and(|t| now.duration_since(t) <= SAMPLE_FRESH_MS)
67+
}
68+
69+
/// 当前停更秒数(未开始停更/不新鲜 → None)。
70+
pub fn stall_secs(&self, now: Instant) -> Option<u64> {
71+
if !self.fresh(now) {
72+
return None;
73+
}
74+
let since = self.stall_since?;
75+
let secs = now.duration_since(since).as_secs();
76+
(secs >= 1).then_some(secs)
77+
}
78+
79+
/// 停更提示边沿:持续 ≥ STALL_SECS 且尚未上抛。
80+
pub fn stall_edge(&self, now: Instant) -> bool {
81+
self.stall_secs(now).is_some_and(|s| s >= STALL_SECS) && !self.stalled
82+
}
83+
84+
/// 恢复边沿:本采样有新帧且此前处于停更提示态。
85+
pub fn recover_edge(&self, got_frame: bool) -> bool {
86+
got_frame && self.stalled
87+
}
88+
89+
/// WGC 重建是否到期(停更 ≥ STALL_SECS 且距上次重建 ≥ 最小间隔)。
90+
pub fn recreate_due(&self, now: Instant) -> bool {
91+
self.stall_secs(now).is_some_and(|s| s >= STALL_SECS)
92+
&& self
93+
.last_recreate_at
94+
.is_none_or(|t| now.duration_since(t) >= RECREATE_MIN_INTERVAL)
95+
}
96+
97+
/// 心跳是否到期。
98+
pub fn heartbeat_due(&self, now: Instant) -> bool {
99+
self.last_heartbeat_at
100+
.is_none_or(|t| now.duration_since(t) >= HEARTBEAT_EVERY)
101+
}
102+
103+
/// 停更提示已上抛 / 心跳已发 / 重建已记(调用方执行副作用后调)。
104+
pub fn mark_stalled(&mut self) {
105+
self.stalled = true;
106+
}
107+
108+
pub fn clear_stalled(&mut self) {
109+
self.stalled = false;
110+
self.stall_since = None;
111+
}
112+
113+
pub fn mark_heartbeat(&mut self, now: Instant) {
114+
self.last_heartbeat_at = Some(now);
115+
}
116+
117+
pub fn mark_recreate(&mut self, now: Instant) {
118+
self.last_recreate_at = Some(now);
119+
}
120+
}
121+
122+
#[cfg(test)]
123+
mod tests {
124+
use super::*;
125+
126+
fn t0() -> Instant {
127+
Instant::now()
128+
}
129+
130+
#[test]
131+
fn fresh_samples_clear_stall_and_recover_edges() {
132+
let now = t0();
133+
let mut lv = FrameLiveness::new();
134+
// 连续无帧 → 5s 进入停更边沿;恢复新帧 → recover 边沿 + 停更清零
135+
lv.observe(now, false);
136+
lv.observe(now + Duration::from_millis(500), false);
137+
let stalled_at = now + Duration::from_secs(6);
138+
assert!(lv.stall_edge(stalled_at), "≥5s 无帧应触发停更边沿");
139+
assert_eq!(lv.stall_secs(stalled_at), Some(6));
140+
lv.mark_stalled();
141+
// 同刻不再重复边沿(去重)
142+
assert!(!lv.stall_edge(stalled_at));
143+
// 有新帧 → 恢复
144+
let rec_at = stalled_at + Duration::from_secs(2);
145+
assert!(lv.recover_edge(true));
146+
lv.observe(rec_at, true);
147+
lv.clear_stalled();
148+
assert_eq!(lv.stall_secs(rec_at + Duration::from_secs(1)), None);
149+
}
150+
151+
#[test]
152+
fn stale_samples_do_not_judge_stall() {
153+
let now = t0();
154+
let mut lv = FrameLiveness::new();
155+
lv.observe(now, false);
156+
// 距上次采样超过新鲜窗口 → 不判停更(idle 探针兜底防御)
157+
let later = now + Duration::from_secs(10);
158+
assert_eq!(lv.stall_secs(later), None);
159+
assert!(!lv.stall_edge(later));
160+
}
161+
162+
#[test]
163+
fn recreate_throttled_by_min_interval() {
164+
let now = t0();
165+
let mut lv = FrameLiveness::new();
166+
lv.observe(now, false);
167+
lv.observe(now + Duration::from_secs(1), false);
168+
let due = now + Duration::from_secs(6);
169+
assert!(lv.recreate_due(due));
170+
lv.mark_recreate(due);
171+
assert!(!lv.recreate_due(due + Duration::from_secs(10)), "30s 节流内不重复重建");
172+
assert!(lv.recreate_due(due + RECREATE_MIN_INTERVAL + Duration::from_secs(1)));
173+
}
174+
175+
#[test]
176+
fn heartbeat_interval() {
177+
let now = t0();
178+
let mut lv = FrameLiveness::new();
179+
assert!(lv.heartbeat_due(now), "首拍即到期");
180+
lv.mark_heartbeat(now);
181+
assert!(!lv.heartbeat_due(now + Duration::from_secs(1)));
182+
assert!(lv.heartbeat_due(now + HEARTBEAT_EVERY + Duration::from_millis(50)));
183+
}
184+
}

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
//! 熵减桌面应用入口(Tauri 装配层)。
1+
//! 熵减桌面应用入口(Tauri 装配层)。
22
//!
33
//! @ai-context: 本文件只做模块声明与应用装配(插件注册 / 状态初始化 / command 注册),
44
//! 不含业务逻辑;业务自底向上分布:types → concat/db → asr/ocr → engine → commands。
@@ -244,6 +244,8 @@ mod follow_along_detect;
244244
mod formula_reconstruct;
245245
mod frame_cluster;
246246
mod frame_features;
247+
// REQ-281(v0.19.6):画面停更监测(WGC watchdog 纯状态机)
248+
mod frame_liveness;
247249
// v0.7.0 M2(REQ-128):前台时间线(前台切换事件落库 + 实践段标记)
248250
mod foreground_timeline;
249251
mod fusion;

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,10 @@ pub fn capture_latest_only(
120120
epoch: Instant,
121121
latest_frame: &std::sync::Arc<std::sync::Mutex<Option<LatestCapturedFrame>>>,
122122
last_capture_error: &mut Option<Instant>,
123+
// REQ-281(v0.19.6):本拍是否取到新帧(停更监测输入;调用方先置 false)
124+
got_frame: &mut bool,
123125
) {
126+
*got_frame = false;
124127
let Some(sampler) = screen else { return };
125128
// ADR-007:目标窗口关闭等捕获事件无论捕获结果如何都要转发(同 process_frame)
126129
match sampler.take_event() {
@@ -131,6 +134,7 @@ pub fn capture_latest_only(
131134
}
132135
match sampler.capture(None) {
133136
Ok(Some(frame)) => {
137+
*got_frame = true;
134138
if let Ok(mut guard) = latest_frame.lock() {
135139
*guard = Some(LatestCapturedFrame {
136140
timestamp_ms: epoch.elapsed().as_millis() as u64,
@@ -195,11 +199,15 @@ pub fn process_frame(
195199
screen_tracker: &mut crate::screen_tracker::ScreenTracker,
196200
// v0.11.5(Task 2):变化区域新颖度基准(独立于全量文本)+ 画面档(阈值自适应)
197201
last_changed_texts: &mut Vec<String>,
202+
// REQ-281(v0.19.6):本拍是否取到新帧(停更监测输入;调用方先置 false)
203+
got_frame: &mut bool,
198204
tier: &str,
199205
) {
206+
*got_frame = false;
200207
let Some(sampler) = screen else { return };
201208
// 字幕区裁剪决策由 M2/REQ-037 RoiTracker 给出(播放区域 + ROI;首帧扫描期全帧)
202209
let capture_result = sampler.capture(None);
210+
*got_frame = matches!(&capture_result, Ok(Some(_)));
203211
// ADR-007:目标窗口关闭等捕获事件无论捕获结果如何都要转发——
204212
// 窗口关闭瞬间若 GDI 捕获也失败,事件不得被吞掉(审查发现)
205213
match sampler.take_event() {

0 commit comments

Comments
 (0)