Эх сурвалжийг харах

docs(d2-02): 第二阶段 MJPEG 预览实现计划(8 任务 TDD)——含业务闭环/影响面登记表,已核实 UseWPF/ControlClient 地址来源

8 任务:MjpegStreamWriter(编码+封帧)→DebugSession.StreamBroken→推流端点→MjpegFrameParser(切帧)→切帧单测→MjpegStreamClient→View 接入→总验回写。
纯逻辑单测 5 个(2 编码+3 切帧),真机出图门控归第三阶段 V-012。
开工前登记 7 条业务边界:零改采集业务/不碰一阶段命令分发/相机锁串行/采集恢复保证/HttpListener 不阻塞/只改预览。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
huangjie 1 өдөр өмнө
parent
commit
61a1b99d1d

+ 923 - 0
项目文档/开发计划/2026-06-24-D2-02-第二阶段-MJPEG实时预览-实现计划.md

@@ -0,0 +1,923 @@
+# D2-02 第二阶段 · MJPEG 实时预览 · 实现计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: 用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 按任务逐个实现。步骤用 `- [ ]` 复选框跟踪。
+> 设计依据:`需求文档/specs/2026-06-24-D2-02-第二阶段-MJPEG实时预览-design.md`。
+> 前置:第一阶段(control 后端会话管理)已代码完成 + 真机验证通过(27 单测绿)。
+
+**Goal:** control 端经本地 HTTP 把某舱相机实时画面以 MJPEG 流推给 operate,operate 解码贴到调试页 `<Image>` 控件;预览中断能自愈到安全态并明确提示操作人员。
+
+**Architecture:** control 端 `MjpegStreamWriter`(纯逻辑:RGB→JPEG 编码 + multipart 帧封装,可单测)+ `ControlHttpServer` 推流分支(专用后台线程抓帧推流,不阻塞 HttpListener);operate 端 `MjpegStreamClient`(读流→状态机切帧→解码 BitmapImage→事件回调)。相机锁全进程一把(SDK 不改),推流与采集互等串行,本轮不优化。
+
+**Tech Stack:** C# net6.0-windows;control 抓帧 `ICamera.GrabStable`;JPEG 编码 `System.Windows.Media.Imaging.JpegBitmapEncoder`;operate `HttpClient` 流式读 + `BitmapImage`;xUnit 单测(control 单测工程 `IvfTl.ControlHost.Tests`)。
+
+---
+
+## 业务闭环 / 影响面登记(开工前必读)
+
+> 用户反复强调:**多考虑业务闭环和对其他功能的影响**。本计划所有改动严格遵守下列边界。
+
+| 关注点 | 结论 | 依据 |
+|---|---|---|
+| **采集/对焦/换气业务逻辑** | **零改动**。只加推流端点 + 后台线程 + operate client/View。 | spec §2.3 / §10.5 |
+| **第一阶段命令分发** | **不碰** `Execute`/`ExecuteMotorOrEeprom`/`Acquire`/`Heartbeat`/`Release`/`SweepExpired`。只对 `DebugSession` **加字段**、对 `DebugSessionManager` **加只读取 session 的方法**(不改既有方法体)。 | 保 27 单测不回归 |
+| **相机锁** | 全进程一把锁不变。推流抓帧走 `lease.Camera.GrabStable`(已自带锁)。A 舱预览与 B 舱采集拍照**互等串行**(慢不崩),本轮不优化,真机观察。 | spec §6 |
+| **采集恢复保证** | 推流线程任何原因退出 → 标记 `StreamBroken` + 不动会话回收路径。会话最终由**心跳 TTL 看门狗**(第一阶段已验)回收 → 该舱采集恢复。推流崩**绝不**让某舱永久停采集。 | spec §7 |
+| **HttpListener 不被阻塞** | 推流端点**分流起后台线程后立即 return**,不进 `Handle()` 统一的 `body→写→Close` 收尾(那会关流)。其他端点行为完全不变。 | spec §4.1 |
+| **operate 调试页其他按钮** | 本阶段**只改预览**(OpenVideo/CloseVideo)+ 加 `<Image>`。读温度/电机/EEPROM 等按钮**不动**(第三阶段才整体改走 client)。 | spec §2.3 / §5.2 |
+
+---
+
+## 文件结构
+
+### control 端(`ivf_tl_operate_2.0/control/`)
+
+| 文件 | 责任 | 新建/改 |
+|---|---|---|
+| `ivf_tl_ControlHost/Debug/MjpegStreamWriter.cs` | **纯逻辑**:RGB(24bpp BGR)→ JPEG 字节编码;把 JPEG 字节封成 multipart 帧字节(`--frame\r\n...\r\n`)。无 IO、无相机依赖,可纯单测。 | 新建 |
+| `ivf_tl_ControlHost/Debug/DebugSession.cs` | 加 `StreamBroken`(bool)字段。 | 改(加字段) |
+| `ivf_tl_ControlHost/Debug/DebugSessionManager.cs` | 加只读方法 `TryGet(sid, out session)`(供推流端点校验会话);**不改既有方法**。 | 改(加方法) |
+| `ivf_tl_ControlHost/ControlHttpServer.cs` | `Handle()` 加 `/debug/preview/stream` 分支:校验会话 → 写 multipart 头 → 起后台推流线程 → return(不走统一收尾)。 | 改(加分支) |
+| `IvfTl.ControlHost.Tests/MjpegStreamWriterTests.cs` | 单测 MjpegStreamWriter(JPEG 合法性 + 帧封装格式)。 | 新建 |
+
+### operate 端(`ivf_tl_operate_2.0/ivf_tl_Operate/`)
+
+| 文件 | 责任 | 新建/改 |
+|---|---|---|
+| `Debug/MjpegFrameParser.cs` | **纯逻辑**:multipart 字节流状态机切帧(喂字节 → 吐完整 JPEG 帧),处理半帧拼接/一块多帧。可纯单测。 | 新建 |
+| `Debug/MjpegStreamClient.cs` | 用 `HttpClient` 流式读 `/debug/preview/stream` → 喂 `MjpegFrameParser` → 每帧解码 `BitmapImage`(Freeze)→ `FrameReceived` 事件;断开/异常 → `Stopped(reason)` 事件。 | 新建 |
+| `View/HouseDebugPageView.xaml` | 预览区加 `<Image x:Name="_previewImage">`。 | 改(加控件) |
+| `View/HouseDebugPageView.xaml.cs` | `OpenVideo/CloseVideo` 改连/断 `MjpegStreamClient`;`Stopped` 回调弹提示。 | 改(2 方法) |
+
+> **operate 单测**:operate 主工程(WPF)无现成 xUnit 单测工程。`MjpegFrameParser` 是纯逻辑,本计划用 control 已有的 `IvfTl.ControlHost.Tests` 工程**通过 `<Compile Include>` 链入** `MjpegFrameParser.cs` 源码做单测(与既有 `临时文件/FaultMapperTest` 同手法,零依赖纯逻辑可跨工程测)。详见 Task 5。
+
+---
+
+### Task 1: control 端 MjpegStreamWriter(RGB→JPEG + multipart 帧封装)
+
+**Files:**
+- Create: `ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/MjpegStreamWriter.cs`
+- Test: `ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/MjpegStreamWriterTests.cs`
+
+- [ ] **Step 1: 写失败测试**
+
+Create `ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/MjpegStreamWriterTests.cs`:
+```csharp
+using System.IO;
+using System.Windows.Media.Imaging;
+using IvfTl.ControlHost.Debug;
+using Xunit;
+
+namespace IvfTl.ControlHost.Tests
+{
+    public class MjpegStreamWriterTests
+    {
+        // 造一张 4x4 纯色 24bpp BGR 像素(每像素 3 字节)。
+        private static byte[] SolidBgr(int w, int h, byte b, byte g, byte r)
+        {
+            var buf = new byte[w * h * 3];
+            for (int i = 0; i < w * h; i++) { buf[i * 3] = b; buf[i * 3 + 1] = g; buf[i * 3 + 2] = r; }
+            return buf;
+        }
+
+        [Fact]
+        public void EncodeJpeg_ProducesDecodableJpeg_WithRightSize()
+        {
+            var rgb = SolidBgr(4, 4, 10, 20, 30);
+            byte[] jpeg = MjpegStreamWriter.EncodeJpeg(rgb, 4, 4);
+
+            Assert.NotNull(jpeg);
+            Assert.True(jpeg.Length > 2);
+            // JPEG 魔数 FF D8 开头
+            Assert.Equal(0xFF, jpeg[0]);
+            Assert.Equal(0xD8, jpeg[1]);
+            // 能被解码器读回、尺寸对
+            var dec = new JpegBitmapDecoder(new MemoryStream(jpeg),
+                BitmapCreateOptions.None, BitmapCacheOption.OnLoad);
+            Assert.Equal(4, dec.Frames[0].PixelWidth);
+            Assert.Equal(4, dec.Frames[0].PixelHeight);
+        }
+
+        [Fact]
+        public void FrameBytes_WrapsJpegInMultipartBoundary()
+        {
+            var jpeg = new byte[] { 0xFF, 0xD8, 1, 2, 0xFF, 0xD9 };
+            byte[] frame = MjpegStreamWriter.FrameBytes(jpeg);
+
+            string head = System.Text.Encoding.ASCII.GetString(frame, 0, 60);
+            Assert.Contains("--frame", head);
+            Assert.Contains("Content-Type: image/jpeg", head);
+            Assert.Contains("Content-Length: 6", head);
+            // 帧尾部含 jpeg 原始字节 + 末尾 \r\n
+            Assert.Equal(0xFF, frame[frame.Length - 8]); // jpeg 起点附近(粗校验帧体存在)
+            Assert.Equal((byte)'\r', frame[frame.Length - 2]);
+            Assert.Equal((byte)'\n', frame[frame.Length - 1]);
+        }
+    }
+}
+```
+
+- [ ] **Step 2: 跑测试确认失败**
+
+Run: `dotnet test ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/IvfTl.ControlHost.Tests.csproj --filter "FullyQualifiedName~MjpegStreamWriterTests"`
+Expected: 编译失败 `MjpegStreamWriter 不存在`。
+
+- [ ] **Step 3: 写实现**
+
+Create `ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/MjpegStreamWriter.cs`:
+```csharp
+using System;
+using System.IO;
+using System.Text;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+
+namespace IvfTl.ControlHost.Debug
+{
+    /// <summary>
+    /// MJPEG 推流的纯逻辑:RGB 像素 → JPEG 字节,JPEG → multipart 帧字节。
+    /// 无 IO、无相机依赖,可纯单测。真正的抓帧/写流由 ControlHttpServer 推流分支驱动。
+    /// 相机抓帧返回 24bpp BGR(GrabStable),WPF JpegBitmapEncoder 需 Bgr24 像素格式。
+    /// </summary>
+    public static class MjpegStreamWriter
+    {
+        public const string Boundary = "frame";
+
+        /// <summary>把 24bpp BGR 像素(width*height*3)编码成 JPEG 字节(质量 85)。</summary>
+        public static byte[] EncodeJpeg(byte[] bgr, int width, int height, int quality = 85)
+        {
+            if (bgr == null || bgr.Length < width * height * 3) return null;
+            int stride = width * 3;
+            var bmp = BitmapSource.Create(width, height, 96, 96,
+                PixelFormats.Bgr24, null, bgr, stride);
+            var encoder = new JpegBitmapEncoder { QualityLevel = quality };
+            encoder.Frames.Add(BitmapFrame.Create(bmp));
+            using (var ms = new MemoryStream())
+            {
+                encoder.Save(ms);
+                return ms.ToArray();
+            }
+        }
+
+        /// <summary>把 JPEG 字节封成一个 multipart/x-mixed-replace 帧(含 boundary 头 + 帧体 + \r\n)。</summary>
+        public static byte[] FrameBytes(byte[] jpeg)
+        {
+            string header = $"--{Boundary}\r\nContent-Type: image/jpeg\r\nContent-Length: {jpeg.Length}\r\n\r\n";
+            byte[] head = Encoding.ASCII.GetBytes(header);
+            byte[] tail = Encoding.ASCII.GetBytes("\r\n");
+            var frame = new byte[head.Length + jpeg.Length + tail.Length];
+            Buffer.BlockCopy(head, 0, frame, 0, head.Length);
+            Buffer.BlockCopy(jpeg, 0, frame, head.Length, jpeg.Length);
+            Buffer.BlockCopy(tail, 0, frame, head.Length + jpeg.Length, tail.Length);
+            return frame;
+        }
+
+        /// <summary>响应头里的 Content-Type 值(供 ControlHttpServer 写头用)。</summary>
+        public static string ContentType => $"multipart/x-mixed-replace; boundary={Boundary}";
+    }
+}
+```
+
+- [ ] **Step 4: 跑测试确认通过**
+
+Run: `dotnet test ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/IvfTl.ControlHost.Tests.csproj --filter "FullyQualifiedName~MjpegStreamWriterTests"`
+Expected: PASS(2 passed)。
+
+> 若编译报 `PixelFormats`/`BitmapSource` 找不到:`ivf_tl_ControlHost.csproj` 已是 `net6.0-windows` 且引用 WPF(StartPreview 用过 `System.Windows.Interop`),`<UseWPF>true</UseWPF>` 若未开则在 csproj 的 `<PropertyGroup>` 加该行。验证:`dotnet build ivf_tl_operate_2.0/control/ivf_tl_ControlHost/ivf_tl_ControlHost.csproj -c Debug` 0 错。
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/MjpegStreamWriter.cs ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/MjpegStreamWriterTests.cs
+git commit -m "feat(d2-02): MjpegStreamWriter 纯逻辑(RGB→JPEG 编码 + multipart 帧封装)+2 单测"
+```
+
+---
+
+### Task 2: DebugSession 加 StreamBroken 字段 + DebugSessionManager 加只读 TryGet
+
+**Files:**
+- Modify: `ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugSession.cs`
+- Modify: `ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugSessionManager.cs`
+- Test: `ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/DebugStreamSessionTests.cs`(新建)
+
+> **影响面**:DebugSession 只加字段(默认 false,不影响既有构造/赋值);DebugSessionManager 只加一个新方法,**不动既有 6 方法体** → 27 单测不回归。
+
+- [ ] **Step 1: 写失败测试**
+
+Create `ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/DebugStreamSessionTests.cs`:
+```csharp
+using System;
+using IvfTl.ControlHost.Debug;
+using IvfTl.ControlHost.Tests.Fakes;
+using Xunit;
+
+namespace IvfTl.ControlHost.Tests
+{
+    public class DebugStreamSessionTests
+    {
+        private DebugSessionManager NewMgr(FakeGate gate, DateTime now)
+            => new DebugSessionManager(_ => gate, () => now, ttlMs: 10000, log: null);
+
+        [Fact]
+        public void StreamBroken_DefaultsFalse()
+        {
+            var s = new DebugSession("sid1", 2, new FakeLease(new FakeGate(2, new FakeSerial()), new FakeSerial(), HardwareUser.OperateDebug), DateTime.UtcNow);
+            Assert.False(s.StreamBroken);
+            s.StreamBroken = true;
+            Assert.True(s.StreamBroken);
+        }
+
+        [Fact]
+        public void TryGet_ReturnsSession_ForValidSid()
+        {
+            var now = new DateTime(2026, 6, 24, 0, 0, 0, DateTimeKind.Utc);
+            var gate = new FakeGate(2, new FakeSerial());
+            var mgr = NewMgr(gate, now);
+            string sid = (string)mgr.Acquire(2).Result;
+
+            Assert.True(mgr.TryGet(sid, out var s));
+            Assert.Equal(2, s.HouseSn);
+            Assert.False(mgr.TryGet("nope", out _));
+        }
+    }
+}
+```
+
+- [ ] **Step 2: 跑测试确认失败**
+
+Run: `dotnet test ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/IvfTl.ControlHost.Tests.csproj --filter "FullyQualifiedName~DebugStreamSessionTests"`
+Expected: 编译失败(`StreamBroken`/`TryGet` 不存在)。
+
+- [ ] **Step 3: 加字段 + 方法**
+
+Edit `ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugSession.cs`,在 `CurrentVer` 字段后加:
+```csharp
+        /// <summary>推流线程因任何原因退出时置 true(spec §4.4)。可回收快信号,不替代心跳 TTL。</summary>
+        public bool StreamBroken { get; set; }
+```
+
+Edit `ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugSessionManager.cs`,在 `Heartbeat` 方法后加(**不改任何既有方法**):
+```csharp
+        /// <summary>只读取会话(供推流端点校验 sid)。不刷新 LastSeen、不改状态。</summary>
+        public bool TryGet(string sid, out DebugSession session)
+        {
+            if (sid != null) return _sessions.TryGetValue(sid, out session);
+            session = null; return false;
+        }
+```
+
+- [ ] **Step 4: 跑测试确认通过 + 全量回归**
+
+Run: `dotnet test ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/IvfTl.ControlHost.Tests.csproj`
+Expected: PASS(29 passed = 原 27 + 本 2)。确认第一阶段 27 个无回归。
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugSession.cs ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugSessionManager.cs ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/DebugStreamSessionTests.cs
+git commit -m "feat(d2-02): DebugSession.StreamBroken 字段 + DebugSessionManager.TryGet 只读方法(推流端点用,不动既有方法)"
+```
+
+---
+
+### Task 3: ControlHttpServer 加 /debug/preview/stream 推流分支
+
+**Files:**
+- Modify: `ivf_tl_operate_2.0/control/ivf_tl_ControlHost/ControlHttpServer.cs`
+
+> **关键影响**:推流分支必须**在 switch 内分流起线程后 return**,绕过 `Handle()` 末尾统一的 `body→Write→Close`(那会关流、终止推流)。其他端点的统一收尾**完全不变**。
+
+- [ ] **Step 1: 在 Handle() 的 switch 加推流分支**
+
+Edit `ivf_tl_operate_2.0/control/ivf_tl_ControlHost/ControlHttpServer.cs`,在 `case "/debug/command":` 块之后、`default:` 之前加:
+```csharp
+                case "/debug/preview/stream":
+                    if (method != "GET") { code = 405; body = Err("method not allowed"); break; }
+                    {
+                        string sid = ctx.Request.QueryString["sessionId"];
+                        if (_debug == null || sid == null || !_debug.TryGet(sid, out var session))
+                        {
+                            code = 404; body = Err("session not found");
+                            break;  // 走统一收尾返回 404
+                        }
+                        // 校验通过:分流起后台推流线程,不走统一收尾(那会 Close 流终止推流)。
+                        StartPreviewStream(ctx, session);
+                        return;
+                    }
+```
+
+- [ ] **Step 2: 加 StartPreviewStream 方法 + 推流线程体**
+
+Edit 同文件,在 `Stop()` 方法前加(顶部 `using` 已有 `System.Threading`/`System.IO`):
+```csharp
+        /// <summary>
+        /// 推流分支:起专用后台线程,抓帧→JPEG→multipart 持续写。
+        /// HttpListener 工作线程立即返回(本方法起线程后即返回),不被推流阻塞。
+        /// 任何退出路径都标记 session.StreamBroken,会话靠心跳 TTL 看门狗最终回收(spec §7)。
+        /// </summary>
+        private void StartPreviewStream(HttpListenerContext ctx, IvfTl.ControlHost.Debug.DebugSession session)
+        {
+            var resp = ctx.Response;
+            resp.StatusCode = 200;
+            resp.ContentType = IvfTl.ControlHost.Debug.MjpegStreamWriter.ContentType;
+            resp.SendChunked = true;  // 流式,长度未知
+            resp.Headers.Add("Cache-Control", "no-cache");
+
+            var t = new Thread(() =>
+            {
+                int errCount = 0;
+                var cam = session.Lease?.Camera;
+                try
+                {
+                    if (cam == null) { _log($"[debug] 推流舱{session.HouseSn} 无相机句柄,放弃"); return; }
+                    cam.SetOpMode(1);  // 实时模式(0=单帧/1=实时,见 ICamera 注释)
+                    var outStream = resp.OutputStream;
+                    while (true)
+                    {
+                        // 会话已被回收(release/超时)→ 停推流
+                        if (!_debug.TryGet(session.SessionId, out _)) { _log($"[debug] 推流舱{session.HouseSn} 会话已失效,停"); break; }
+                        try
+                        {
+                            byte[] bgr = cam.GrabStable();   // 走全进程相机锁,与采集/对焦串行
+                            if (bgr == null) { Thread.Sleep(100); continue; }
+                            byte[] jpeg = IvfTl.ControlHost.Debug.MjpegStreamWriter.EncodeJpeg(bgr, cam.Width, cam.Height);
+                            if (jpeg == null) { Thread.Sleep(100); continue; }
+                            byte[] frame = IvfTl.ControlHost.Debug.MjpegStreamWriter.FrameBytes(jpeg);
+                            outStream.Write(frame, 0, frame.Length);
+                            outStream.Flush();
+                            errCount = 0;
+                            Thread.Sleep(66);  // ~15fps(spec §4.2)
+                        }
+                        catch (IOException) { _log($"[debug] 推流舱{session.HouseSn} 客户端断开"); break; }     // operate 关预览/崩溃:正常退出
+                        catch (HttpListenerException) { _log($"[debug] 推流舱{session.HouseSn} 连接断开"); break; }
+                        catch (Exception ex)
+                        {
+                            errCount++;
+                            _log($"[debug] 推流舱{session.HouseSn} 抓帧/编码异常({errCount}/5): {ex.Message}");
+                            if (errCount >= 5) { _log($"[debug] 推流舱{session.HouseSn} 连续错误过多,停"); break; }
+                            Thread.Sleep(500);
+                        }
+                    }
+                }
+                catch (Exception ex) { _log($"[debug] 推流舱{session.HouseSn} 线程异常: {ex.Message}"); }
+                finally
+                {
+                    session.StreamBroken = true;   // 可回收快信号;会话最终由心跳 TTL 看门狗收(不在此 Dispose,避免与命令分发/超时回收争 lease)
+                    try { resp.OutputStream.Close(); } catch { }
+                    try { resp.Close(); } catch { }
+                    _log($"[debug] 推流舱{session.HouseSn} 线程结束");
+                }
+            });
+            t.IsBackground = true;
+            t.Name = $"MjpegStream-h{session.HouseSn}";
+            t.Start();
+        }
+```
+
+- [ ] **Step 3: 编译确认 0 错**
+
+Run: `dotnet build ivf_tl_operate_2.0/control/ivf_tl_ControlHost/ivf_tl_ControlHost.csproj -c Debug`
+Expected: Build succeeded, 0 Error。
+(若 operate.exe/control 正运行锁 DLL 报 MSB3021,先停 control 实例再编。)
+
+- [ ] **Step 4: 全量单测回归**
+
+Run: `dotnet test ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/IvfTl.ControlHost.Tests.csproj`
+Expected: PASS(29 passed,无回归)。
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add ivf_tl_operate_2.0/control/ivf_tl_ControlHost/ControlHttpServer.cs
+git commit -m "feat(d2-02): ControlHttpServer /debug/preview/stream 推流分支——专用后台线程抓帧→JPEG→multipart,不阻塞 HttpListener,退出标记 StreamBroken"
+```
+
+---
+
+### Task 4: operate 端 MjpegFrameParser(multipart 切帧状态机,纯逻辑)
+
+**Files:**
+- Create: `ivf_tl_operate_2.0/ivf_tl_Operate/Debug/MjpegFrameParser.cs`
+
+> 纯逻辑、零依赖,单测在 Task 5(链入 control 测试工程跑)。本 Task 先实现 + 编译。
+
+- [ ] **Step 1: 写实现**
+
+Create `ivf_tl_operate_2.0/ivf_tl_Operate/Debug/MjpegFrameParser.cs`:
+```csharp
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace ivf_tl_Operate.Debug
+{
+    /// <summary>
+    /// MJPEG multipart 流切帧状态机(纯逻辑)。喂入任意大小的字节块,吐出完整 JPEG 帧。
+    /// 处理:一帧跨多个块、一个块含多帧、半个头跨块。只认 Content-Length 截帧(不靠扫 boundary 找帧尾,稳)。
+    /// 流格式:--frame\r\nContent-Type: image/jpeg\r\nContent-Length: N\r\n\r\n[N 字节 JPEG]\r\n
+    /// </summary>
+    public sealed class MjpegFrameParser
+    {
+        private readonly List<byte> _buf = new List<byte>();
+        private int _expectedLen = -1;   // -1 = 还没解析到帧头;>=0 = 正在收帧体
+        private int _bodyStart = -1;
+
+        /// <summary>喂入一段字节,返回这次能切出的完整 JPEG 帧(可能 0~多帧)。</summary>
+        public IEnumerable<byte[]> Feed(byte[] chunk, int count)
+        {
+            for (int i = 0; i < count; i++) _buf.Add(chunk[i]);
+            var frames = new List<byte[]>();
+
+            while (true)
+            {
+                if (_expectedLen < 0)
+                {
+                    // 找头部结束标志 \r\n\r\n
+                    int sep = IndexOfDoubleCrlf(_buf);
+                    if (sep < 0) break;  // 头还没收全,等下一块
+                    string header = Encoding.ASCII.GetString(_buf.ToArray(), 0, sep);
+                    int len = ParseContentLength(header);
+                    if (len < 0)
+                    {
+                        // 头里没 Content-Length(异常/坏帧)→ 丢弃到分隔符后,继续
+                        _buf.RemoveRange(0, sep + 4);
+                        continue;
+                    }
+                    _expectedLen = len;
+                    _bodyStart = sep + 4;  // \r\n\r\n 之后是帧体
+                }
+
+                // 收帧体:需要 _bodyStart + _expectedLen 字节
+                if (_buf.Count < _bodyStart + _expectedLen) break;  // 帧体没收全,等下一块
+
+                var jpeg = new byte[_expectedLen];
+                _buf.CopyTo(_bodyStart, jpeg, 0, _expectedLen);
+                frames.Add(jpeg);
+
+                // 移除已消费的帧(头 + 体 + 可能的尾 \r\n)。下一帧从 --frame 开始,统一靠下轮找 \r\n\r\n。
+                int consumed = _bodyStart + _expectedLen;
+                // 跳过帧体后的 \r\n(若存在)
+                if (_buf.Count >= consumed + 2 && _buf[consumed] == (byte)'\r' && _buf[consumed + 1] == (byte)'\n')
+                    consumed += 2;
+                _buf.RemoveRange(0, consumed);
+                _expectedLen = -1; _bodyStart = -1;
+            }
+            return frames;
+        }
+
+        private static int IndexOfDoubleCrlf(List<byte> buf)
+        {
+            for (int i = 0; i + 3 < buf.Count; i++)
+                if (buf[i] == (byte)'\r' && buf[i + 1] == (byte)'\n' && buf[i + 2] == (byte)'\r' && buf[i + 3] == (byte)'\n')
+                    return i;
+            return -1;
+        }
+
+        private static int ParseContentLength(string header)
+        {
+            foreach (var line in header.Split(new[] { "\r\n" }, StringSplitOptions.RemoveEmptyEntries))
+            {
+                int colon = line.IndexOf(':');
+                if (colon < 0) continue;
+                if (line.Substring(0, colon).Trim().Equals("Content-Length", StringComparison.OrdinalIgnoreCase))
+                    if (int.TryParse(line.Substring(colon + 1).Trim(), out int n)) return n;
+            }
+            return -1;
+        }
+    }
+}
+```
+
+- [ ] **Step 2: 编译 operate 确认 0 错**
+
+Run: `dotnet build ivf_tl_operate_2.0/ivf_tl_Operate/ivf_tl_Operate.csproj -c Debug`
+Expected: Build succeeded, 0 Error。
+(operate.exe 正运行会锁 DLL,先关 operate 再编。)
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add ivf_tl_operate_2.0/ivf_tl_Operate/Debug/MjpegFrameParser.cs
+git commit -m "feat(d2-02): operate MjpegFrameParser multipart 切帧状态机(纯逻辑,靠 Content-Length 截帧,处理半帧拼接/一块多帧)"
+```
+
+---
+
+### Task 5: MjpegFrameParser 单测(链入 control 测试工程)
+
+**Files:**
+- Test: `ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/MjpegFrameParserTests.cs`(新建)
+- Modify: `ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/IvfTl.ControlHost.Tests.csproj`(`<Compile Include>` 链入 operate 的 parser 源码)
+
+> operate 主工程是 WPF 无单测工程;parser 纯逻辑零依赖,借 control 测试工程 `<Compile Include>` 链入源码测(同 `临时文件/FaultMapperTest` 手法)。namespace 是 `ivf_tl_Operate.Debug`,跨工程引用源码不引用程序集。
+
+- [ ] **Step 1: csproj 链入 parser 源码**
+
+Edit `ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/IvfTl.ControlHost.Tests.csproj`,在 `</Project>` 前加一个 `<ItemGroup>`:
+```xml
+  <ItemGroup>
+    <!-- operate 的纯逻辑 MjpegFrameParser 链入源码做单测(operate 主工程是 WPF 无单测工程)。 -->
+    <Compile Include="..\..\ivf_tl_Operate\Debug\MjpegFrameParser.cs" Link="Linked\MjpegFrameParser.cs" />
+  </ItemGroup>
+```
+
+- [ ] **Step 2: 写失败测试**
+
+Create `ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/MjpegFrameParserTests.cs`:
+```csharp
+using System.Collections.Generic;
+using System.Text;
+using ivf_tl_Operate.Debug;
+using Xunit;
+
+namespace IvfTl.ControlHost.Tests
+{
+    public class MjpegFrameParserTests
+    {
+        // 造一个 multipart 帧字节(与 control MjpegStreamWriter.FrameBytes 同格式)。
+        private static byte[] MakeFrame(byte[] jpeg)
+        {
+            string head = $"--frame\r\nContent-Type: image/jpeg\r\nContent-Length: {jpeg.Length}\r\n\r\n";
+            var ms = new List<byte>();
+            ms.AddRange(Encoding.ASCII.GetBytes(head));
+            ms.AddRange(jpeg);
+            ms.AddRange(Encoding.ASCII.GetBytes("\r\n"));
+            return ms.ToArray();
+        }
+
+        private static List<byte[]> FeedAll(MjpegFrameParser p, byte[] data)
+        {
+            var all = new List<byte[]>();
+            all.AddRange(p.Feed(data, data.Length));
+            return all;
+        }
+
+        [Fact]
+        public void SingleWholeFrame_YieldsOneJpeg()
+        {
+            var jpeg = new byte[] { 0xFF, 0xD8, 1, 2, 3, 0xFF, 0xD9 };
+            var p = new MjpegFrameParser();
+            var frames = FeedAll(p, MakeFrame(jpeg));
+            Assert.Single(frames);
+            Assert.Equal(jpeg, frames[0]);
+        }
+
+        [Fact]
+        public void TwoFramesInOneChunk_YieldsTwo()
+        {
+            var j1 = new byte[] { 0xFF, 0xD8, 1, 0xFF, 0xD9 };
+            var j2 = new byte[] { 0xFF, 0xD8, 9, 8, 0xFF, 0xD9 };
+            var combined = new List<byte>();
+            combined.AddRange(MakeFrame(j1));
+            combined.AddRange(MakeFrame(j2));
+            var p = new MjpegFrameParser();
+            var frames = FeedAll(p, combined.ToArray());
+            Assert.Equal(2, frames.Count);
+            Assert.Equal(j1, frames[0]);
+            Assert.Equal(j2, frames[1]);
+        }
+
+        [Fact]
+        public void FrameSplitAcrossChunks_Reassembles()
+        {
+            var jpeg = new byte[] { 0xFF, 0xD8, 5, 6, 7, 8, 0xFF, 0xD9 };
+            byte[] full = MakeFrame(jpeg);
+            var p = new MjpegFrameParser();
+            // 在帧中间切两半喂
+            int mid = full.Length / 2;
+            var first = new byte[mid];
+            var second = new byte[full.Length - mid];
+            System.Array.Copy(full, 0, first, 0, mid);
+            System.Array.Copy(full, mid, second, 0, second.Length);
+
+            var frames = new List<byte[]>();
+            frames.AddRange(p.Feed(first, first.Length));
+            Assert.Empty(frames);  // 半帧,还吐不出
+            frames.AddRange(p.Feed(second, second.Length));
+            Assert.Single(frames);
+            Assert.Equal(jpeg, frames[0]);
+        }
+    }
+}
+```
+
+- [ ] **Step 3: 跑测试确认通过**
+
+Run: `dotnet test ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/IvfTl.ControlHost.Tests.csproj --filter "FullyQualifiedName~MjpegFrameParserTests"`
+Expected: PASS(3 passed)。
+
+- [ ] **Step 4: 全量回归**
+
+Run: `dotnet test ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/IvfTl.ControlHost.Tests.csproj`
+Expected: PASS(32 passed = 29 + 本 3)。
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/MjpegFrameParserTests.cs ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/IvfTl.ControlHost.Tests.csproj
+git commit -m "test(d2-02): MjpegFrameParser 3 单测(整帧/一块多帧/半帧拼接),链入 control 测试工程跑"
+```
+
+---
+
+### Task 6: operate 端 MjpegStreamClient(流式读 + 解码 + 事件回调)
+
+**Files:**
+- Create: `ivf_tl_operate_2.0/ivf_tl_Operate/Debug/MjpegStreamClient.cs`
+
+> 含 `HttpClient` 流式读 + WPF `BitmapImage` 解码 + 线程,真机/真 UI 才能端到端验,本 Task 只到编译。逻辑核心(切帧)已在 Task 5 单测。
+
+- [ ] **Step 1: 写实现**
+
+Create `ivf_tl_operate_2.0/ivf_tl_Operate/Debug/MjpegStreamClient.cs`:
+```csharp
+using System;
+using System.IO;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Windows.Media.Imaging;
+
+namespace ivf_tl_Operate.Debug
+{
+    /// <summary>
+    /// operate 端 MJPEG 预览客户端:流式读 control /debug/preview/stream → 切帧 → 解码 BitmapImage → 事件回调。
+    /// 断开/异常 → Stopped 事件(View 据此提示操作人员手动重开,不自动重连,spec §7)。
+    /// </summary>
+    public sealed class MjpegStreamClient : IDisposable
+    {
+        private readonly string _baseUrl;   // 如 http://127.0.0.1:38080
+        private HttpClient _http;
+        private CancellationTokenSource _cts;
+        private Task _readTask;
+
+        /// <summary>收到一帧(已 Freeze,可跨线程贴 UI)。</summary>
+        public event Action<BitmapImage> FrameReceived;
+        /// <summary>预览停止(reason:断开原因,供 View 提示)。</summary>
+        public event Action<string> Stopped;
+
+        public bool IsRunning { get; private set; }
+
+        public MjpegStreamClient(string baseUrl) { _baseUrl = baseUrl.TrimEnd('/'); }
+
+        /// <summary>开始预览。sessionId = 第一阶段 acquire 返回的会话 id。</summary>
+        public void Start(string sessionId)
+        {
+            if (IsRunning) return;
+            IsRunning = true;
+            _cts = new CancellationTokenSource();
+            _http = new HttpClient { Timeout = Timeout.InfiniteTimeSpan };  // 长连接,不超时掐断
+            string url = $"{_baseUrl}/debug/preview/stream?sessionId={sessionId}";
+            _readTask = Task.Run(() => ReadLoop(url, _cts.Token));
+        }
+
+        private async Task ReadLoop(string url, CancellationToken token)
+        {
+            string reason = "预览已结束";
+            try
+            {
+                using (var resp = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, token))
+                {
+                    if (!resp.IsSuccessStatusCode)
+                    {
+                        reason = resp.StatusCode == System.Net.HttpStatusCode.NotFound
+                            ? "调试会话已超时,请重新进入调试"
+                            : $"预览打开失败({(int)resp.StatusCode})";
+                        return;
+                    }
+                    var parser = new MjpegFrameParser();
+                    using (var stream = await resp.Content.ReadAsStreamAsync())
+                    {
+                        var buf = new byte[64 * 1024];
+                        while (!token.IsCancellationRequested)
+                        {
+                            int n = await stream.ReadAsync(buf, 0, buf.Length, token);
+                            if (n <= 0) { reason = "预览连接已断开,请重新打开预览"; break; }
+                            foreach (var jpeg in parser.Feed(buf, n))
+                            {
+                                var img = Decode(jpeg);
+                                if (img != null) FrameReceived?.Invoke(img);
+                            }
+                        }
+                    }
+                }
+            }
+            catch (OperationCanceledException) { reason = "预览已关闭"; }    // 主动 Stop
+            catch (Exception ex) { reason = $"预览中断,请重新打开预览({ex.Message})"; }
+            finally
+            {
+                IsRunning = false;
+                Stopped?.Invoke(reason);
+            }
+        }
+
+        private static BitmapImage Decode(byte[] jpeg)
+        {
+            try
+            {
+                var img = new BitmapImage();
+                img.BeginInit();
+                img.CacheOption = BitmapCacheOption.OnLoad;   // 解完即脱离流
+                img.StreamSource = new MemoryStream(jpeg);
+                img.EndInit();
+                img.Freeze();   // 跨线程贴 UI 必须 Freeze
+                return img;
+            }
+            catch { return null; }   // 坏帧丢弃,不打断流
+        }
+
+        /// <summary>主动停止预览(关预览按钮/返回)。</summary>
+        public void Stop()
+        {
+            try { _cts?.Cancel(); } catch { }
+            try { _http?.Dispose(); } catch { }
+            IsRunning = false;
+        }
+
+        public void Dispose() => Stop();
+    }
+}
+```
+
+- [ ] **Step 2: 编译 operate 确认 0 错**
+
+Run: `dotnet build ivf_tl_operate_2.0/ivf_tl_Operate/ivf_tl_Operate.csproj -c Debug`
+Expected: Build succeeded, 0 Error。
+(若报 `HttpClient` 找不到:net6.0-windows 内置 `System.Net.Http`,无需额外包。)
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add ivf_tl_operate_2.0/ivf_tl_Operate/Debug/MjpegStreamClient.cs
+git commit -m "feat(d2-02): operate MjpegStreamClient 流式读+解码 BitmapImage(Freeze)+FrameReceived/Stopped 事件;断开明确提示不自动重连"
+```
+
+---
+
+### Task 7: 调试页 View 接入预览(OpenVideo/CloseVideo 改走 MjpegStreamClient)
+
+**Files:**
+- Modify: `ivf_tl_operate_2.0/ivf_tl_Operate/View/HouseDebugPageView.xaml`(加 `<Image>`)
+- Modify: `ivf_tl_operate_2.0/ivf_tl_Operate/View/HouseDebugPageView.xaml.cs`(OpenVideo/CloseVideo + 提示)
+
+> **影响面**:只动预览这一块(OpenVideo/CloseVideo + 加 1 个 Image 控件 + Stopped 提示)。读温度/电机/EEPROM 等所有命令按钮、Start_Click/End_Click 的非预览部分**全不动**。`vm.StartPreview/StopPreview` 调用点替换掉(那两个 vm 方法走死栈,第三阶段删;本阶段预览不再调它们)。
+>
+> **control 地址来源(已核实)**:operate 侧 `Helpers/ControlClient.cs` 已有 `static int Port`(从 `controlPort` appSetting 取,默认 38080)+ private `BaseUrl`。本阶段把 `ControlClient.BaseUrl` 提升为 **public**(一行可见性改),`MjpegStreamClient` 直接用 `ControlClient.BaseUrl`,**不往 VM 塞地址属性**(沿用现有跨进程客户端模式)。
+>
+> **sessionId 来源(已澄清)**:第一阶段命令分发**尚未接进 operate VM**(那是第三阶段)。本阶段预览要独立真机验证,需要一个已 acquire 的 sid。处理:VM 加一个 `public string CurrentSessionId { get; set; }`(纯持有,第三阶段 acquire 时赋值;本阶段真机验证时由 Claude 先 curl `/debug/acquire` 拿到 sid 后通过调试入口赋上,或临时硬编码验证)。**不改 VM 任何既有方法**。
+
+- [ ] **Step 0: 把 ControlClient.BaseUrl 提升为 public**
+
+Edit `ivf_tl_operate_2.0/ivf_tl_Operate/Helpers/ControlClient.cs:23`,把 `private static string BaseUrl` 改为 `public static string BaseUrl`(仅可见性,值不变)。
+
+- [ ] **Step 1: XAML 加预览 Image 控件**
+
+先确认原来贴窗口的坐标(`OpenVideo` 里 `StartPreview(hwnd, 328, 805, 800, 600)`)。Edit `HouseDebugPageView.xaml`,在显示画面的区域(原贴窗口位置附近的容器)加:
+```xml
+<!-- D2-02 第二阶段:MJPEG 预览画面(替代原贴窗口句柄方式) -->
+<Image x:Name="_previewImage" Width="800" Height="600" Stretch="Uniform"
+       HorizontalAlignment="Left" VerticalAlignment="Top" Margin="328,805,0,0"/>
+```
+(具体父容器/Margin 对齐原画面区域;若原用 Canvas 定位,放进同一 Canvas 用同坐标。打开 xaml 看清结构再放。)
+
+- [ ] **Step 2: 改 OpenVideo/CloseVideo + 加字段 + Stopped 提示**
+
+Edit `HouseDebugPageView.xaml.cs`:
+
+(a) 加 using(文件顶部):
+```csharp
+using ivf_tl_Operate.Debug;
+```
+
+(b) 加字段(在 `private bool isOpen;` 附近;baseUrl 取 control 本地端口,沿用项目里 control HTTP 地址来源):
+```csharp
+private MjpegStreamClient _mjpeg;
+```
+
+(c) 替换 `OpenVideo()` 方法体为:
+```csharp
+        private void OpenVideo()
+        {
+            try
+            {
+                if (isOpen) { AddMessageInfo($"图像已打开"); return; }
+                // sessionId:第一阶段 acquire 返回的会话 id。第三阶段整页改走 DebugSessionClient 后由其提供;
+                // 本阶段预览独立验证时,sessionId 来自 vm 当前借用会话(vm.CurrentSessionId,见下注)。
+                string sessionId = vm.CurrentSessionId;
+                if (string.IsNullOrEmpty(sessionId)) { AddMessageInfo("未借用会话,无法预览(请先初始化)"); return; }
+
+                _mjpeg = new MjpegStreamClient(ivf_tl_Operate.Helpers.ControlClient.BaseUrl);
+                _mjpeg.FrameReceived += img => Dispatcher.Invoke(() => { _previewImage.Source = img; });
+                _mjpeg.Stopped += reason => Dispatcher.Invoke(() =>
+                {
+                    isOpen = false;
+                    AddMessageInfo($"⚠ {reason}");   // 明确提示操作人员手动重开(不自动重连)
+                });
+                _mjpeg.Start(sessionId);
+                isOpen = true;
+                AddMessageInfo("图像已打开(MJPEG 实时预览)");
+            }
+            catch (Exception ex)
+            {
+                ExLog(ex, "OpenVideo");
+                AddMessageInfo($"图像打开异常:{ex.Message}");
+            }
+        }
+```
+
+(d) 替换 `CloseVideo()` 方法体为:
+```csharp
+        private void CloseVideo()
+        {
+            try
+            {
+                if (!isOpen) { AddMessageInfo($"图像未打开,无需关闭"); return; }
+                _mjpeg?.Stop();
+                _mjpeg = null;
+                Dispatcher.Invoke(() => { _previewImage.Source = null; });
+                isOpen = false;
+                AddMessageInfo("图像已关闭");
+            }
+            catch (Exception ex)
+            {
+                ExLog(ex, "CloseVideo");
+                AddMessageInfo($"图像关闭异常:{ex.Message}");
+            }
+        }
+```
+
+> **注(sessionId / 地址来源)**:`ControlClient.BaseUrl`(Step 0 已提 public,= `http://127.0.0.1:{controlPort}`)直接用。`vm.CurrentSessionId` 是 VM 上新加的 `public string CurrentSessionId { get; set; }`(纯持有,第三阶段 acquire 时赋值;本阶段真机验证由 Claude 先 curl `/debug/acquire` 拿 sid 赋上)。**不改 VM 任何既有方法、不加地址属性**(地址走 ControlClient)。
+
+- [ ] **Step 3: 编译 operate 确认 0 错**
+
+Run: `dotnet build ivf_tl_operate_2.0/ivf_tl_Operate/ivf_tl_Operate.csproj -c Debug`
+Expected: Build succeeded, 0 Error(XAML→BAML 绑定/控件名解析全对)。
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add ivf_tl_operate_2.0/ivf_tl_Operate/View/HouseDebugPageView.xaml ivf_tl_operate_2.0/ivf_tl_Operate/View/HouseDebugPageView.xaml.cs ivf_tl_operate_2.0/ivf_tl_Operate/ViewModel/HouseDebugPageViewModel.cs
+git commit -m "feat(d2-02): 调试页预览接入 MjpegStreamClient——OpenVideo/CloseVideo 改连/断 MJPEG 流+<Image>显示+断开提示;VM 加 ControlBaseUrl/CurrentSessionId(不动既有方法)"
+```
+
+---
+
+### Task 8: codegraph sync + Release 编译总验 + 文档回写
+
+**Files:**
+- Modify: 进度文档(见 CLAUDE.md 回写矩阵)
+
+- [ ] **Step 1: codegraph 增量同步**
+
+Run: `codegraph sync`
+Expected: 索引更新(新增 4 源文件入图)。
+
+- [ ] **Step 2: control + operate Release 双编译**
+
+Run: `dotnet build ivf_tl_operate_2.0/control/ivf_tl_ControlHost/ivf_tl_ControlHost.csproj -c Release`
+Run: `dotnet build ivf_tl_operate_2.0/ivf_tl_Operate/ivf_tl_Operate.csproj -c Release`
+Expected: 均 0 错。(真机/连内网必须 Release,见 CLAUDE.md §六。先停 control/operate 实例解 DLL 锁。)
+
+- [ ] **Step 3: 全量单测最终回归**
+
+Run: `dotnet test ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/IvfTl.ControlHost.Tests.csproj`
+Expected: PASS(32 passed)。
+
+- [ ] **Step 4: 回写文档(CLAUDE.md 第三节回写矩阵)**
+
+- `进度/进度状态.yaml`:覆盖断点(MJPEG 第二阶段代码完成,残真机出图门控)。
+- `进度/交接卡.md`:追加一段(改动/核实/踩坑/下一步)。
+- `进度/工作计划表.md`:D2-02 第二阶段状态 🟢 代码完成待真机。
+- `进度/待验证清单.md`:加阶段2 MJPEG 真机门控项(归 V-012/第三阶段)。
+- `进度/进度数据.js`:让监控面板反映。
+
+- [ ] **Step 5: Commit(代码+文档已对齐)**
+
+```bash
+git add 项目文档/
+git commit -m "docs(d2-02): 第二阶段 MJPEG 预览代码完成回写——断点/交接卡/计划表/待验证清单同步,残真机出图门控(归 V-012)"
+```
+
+---
+
+## 真机门控(归第三阶段 V-012,不在本计划阻塞)
+
+本计划交付**代码完成 + 纯逻辑单测全绿 + Release 双编译 0 错**。下列需真机/真 UI:
+- MJPEG 真机出图:acquire 某舱 → operate 端 `<Image>` 看到该舱实时画面。
+- 崩溃自愈:断预览/杀 operate → 推流线程自停 + 标记 StreamBroken → 心跳 TTL 看门狗回收 → 该舱采集恢复(/status 反映)。
+- 相机锁冲突实测:A 舱预览 + B 舱采集拍照同时,观察实际影响(spec §6.3)。
+
+随第三阶段(operate 完整接入)一并真机验收。
+
+---
+
+## 自查结论(对照 spec)
+
+- **spec §3 数据流** → Task 1(编码/封帧)+Task 3(推流)+Task 4/6(operate 读流解码)全覆盖。
+- **spec §4 control 推流(端点/线程/编码/StreamBroken)** → Task 1(EncodeJpeg/FrameBytes)+Task 2(StreamBroken/TryGet)+Task 3(端点+线程)。
+- **spec §5 operate 解码** → Task 4(切帧)+Task 6(client 解码事件)+Task 7(View 接入 + Image)。
+- **spec §6 相机锁冲突** → 不优化,Task 3 抓帧走 `GrabStable`(自带锁),业务闭环登记表已标注影响。
+- **spec §7 崩溃自愈+提示** → Task 3(三种退出路径 + StreamBroken + finally 兜底)+Task 6(Stopped 事件+reason)+Task 7(Stopped→AddMessageInfo 提示,不自动重连)。
+- **spec §8 测试** → Task 1(编码 2 测)+Task 5(切帧 3 测);真机门控归第三阶段。
+- **类型一致**:`MjpegStreamWriter.EncodeJpeg/FrameBytes/ContentType/Boundary`、`DebugSession.StreamBroken`、`DebugSessionManager.TryGet`、`MjpegFrameParser.Feed`、`MjpegStreamClient.Start/Stop/FrameReceived/Stopped`、`ControlClient.BaseUrl`(public)、`vm.CurrentSessionId` 跨 Task 一致。
+- **业务闭环/影响面**:开工前登记表 7 条边界贯穿全计划(零改采集业务/不碰第一阶段命令分发/相机锁串行/采集恢复保证/HttpListener 不阻塞/只改预览不碰其他按钮)。