# 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 解码贴到调试页 `` 控件;预览中断能自愈到安全态并明确提示操作人员。 **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)+ 加 ``。读温度/电机/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` | 预览区加 ``。 | 改(加控件) | | `View/HouseDebugPageView.xaml.cs` | `OpenVideo/CloseVideo` 改连/断 `MjpegStreamClient`;`Stopped` 回调弹提示。 | 改(2 方法) | > **operate 单测**:operate 主工程(WPF)无现成 xUnit 单测工程。`MjpegFrameParser` 是纯逻辑,本计划用 control 已有的 `IvfTl.ControlHost.Tests` 工程**通过 `` 链入** `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 { /// /// MJPEG 推流的纯逻辑:RGB 像素 → JPEG 字节,JPEG → multipart 帧字节。 /// 无 IO、无相机依赖,可纯单测。真正的抓帧/写流由 ControlHttpServer 推流分支驱动。 /// 相机抓帧返回 24bpp BGR(GrabStable),WPF JpegBitmapEncoder 需 Bgr24 像素格式。 /// public static class MjpegStreamWriter { public const string Boundary = "frame"; /// 把 24bpp BGR 像素(width*height*3)编码成 JPEG 字节(质量 85)。 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(); } } /// 把 JPEG 字节封成一个 multipart/x-mixed-replace 帧(含 boundary 头 + 帧体 + \r\n)。 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; } /// 响应头里的 Content-Type 值(供 ControlHttpServer 写头用)。 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`),`true` 若未开则在 csproj 的 `` 加该行。验证:`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 /// 推流线程因任何原因退出时置 true(spec §4.4)。可回收快信号,不替代心跳 TTL。 public bool StreamBroken { get; set; } ``` Edit `ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugSessionManager.cs`,在 `Heartbeat` 方法后加(**不改任何既有方法**): ```csharp /// 只读取会话(供推流端点校验 sid)。不刷新 LastSeen、不改状态。 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 /// /// 推流分支:起专用后台线程,抓帧→JPEG→multipart 持续写。 /// HttpListener 工作线程立即返回(本方法起线程后即返回),不被推流阻塞。 /// 任何退出路径都标记 session.StreamBroken,会话靠心跳 TTL 看门狗最终回收(spec §7)。 /// 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 { /// /// MJPEG multipart 流切帧状态机(纯逻辑)。喂入任意大小的字节块,吐出完整 JPEG 帧。 /// 处理:一帧跨多个块、一个块含多帧、半个头跨块。只认 Content-Length 截帧(不靠扫 boundary 找帧尾,稳)。 /// 流格式:--frame\r\nContent-Type: image/jpeg\r\nContent-Length: N\r\n\r\n[N 字节 JPEG]\r\n /// public sealed class MjpegFrameParser { private readonly List _buf = new List(); private int _expectedLen = -1; // -1 = 还没解析到帧头;>=0 = 正在收帧体 private int _bodyStart = -1; /// 喂入一段字节,返回这次能切出的完整 JPEG 帧(可能 0~多帧)。 public IEnumerable Feed(byte[] chunk, int count) { for (int i = 0; i < count; i++) _buf.Add(chunk[i]); var frames = new List(); 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 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`(`` 链入 operate 的 parser 源码) > operate 主工程是 WPF 无单测工程;parser 纯逻辑零依赖,借 control 测试工程 `` 链入源码测(同 `临时文件/FaultMapperTest` 手法)。namespace 是 `ivf_tl_Operate.Debug`,跨工程引用源码不引用程序集。 - [ ] **Step 1: csproj 链入 parser 源码** Edit `ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/IvfTl.ControlHost.Tests.csproj`,在 `` 前加一个 ``: ```xml ``` - [ ] **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(); ms.AddRange(Encoding.ASCII.GetBytes(head)); ms.AddRange(jpeg); ms.AddRange(Encoding.ASCII.GetBytes("\r\n")); return ms.ToArray(); } private static List FeedAll(MjpegFrameParser p, byte[] data) { var all = new List(); 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(); 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(); 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 { /// /// operate 端 MJPEG 预览客户端:流式读 control /debug/preview/stream → 切帧 → 解码 BitmapImage → 事件回调。 /// 断开/异常 → Stopped 事件(View 据此提示操作人员手动重开,不自动重连,spec §7)。 /// public sealed class MjpegStreamClient : IDisposable { private readonly string _baseUrl; // 如 http://127.0.0.1:38080 private HttpClient _http; private CancellationTokenSource _cts; private Task _readTask; /// 收到一帧(已 Freeze,可跨线程贴 UI)。 public event Action FrameReceived; /// 预览停止(reason:断开原因,供 View 提示)。 public event Action Stopped; public bool IsRunning { get; private set; } public MjpegStreamClient(string baseUrl) { _baseUrl = baseUrl.TrimEnd('/'); } /// 开始预览。sessionId = 第一阶段 acquire 返回的会话 id。 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; } // 坏帧丢弃,不打断流 } /// 主动停止预览(关预览按钮/返回)。 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`(加 ``) - 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 任何既有方法**。 > **★实例一次性(Task6 代码审查 I-1)**:`MjpegStreamClient` 是一次性实例,Stop/Dispose 后**不可复用 Start**(否则旧后台 Task 尾帧/Stopped 会串台到新会话)。本 Task 必须:**OpenVideo 每次 `new MjpegStreamClient`,CloseVideo 里 `_mjpeg.Stop()` 后置 `_mjpeg = null` 丢弃实例**。绝不复用同一个 client 实例反复 Start。 - [ ] **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 ``` (具体父容器/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 流+显示+断开提示;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: - **★预览启动前置(Task7 审查 M-1)**:第二阶段 `CurrentSessionId` 无 UI 赋值点(第三阶段 acquire 才赋值)。真机验证预览**必须先 curl `/debug/acquire` 拿 sid → 把 sid 赋给 `vm.CurrentSessionId`**(调试入口/临时赋值),否则 OpenVideo 走"未借用会话,无法预览"分支、预览点不开(这是阶段边界,非 bug)。 - **★★会话 10s 无心跳自断(最终整体审查发现,真机必踩)**:第二阶段 operate 端**不发心跳**(心跳归第三阶段)。开预览后若不操作、无外部心跳,`LastSeen` 停在 acquire 时刻,**TTL 10s(Program.cs ttlMs=10000,看门狗每3s扫)到点 → SweepExpired 回收会话 → 推流线程下轮 TryGet 失败自停 → 预览自断**,operate 弹"调试会话已超时"。**这不是预览 bug,是 spec 已知设计缺口**(推流端点 TryGet 只读不刷新 LastSeen,心跳归第三阶段)。真机验证第二阶段预览**必须制造心跳续命**:开预览后用 curl 每 ~5s 打一次 `POST /debug/heartbeat {sessionId}`,观察预览能否稳定持续 >30s。第三阶段接入 DebugSessionClient 周期心跳后此坑自然消失,**无需改第二阶段代码**。 - MJPEG 真机出图:acquire 某舱 → operate 端 `` 看到该舱实时画面。 - **关预览/返回后画面干净无残帧(Task7 审查 I-1 已代码根治"当值实例"闸)**:真机确认关预览后画面立即清空、不闪残帧。 - **画面方向核查(Task1 代码审查提出)**:旧 `Camera.SaveBmpPic` 落盘前有 `RotateNoneFlipY`(相机原始 buffer 可能是 bottom-up)。MjpegStreamWriter 不做翻转 → 真机预览可能上下倒置。**若倒置,在推流调用层(Task3 抓帧后)补一次 Y 翻转**,勿改纯逻辑类 MjpegStreamWriter。 - 崩溃自愈:断预览/杀 operate → 推流线程自停 + 标记 StreamBroken → 心跳 TTL 看门狗回收 → 该舱采集恢复(/status 反映)。 - **★重点压测(Task3 代码审查 I-1)**:**预览中反复 release/超时回收同一舱**——推流线程抓帧与会话回收 Dispose 相机存在 use-after-free 窗口(底层 `Camera.UnInit` 只置 `IsInit=false` 不置 `IsStart=false`,`GetRgbData` 的 `IsStart` 护栏拦不住已 Dispose 相机 → 下一轮抓帧进 native 踩已释放 pDest/已卸载 hImager)。被全局相机锁串行 + native 的 HPCSE 兜底降级到"大概率只丢一帧",但 .NET6 上 HPCSE 不保证可恢复。**真机务必压**:开预览同时反复 release/超时回收,确认 control 不偶发崩溃/native 日志无致命报错。若真机暴露崩溃,最小修法=让底层 `Camera.UnInit()` 顺带置 `IsStart=false`(单独评估,影响面超出本阶段)。 - **SetOpMode(1) 实参被底层忽略(Task3 审查 M-3)**:`CameraImpl.SetOpMode(byte)` 转发底层无参 `SetOpMode()`,传 1 不生效。真机确认"连续 GrabStable 确实能出帧"(底层默认模式是否支持连续抓帧)。 - **SendChunked + multipart 端到端分帧(Task3 审查 M-1)**:control 用 chunked transfer + multipart 双层分帧,operate `MjpegStreamClient` 需先解 chunked 再按 boundary 切帧(HttpClient 自动解 chunked)。端到端联调确认分帧正常。 - 相机锁冲突实测: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 不阻塞/只改预览不碰其他按钮)。