瀏覽代碼

docs(d2-02): 第二阶段 MJPEG 实时预览架构设计——control 专用线程推流+operate 解码显示+崩溃自愈提示

经 brainstorming 与用户逐项确认:
- JPEG 编码放 control 端(压缩 20-100 倍,带宽优于推原始 RGB)
- 专用后台线程推流(与命令分发解耦,崩了不影响主服务)
- 相机锁全进程一把锁(SDK 不改),A/B 舱预览/采集会互等,本轮不优化留真机观察
- 不自动重连,断了明确提示操作人员手动重开

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
huangjie 1 天之前
父節點
當前提交
c6844f12b1
共有 1 個文件被更改,包括 221 次插入0 次删除
  1. 221 0
      项目文档/需求文档/specs/2026-06-24-D2-02-第二阶段-MJPEG实时预览-design.md

+ 221 - 0
项目文档/需求文档/specs/2026-06-24-D2-02-第二阶段-MJPEG实时预览-design.md

@@ -0,0 +1,221 @@
+# D2-02 第二阶段 · MJPEG 实时预览 · 架构设计
+
+> 立项:2026-06-24(经 brainstorming 逐项澄清,关键决策已与用户确认)。
+> 上游:`specs/2026-06-23-D2-02-调试页命令代理-design.md`(D2-02 主设计 §9 MJPEG 预览的细化)。
+> 前置:D2-02 **第一阶段(control 后端会话管理)已代码完成 + 真机验证通过**(借舱/命令/心跳/超时回收 27 单测绿 + 真机冒烟过)。
+> 范围:只动调试页"实时预览"这一块——control 端加推流端点、operate 端加解码显示。**不碰** control 采集/对焦/换气业务逻辑,**不碰** 第一阶段已就绪的命令分发。
+
+---
+
+## 1. 背景与问题
+
+### 1.1 调试页预览原来怎么工作
+
+operate 调试页(工程师手动调下位机参数用)有一个"实时画面"功能:借用某舱后,把该舱相机的实时画面显示出来,工程师边看画面边调对焦/水平位置。
+
+**原来的做法**(同进程,贴窗口句柄):
+```
+operate 调试页 OpenVideo()
+   → 拿到 operate 主窗口句柄 hwnd
+   → vm.StartPreview(hwnd, ...) → 底层 Camera.Usb2Start(hwnd,...)
+   → 相机 SDK 直接把画面渲染贴到这个窗口区域(operate 进程内)
+```
+
+### 1.2 拆分后为什么断了
+
+operate/control 双进程拆分后,相机搬进了 control 进程,由 `HardwareAccessLayer`(HAL)单例持有。**operate 进程根本拿不到 control 进程里的相机句柄**,`StartPreview(贴窗口句柄)` 这套彻底不能用了——窗口句柄是 operate 进程的,相机在 control 进程,跨不过去。
+
+所以预览必须改成**跨进程**:control 把相机画面**编码成图片流**经本地 HTTP 推给 operate,operate **解码后贴到界面控件**。这就是 MJPEG(Motion JPEG)方案——一帧帧 JPEG 图片连续推送,组成动态画面。
+
+---
+
+## 2. 目标与范围
+
+### 2.1 目标
+- operate 调试页恢复"实时预览"能力:借用某舱 → 看到该舱相机实时画面 → 调焦/调参 → 归还。
+- 预览中断(operate 关预览 / 崩溃 / 抓帧失败 / 会话超时)能**自动恢复到安全状态**(不泄漏线程、不卡住采集),并**明确提示操作人员**重新打开。
+
+### 2.2 范围内
+- control 端:新增 `GET /debug/preview/stream?sessionId=` 端点,专用后台线程抓帧 → JPEG 编码 → 按 MJPEG 格式推流。
+- operate 端:新增 `MjpegStreamClient`(读流 → 切帧 → 解码 → 贴界面);调试页 View 的 `OpenVideo/CloseVideo` 从"贴窗口句柄"改为"连/断 MJPEG 流";调试页 XAML 加一个 `<Image>` 控件显示预览。
+
+### 2.3 范围外(本轮明确不做)
+- **不做自动重连**:预览断了**明确提示工程师手动重开**,不悄悄重连(避免掩盖问题)。
+- 不做帧率自适应/降帧优化:本轮按固定目标帧率(~15fps)实现,相机锁冲突的真机影响留待第三阶段真机门控观察后再决定是否优化(见 §6)。
+- 不动第一阶段已就绪的命令分发(读数/电机/EEPROM/阀门)。
+- 不动 operate 调试页接入(2 个 VM 改走 client)——那是**第三阶段**的事。本阶段 operate 侧只改预览这一块的 View 代码 + 新增 client,先让预览能独立验证。
+
+---
+
+## 3. 整体数据流
+
+```
+operate 调试页                                  control 进程
+─────────────                                  ──────────────
+工程师点【初始化】→ acquire 拿到 sessionId(第一阶段已实现)
+工程师开预览
+   MjpegStreamClient.Start(sessionId)
+   GET /debug/preview/stream?sessionId=xxx ────► ① 校验 sessionId(失效→404)
+                                                 ② 写 multipart 响应头
+                                                 ③ 起专用后台线程 StreamThread
+                          ◄════════════════════ [后台循环] 抓帧→JPEG编码→推一帧
+   读流 → 切 --frame boundary                    每帧:--frame / Content-Type: image/jpeg
+   → 每帧 JPEG 解码成 BitmapImage                       / Content-Length / <jpeg字节>
+   → 贴到调试页 <Image> 控件
+                          ◄════════════════════ (持续推帧,~15fps)
+
+工程师关预览 / 崩溃
+   MjpegStreamClient.Stop() → 断开 HTTP        ────► 写下一帧时 TCP 已断 → 抛异常
+                                                 → 推流线程自停 + 标记会话 StreamBroken
+                                                 → 该会话进入可回收态(看门狗兜底)
+```
+
+---
+
+## 4. control 端:MJPEG 推流
+
+### 4.1 端点契约
+
+| 方法 | 路径 | 入参 | 返回 | 说明 |
+|---|---|---|---|---|
+| GET | `/debug/preview/stream` | `?sessionId=`(query) | `multipart/x-mixed-replace; boundary=frame`(持续 JPEG 帧流) | 实时预览 |
+
+- 仍挂在现有 `ControlHttpServer`(127.0.0.1 only)。
+- 校验失败(sessionId 不存在/失效):返回 `404`,不进推流。
+- **关键区别**:其他 `/debug/*` 端点都是"算 body 一次性写完关闭流";本端点**不关闭流**,而是把响应流交给后台线程持续写。`ControlHttpServer.Handle()` 里对此 path **不走统一的 `body→写→Close` 收尾**,而是分流出去起线程后**直接 return**(让 HttpListener 工作线程立刻回到接收循环,不被推流阻塞)。
+
+### 4.2 推流线程(专用后台线程)
+
+`acquire` 成功 → 开预览时,control 起一个**专属该次预览**的后台线程:
+
+```
+StreamThread(session, outputStream):
+  设相机为实时/单帧模式(SetOpMode)
+  while (会话仍有效 && 流未断开):
+    try:
+      ① 抓帧:lease.Camera.GrabStable()  → RGB 字节(走全局相机锁)
+         抓到 null → 记日志、短暂等待、continue
+      ② JPEG 编码:RGB → JpegBitmapEncoder(质量~85)→ JPEG 字节
+      ③ 写一帧:--frame\r\n
+                Content-Type: image/jpeg\r\n
+                Content-Length: {N}\r\n\r\n
+                <jpeg 字节>\r\n
+         Flush()
+      ④ 帧率控制:Sleep(~66ms)  → 目标 ~15fps
+    catch (IOException / 流写失败):
+      operate 断开(关预览/崩溃)→ 正常退出循环
+    catch (其他异常):
+      记错误日志;连续错误计数++;超过阈值(如 5)→ 主动退出
+  finally:
+    标记 session.StreamBroken = true  (进入可回收态)
+    清理流
+```
+
+### 4.3 JPEG 编码
+
+- `GrabStable()` 返回 24bpp BGR 原始像素(1600×1200×3 ≈ 5.76MB)。
+- 用 WPF `JpegBitmapEncoder`(或 `System.Drawing`)编码成 JPEG(质量 85,压缩后通常几十~两百 KB)。
+- **决策依据(已与用户确认)**:control 端编码 JPEG 而非推原始 RGB——本地回环虽快,但 JPEG 压缩 20~100 倍,大幅降低传输量,编码开销(几毫秒/帧)对预览帧率不是瓶颈,且 operate 端解码极简(`BitmapImage` 直接吃 JPEG)。
+
+### 4.4 会话状态扩展
+
+`DebugSession` 加一个标记字段,推流线程退出时置位,让看门狗/命令分发知道"这个会话的预览已断":
+- `StreamBroken`(bool):推流线程因任何原因退出时置 `true`。
+- 第一阶段的超时看门狗 `SweepExpired` 已能兜底回收(无心跳→TTL→Dispose);`StreamBroken` 是"更快的信号"(预览断了通常意味着 operate 没了),可作为额外的可回收判据,但**不强依赖**——心跳 TTL 始终是最终安全保证。
+
+---
+
+## 5. operate 端:MJPEG 解码显示
+
+### 5.1 新增 `MjpegStreamClient`
+
+| 职责 | 说明 |
+|---|---|
+| 连接 | `HttpClient`/`HttpWebRequest` 发 `GET /debug/preview/stream?sessionId=`,以**流式**读响应(不等整个响应结束)。 |
+| 切帧 | 状态机按 `--frame` boundary + `Content-Length` 切出每一帧 JPEG 字节。 |
+| 解码 | 每帧字节 → `BitmapImage`(`StreamSource` + `BitmapCacheOption.OnLoad`,解完即脱离流,可跨线程贴 UI)。 |
+| 回调 | `FrameReceived(BitmapImage)` 事件 → View 贴到 `<Image>`;`Stopped(reason)` 事件 → 断开/异常时通知 View 提示。 |
+| 停止 | `Stop()` 主动断开;读流异常时自动触发 `Stopped`。 |
+
+### 5.2 调试页 View 改造
+
+`View/HouseDebugPageView.xaml.cs`:
+
+| 现状 | 改成 |
+|---|---|
+| `OpenVideo()`:取 hwnd → `vm.StartPreview(hwnd,...)` 贴窗口 | `OpenVideo()`:`mjpegClient.Start(sessionId)`,订阅 `FrameReceived` → 贴 `<Image>` |
+| `CloseVideo()`:`vm.StopPreview()` | `CloseVideo()`:`mjpegClient.Stop()` |
+| 画面由相机 SDK 渲染到窗口区域 | 画面由 `<Image>` 控件显示解码后的 `BitmapImage` |
+
+`View/HouseDebugPageView.xaml`:在原来贴窗口的区域加一个 `<Image x:Name="_previewImage">` 控件(纯展示,镜像现有布局)。
+
+> 注:本阶段 `OpenVideo` 拿 sessionId 的来源——第三阶段会把整个调试页改走 `DebugSessionClient`(acquire 返回 sessionId)。本阶段为先让预览独立可验,sessionId 可由"手动 acquire 一个测试会话"提供(真机验证时用 curl/客户端先 acquire);完整接入随第三阶段落地。MjpegStreamClient 本身只认 sessionId,与如何拿到 sessionId 解耦。
+
+### 5.3 缓冲瓶调试(舱11)
+
+`BufferDebugView` 无相机、无预览,本阶段**不涉及**。
+
+---
+
+## 6. 相机锁冲突(已知影响,本轮不优化)
+
+### 6.1 约束
+相机 SDK(`mvcapi.dll`)**非线程安全**,`ICameraGate` 是**全进程一把锁**(`CameraGateImpl`:跨相机、跨调用者同一把锁)。相机 SDK 是外部 DLL,**不改**。
+
+### 6.2 影响
+- 推流线程持续抓帧(~每 66ms 一次)会持续争用全局相机锁。
+- **同一进程内其他舱正在采集拍照 / 自动对焦时**,会与推流互相等锁:
+  - 正在培养的 B 舱拍照:推流让它等最多一帧时间(~66ms),拍照延迟基本无感。
+  - B 舱自动对焦(逐层扫描抓帧):会被推流穿插,对焦每层多等几十毫秒,整个对焦过程变慢几秒。
+- **不会死锁、不会崩溃**(都走同一把锁串行),只是慢。
+
+### 6.3 决策(已与用户确认)
+- **业务约束**:A 舱调试时,B 舱可能正在培养拍照,**不能限制"仅空闲时段调试"**。
+- **本轮按固定 ~15fps 实现,不加降帧/优先级保护**。相机锁冲突的真实影响在第三阶段真机门控(V-012)时观察;若实测影响采集节拍过大,再评估降帧或按相机分锁(需真机验证 SDK 是否真的能并行,见 `CameraGateImpl` 注释 V-011)。
+
+---
+
+## 7. 崩溃自愈 + 操作人员提示(用户特别要求)
+
+| 场景 | control 端行为 | operate 端(操作人员看到) |
+|---|---|---|
+| 工程师**主动关预览** | 流断 → 写帧抛 IOException → 线程正常退出 → 标记 StreamBroken | 正常,无提示 |
+| operate **崩溃/被杀** | 同上(TCP 断)→ 线程退出 → 会话靠心跳 TTL 被看门狗回收 → 该舱采集恢复 | 重启 operate 后需重新进调试 |
+| **抓帧/编码连续失败** | 错误计数超阈值 → 线程主动退出 → 标记 StreamBroken | `MjpegStreamClient.Stopped` 触发 → 界面提示"预览已中断,请重新打开预览" |
+| **会话超时**(心跳停) | 看门狗回收会话 → 推流端点下次校验失败 / 线程检测到会话没了自停 | 界面提示"调试会话已超时,请重新进入调试" |
+
+**关键原则**:
+1. **推流线程崩了只影响预览**——命令分发、心跳、采集都不受影响(线程独立)。
+2. **绝不悄悄自动重连**——断了就明确提示,让操作人员知道并手动重开(符合用户要求)。
+3. **采集恢复始终有保证**——无论哪种异常,会话最终被回收(主动 release 快路径 / 心跳 TTL 看门狗兜底),该舱采集一定恢复,不会被永久卡住。
+
+---
+
+## 8. 测试策略
+
+### 8.1 纯逻辑单测(无需真机,control 单测工程 IvfTl.ControlHost.Tests)
+- **MJPEG 帧切分**:给定一段 multipart 字节流,operate 端 client 状态机能正确切出每一帧 JPEG(边界识别 / Content-Length 截取 / 跨缓冲块的半帧拼接)。
+- **JPEG 编码**:给定一段已知 RGB 像素,编码出的字节是合法 JPEG(可被解码器读回、尺寸正确)。
+- **会话状态**:推流线程退出时 `StreamBroken` 正确置位;失效会话请求推流被拒(404)。
+
+### 8.2 真机门控(归第三阶段 V-012,Claude 自主真机跑,UAC 静默提权)
+- 预览真机出图:acquire 某舱 → 开 MJPEG → operate 端看到该舱实时画面。
+- 崩溃自动回收:断开预览/杀 operate → control 推流线程自停 + 会话回收 → 该舱采集恢复(/status 反映)。
+- 相机锁冲突实测:A 舱预览 + B 舱采集拍照同时,观察拍照节拍/预览帧率的实际影响(§6.3 决策依据)。
+
+---
+
+## 9. 与第三阶段的衔接
+
+本阶段交付 `MjpegStreamClient` + control 推流端点 + 调试页 View 预览改造,**预览可独立真机验证**。
+第三阶段(operate 完整接入)再把 2 个调试 VM 的所有硬件操作改走 `DebugSessionClient`,预览自然接上本阶段的 `MjpegStreamClient`,届时随 V-012 一并真机走位验收,完成后解锁 D3-04(删 operate 死栈)。
+
+---
+
+## 10. 风险与注意
+
+1. **HttpListener 长连接稳定性**:开预览几小时,推流线程 + 长连接要稳,确保不被默认超时掐断;异常要能让会话进可回收态而非泄漏线程(§4.2 finally 兜底)。
+2. **跨线程贴 UI**:`BitmapImage` 必须 `Freeze()`(或 `OnLoad` 后脱离流)才能从后台读流线程安全传到 WPF UI 线程贴 `<Image>`。
+3. **相机锁争用**:见 §6,本轮不优化,真机观察。
+4. **半帧拼接**:MJPEG 流可能在任意字节处分块到达,client 切帧状态机要正确处理"一帧跨多个读缓冲块""一个缓冲块含多帧"。
+5. **不动 control 业务逻辑**:本设计只加推流端点 + 后台线程 + operate client/View;采集/对焦/换气零改动,降低回归风险。