|
| 1 | +//! 画面档 / 档案三维运行期(live_session_frame.rs 的拆分子模块)。 |
| 2 | +//! |
| 3 | +//! @ai-context: 采样 tick 上的**档案决策三块**集中于此——①画面价值观测注入(帧切换 |
| 4 | +//! 上升沿 + OCR 面积)与升档静默/降档确认;②`profile_override` 三维热切换 |
| 5 | +//! 消费(command 写入,本 worker 是唯一消费者,`guard.take()`);③领域 |
| 6 | +//! 自动重评(150s 窗口 + B站选集 OCR 证据 + ASR 开场白)。 |
| 7 | +//! @ai-context: 锁顺序(不得倒置,D3 相邻约束):消费 override 时持 `profile_override` |
| 8 | +//! → 取 `applied_tier`(同拍),emit 在持锁中发出;即 |
| 9 | +//! `profile_override → applied_tier/applied_profile`。 |
| 10 | +//! @ai-context: 副作用 = `scheduler.retune`(采样档变更)/ 写 `applied_tier`、 |
| 11 | +//! `applied_profile` 共享槽 / `app.emit`(live:tier-changed、 |
| 12 | +//! live:tier-downgrade-request、live:profile-updated)/ 防御性 eprintln。 |
| 13 | +//! 领域重评读 `subtitle_segments` 时守卫保持短(仅 filter/map/join)。 |
| 14 | +
|
| 15 | +use tauri::Emitter; |
| 16 | + |
| 17 | +use super::FrameWorkerState; |
| 18 | + |
| 19 | +impl FrameWorkerState { |
| 20 | + /// 采样 tick 的档案决策拍(tier 观测 → 升降档 → override 消费 → 领域重评)。 |
| 21 | + /// |
| 22 | + /// @ai-context: `now_ms` 由调用方注入(同一采样 tick 内、process_frame **之后** |
| 23 | + /// 现取的补偿纪元毫秒)——顺序不可前移:tier 观测依赖 |
| 24 | + /// `stats.diff_pass/ocr_ok` 在本拍 `process_frame` 内已更新。 |
| 25 | + pub(super) fn profile_tick(&mut self, now_ms: u64) { |
| 26 | + // v0.9.0 M2(REQ-189):画面价值观测注入(每采样 tick)——帧切换 |
| 27 | + // 上升沿(diff_pass 增量)、OCR 面积占比(ocr_ok 增量:本版以 |
| 28 | + // 固定 0.4 近似——全帧变化路径即画面有文字;区域构成留 M4 迭代) |
| 29 | + // @review C12: has_structure 恒 false(区域构成信号暂缺实际注入) |
| 30 | + self.tier_observer.observe( |
| 31 | + now_ms / 1000, |
| 32 | + self.stats.diff_pass > self.last_tier_diff_pass, |
| 33 | + (self.stats.ocr_ok > self.last_tier_ocr_ok).then_some(0.4), |
| 34 | + false, |
| 35 | + ); |
| 36 | + self.last_tier_diff_pass = self.stats.diff_pass; |
| 37 | + self.last_tier_ocr_ok = self.stats.ocr_ok; |
| 38 | + // 重评窗口结算后:升档静默生效(retune 采样器);降档需确认—— |
| 39 | + // 确认结果经 tier_override 共享状态回流(前端 confirm_tier_downgrade) |
| 40 | + if let Some(new_tier) = self.tier_observer.current_tier() { |
| 41 | + let applied = self.tier_applied_tier; |
| 42 | + if applied != Some(new_tier) { |
| 43 | + let change = crate::video_tier_detect::decide_change(applied, Some(new_tier)); |
| 44 | + let budget = crate::video_profile_spec_data::sampling_for_tier(new_tier); |
| 45 | + match change { |
| 46 | + crate::video_tier_detect::TierChange::UpgradeSilent |
| 47 | + | crate::video_tier_detect::TierChange::None => { |
| 48 | + // 升档/首定档静默应用(更积极采样无损失);同档无需动作 |
| 49 | + self.scheduler.retune(budget); |
| 50 | + self.tier_applied_tier = Some(new_tier); |
| 51 | + if let Ok(mut guard) = self.applied_tier.lock() { |
| 52 | + *guard = Some(new_tier); |
| 53 | + } |
| 54 | + let _ = self.app.emit( |
| 55 | + "live:tier-changed", |
| 56 | + serde_json::json!({ |
| 57 | + "tier": new_tier.as_str(), |
| 58 | + "reason": "upgrade-silent", |
| 59 | + }), |
| 60 | + ); |
| 61 | + } |
| 62 | + crate::video_tier_detect::TierChange::DowngradeConfirm => { |
| 63 | + // 降档需确认:读取共享确认状态——用户已确认 → 应用; |
| 64 | + // 未确认 → 保持现状档(不丢信息),下轮重评再询 |
| 65 | + let confirmed = self.tier_override |
| 66 | + .lock() |
| 67 | + .ok() |
| 68 | + .and_then(|g| *g) |
| 69 | + .filter(|t| *t == new_tier); |
| 70 | + if confirmed.is_some() { |
| 71 | + self.scheduler.retune(budget); |
| 72 | + self.tier_applied_tier = Some(new_tier); |
| 73 | + if let Ok(mut guard) = self.applied_tier.lock() { |
| 74 | + *guard = Some(new_tier); |
| 75 | + } |
| 76 | + if let Ok(mut guard) = self.tier_override.lock() { |
| 77 | + *guard = None; |
| 78 | + } |
| 79 | + let _ = self.app.emit( |
| 80 | + "live:tier-changed", |
| 81 | + serde_json::json!({ |
| 82 | + "tier": new_tier.as_str(), |
| 83 | + "reason": "downgrade-confirmed", |
| 84 | + }), |
| 85 | + ); |
| 86 | + } else { |
| 87 | + let _ = self.app.emit( |
| 88 | + "live:tier-downgrade-request", |
| 89 | + serde_json::json!({ |
| 90 | + "from": self.tier_applied_tier.map(|t| t.as_str()), |
| 91 | + "to": new_tier.as_str(), |
| 92 | + }), |
| 93 | + ); |
| 94 | + } |
| 95 | + } |
| 96 | + } |
| 97 | + } |
| 98 | + } |
| 99 | + // ── v0.11.5 Task 6: 消费档案三维覆写 ── |
| 100 | + if let Ok(mut guard) = self.profile_override.lock() { |
| 101 | + if let Some(po) = guard.take() { |
| 102 | + let mut changed = false; |
| 103 | + if let Some(t) = po.tier { |
| 104 | + let budget = crate::video_profile_spec_data::sampling_for_tier(t); |
| 105 | + self.scheduler.retune(budget); |
| 106 | + self.tier_applied_tier = Some(t); |
| 107 | + if let Ok(mut ag) = self.applied_tier.lock() { *ag = Some(t); } |
| 108 | + changed = true; |
| 109 | + } |
| 110 | + if let Some(f) = po.form { self.current_form = Some(f); changed = true; } |
| 111 | + // v0.11.5 审查修复(A3):domain 为 None(用户未选领域)→ |
| 112 | + // 重置锁定,重新启用自动检测(领域重评不再跳过覆盖); |
| 113 | + // v0.13.6(审查修复):领域一并清空——避免 emit 出 |
| 114 | + // domain=旧/fine=[] 的不一致快照("领域自动"语义) |
| 115 | + if po.domain.is_none() && self.domain_user_locked { |
| 116 | + self.domain_user_locked = false; |
| 117 | + self.current_domain_kind = None; |
| 118 | + self.current_fine_ids.clear(); |
| 119 | + } |
| 120 | + if let Some(d) = po.domain { |
| 121 | + self.current_domain_kind = Some(d); |
| 122 | + // 用户手动覆写 → 锁定该维度(重评不覆盖) |
| 123 | + self.domain_user_locked = true; |
| 124 | + changed = true; |
| 125 | + // v0.13.6(REQ-220):细目随领域覆写(空=仅粗领域,合法) |
| 126 | + self.current_fine_ids = po.fine.clone(); |
| 127 | + } |
| 128 | + if changed { |
| 129 | + let snapshot = crate::live_session::ProfileOverride { |
| 130 | + form: self.current_form, |
| 131 | + tier: self.tier_applied_tier, |
| 132 | + domain: self.current_domain_kind, |
| 133 | + fine: self.current_fine_ids.clone(), |
| 134 | + }; |
| 135 | + if let Ok(mut ag) = self.applied_profile.lock() { *ag = Some(snapshot); } |
| 136 | + let _ = self.app.emit("live:profile-updated", serde_json::json!({ |
| 137 | + "form": self.current_form.map(|f| f.as_str()), |
| 138 | + "tier": self.tier_applied_tier.map(|t| t.as_str()), |
| 139 | + "domain": self.current_domain_kind.map(|d| d.as_str()), |
| 140 | + "fine": self.current_fine_ids, |
| 141 | + })); |
| 142 | + } else { |
| 143 | + // v0.11.5 审查修复(A2):override 取到全空值(command 层 |
| 144 | + // 应已拒绝全空,此为防御) |
| 145 | + eprintln!("[LiveSession] profile_override 取到全空值(command 层应已拒绝全空,此为防御)"); |
| 146 | + } |
| 147 | + } |
| 148 | + } |
| 149 | + // ── v0.11.5 Task 6: 领域自动重评(同画面档窗口节拍)── |
| 150 | + let profile_reeval_now = now_ms / 1000; |
| 151 | + if profile_reeval_now >= self.last_profile_reeval_secs + 150 { |
| 152 | + self.last_profile_reeval_secs = profile_reeval_now; |
| 153 | + // v0.11.5 Task 7: B站 选集 OCR 证据增强——标题确认 B站 且累计 |
| 154 | + // OCR 中选集命中(`P3/12`/`第X集`,adapt_bilibili_episode 解析) |
| 155 | + // → 累计 OCR 文本提升为平台证据(命中才加权,不命中不惩罚) |
| 156 | + let mut platform_tags: Vec<String> = Vec::new(); |
| 157 | + if crate::platform_adapter::infer_platform( |
| 158 | + Some(&self.window_title), |
| 159 | + None, |
| 160 | + ) == Some(crate::platform_adapter::PlatformKind::Bilibili) |
| 161 | + && self.accumulated_ocr_text |
| 162 | + .iter() |
| 163 | + .any(|t| crate::platform_adapter::adapt_bilibili_episode(t).is_some()) |
| 164 | + { |
| 165 | + platform_tags = self.accumulated_ocr_text.clone(); |
| 166 | + } |
| 167 | + // v0.11.5 Task 7: ASR 开场白——前 30s 段文本(现有段累计可达, |
| 168 | + // 不为它新建数据流);无段 → None 诚实降级 |
| 169 | + let asr_opening: Option<String> = self.subtitle_segments |
| 170 | + .lock() |
| 171 | + .ok() |
| 172 | + .map(|g| { |
| 173 | + g.iter() |
| 174 | + .filter(|s| s.start_ms <= 30_000) |
| 175 | + .map(|s| s.text.clone()) |
| 176 | + .collect::<Vec<_>>() |
| 177 | + .join(" ") |
| 178 | + }) |
| 179 | + .filter(|t| !t.trim().is_empty()); |
| 180 | + let domain_signal = crate::video_profile_domain::DomainSignals { |
| 181 | + title: Some(self.window_title.clone()), |
| 182 | + platform_tags, |
| 183 | + user_confirmed: None, |
| 184 | + term_freq: self.accumulated_ocr_text.clone(), |
| 185 | + asr_opening, |
| 186 | + }; |
| 187 | + let detected = crate::video_profile_domain::detect_domain(&domain_signal); |
| 188 | + // 终审 I-1:用户已手动覆写领域 → 跳过自动覆盖(用户裁决优先) |
| 189 | + if !self.domain_user_locked |
| 190 | + && detected.kind.is_some() |
| 191 | + && detected.kind != self.current_domain_kind |
| 192 | + && detected.confidence >= 0.6 |
| 193 | + { |
| 194 | + self.current_domain_kind = detected.kind; |
| 195 | + // v0.13.6:自动重评命中的细目随之生效(curated 预选;空则仅粗领域) |
| 196 | + self.current_fine_ids = detected.fine_ids; |
| 197 | + let snapshot = crate::live_session::ProfileOverride { |
| 198 | + form: self.current_form, |
| 199 | + tier: self.tier_applied_tier, |
| 200 | + domain: self.current_domain_kind, |
| 201 | + fine: self.current_fine_ids.clone(), |
| 202 | + }; |
| 203 | + if let Ok(mut ag) = self.applied_profile.lock() { *ag = Some(snapshot); } |
| 204 | + let _ = self.app.emit("live:profile-updated", serde_json::json!({ |
| 205 | + "form": self.current_form.map(|f| f.as_str()), |
| 206 | + "tier": self.tier_applied_tier.map(|t| t.as_str()), |
| 207 | + "domain": self.current_domain_kind.map(|d| d.as_str()), |
| 208 | + "fine": self.current_fine_ids, |
| 209 | + })); |
| 210 | + } |
| 211 | + } |
| 212 | + } |
| 213 | +} |
0 commit comments