Skip to content

Commit 129b422

Browse files
committed
refactor(rust): 拆 live_session_frame 第 4 步 — 播放器探测
1 parent dd4dabf commit 129b422

4 files changed

Lines changed: 172 additions & 137 deletions

File tree

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
//! 播放器行为/信息探测(live_session_frame.rs 的拆分子模块)。
2+
//!
3+
//! @ai-context: 屏幕采样拍的两个**低频旁路**集中于此——5s 播放器暂停图标检测
4+
//! (Pause↔Play 状态机,从 latest_frame 取帧做纯 CV)与 10s 播放器区域
5+
//! OCR 信息探测(时间对/分P → live:session-info)。两者都是"无证据不推断":
6+
//! 无帧/转换失败/OCR 失败一律静默保持或跳过。
7+
//! @ai-context: 副作用 = 读 latest_frame 共享槽(`if let` 守卫持锁窗口逐字保留,D3 不缩短)/
8+
//! `db` 落库(PlayerAction)/ `pause.request_pause` 条件锁存 / `app.emit`
9+
//! (live:media-paused、live:session-info)/ 有界等待 OCR(OCR_REQUEST_TIMEOUT)。
10+
//! @ai-context: `probe_player_info` 同时服务暂停轻量轮询(live_session_pause_poll),
11+
//! 故为 `pub(super)` 自由函数而非方法;10s/5s 节流由调用方控制。
12+
13+
use std::sync::atomic::Ordering;
14+
use std::sync::{Arc, Mutex};
15+
use std::time::{Duration, Instant};
16+
17+
use tauri::Emitter;
18+
19+
use super::{FrameWorkerState, LatestCapturedFrame};
20+
21+
impl FrameWorkerState {
22+
/// 播放器行为检测拍 + 播放器信息探测拍(同一采样 tick 内的两个低频旁路)。
23+
///
24+
/// @ai-context: `comp_epoch` 由调用方注入(主循环当拍构造)——不在此重算,
25+
/// 否则会多读一次 `total_paused_ms` 改变事件时戳基准。
26+
pub(super) fn player_tick(&mut self, comp_epoch: Instant) {
27+
// M1/REQ-125:播放器行为检测(5s 节流——非每帧;从最新帧缓存取帧做
28+
// 暂停图标检测;Pause→无图标 状态机推导 Play 事件;无帧/转换失败 →
29+
// 状态保持(诚实:无证据不推断))
30+
// 审查修复(v0.7.0 新增代码审查):
31+
// ① MEDIUM-6:now_ms 在此处现取(原用采样块开头的旧时刻——OCR 耗时
32+
// + 5s 周期叠加使暂停事件时戳滞后 5-10s);
33+
// ② MEDIUM-9:首次检测只初始化状态不写事件(录制开始前已暂停的视频
34+
// 首轮 paused=true ≠ 初始 false 会写非转换假 Pause)
35+
if self.last_player_check_at.elapsed() >= Duration::from_secs(5) {
36+
self.last_player_check_at = Instant::now();
37+
let check_now_ms = comp_epoch.elapsed().as_millis() as u64;
38+
if let Some(f) = self.latest_frame.lock().ok().and_then(|g| g.clone()) {
39+
if let Some(img) =
40+
crate::region_ocr::bgra_to_rgb_image(&f.bgraw, f.width, f.height)
41+
{
42+
let paused =
43+
crate::player_behavior::detect_player_action(&img).is_some();
44+
if !self.player_state_initialized {
45+
// 首次检测:仅记录基线状态,不写事件(防假 Pause)
46+
self.player_state_initialized = true;
47+
self.last_player_paused = paused;
48+
// P2:基线即暂停(会话开始时视频已暂停)→ 自动暂停。
49+
// 不写假 Pause 事件(MEDIUM-9),但锁存媒体条件——
50+
// 音频/捕获线程沿边沿同步暂停(批 2a 经 request API)
51+
if paused && !self.pause.paused.load(Ordering::SeqCst) {
52+
let _ = self.pause
53+
.request_pause(crate::pause_state::PauseSource::Media);
54+
let _ = self.app.emit("live:media-paused", ());
55+
eprintln!("[ScreenWorker] 视频处于暂停态,会话自动暂停");
56+
}
57+
} else if paused != self.last_player_paused {
58+
self.last_player_paused = paused;
59+
let action = if paused {
60+
crate::player_behavior::PlayerAction {
61+
kind: crate::player_behavior::PlayerActionKind::Pause,
62+
value: None,
63+
}
64+
} else {
65+
crate::player_behavior::PlayerAction {
66+
kind: crate::player_behavior::PlayerActionKind::Play,
67+
value: None,
68+
}
69+
};
70+
crate::player_behavior::record_action(
71+
&action,
72+
check_now_ms,
73+
self.session_id,
74+
&self.db,
75+
);
76+
if paused && !self.pause.paused.load(Ordering::SeqCst) {
77+
// P2:检测到视频暂停 → 自动暂停捕获(媒体条件;
78+
// 下一轮循环进入轻量轮询,恢复检测不中断)。
79+
// 审查 F5:已暂停(pause=true)时不重复记账/发事件
80+
// (同迭代双系统重复 Pause——机器层同样幂等)
81+
let _ = self.pause
82+
.request_pause(crate::pause_state::PauseSource::Media);
83+
let _ = self.app.emit("live:media-paused", ());
84+
eprintln!("[ScreenWorker] 检测到视频暂停,自动暂停捕获");
85+
}
86+
} else if paused && !self.pause.paused.load(Ordering::SeqCst) {
87+
// P2 兜底(批 2a 语义推广——机器层"manual 解除瞬间重评
88+
// 估 auto 条件"的 worker 侧实现):手动恢复后视频仍
89+
// 暂停 → 重新锁存媒体条件(捕获跟随视频状态,用户
90+
// 手动继续不覆盖);经 request API 只记条件不动作
91+
let _ = self.pause
92+
.request_pause(crate::pause_state::PauseSource::Media);
93+
let _ = self.app.emit("live:media-paused", ());
94+
eprintln!("[ScreenWorker] 视频处于暂停态,重新自动暂停");
95+
}
96+
}
97+
}
98+
}
99+
// v0.7.2(REQ-151):播放器信息探测(10s 节流)——播放器区域 OCR
100+
// 文本(时间对 `12:34 / 1:23:45`、分P `P3/12`)→ 会话信息更新 →
101+
// 值变化才 emit live:session-info(防 IPC 风暴);无播放区域/OCR
102+
// 失败 → 静默跳过(诚实:不猜不填;下轮再试)
103+
if self.last_info_probe_at.elapsed() >= Duration::from_secs(10) {
104+
self.last_info_probe_at = Instant::now();
105+
probe_player_info(&self.app, &self.engines, &self.session_info, &self.roi_tracker, &self.latest_frame);
106+
}
107+
}
108+
}
109+
110+
/// 播放器信息探测(REQ-151,v0.7.2):播放器区域 OCR 文本(时间对/分P)→
111+
/// 会话信息更新 → 值变化才 emit live:session-info(防 IPC 风暴)。
112+
///
113+
/// @ai-context: 主采样循环与自动暂停轻量轮询共用——暂停时播放器时间文本仍
114+
/// 在画面,时长/集号识别不因暂停缺席(10s 节流由调用方控制);
115+
/// 无播放区域/OCR 失败 → 静默跳过(诚实:不猜不填,下轮再试)。
116+
pub(super) fn probe_player_info(
117+
app: &tauri::AppHandle,
118+
engines: &crate::engine::EnginePool,
119+
session_info: &crate::session_info::SessionInfoCollector,
120+
roi_tracker: &crate::region_tracker::RoiTracker,
121+
latest_frame: &Arc<Mutex<Option<LatestCapturedFrame>>>,
122+
) {
123+
let mut probe = latest_frame
124+
.lock()
125+
.ok()
126+
.and_then(|g| g.clone())
127+
.unwrap_or(LatestCapturedFrame { timestamp_ms: 0, bgraw: Vec::new(), width: 0, height: 0 });
128+
if probe.bgraw.is_empty() {
129+
return;
130+
}
131+
if let Some(rect) = roi_tracker.playback_rect() {
132+
let w = probe.width as i32;
133+
let h = probe.height as i32;
134+
let q = crate::capture::frame_diff::Rect {
135+
left: rect.left.clamp(0, w),
136+
top: rect.top.clamp(0, h),
137+
right: rect.right.clamp(0, w),
138+
bottom: rect.bottom.clamp(0, h),
139+
};
140+
if q.width() > 0 && q.height() > 0 {
141+
crate::capture::frame_diff::crop_frame(
142+
&mut probe.bgraw,
143+
&mut probe.width,
144+
&mut probe.height,
145+
Some(&q),
146+
);
147+
}
148+
}
149+
if probe.bgraw.is_empty() {
150+
return;
151+
}
152+
// P4:OCR 输入缩小(播放器 UI 文字大,质量无损)
153+
crate::capture::frame_diff::downscale_bgra(&mut probe.bgraw, &mut probe.width, &mut probe.height, 960);
154+
let Some(img) =
155+
crate::region_ocr::bgra_to_rgb_image(&probe.bgraw, probe.width, probe.height)
156+
else {
157+
return;
158+
};
159+
// H2 修复:有界等待变体——探测帧 OCR 卡死时超时即弃(实时链路不得无限阻塞)
160+
let Ok(blocks) = engines.recognize_image_timeout(img, crate::engine::OCR_REQUEST_TIMEOUT) else { return };
161+
let text = blocks.iter().map(|b| b.text.as_str()).collect::<Vec<_>>().join(" ");
162+
if session_info.observe_player_text(&text) {
163+
let _ = app.emit("live:session-info", session_info.snapshot());
164+
}
165+
}

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

