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 |
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 合法性 + 帧封装格式)。 | 新建 |
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。
Files:
ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/MjpegStreamWriter.csTest: 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]);
}
}
}
Run: dotnet test ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/IvfTl.ControlHost.Tests.csproj --filter "FullyQualifiedName~MjpegStreamWriterTests"
Expected: 编译失败 MjpegStreamWriter 不存在。
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}";
}
}
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 Debug0 错。
[ ] 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 单测"
Files:
ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugSession.csivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugSessionManager.csivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/DebugStreamSessionTests.cs(新建)影响面:DebugSession 只加字段(默认 false,不影响既有构造/赋值);DebugSessionManager 只加一个新方法,不动既有 6 方法体 → 27 单测不回归。
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 _));
}
}
}
Run: dotnet test ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/IvfTl.ControlHost.Tests.csproj --filter "FullyQualifiedName~DebugStreamSessionTests"
Expected: 编译失败(StreamBroken/TryGet 不存在)。
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;
}
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 只读方法(推流端点用,不动既有方法)"
Files:
ivf_tl_operate_2.0/control/ivf_tl_ControlHost/ControlHttpServer.cs关键影响:推流分支必须在 switch 内分流起线程后 return,绕过
Handle()末尾统一的body→Write→Close(那会关流、终止推流)。其他端点的统一收尾完全不变。
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;
}
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();
}
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 实例再编。)
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"
Files:
ivf_tl_operate_2.0/ivf_tl_Operate/Debug/MjpegFrameParser.cs纯逻辑、零依赖,单测在 Task 5(链入 control 测试工程跑)。本 Task 先实现 + 编译。
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;
}
}
}
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 截帧,处理半帧拼接/一块多帧)"
Files:
ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/MjpegFrameParserTests.cs(新建)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,跨工程引用源码不引用程序集。
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>
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]);
}
}
}
Run: dotnet test ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/IvfTl.ControlHost.Tests.csproj --filter "FullyQualifiedName~MjpegFrameParserTests"
Expected: PASS(3 passed)。
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 测试工程跑"
Files:
ivf_tl_operate_2.0/ivf_tl_Operate/Debug/MjpegStreamClient.cs含
HttpClient流式读 + WPFBitmapImage解码 + 线程,真机/真 UI 才能端到端验,本 Task 只到编译。逻辑核心(切帧)已在 Task 5 单测。
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();
}
}
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 事件;断开明确提示不自动重连"
Files:
ivf_tl_operate_2.0/ivf_tl_Operate/View/HouseDebugPageView.xaml(加 <Image>)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(从controlPortappSetting 取,默认 38080)+ privateBaseUrl。本阶段把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 任何既有方法。
Edit ivf_tl_operate_2.0/ivf_tl_Operate/Helpers/ControlClient.cs:23,把 private static string BaseUrl 改为 public static string BaseUrl(仅可见性,值不变)。
先确认原来贴窗口的坐标(OpenVideo 里 StartPreview(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 看清结构再放。)
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)。
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(不动既有方法)"
Files:
Modify: 进度文档(见 CLAUDE.md 回写矩阵)
[ ] Step 1: codegraph 增量同步
Run: codegraph sync
Expected: 索引更新(新增 4 源文件入图)。
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 锁。)
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)"
本计划交付代码完成 + 纯逻辑单测全绿 + Release 双编译 0 错。下列需真机/真 UI:
<Image> 看到该舱实时画面。随第三阶段(operate 完整接入)一并真机验收。
GrabStable(自带锁),业务闭环登记表已标注影响。MjpegStreamWriter.EncodeJpeg/FrameBytes/ContentType/Boundary、DebugSession.StreamBroken、DebugSessionManager.TryGet、MjpegFrameParser.Feed、MjpegStreamClient.Start/Stop/FrameReceived/Stopped、ControlClient.BaseUrl(public)、vm.CurrentSessionId 跨 Task 一致。