|
|
@@ -139,6 +139,19 @@ namespace IvfTl.ControlHost
|
|
|
body = JsonConvert.SerializeObject(r);
|
|
|
}
|
|
|
break;
|
|
|
+ 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;
|
|
|
+ }
|
|
|
default:
|
|
|
code = 404; body = Err("not found");
|
|
|
break;
|
|
|
@@ -189,6 +202,69 @@ namespace IvfTl.ControlHost
|
|
|
catch (Exception ex) { _log("解析 body 异常:" + ex.Message); return null; }
|
|
|
}
|
|
|
|
|
|
+ /// <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();
|
|
|
+ }
|
|
|
+
|
|
|
public void Stop()
|
|
|
{
|
|
|
try { _cts?.Cancel(); _listener?.Stop(); _listener?.Close(); }
|