Skip to content

Commit c8e01d9

Browse files
committed
refactor(rust): 拆 live_session_frame 第 3 步 — 暂停轮询
1 parent 2e339a0 commit c8e01d9

4 files changed

Lines changed: 238 additions & 183 deletions

File tree

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

Lines changed: 8 additions & 162 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,10 @@ use crate::capture::frame_diff::SampleRegion;
1818
use crate::db::Db;
1919
use crate::engine::EnginePool;
2020
use crate::fusion::SubtitleSegment;
21-
use crate::live_frame_process::{capture_latest_only, persist_voted_subtitle, process_frame};
21+
use crate::live_frame_process::{persist_voted_subtitle, process_frame};
2222

23-
use live_session_liveness::{liveness_check, watchdog_paused_probe};
23+
use live_session_liveness::liveness_check;
24+
use live_session_pause_poll::media_sound_recent;
2425
use live_session_worker_state::FrameWorkerState;
2526

2627
/// 采样节拍(ms):与音频消费解耦,固定 1s 一拍(审查 M5 修复)。
@@ -48,25 +49,10 @@ pub(super) mod live_session_liveness;
4849
#[path = "live_session_worker_state.rs"]
4950
pub(super) mod live_session_worker_state;
5051

51-
/// REQ-291:媒体级"最近有声"判定(窗口=SOUND_RECENT_MS ≥ 采样拍 1s——
52-
/// 保证一拍内出现的声音必被读到;锁中毒按无声处理,零阻断)。
53-
fn media_sound_recent(slot: &Arc<Mutex<Option<Instant>>>) -> bool {
54-
let last = slot.lock().ok().and_then(|g| *g);
55-
last.is_some_and(|t| t.elapsed().as_millis() as u64 <= crate::media_state::SOUND_RECENT_MS)
56-
}
52+
/// 暂停隔离与轻量轮询(前台门控拍/暂停期恢复检测;§3)。
53+
#[path = "live_session_pause_poll.rs"]
54+
pub(super) mod live_session_pause_poll;
5755

58-
/// 暂停分支轻量轮询门控(纯函数;P2-4 审查修复抽离以便单测)。
59-
///
60-
/// @ai-context(Why):manual 锁存期语义=用户冻结,worker 不跟随任何自动源
61-
/// (全冻结不轮询);media **或 fg** 任一 auto 条件持有时维持
62-
/// 1s 取帧 + 媒体检测拍——fg 暂停期媒体条件照常锁存:用户离开
63-
/// 期间视频暂停/结束即锁存 media(否则回位 fg 解除即"恢复采集",
64-
/// 主路径需 ~3-4s 滞回才重锁存 media,期间环境声混入/UI 闪采集
65-
/// 中/时间轴伪运行段);回位链条 = fg 解除 → media 仍 held →
66-
/// 不真恢复 → 视频恢复播放 → 自动真恢复。
67-
fn light_poll_enabled(cond: crate::pause_state::PauseConditions) -> bool {
68-
!cond.manual && (cond.media || cond.foreground)
69-
}
7056

7157
/// 屏幕采样线程入口(TD-026 修复:OCR 从会话线程移出,音频消费不再被阻塞)。
7258
///
@@ -127,151 +113,11 @@ pub fn run_screen_worker(
127113
);
128114

