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