Skip to content

Commit 53d2b5c

Browse files
committed
feat(classroom): 采集信息面板——平台时长合集字幕
1 parent f992d8a commit 53d2b5c

9 files changed

Lines changed: 634 additions & 4 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,8 @@ mod subtitle_ocr;
151151
mod speaker_change;
152152
// v0.7.2(REQ-152):视频系列(合集)检测——标题序列号提取/平台后缀剥离(纯逻辑)
153153
mod series_detect;
154+
// v0.7.2(REQ-151):会话信息聚合——采集信息面板数据源(平台/时长/合集/字幕)
155+
mod session_info;
154156
// v0.7.0 M1.5(REQ-108):统一信号事件域(类型/分级/容量守卫;数据层在 db_session_events)
155157
mod session_events;
156158
mod symbol_normalize;

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

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ pub struct LiveSessionManager {
8383
pause: crate::capture::audio_loopback::SessionPause,
8484
/// P3:预备会话(引擎预热——选窗口阶段后台加载;start 交接复用)
8585
prepared: Arc<Mutex<Option<PreparedSession>>>,
86+
/// v0.7.2(REQ-151):会话信息聚合(面板数据源——屏幕 worker 写入,事件/命令读取)
87+
session_info: crate::session_info::SessionInfoCollector,
8688
}
8789

8890
impl Default for LiveSessionManager {
@@ -99,6 +101,7 @@ impl Clone for LiveSessionManager {
99101
latest_frame: self.latest_frame.clone(),
100102
pause: self.pause.clone(),
101103
prepared: self.prepared.clone(),
104+
session_info: self.session_info.clone(),
102105
}
103106
}
104107
}
@@ -111,6 +114,7 @@ impl LiveSessionManager {
111114
latest_frame: Arc::new(Mutex::new(None)),
112115
pause: Default::default(),
113116
prepared: Arc::new(Mutex::new(None)),
117+
session_info: crate::session_info::SessionInfoCollector::new(),
114118
}
115119
}
116120