Lines changed: 5 additions & 136 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ pub(super) mod live_session_worker_state;
5353
#[path = "live_session_pause_poll.rs"]
5454
pub(super) mod live_session_pause_poll;
5555

56+
/// 播放器行为/信息探测(5s 图标检测 + 10s 区域 OCR;§3)。
57+
#[path = "live_player_probe.rs"]
58+
pub(super) mod live_player_probe;
59+
5660

5761
/// 屏幕采样线程入口(TD-026 修复:OCR 从会话线程移出,音频消费不再被阻塞)。
5862
///
@@ -436,86 +440,7 @@ pub fn run_screen_worker(
436440
}));
437441
}
438442
}
439-
// M1/REQ-125:播放器行为检测(5s 节流——非每帧;从最新帧缓存取帧做
440-
// 暂停图标检测;Pause→无图标 状态机推导 Play 事件;无帧/转换失败 →
441-
// 状态保持(诚实:无证据不推断))
442-
// 审查修复(v0.7.0 新增代码审查):
443-
// ① MEDIUM-6:now_ms 在此处现取(原用采样块开头的旧时刻——OCR 耗时
444-
// + 5s 周期叠加使暂停事件时戳滞后 5-10s);
445-
// ② MEDIUM-9:首次检测只初始化状态不写事件(录制开始前已暂停的视频
446-
// 首轮 paused=true ≠ 初始 false 会写非转换假 Pause)
447-
if w.last_player_check_at.elapsed() >= Duration::from_secs(5) {
448-
w.last_player_check_at = Instant::now();
449-
let check_now_ms = comp_epoch.elapsed().as_millis() as u64;
450-
if let Some(f) = w.latest_frame.lock().ok().and_then(|g| g.clone()) {
451-
if let Some(img) =
452-
crate::region_ocr::bgra_to_rgb_image(&f.bgraw, f.width, f.height)
453-
{
454-
let paused =
455-
crate::player_behavior::detect_player_action(&img).is_some();
456-
if !w.player_state_initialized {
457-
// 首次检测:仅记录基线状态,不写事件(防假 Pause)
458-
w.player_state_initialized = true;
459-
w.last_player_paused = paused;
460-
// P2:基线即暂停(会话开始时视频已暂停)→ 自动暂停。
461-
// 不写假 Pause 事件(MEDIUM-9),但锁存媒体条件——
462-
// 音频/捕获线程沿边沿同步暂停(批 2a 经 request API)
463-
if paused && !w.pause.paused.load(Ordering::SeqCst) {
464-
let _ = w.pause
465-
.request_pause(crate::pause_state::PauseSource::Media);
466-
let _ = w.app.emit("live:media-paused", ());
467-
eprintln!("[ScreenWorker] 视频处于暂停态,会话自动暂停");
468-
}
469-
} else if paused != w.last_player_paused {
470-
w.last_player_paused = paused;
471-
let action = if paused {
472-
crate::player_behavior::PlayerAction {
473-
kind: crate::player_behavior::PlayerActionKind::Pause,
474-
value: None,
475-
}
476-
} else {
477-
crate::player_behavior::PlayerAction {
478-
kind: crate::player_behavior::PlayerActionKind::Play,
479-
value: None,
480-
}
481-
};
482-
crate::player_behavior::record_action(
483-
&action,
484-
check_now_ms,
485-
w.session_id,
486-
&w.db,
487-
);
488-
if paused && !w.pause.paused.load(Ordering::SeqCst) {
489-
// P2:检测到视频暂停 → 自动暂停捕获(媒体条件;
490-
// 下一轮循环进入轻量轮询,恢复检测不中断)。
491-
// 审查 F5:已暂停(pause=true)时不重复记账/发事件
492-
// (同迭代双系统重复 Pause——机器层同样幂等)
493-
let _ = w.pause
494-
.request_pause(crate::pause_state::PauseSource::Media);
495-
let _ = w.app.emit("live:media-paused", ());
496-
eprintln!("[ScreenWorker] 检测到视频暂停,自动暂停捕获");
497-
}
498-
} else if paused && !w.pause.paused.load(Ordering::SeqCst) {
499-
// P2 兜底(批 2a 语义推广——机器层"manual 解除瞬间重评
500-
// 估 auto 条件"的 worker 侧实现):手动恢复后视频仍
501-
// 暂停 → 重新锁存媒体条件(捕获跟随视频状态,用户
502-
// 手动继续不覆盖);经 request API 只记条件不动作
503-
let _ = w.pause
504-
.request_pause(crate::pause_state::PauseSource::Media);
505-
let _ = w.app.emit("live:media-paused", ());
506-
eprintln!("[ScreenWorker] 视频处于暂停态,重新自动暂停");
507-
}
508-
}
509-
}
510-
}
511-
// v0.7.2(REQ-151):播放器信息探测(10s 节流)——播放器区域 OCR
512-
// 文本(时间对 `12:34 / 1:23:45`、分P `P3/12`)→ 会话信息更新 →
513-
// 值变化才 emit live:session-info(防 IPC 风暴);无播放区域/OCR
514-
// 失败 → 静默跳过(诚实:不猜不填;下轮再试)
515-
if w.last_info_probe_at.elapsed() >= Duration::from_secs(10) {
516-
w.last_info_probe_at = Instant::now();
517-
probe_player_info(&w.app, &w.engines, &w.session_info, &w.roi_tracker, &w.latest_frame);
518-
}
443+
w.player_tick(comp_epoch);
519444
}
520445
// 诊断:每 15s 打印采样统计(会话无 OCR 时定位失败阶段;静默失败可见化)
521446
if w.stats
@@ -544,62 +469,6 @@ pub fn run_screen_worker(
544469
eprintln!("[ScreenWorker] 屏幕采样线程退出(会话 {})", w.session_id);
545470
}
546471

