Skip to content

Commit 05ac9f0

Browse files
committed
refactor(rust): 拆 live_session_frame 第 6 步 — 采样消费
1 parent 1479091 commit 05ac9f0

3 files changed

Lines changed: 204 additions & 142 deletions

File tree

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
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

Comments
 (0)