|
| 1 | +//! 采样 tick 消费(live_session_frame.rs 的拆分子模块)。 |
| 2 | +//! |
| 3 | +//! @ai-context: 1s 主循环里四个**节拍体**集中于此——①REQ-291 随播随停 1s 媒体拍 |
| 4 | +//! (声画双通道 → 锁存 media 条件);②M4 每 2s CPU 负载降级拍; |
| 5 | +//! ③采样 1s 拍(前台切换 ROI 重扫 → 自适应档/空闲降频 → `process_frame` |
| 6 | +//! → 停更监测 → 画面动时刻 → OCR 文本 FIFO 累计 → 档案拍 → 播放器拍); |
| 7 | +//! ④15s 采样统计诊断打印。 |
| 8 | +//! @ai-context: **块间隐式数据流逐字保留**——`stats.diff_pass/ocr_ok` 在 `process_frame` |
| 9 | +//! 内更新、其后才被 `profile_tick` 读差量;`accumulated_ocr_text` 更新后 |
| 10 | +//! 才被领域重评读;`got_frame` 由 `process_frame`/`capture_latest_only` |
| 11 | +//! 内部清零后被读。顺序、读点位置、节拍层级一律不变。 |
| 12 | +//! @ai-context: 副作用 = 屏幕取帧 + OCR/落库(经 process_frame)/ `pause.request_pause` |
| 13 | +//! / `app.emit`(live:media-paused)/ eprintln 诊断;`speech_active` 读 |
| 14 | +//! 一律 `Relaxed`(勿改 SeqCst);空转 sleep 留在主循环。 |
| 15 | +
|
| 16 | +use std::sync::atomic::Ordering; |
| 17 | +use std::time::{Duration, Instant}; |
| 18 | + |
| 19 | +use tauri::Emitter; |
| 20 | + |
| 21 | +use super::live_session_liveness::liveness_check; |
| 22 | +use super::live_session_pause_poll::media_sound_recent; |
| 23 | +use super::{FrameWorkerState, IDLE_PROBE_INTERVAL_MS, SAMPLE_TICK_MS}; |
| 24 | +use crate::capture::frame_diff::SampleRegion; |
| 25 | +use crate::live_frame_process::process_frame; |
| 26 | + |
| 27 | +impl FrameWorkerState { |
| 28 | + /// REQ-291 随播随停 1s 拍(未暂停路径):声画双通道确认 → 锁存 media 条件。 |
| 29 | + /// |
| 30 | + /// @ai-context: `comp_epoch` 由调用方注入(主循环当拍构造,恢复后补偿值恒定); |
| 31 | + /// 调用方已保证非暂停(`!paused_now` 在调用点判定,语义与拆分前的 |
| 32 | + /// `if !paused_now && …` 短路顺序一致)。Idle 静默期仍判暂停。 |
| 33 | + pub(super) fn media_tick(&mut self, comp_epoch: Instant) { |
| 34 | + // REQ-291(v0.19.7):随播随停 1s 拍(独立于采样——idle 静默期仍判暂停; |
| 35 | + // 手动暂停不判:manual 锁存期语义是用户冻结,不跟随视频——批 2a 起 |
| 36 | + // 主路径只在未暂停时运行,暂停期恢复检测在暂停分支按 auto 条件 |
| 37 | + // (media/fg)轮询) |
| 38 | + if self.last_media_tick.elapsed() >= Duration::from_secs(1) { |
| 39 | + self.last_media_tick = Instant::now(); |
| 40 | + let sound_recent = media_sound_recent(&self.media_sound); |
| 41 | + let motion_recent = self.last_motion_at.is_some_and(|t| { |
| 42 | + self.last_media_tick.duration_since(t) <= Duration::from_millis(1500) |
| 43 | + }); |
| 44 | + let decision = self.media_detector.tick(sound_recent, motion_recent); |
| 45 | + if decision == crate::media_state::MediaDecision::Suspend { |
| 46 | + let ms = comp_epoch.elapsed().as_millis() as u64; |
| 47 | + // 批 2a:经 request API 锁存媒体条件(暂停动作由机器层完成—— |
| 48 | + // manual 锁存期 auto 提议只记条件不动作) |
| 49 | + let _ = self.pause.request_pause(crate::pause_state::PauseSource::Media); |
| 50 | + crate::player_behavior::record_action( |
| 51 | + &crate::player_behavior::PlayerAction { |
| 52 | + kind: crate::player_behavior::PlayerActionKind::Pause, |
| 53 | + value: None, |
| 54 | + }, |
| 55 | + ms, |
| 56 | + self.session_id, |
| 57 | + &self.db, |
| 58 | + ); |
| 59 | + let _ = self.app.emit("live:media-paused", ()); |
| 60 | + eprintln!("[ScreenWorker] 随播随停:声画双通道确认视频暂停 → 自动暂停捕获"); |
| 61 | + } |
| 62 | + // 注:主路径 Resume 决策(审查 F1 曾清 auto_paused 标记)已随 |
| 63 | + // auto_paused 删除——检测器相位自更新;媒体解除只发生在暂停分支 |
| 64 | + // 的恢复检测(机器层保证暂停 ⇔ 条件锁存,主路径无残留标记可清) |
| 65 | + } |
| 66 | + } |
| 67 | + |
| 68 | + /// M4/REQ-039 P8:每 2s 采样 CPU 负载(降级标志变化打印——静默失败可见化)。 |
| 69 | + pub(super) fn load_tick(&mut self) { |
| 70 | + // M4:每 2s 采样 CPU 负载(降级标志变化打印——静默失败可见化) |
| 71 | + if self.last_load_check_at.elapsed() >= Duration::from_secs(2) { |
| 72 | + self.last_load_check_at = Instant::now(); |
| 73 | + let new_degraded = self.load_monitor.tick(); |
| 74 | + if new_degraded != self.degraded { |
| 75 | + self.degraded = new_degraded; |
| 76 | + if self.degraded { |
| 77 | + eprintln!("[ScreenWorker] 负载高,采样降级(全帧 0.1fps 封顶,REQ-039 P8)"); |
| 78 | + } else { |
| 79 | + eprintln!("[ScreenWorker] 负载恢复,采样档位还原"); |
| 80 | + } |
| 81 | + } |
| 82 | + } |
| 83 | + } |
| 84 | + |
| 85 | + /// 采样 1s 拍主体(含自适应档/空闲降频/process_frame/停更监测/档案与播放器拍)。 |
| 86 | + /// |
| 87 | + /// @ai-context: 节流判定在方法内(与拆分前的 `if last_sample_at…` 逐字一致); |
| 88 | + /// `now_ms` 在 process_frame **之前**取(前台时间线/空闲降频/ |
| 89 | + /// 档案拍共用同一时刻——顺序不可前移/后移)。 |
| 90 | + pub(super) fn sample_tick(&mut self, comp_epoch: Instant) { |
| 91 | + if self.last_sample_at.elapsed().as_millis() as u64 >= SAMPLE_TICK_MS { |
| 92 | + self.last_sample_at = Instant::now(); |
| 93 | + // REQ-084:前台窗口切换检测(每秒一次)——前台与录制目标不一致 → |
| 94 | + // ROI 强制重扫 + 字幕处理冻结(防其他窗口底部内容被当字幕); |
| 95 | + // 无目标窗口(全屏捕获)或探测失败 → 静默跳过(误触发阈值校准) |
| 96 | + let foreign = match (self.hwnd, crate::windows::foreground_hwnd()) { |
| 97 | + (Some(target), Some(fg)) => fg != target, |
| 98 | + _ => false, |
| 99 | + }; |
| 100 | + self.roi_tracker.on_foreground_switch(foreign); |
| 101 | + // B3(P3 简化版)+ M4:语音活跃度 + 负载档驱动自适应采样 |
| 102 | + let mut region = self.scheduler.next_region(self.speech_active.load(Ordering::Relaxed), self.degraded); |
| 103 | + // M5/REQ-073:空闲降频状态机——画面变化信号 = diff 通过计数增长 |
| 104 | + // (process_frame 内更新,同线程可见);idle 时跳过采样(引擎 |
| 105 | + // 阻塞空闲零 CPU);空闲期低频探针(5s 一次全帧)检测无声恢复 |
| 106 | + let now_ms = comp_epoch.elapsed().as_millis() as u64; |
| 107 | + // M16/REQ-128:前台时间线监控(独立 2s 轮询——不改 region_tracker 行为; |
| 108 | + // 变化 → ForegroundSwitch 事件落库;观测失败 None → 静默跳过) |
| 109 | + if now_ms.saturating_sub(self.last_fg_poll_ms) >= 2_000 { |
| 110 | + self.last_fg_poll_ms = now_ms; |
| 111 | + self.foreground_monitor.observe( |
| 112 | + crate::windows::foreground_hwnd(), |
| 113 | + now_ms, |
| 114 | + self.session_id, |
| 115 | + &self.db, |
| 116 | + ); |
| 117 | + } |
| 118 | + let changed = self.stats.diff_pass > self.last_diff_pass; |
| 119 | + self.last_diff_pass = self.stats.diff_pass; |
| 120 | + let _ = self.idle_governor.observe( |
| 121 | + self.speech_active.load(Ordering::Relaxed), |
| 122 | + changed, |
| 123 | + now_ms, |
| 124 | + ); |
| 125 | + let idle = self.idle_governor.is_idle(); |
| 126 | + let probe = idle && now_ms.saturating_sub(self.last_probe_ms) >= IDLE_PROBE_INTERVAL_MS; |
| 127 | + if probe { |
| 128 | + self.last_probe_ms = now_ms; |
| 129 | + region = SampleRegion::Full; |
| 130 | + } |
| 131 | + if (region != SampleRegion::Skip && !idle) || probe { |
| 132 | + process_frame( |
| 133 | + self.screen.as_mut(), &mut self.trigger, &mut self.voter, &mut self.last_frame_text, &mut self.last_preview, |
| 134 | + &self.db, &self.engines, &self.app, self.session_id, region, &self.subtitle_segments, comp_epoch, |
| 135 | + &mut self.last_capture_error, &mut self.last_full_texts, &mut self.stats, |
| 136 | + &mut self.roi_tracker, &mut self.frame_samples, |
| 137 | + &mut self.last_archived_text, &mut self.last_archived_at, &self.latest_frame, |
| 138 | + &mut self.image_store, &self.ui_junk, &mut self.screen_tracker, |
| 139 | + // v0.11.5(Task 2):变化区域基准 + 生效画面档(None=未定档→medium 默认) |
| 140 | + &mut self.last_changed_texts, |
| 141 | + &mut self.got_frame, |
| 142 | + self.tier_applied_tier.map(|t| t.as_str()).unwrap_or("medium"), |
| 143 | + ); |
| 144 | + // REQ-281(v0.19.6):停更监测 + WGC watchdog + 帧心跳(真实采样拍) |
| 145 | + liveness_check(&self.app, self.screen.as_mut(), &mut self.liveness, self.got_frame, Instant::now()); |
| 146 | + // REQ-291:画面动时刻(媒体拍 motion_recent 数据源——WGC 内容驱动 |
| 147 | + // 出帧,got_frame 即"画面变了"的近真信号) |
| 148 | + if self.got_frame { |
| 149 | + self.last_motion_at = Some(Instant::now()); |
| 150 | + } |
| 151 | + // v0.11.5(Task 6):OCR 文本累计(去重→领域检测用) |
| 152 | + // v0.11.5 审查修复(A6):FIFO 上限 50→100——领域检测信号缓存 |
| 153 | + // 保守放大,避免 B站选集证据(`P3/12`/`第3集/共12集`)在 |
| 154 | + // 150s 重评窗口前被 FIFO 淘汰而丢失平台证据 |
| 155 | + for t in &self.last_changed_texts { |
| 156 | + if !self.accumulated_ocr_text.contains(t) { |
| 157 | + self.accumulated_ocr_text.push(t.clone()); |
| 158 | + if self.accumulated_ocr_text.len() > 100 { |
| 159 | + self.accumulated_ocr_text.remove(0); |
| 160 | + } |
| 161 | + } |
| 162 | + } |
| 163 | + } |
| 164 | + self.profile_tick(now_ms); |
| 165 | + self.player_tick(comp_epoch); |
| 166 | + } |
| 167 | + } |
| 168 | + |
| 169 | + /// 15s 采样统计诊断打印(会话无 OCR 时定位失败阶段;静默失败可见化)。 |
| 170 | + pub(super) fn diagnostic_tick(&mut self) { |
| 171 | + // 诊断:每 15s 打印采样统计(会话无 OCR 时定位失败阶段;静默失败可见化) |
| 172 | + if self.stats |
| 173 | + .last_log_at |
| 174 | + .is_none_or(|t| t.elapsed() >= Duration::from_secs(15)) |
| 175 | + { |
| 176 | + self.stats.last_log_at = Some(Instant::now()); |
| 177 | + eprintln!( |
| 178 | + "[ScreenWorker] 采样统计: sampled={} no_change={} capture_err={} diff_pass={} diff_skip={} ocr_ok={} ocr_err={} junk_filtered={} panel_filtered={}", |
| 179 | + self.stats.sampled, self.stats.no_change, self.stats.capture_err, self.stats.diff_pass, self.stats.diff_skip, self.stats.ocr_ok, self.stats.ocr_err, self.stats.junk_filtered, self.stats.panel_filtered |
| 180 | + ); |
| 181 | + } |
| 182 | + } |
| 183 | +} |
0 commit comments