547-
/// 播放器信息探测(REQ-151,v0.7.2):播放器区域 OCR 文本(时间对/分P)→
548-
/// 会话信息更新 → 值变化才 emit live:session-info(防 IPC 风暴)。
549-
///
550-
/// @ai-context: 主采样循环与自动暂停轻量轮询共用——暂停时播放器时间文本仍
551-
/// 在画面,时长/集号识别不因暂停缺席(10s 节流由调用方控制);
552-
/// 无播放区域/OCR 失败 → 静默跳过(诚实:不猜不填,下轮再试)。
553-
fn probe_player_info(
554-
app: &tauri::AppHandle,
555-
engines: &crate::engine::EnginePool,
556-
session_info: &crate::session_info::SessionInfoCollector,
557-
roi_tracker: &crate::region_tracker::RoiTracker,
558-
latest_frame: &Arc<Mutex<Option<LatestCapturedFrame>>>,
559-
) {
560-
let mut probe = latest_frame
561-
.lock()
562-
.ok()
563-
.and_then(|g| g.clone())
564-
.unwrap_or(LatestCapturedFrame { timestamp_ms: 0, bgraw: Vec::new(), width: 0, height: 0 });
565-
if probe.bgraw.is_empty() {
566-
return;
567-
}
568-
if let Some(rect) = roi_tracker.playback_rect() {
569-
let w = probe.width as i32;
570-
let h = probe.height as i32;
571-
let q = crate::capture::frame_diff::Rect {
572-
left: rect.left.clamp(0, w),
573-
top: rect.top.clamp(0, h),
574-
right: rect.right.clamp(0, w),
575-
bottom: rect.bottom.clamp(0, h),
576-
};
577-
if q.width() > 0 && q.height() > 0 {
578-
crate::capture::frame_diff::crop_frame(
579-
&mut probe.bgraw,
580-
&mut probe.width,
581-
&mut probe.height,
582-
Some(&q),
583-
);
584-
}
585-
}
586-
if probe.bgraw.is_empty() {
587-
return;
588-
}
589-
// P4:OCR 输入缩小(播放器 UI 文字大,质量无损)
590-
crate::capture::frame_diff::downscale_bgra(&mut probe.bgraw, &mut probe.width, &mut probe.height, 960);
591-
let Some(img) =
592-
crate::region_ocr::bgra_to_rgb_image(&probe.bgraw, probe.width, probe.height)
593-
else {
594-
return;
595-
};
596-
// H2 修复:有界等待变体——探测帧 OCR 卡死时超时即弃(实时链路不得无限阻塞)
597-
let Ok(blocks) = engines.recognize_image_timeout(img, crate::engine::OCR_REQUEST_TIMEOUT) else { return };
598-
let text = blocks.iter().map(|b| b.text.as_str()).collect::<Vec<_>>().join(" ");
599-
if session_info.observe_player_text(&text) {
600-
let _ = app.emit("live:session-info", session_info.snapshot());
601-
}
602-
}
603472

604473
/// 单测独立文件(保持本文件 ≤300 行,AGENTS.md §3)。
605474
#[cfg(test)]

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ impl FrameWorkerState {
188188
// 会话开始时视频已暂停的场景,时长/集号识别不因此缺席
189189
if self.last_info_probe_at.elapsed() >= Duration::from_secs(10) {
190190
self.last_info_probe_at = Instant::now();
191-
super::probe_player_info(&self.app, &self.engines, &self.session_info, &self.roi_tracker, &self.latest_frame);
191+
super::live_player_probe::probe_player_info(&self.app, &self.engines, &self.session_info, &self.roi_tracker, &self.latest_frame);
192192
}
193193
}
194194
}

0 commit comments

Comments
 (0)