MjpegStreamClient.cs 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
  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. /// 实例一次性:Stop/Dispose 后不可复用,需重新 new。View 每次开预览应 new 新实例。
  13. /// </summary>
  14. public sealed class MjpegStreamClient : IDisposable
  15. {
  16. private readonly string _baseUrl; // 如 http://127.0.0.1:38080
  17. private HttpClient _http;
  18. private CancellationTokenSource _cts;
  19. private bool _disposed;
  20. /// <summary>收到一帧(已 Freeze,可跨线程贴 UI)。</summary>
  21. public event Action<BitmapImage> FrameReceived;
  22. /// <summary>预览停止(reason:断开原因,供 View 提示)。</summary>
  23. public event Action<string> Stopped;
  24. public bool IsRunning { get; private set; }
  25. public MjpegStreamClient(string baseUrl) { _baseUrl = baseUrl.TrimEnd('/'); }
  26. /// <summary>开始预览。sessionId = 第一阶段 acquire 返回的会话 id。</summary>
  27. public void Start(string sessionId)
  28. {
  29. if (_disposed) return;
  30. if (IsRunning) return;
  31. IsRunning = true;
  32. _cts = new CancellationTokenSource();
  33. _http = new HttpClient { Timeout = Timeout.InfiniteTimeSpan }; // 长连接,不超时掐断
  34. string url = $"{_baseUrl}/debug/preview/stream?sessionId={sessionId}";
  35. _ = Task.Run(() => ReadLoop(url, _cts.Token));
  36. }
  37. private async Task ReadLoop(string url, CancellationToken token)
  38. {
  39. string reason = "预览已结束";
  40. try
  41. {
  42. using (var resp = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, token))
  43. {
  44. if (!resp.IsSuccessStatusCode)
  45. {
  46. reason = resp.StatusCode == System.Net.HttpStatusCode.NotFound
  47. ? "调试会话已超时,请重新进入调试"
  48. : $"预览打开失败({(int)resp.StatusCode})";
  49. return;
  50. }
  51. var parser = new MjpegFrameParser();
  52. using (var stream = await resp.Content.ReadAsStreamAsync())
  53. {
  54. var buf = new byte[64 * 1024];
  55. while (!token.IsCancellationRequested)
  56. {
  57. int n = await stream.ReadAsync(buf, 0, buf.Length, token);
  58. if (n <= 0) { reason = "预览连接已断开,请重新打开预览"; break; }
  59. foreach (var jpeg in parser.Feed(buf, n))
  60. {
  61. var img = Decode(jpeg);
  62. if (img != null) FrameReceived?.Invoke(img);
  63. }
  64. }
  65. }
  66. }
  67. }
  68. catch (OperationCanceledException) { reason = "预览已关闭"; } // 主动 Stop
  69. catch (Exception ex)
  70. {
  71. reason = token.IsCancellationRequested
  72. ? "预览已关闭" // 主动 Stop 期间抛的任何异常都归为"已关闭"
  73. : $"预览中断,请重新打开预览({ex.Message})";
  74. }
  75. finally
  76. {
  77. IsRunning = false;
  78. try { _http?.Dispose(); } catch { } // 谁起的谁清:HttpClient 在 ReadLoop 结束时释放
  79. Stopped?.Invoke(reason);
  80. }
  81. }
  82. private static BitmapImage Decode(byte[] jpeg)
  83. {
  84. try
  85. {
  86. var img = new BitmapImage();
  87. using (var ms = new MemoryStream(jpeg))
  88. {
  89. img.BeginInit();
  90. img.CacheOption = BitmapCacheOption.OnLoad; // 解完即脱离流
  91. img.StreamSource = ms;
  92. img.EndInit();
  93. }
  94. img.Freeze(); // 跨线程贴 UI 必须 Freeze
  95. return img;
  96. }
  97. catch { return null; } // 坏帧丢弃,不打断流
  98. }
  99. /// <summary>主动停止预览(关预览按钮/返回)。token 取消是唯一停止信号,_http 由 ReadLoop finally 清理。</summary>
  100. public void Stop()
  101. {
  102. try { _cts?.Cancel(); } catch { }
  103. IsRunning = false;
  104. }
  105. public void Dispose()
  106. {
  107. _disposed = true;
  108. Stop();
  109. }
  110. }
  111. }