MjpegStreamClient.cs 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
  1. using System;
  2. using System.IO;
  3. using System.Net.Http;
  4. using System.Threading;
  5. using System.Threading.Tasks;
  6. using System.Windows.Media.Imaging;
  7. namespace ivf_tl_Operate.Debug
  8. {
  9. /// <summary>
  10. /// operate 端 MJPEG 预览客户端:流式读 control /debug/preview/stream → 切帧 → 解码 BitmapImage → 事件回调。
  11. /// 断开/异常 → Stopped 事件(View 据此提示操作人员手动重开,不自动重连,spec §7)。
  12. /// </summary>
  13. public sealed class MjpegStreamClient : IDisposable
  14. {
  15. private readonly string _baseUrl; // 如 http://127.0.0.1:38080
  16. private HttpClient _http;
  17. private CancellationTokenSource _cts;
  18. private Task _readTask;
  19. /// <summary>收到一帧(已 Freeze,可跨线程贴 UI)。</summary>
  20. public event Action<BitmapImage> FrameReceived;
  21. /// <summary>预览停止(reason:断开原因,供 View 提示)。</summary>
  22. public event Action<string> Stopped;
  23. public bool IsRunning { get; private set; }
  24. public MjpegStreamClient(string baseUrl) { _baseUrl = baseUrl.TrimEnd('/'); }
  25. /// <summary>开始预览。sessionId = 第一阶段 acquire 返回的会话 id。</summary>
  26. public void Start(string sessionId)
  27. {
  28. if (IsRunning) return;
  29. IsRunning = true;
  30. _cts = new CancellationTokenSource();
  31. _http = new HttpClient { Timeout = Timeout.InfiniteTimeSpan }; // 长连接,不超时掐断
  32. string url = $"{_baseUrl}/debug/preview/stream?sessionId={sessionId}";
  33. _readTask = Task.Run(() => ReadLoop(url, _cts.Token));
  34. }
  35. private async Task ReadLoop(string url, CancellationToken token)
  36. {
  37. string reason = "预览已结束";
  38. try
  39. {
  40. using (var resp = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, token))
  41. {
  42. if (!resp.IsSuccessStatusCode)
  43. {
  44. reason = resp.StatusCode == System.Net.HttpStatusCode.NotFound
  45. ? "调试会话已超时,请重新进入调试"
  46. : $"预览打开失败({(int)resp.StatusCode})";
  47. return;
  48. }
  49. var parser = new MjpegFrameParser();
  50. using (var stream = await resp.Content.ReadAsStreamAsync())
  51. {
  52. var buf = new byte[64 * 1024];
  53. while (!token.IsCancellationRequested)
  54. {
  55. int n = await stream.ReadAsync(buf, 0, buf.Length, token);
  56. if (n <= 0) { reason = "预览连接已断开,请重新打开预览"; break; }
  57. foreach (var jpeg in parser.Feed(buf, n))
  58. {
  59. var img = Decode(jpeg);
  60. if (img != null) FrameReceived?.Invoke(img);
  61. }
  62. }
  63. }
  64. }
  65. }
  66. catch (OperationCanceledException) { reason = "预览已关闭"; } // 主动 Stop
  67. catch (Exception ex) { reason = $"预览中断,请重新打开预览({ex.Message})"; }
  68. finally
  69. {
  70. IsRunning = false;
  71. Stopped?.Invoke(reason);
  72. }
  73. }
  74. private static BitmapImage Decode(byte[] jpeg)
  75. {
  76. try
  77. {
  78. var img = new BitmapImage();
  79. img.BeginInit();
  80. img.CacheOption = BitmapCacheOption.OnLoad; // 解完即脱离流
  81. img.StreamSource = new MemoryStream(jpeg);
  82. img.EndInit();
  83. img.Freeze(); // 跨线程贴 UI 必须 Freeze
  84. return img;
  85. }
  86. catch { return null; } // 坏帧丢弃,不打断流
  87. }
  88. /// <summary>主动停止预览(关预览按钮/返回)。</summary>
  89. public void Stop()
  90. {
  91. try { _cts?.Cancel(); } catch { }
  92. try { _http?.Dispose(); } catch { }
  93. IsRunning = false;
  94. }
  95. public void Dispose() => Stop();
  96. }
  97. }