فهرست منبع

feat(d2-02): operate MjpegFrameParser multipart 切帧状态机(纯逻辑)+3 单测(整帧/一块多帧/半帧拼接),链入 control 测试工程

huangjie 1 روز پیش
والد
کامیت
4ef77696a2

+ 4 - 0
ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/IvfTl.ControlHost.Tests.csproj

@@ -14,4 +14,8 @@
     <ProjectReference Include="..\ivf_tl_ControlHost\ivf_tl_ControlHost.csproj" />
     <ProjectReference Include="..\IvfTl.Hardware\IvfTl.Hardware.csproj" />
   </ItemGroup>
+  <ItemGroup>
+    <!-- operate 的纯逻辑 MjpegFrameParser 链入源码做单测(operate 主工程是 WPF 无单测工程)。 -->
+    <Compile Include="..\..\ivf_tl_Operate\Debug\MjpegFrameParser.cs" Link="Linked\MjpegFrameParser.cs" />
+  </ItemGroup>
 </Project>

+ 74 - 0
ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/MjpegFrameParserTests.cs

@@ -0,0 +1,74 @@
+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]);
+        }
+    }
+}

+ 81 - 0
ivf_tl_operate_2.0/ivf_tl_Operate/Debug/MjpegFrameParser.cs

@@ -0,0 +1,81 @@
+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;
+        }
+    }
+}