|
@@ -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 不阻塞/只改预览不碰其他按钮)。
|