129115
while !w.stop.load(Ordering::SeqCst) {
130-
// ── 前台门控采样(批 2a;250ms 节拍)──
131-
// @ai-context: 非 manual 锁存期持续观察(含媒体/前台暂停期间——暂停期
132-
// 也允许前台源锁存/解除);manual 锁存期冻结。观察分类:
133-
// 前台=目标 → Target;前台=本进程自窗(浮窗/overlay/原生
134-
// 对话框)→ Neutral(中性:不推进也不撤销);其余 Foreign;
135-
// 全屏无锚点/探测失败 → Neutral(无证据不推断)
136-
if w.fg_eligible {
137-
let fg_now_ms = w.epoch.elapsed().as_millis() as u64;
138-
if fg_now_ms.saturating_sub(w.last_fg_gate_ms) >= crate::foreground_pause::FG_TICK_MS
139-
&& !w.pause.manual_held()
140-
{
141-
w.last_fg_gate_ms = fg_now_ms;
142-
let obs = match (w.hwnd, crate::windows::foreground_hwnd()) {
143-
(Some(target), Some(fg)) if fg == target => {
144-
crate::foreground_pause::ForegroundObs::Target
145-
}
146-
(Some(_), Some(fg)) if crate::windows::is_self_hwnd(fg) => {
147-
crate::foreground_pause::ForegroundObs::Neutral
148-
}
149-
(Some(_), Some(_)) => crate::foreground_pause::ForegroundObs::Foreign,
150-
_ => crate::foreground_pause::ForegroundObs::Neutral,
151-
};
152-
match w.fg_gate.tick(obs) {
153-
crate::foreground_pause::ForegroundDecision::Suspend => {
154-
let _ = w.pause
155-
.request_pause(crate::pause_state::PauseSource::Foreground);
156-
eprintln!("[ScreenWorker] 前台离开目标窗口(连续确认)→ 自动暂停捕获");
157-
}
158-
crate::foreground_pause::ForegroundDecision::Resume => {
159-
let _ = w.pause
160-
.request_release(crate::pause_state::PauseSource::Foreground);
161-
eprintln!("[ScreenWorker] 前台回到目标窗口 → 解除前台暂停");
162-
}
163-
crate::foreground_pause::ForegroundDecision::None => {}
164-
}
165-
}
166-
}
116+
w.foreground_gate_tick();
167117
// ── 暂停检查(2026-08 A1 硬暂停;批 2a 来源感知扩展)──
168118
let paused_now = w.pause.paused.load(Ordering::SeqCst);
169119
if paused_now {
170-
if !w.worker_paused {
171-
w.worker_paused = true;
172-
eprintln!("[ScreenWorker] 会话暂停,画面链冻结(等恢复/来源解除)");
173-
}
174-
// 非 manual 锁存:auto 条件(media/fg)持有时轻量轮询找恢复信号。
175-
// P2-4:门控扩到 fg 期——fg 暂停期也维持媒体检测(视频暂停/结束即
176-
// 锁存 media;否则回位 fg 解除即伪恢复采集,见 light_poll_enabled
177-
// 注释);fg 条件的解除仍由上方门控节拍负责(互不解除只解自己)
178-
if light_poll_enabled(w.pause.conditions()) {
179-
// P2 自动暂停:轻量轮询——仅取帧刷新 latest_frame + 播放检测。
180-
// 检测读的就是 latest_frame,不刷新则永远看到暂停帧 → 无法发现
181-
// 恢复;1s 一拍仅取帧(零分析),5s 一拍检测(沿用 REQ-125 节流)
182-
let comp_epoch = w.compensated_epoch();
183-
if w.last_sample_at.elapsed().as_millis() as u64 >= SAMPLE_TICK_MS {
184-
w.last_sample_at = Instant::now();
185-
capture_latest_only(
186-
w.screen.as_mut(),
187-
&w.app,
188-
comp_epoch,
189-
&w.latest_frame,
190-
&mut w.last_capture_error,
191-
&mut w.got_frame,
192-
);
193-
// 审查 F3:暂停期 watchdog 探针(无提示)——WGC 会话失活时
194-
// 恢复检测永远读不到新帧 → 自动暂停永久卡死;此处定期自愈。
195-
// P2-4:轻量轮询扩到 fg 期后探针随之覆盖 fg 期(fg 期视频
196-
// 暂停/画面停更同样会饿死媒体恢复检测,需同款 WGC 自愈;
197-
// 探针只观测 + 复活,任意暂停期无提示副作用)
198-
watchdog_paused_probe(
199-
w.screen.as_mut(),
200-
&mut w.liveness,
201-
w.got_frame,
202-
Instant::now(),
203-
);
204-
// REQ-291 快恢复(主通道):声画任一恢复 ≤~1.5s 解除自动暂停
205-
// (OCR 5s 判定降级为辅助——保留其后兜底)
206-
if w.last_media_tick.elapsed() >= Duration::from_secs(1) {
207-
w.last_media_tick = Instant::now();
208-
let sound_recent = media_sound_recent(&w.media_sound);
209-
if w.media_detector.tick(sound_recent, w.got_frame)
210-
== crate::media_state::MediaDecision::Resume
211-
{
212-
let resume_ms = comp_epoch.elapsed().as_millis() as u64;
213-
crate::player_behavior::record_action(
214-
&crate::player_behavior::PlayerAction {
215-
kind: crate::player_behavior::PlayerActionKind::Play,
216-
value: None,
217-
},
218-
resume_ms,
219-
w.session_id,
220-
&w.db,
221-
);
222-
// 批 2a:经 request API 解除媒体条件(只解自己——fg
223-
// 仍锁存则暂停延续,本 worker 暂停分支继续等)
224-
let _ = w.pause
225-
.request_release(crate::pause_state::PauseSource::Media);
226-
w.last_player_paused = false;
227-
let _ = w.app.emit("live:media-resumed", ());
228-
eprintln!("[ScreenWorker] 随播随停:声画恢复 → 自动解除暂停");
229-
}
230-
}
231-
// P2-4:OCR 恢复判定只在 media 条件持有时有意义——fg-only
232-
// 暂停期视频正常播放(帧无暂停图标),裸跑会把"播放中"误当
233-
// 恢复沿,每 5s 落一次伪 Play/伪 live:media-resumed(时间轴
234-
// 伪运行段);media 持有时仍是 REQ-125 恢复兜底(5s 判定)
235-
if w.pause.media_held()
236-
&& w.last_player_check_at.elapsed() >= Duration::from_secs(5)
237-
{
238-
w.last_player_check_at = Instant::now();
239-
let check_now_ms = comp_epoch.elapsed().as_millis() as u64;
240-
if let Some(f) = w.latest_frame.lock().ok().and_then(|g| g.clone()) {
241-
if let Some(img) =
242-
crate::region_ocr::bgra_to_rgb_image(&f.bgraw, f.width, f.height)
243-
{
244-
let still_paused =
245-
crate::player_behavior::detect_player_action(&img).is_some();
246-
if !still_paused {
247-
// 恢复播放:落 Play 事件(REQ-125 语义一致)+
248-
// 解除媒体条件(音频/捕获线程沿边沿自动恢复)
249-
crate::player_behavior::record_action(
250-
&crate::player_behavior::PlayerAction {
251-
kind: crate::player_behavior::PlayerActionKind::Play,
252-
value: None,
253-
},
254-
check_now_ms,
255-
w.session_id,
256-
&w.db,
257-
);
258-
let _ = w.pause
259-
.request_release(crate::pause_state::PauseSource::Media);
260-
w.last_player_paused = false;
261-
let _ = w.app.emit("live:media-resumed", ());
262-
eprintln!("[ScreenWorker] 视频恢复播放,自动解除暂停");
263-
}
264-
}
265-
}
266-
}
267-
// v0.7.2(REQ-151):暂停态也探测播放器信息(时间文本仍在画面)——
268-
// 会话开始时视频已暂停的场景,时长/集号识别不因此缺席
269-
if w.last_info_probe_at.elapsed() >= Duration::from_secs(10) {
270-
w.last_info_probe_at = Instant::now();
271-
probe_player_info(&w.app, &w.engines, &w.session_info, &w.roi_tracker, &w.latest_frame);
272-
}
273-
}
274-
}
120+
w.poll_while_paused();
275121
std::thread::sleep(Duration::from_millis(WORKER_POLL_MS));
276122
continue;
277123
}

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

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@
55
//! @ai-context: bgra_to_rgb_image 已随 M4 编排函数移入 region_ocr.rs
66
//! (保持本文件内联测试的引用更新)。
77
8-
use super::*;
9-
use crate::pause_state::PauseConditions;
108
use crate::region_ocr::bgra_to_rgb_image;
119

1210
#[test]
@@ -27,23 +25,4 @@ fn bgra_rejects_mismatched_size() {
2725
assert!(bgra_to_rgb_image(&[], 0, 0).is_none());
2826
}
2927

30-
#[test]
31-
fn light_poll_runs_for_media_or_foreground_pause_but_not_manual() {
32-
// Arrange & Act & Assert(P2-4 门控真值表):
33-
assert!(!light_poll_enabled(PauseConditions { manual: true, foreground: false, media: false }),
34-
"manual 锁存期全冻结(用户冻结语义,不跟随任何自动源)");
35-
assert!(light_poll_enabled(PauseConditions { manual: false, foreground: false, media: true }),
36-
"media 暂停期维持轻量轮询(既有语义不回归)");
37-
assert!(light_poll_enabled(PauseConditions { manual: false, foreground: true, media: false }),
38-
"P2-4:fg 暂停期也维持媒体检测——离开期间视频暂停/结束即锁存 media");
39-
assert!(light_poll_enabled(PauseConditions { manual: false, foreground: true, media: true }));
40-
assert!(!light_poll_enabled(PauseConditions::default()), "防御:无持因不轮询");
41-
}
4228

43-
#[test]
44-
fn light_poll_manual_latch_overrides_every_auto_condition() {
45-
// Arrange & Act & Assert:manual 与任意 auto 条件叠加时门控仍关闭
46-
assert!(!light_poll_enabled(PauseConditions { manual: true, foreground: true, media: false }));
47-
assert!(!light_poll_enabled(PauseConditions { manual: true, foreground: false, media: true }));
48-
assert!(!light_poll_enabled(PauseConditions { manual: true, foreground: true, media: true }));
49-
}

0 commit comments

Comments
 (0)