@@ -183,6 +187,10 @@ impl LiveSessionManager {
183187
// 跨会话残留会让新会话起始即暂停、时间戳偏移)
184188
self.pause.reset();
185189
let pause = self.pause.clone();
190+
// v0.7.2(REQ-151):从窗口标题初始化会话信息(平台/系列/集号——
191+
// 标题信号零成本;时长/分P 由屏幕 worker 播放器 OCR 增量补充)
192+
self.session_info
193+
.init_from_title(params.source_window.as_deref().unwrap_or(&params.title));
186194

187195
// P3:尝试交接预备线程
188196
if let Some(p) = self.prepared.lock().expect("prepared lock poisoned").take() {
@@ -194,6 +202,7 @@ impl LiveSessionManager {
194202
stop: flag.clone(),
195203
latest_frame: latest_frame.clone(),
196204
pause: pause.clone(),
205+
session_info: self.session_info.clone(),
197206
};
198207
match p.tx.send(PrepareMsg::Start(Box::new(handoff))) {
199208
Ok(()) => {
@@ -208,7 +217,7 @@ impl LiveSessionManager {
208217
let thread = std::thread::Builder::new()
209218
.name("entropy-live-session".into())
210219
.spawn(move || {
211-
run_session(h.stop, h.params, h.session_id, h.latest_frame, h.pause)
220+
run_session(h.stop, h.params, h.session_id, h.latest_frame, h.pause, h.session_info)
212221
})
213222
.map_err(|e| AppError::Io(format!("启动会话线程失败: {}", e)))?;
214223
*guard = Some(ActiveSession { stop_flag, thread, session_id });
@@ -241,9 +250,13 @@ impl LiveSessionManager {
241250
}
242251

243252
// 回退:内联加载(现状路径——模型加载在会话线程内完成)
253+
// 会话信息聚合器先 clone(move 闭包不得借用 &self)
254+
let inline_session_info = self.session_info.clone();
244255
let thread = std::thread::Builder::new()
245256
.name("entropy-live-session".into())
246-
.spawn(move || run_session(flag, params, session_id, latest_frame, pause))
257+
.spawn(move || {
258+
run_session(flag, params, session_id, latest_frame, pause, inline_session_info)
259+
})
247260
.map_err(|e| AppError::Io(format!("启动会话线程失败: {}", e)))?;
248261
*guard = Some(ActiveSession { stop_flag, thread, session_id });
249262
Ok(session_id)
@@ -395,6 +408,8 @@ fn run_session(
395408
latest_frame: Arc<Mutex<Option<crate::live_session_frame::LatestCapturedFrame>>>,
396409
// 2026-08 A1:会话暂停共享状态(manager 持同一实例;捕获线程维护补偿时长)
397410
pause: crate::capture::audio_loopback::SessionPause,
411+
// v0.7.2(REQ-151):会话信息聚合(屏幕 worker 播放器 OCR 写入)
412+
session_info: crate::session_info::SessionInfoCollector,
398413
) {
399414
let db = params.db.clone();
400415
let engines = params.engines.clone();
@@ -422,7 +437,16 @@ fn run_session(
422437
return;
423438
}
424439
};
425-
run_session_after_engine(asr_engine, stop, params, session_id, latest_frame, pause, epoch);
440+
run_session_after_engine(
441+
asr_engine,
442+
stop,
443+
params,
444+
session_id,
445+
latest_frame,
446+
pause,
447+
epoch,
448+
session_info,
449+
);
426450
}
427451

428452
/// 会话装配后半段(引擎就绪后):音频捕获 → 屏幕 worker → 主循环 → 后台融合。
@@ -443,12 +467,16 @@ pub(crate) fn run_session_after_engine(
443467
pause: crate::capture::audio_loopback::SessionPause,
444468
// A1:会话纪元——音频/屏幕/flush 三处时间戳的唯一基准(ADR-008)
445469
epoch: Instant,
470+
// v0.7.2(REQ-151):会话信息聚合(屏幕 worker 播放器 OCR 写入)
471+
session_info: crate::session_info::SessionInfoCollector,
446472
) {
447473
let db = params.db.clone();
448474
let engines = params.engines.clone();
449475
// ADR-007:会话启动成功(引擎就绪)→ 广播录制态(前端全局采集徽标依赖此事件;
450476
// 音频/屏幕后续故障走自动恢复不再终止会话)
451477
let _ = params.app.emit("live:status", "recording");
478+
// v0.7.2(REQ-151):标题信息就绪即推送(平台/系列/集号;时长待播放器 OCR)
479+
let _ = params.app.emit("live:session-info", session_info.snapshot());
452480

453481
// 2) 音频捕获:捕获线程 → channel → 会话线程(引擎非 Send)
454482
// @ai-context: ADR-007:start 不再因设备缺失返回 Err——捕获线程内部自动重连
@@ -531,6 +559,8 @@ pub(crate) fn run_session_after_engine(
531559
let foreground_monitor = crate::foreground_timeline::ForegroundMonitor::new(params.hwnd);
532560
// 2026-08 A1:屏幕 worker 共享暂停状态(暂停跳过采样;恢复后时间戳补偿)
533561
let worker_pause = pause.clone();
562+
// v0.7.2(REQ-151):会话信息聚合(worker 播放器 OCR 写入)
563+
let worker_session_info = session_info.clone();
534564
match std::thread::Builder::new()
535565
.name("entropy-screen-worker".into())
536566
.spawn(move || {
@@ -553,6 +583,8 @@ pub(crate) fn run_session_after_engine(
553583
foreground_monitor,
554584
// 2026-08 A1:暂停共享状态
555585
worker_pause,
586+
// v0.7.2(REQ-151):会话信息聚合(播放器 OCR 写入)
587+
worker_session_info,
556588
)
557589
}) {
558590
Ok(h) => Some(h),

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

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ use std::sync::atomic::{AtomicBool, Ordering};
1212
use std::sync::{Arc, Mutex};
1313
use std::time::{Duration, Instant};
1414

15+
use tauri::Emitter;
16+
1517
use crate::capture::frame_diff::{DualRateScheduler, SampleRegion};
1618
use crate::capture::ScreenCaptureSampler;
1719
use crate::db::Db;
@@ -69,6 +71,8 @@ pub fn run_screen_worker(
6971
mut foreground_monitor: crate::foreground_timeline::ForegroundMonitor,
7072
// 2026-08 A1:会话暂停共享状态(暂停跳过采样;恢复后时间戳补偿暂停时长)
7173
pause: crate::capture::audio_loopback::SessionPause,
74+
// v0.7.2(REQ-151):会话信息聚合(播放器 OCR 文本 → 平台/时长/合集信息面板)
75+
session_info: crate::session_info::SessionInfoCollector,
7276
) {
7377
let mut screen = match ScreenCaptureSampler::new(hwnd.map(crate::windows::hwnd_from_i64)) {
7478
Ok(s) => {
@@ -134,6 +138,9 @@ pub fn run_screen_worker(
134138
let mut last_player_check_at = Instant::now();
135139
let mut last_player_paused = false;
136140
let mut player_state_initialized = false;
141+
// v0.7.2(REQ-151):播放器信息探测节流(10s 一次——播放器区域 OCR 成本
142+
// ~100-300ms,秒级粒度足够;信息变化才 emit)
143+
let mut last_info_probe_at = Instant::now();
137144
// 2026-08 A1:暂停边沿跟踪(暂停期画面链整体冻结:采样/前台监控/播放器
138145
// 检测全部跳过——"会话时间"在暂停期间不前进)
139146
let mut worker_paused = pause.paused.load(Ordering::SeqCst);
@@ -341,6 +348,68 @@ pub fn run_screen_worker(
341348
}
342349
}
343350
}
351+
// v0.7.2(REQ-151):播放器信息探测(10s 节流)——播放器区域 OCR
352+
// 文本(时间对 `12:34 / 1:23:45`、分P `P3/12`)→ 会话信息更新 →
353+
// 值变化才 emit live:session-info(防 IPC 风暴);无播放区域/OCR
354+
// 失败 → 静默跳过(诚实:不猜不填;下轮再试)
355+
if last_info_probe_at.elapsed() >= Duration::from_secs(10) {
356+
last_info_probe_at = Instant::now();
357+
let mut probe =
358+
latest_frame.lock().ok().and_then(|g| g.clone()).unwrap_or(LatestCapturedFrame {
359+
timestamp_ms: 0,
360+
bgraw: Vec::new(),
361+
width: 0,
362+
height: 0,
363+
});
364+
if !probe.bgraw.is_empty() {
365+
if let Some(rect) = roi_tracker.playback_rect() {
366+
let w = probe.width as i32;
367+
let h = probe.height as i32;
368+
let q = crate::capture::frame_diff::Rect {
369+
left: rect.left.clamp(0, w),
370+
top: rect.top.clamp(0, h),
371+
right: rect.right.clamp(0, w),
372+
bottom: rect.bottom.clamp(0, h),
373+
};
374+
if q.width() > 0 && q.height() > 0 {
375+
crate::capture::frame_diff::crop_frame(
376+
&mut probe.bgraw,
377+
&mut probe.width,
378+
&mut probe.height,
379+
Some(&q),
380+
);
381+
}
382+
}
383+
if !probe.bgraw.is_empty() {
384+
// P4:OCR 输入缩小(播放器 UI 文字大,质量无损)
385+
crate::capture::frame_diff::downscale_bgra(
386+
&mut probe.bgraw,
387+
&mut probe.width,
388+
&mut probe.height,
389+
960,
390+
);
391+
if let Some(img) = crate::region_ocr::bgra_to_rgb_image(
392+
&probe.bgraw,
393+
probe.width,
394+
probe.height,
395+
) {
396+
if let Ok(blocks) = engines.recognize_image(img) {
397+
let text = blocks
398+
.iter()
399+
.map(|b| b.text.as_str())
400+
.collect::<Vec<_>>()
401+
.join(" ");
402+
if session_info.observe_player_text(&text) {
403+
let _ = app.emit(
404+
"live:session-info",
405+
session_info.snapshot(),
406+
);
407+
}
408+
}
409+
}
410+
}
411+
}
412+
}
344413
}
345414
// 诊断:每 15s 打印采样统计(会话无 OCR 时定位失败阶段;静默失败可见化)
346415
if stats

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ pub struct StartHandoff {
4545
pub latest_frame: Arc<Mutex<Option<crate::live_session_frame::LatestCapturedFrame>>>,
4646
/// 会话暂停共享状态(A1:manager 持同一实例,捕获线程维护补偿时长)
4747
pub pause: crate::capture::audio_loopback::SessionPause,
48+
/// v0.7.2(REQ-151):会话信息聚合(屏幕 worker 播放器 OCR 写入)
49+
pub session_info: crate::session_info::SessionInfoCollector,
4850
}
4951

5052
/// 预备线程消息(Start 载荷装箱——消息只发一次,避免大枚举变体常驻栈上)。
@@ -135,6 +137,7 @@ pub fn run_prepared(rx: mpsc::Receiver<PrepareMsg>, env: PrepareEnv, status: Arc
135137
stop,
136138
latest_frame,
137139
pause,
140+
session_info,
138141
} = *handoff;
139142
// 会话纪元在交接后创建(模型已加载——无 A1 秒级偏移)
140143
let epoch = Instant::now();
@@ -146,6 +149,7 @@ pub fn run_prepared(rx: mpsc::Receiver<PrepareMsg>, env: PrepareEnv, status: Arc
146149
latest_frame,
147150
pause,
148151
epoch,
152+
session_info,
149153
);
150154
return;
151155
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,12 @@ impl RoiTracker {
198198
self.roi
199199
}
200200

201+
/// 当前播放区域(帧坐标系;v0.7.2 REQ-151:会话信息面板的播放器文本
202+
/// 探测裁剪源;未检测到播放区域 → None,调用方退化为全帧/跳过)。
203+
pub fn playback_rect(&self) -> Option<Rect> {
204+
self.video_rect
205+
}
206+
201207
/// 字幕带(ADR-011 带外判定基准):播放区域底部 25% 带,
202208
/// 无播放区域退化为窗口底部 25% 带(prior_roi 语义)。
203209
pub fn subtitle_band(&self) -> Rect {

0 commit comments

Comments
 (0)