2026-06-24-D2-02-第二阶段-MJPEG实时预览-实现计划.md 43 KB

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:

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:

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

    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:

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 字段后加:

        /// <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 方法后加(不改任何既有方法):

        /// <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

    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: 之前加:

                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):

        /// <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

    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:

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

    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>:

  <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:

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

    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:

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

    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 控件

先确认原来贴窗口的坐标(OpenVideoStartPreview(hwnd, 328, 805, 800, 600))。Edit HouseDebugPageView.xaml,在显示画面的区域(原贴窗口位置附近的容器)加:

<!-- 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(文件顶部):

using ivf_tl_Operate.Debug;

(b) 加字段(在 private bool isOpen; 附近;baseUrl 取 control 本地端口,沿用项目里 control HTTP 地址来源):

private MjpegStreamClient _mjpeg;

(c) 替换 OpenVideo() 方法体为:

        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() 方法体为:

        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

    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(代码+文档已对齐)

    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/BoundaryDebugSession.StreamBrokenDebugSessionManager.TryGetMjpegFrameParser.FeedMjpegStreamClient.Start/Stop/FrameReceived/StoppedControlClient.BaseUrl(public)、vm.CurrentSessionId 跨 Task 一致。
  • 业务闭环/影响面:开工前登记表 7 条边界贯穿全计划(零改采集业务/不碰第一阶段命令分发/相机锁串行/采集恢复保证/HttpListener 不阻塞/只改预览不碰其他按钮)。