|
| 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 | +} |
0 commit comments