通过Canvas在网页中将后端发来的一帧帧图片渲染成“视频”的实现过程

1.背景

最近有这样的场景,网页端需要显示现场无人系统(机器人)的摄像头数据(图片)。值得注意的是,一个无人系统(机器人)它身上可能挂载若干个摄像头,这若干个摄像头都需要在前端的若干个小区域内显示;另外不同的用户访问前端网页,每个用户都访问他自己想关注的无人系统(机器人)摄像头数据。而前端直接和现场的无人系统对接是不合适的:因为对于同一个无人系统,可能不同的用户同一时间或相近时间都访问它,导致该无人系统要处理反馈多份资源请求,并且很容易导致超过机器人的处理负荷;另外对于前端来讲,他并不知知道应该和现场的哪一个无人系统进行对接(因为前端并没有现场的无人系统相关身份数据,无法做识别)。

为此,设计了如下方案,现场的无人系统统一和数据中转服务器对接,每个机器人都只给一份实时摄像头数据给数据中转服务器。数据中转服务器建立websocket服务端程序,并处理网页端的请求(请求获取特定机器人的所有摄像头信息),数据中转服务器根据网页端的请求,对请求信息进行解析,并创建特定的websocket服务实例。具体通信示意图如下:

这里所提到的前端网页,实际是业务中的可视化大屏,他对之前项目的已有功能有些注意点:

  • 总控大屏现有对接无人系统的视频使用的是后端发给前端的rtsp流地址,默认使用的是该方式。但后续无人系统(机器人)传输的数据也有可能是一帧帧二进制图片数据
  • 原有前端使用的组件适用接收rtsp流方式,不适用新的接收图片帧的方式,前端需要做两套模式区分(区别开发:一套<video>,一套<canvas>)
  • 在无人系统(机器人)传输的数据是一帧帧二进制图片数据的情况下,有可能该无人系统有多个摄像头,它会传输多组独立的图片帧数据(前端最多支持4个摄像头数据)

2.约定接口

针对以上内容进行分析,并为了兼容已有实现的功能,约定如下大屏与数据中转器的接口方式:

网页端通过GET请求,调用数据中转服务器接口,请求接口地址为:

http://ip:port/api/usdisplay?usid=2 。其中请求参数usid代表前端给数据中转服务器(后端)传递的无人系统id.

数据中转服务器需要根据无人系统id,判断该无人系统摄像头数据传递是使用的哪种方式?并根据特定的方式返回前端结果,前端根据不同的模式,执行不同的渲染方式。

数据中转服务器(后端)返回前端的结果格式为:

  • 以rtsp模式,如果一个无人系统有3个摄像头举例
复制代码
 1 {
 2     "code": 200,
 3     "success": true,
 4     "data": {
 5         "mode": "rtspurl",
 6         "url": [
 7             "rtsp: //127.0.0.1:8081",
 8             "rtsp: //127.0.0.1:8082",
 9             "rtsp: //127.0.0.1:8083"
10         ]
11     }
12 }
  • 以websocket模式,如果一个无人系统有3个摄像头举例
复制代码
{
    "code": 200,
    "success": true,
    "data": {
        "mode": "websocketurl",
        "url": [
            "ws://127.0.0.1:8080/api/websocket?usid=2&cam=0",
            "ws://127.0.0.1:8080/api/websocket?usid=2&cam=1",
            "ws://127.0.0.1:8080/api/websocket?usid=2&cam=2",
        ]
    }
}

3.前端开发过程

3.1 div结构设计

复制代码
 1     <div class="chartarea">
 2             <div class="charttitle"><span>态势总览</span></div>
 3             <div class="chartdata" id="videoGrid">
 4               <!-- 四个视频区域 -->
 5               <div class="video-container" data-camera="1">
 6                 <video class="video-stream" autoplay muted></video>
 7                 <div class="camera-label"></div>
 8               </div>
 9               <div class="video-container" data-camera="2">
10                 <video class="video-stream" autoplay muted></video>
11                 <div class="camera-label"></div>
12               </div>
13               <div class="video-container" data-camera="3">
14                 <video class="video-stream" autoplay muted></video>
15                 <div class="camera-label"></div>
16               </div>
17               <div class="video-container" data-camera="4">
18                 <video class="video-stream" autoplay muted></video>
19                 <div class="camera-label"></div>
20               </div>
21             </div>
22           </div>

主要是在一个区域内预先占用4个小区域,每个小区域用于显示同一个无人系统的一个摄像头信息,最多支持显示同一个无人系统的4个摄像头信息(实际显示其中的1-4个小区域是以实际同一个无人系统的摄像头个数而定的)。

以上的html结构最先是为了支持rtsp视频流而设计的,对于当前的图片帧显示使用的Canvas技术不适用,所以如果是在图片帧显示的模式下,后续需要通过js动态的修改html结果,切换为<canvas>相关标签结构。

以上现有的html结构对应的CSS样式如下:

复制代码
 1 .chartarea {
 2   width: 95%;
 3   height: 31%;
 4   margin-top: 3.5%;
 5 }
 6 .innerright .chartarea {
 7   margin-left: 3%;
 8   margin-right: 2%;
 9 }
10 .charttitle {
11   width: 100%;
12   height: 15%;
13   background-image: url("/img/visualImages/20_chart_title.png");
14   background-size: 100% 100%;
15 }
16 .charttitle>span {
17   height: 100%;
18   margin-left: 5%;
19   display: flex;
20   align-items: center;
21   font-size: 0.8vw;
22   color: #fff;
23   font-weight: 700;
24 }
25 .chartdata {
26   width: 100%;
27   height: 85%;
28   /* background-image: url("/img/visualImages/21_chart_background.png");
29     background-size: cover;
30     background-repeat: no-repeat;
31     background-position:top left; */
32 
33   /* 当背景图片无法完整铺满整个div,但自己又想即时图片变形不合比例拉伸,也要铺满,这是种好方式! */
34   /* 这种方法会将背景图片拉伸以完全覆盖div的宽度和高度,可能会导致图片变形,特别是如果图片的原始宽高比与div的宽高比不匹配时。 */
35   background-image: url("/img/visualImages/21_chart_background.png");
36   background-size: 100% 100%;
37 }
38 #videoGrid {
39   flex: 1;
40   display: grid;
41   grid-template-columns: 0.48fr 0.48fr;
42   grid-template-rows: 0.49fr 0.49fr;
43   /* gap: 5px; */
44   gap: 2%;
45   padding: 1.5%;
46 }
47 
48 .video-container {
49   position: relative;
50   background-color: #000;
51   border-radius: 4px;
52   overflow: hidden;
53 }
54 .video-stream
55  {
56   width: 100%;
57   height: 100%;
58   object-fit: cover;
59 }
60 
61 .camera-label {
62   position: absolute;
63   bottom: 5px;
64   left: 5px;
65   color: white;
66   background-color: rgba(0, 0, 0, 0.5);
67   padding: 2px 5px;
68   border-radius: 3px;
69   font-size: 12px;
70 }

在上面的4个小视频区域,当用户点击其中任意一个有视频的小区域时,会弹出一个视频放大显示的弹出框,其对应的html结构和css如下:

复制代码
1 <!-- 视频放大弹出框构建 -->
2           <div id="videoModal" class="modal">
3             <div class="modal-content">
4               <span class="close-btn">&times;</span>
5               <video id="modalVideo" autoplay controls></video>
6               <div class="modal-camera-label"></div>
7             </div>
8           </div>
复制代码
 1 /* 弹窗样式 */
 2 .modal {
 3   display: none;
 4   position: fixed;
 5   z-index: 1100;
 6   left: 0;
 7   top: 0;
 8   width: 100%;
 9   height: 100%;
10   background-color: rgba(0, 0, 0, 0.8);
11   justify-content: center;
12   align-items: center;
13 }
14 .modal-content {
15   position: relative;
16   width: 70vw;
17   height: 75vh;
18   background-color: #000;
19   border-radius: 5px;
20   overflow: hidden;
21 }
22 
23 .close-btn {
24   position: absolute;
25   top: 10px;
26   right: 15px;
27   color: white;
28   font-size: 28px;
29   font-weight: bold;
30   cursor: pointer;
31   z-index: 1001;
32 }
33 .close-btn:hover {
34   color: #ccc;
35 }
36 .close-btn {
37   font-size: 24px;
38   font-weight: bold;
39   color: #999;
40   cursor: pointer;
41 }
42 #modalVideo{
43   width: 100%;
44   height: 100%;
45   object-fit: contain;
46 }
47 .modal-camera-label {
48   position: absolute;
49   bottom: 10px;
50   left: 10px;
51   color: white;
52   background-color: rgba(0, 0, 0, 0.5);
53   padding: 5px 10px;
54   border-radius: 3px;
55   font-size: 14px;
56 }

3.2 js函数设计

3.2.1 设计统一的入口函数

设计统一的入口函数USDisplay(),当用户访问特定的tab页时触发该函数。USDisplay()通过Get请求、以无人系统id作为请求参数,访问数据中转服务器程序,数据中转服务器程序根据请求的无人系统id,分析判断该无人系统视频传输的模式,并执行模式信息反馈。

代码设计如下:

复制代码
 1 export function USDisplay() {
 2   //1 根据无人系统id,发送请求后端,并解析后端返回的是哪种模式
 3   //Get请求
 4   var result = null;
 5   $.ajax({
 6     type: 'GET',
 7     //url: ipport + '/api/usdisplay',  //!!!!后续由后端确定ip
 8     url: 'http://127.0.0.1:8080' + '/api/usdisplay',  //20250815临时测试用
 9     data: {
10       //usid: clickedUnmanedDVId  //无人系统id
11       usid: 3  //无人系统id //20250815临时测试用
12     },
13     dataType: 'json', // 期望的后端返回数据格式
14     async: false,
15     success: function (res) {
16       result = res;
17       console.log('成功拿到数据了----',result);
18     },
19     error: function (xhr, status, error) {
20       console.log("error result",result);
21       console.error('USDisplay API请求失败:', status, error);
22 
23       showConnectionStatus('API连接失败', 'error');
24     }
25   });
26 
27   var urlarray = [];//用于存储rtspurl/websocketurl地址数组
28   //解析模式
29   if (result.code === 200 && result.success === true && !!result.data && isNotEmptyObject(result.data)) {
30     //模式一:直接rtsp流(也有弊端,前端直连机器人视频,如果网页访问的用户过多,会导致机器人负荷过大,后期也需要数据中台中转)
31     if (result.data.mode === "rtspurl") {
32       urlarray = result.data.url;
33       if (urlarray.length >= 1) {
34         //-1----清理之前的连接资源
35         cleanupPreviousConnections();
36         //-2----rtsp构建显示逻辑
37         usrtspmode(urlarray);
38       }
39     }
40     //模式二:数据中台作为websocket服务端,网页端作为websocket客户端
41     else if (result.data.mode === "websocketurl") {
42       urlarray = result.data.url;
43       if (urlarray.length >= 1) {
44         //-1----清理之前的连接资源
45         cleanupPreviousConnections();
46         //-2----websocket构建显示逻辑
47         uswebsocketmode(urlarray);
48       }
49     }
50     //说明后端没有返回任何模式,不做任何处理
51     else{
52       console.warn('机器人rtsp/图片帧:后端未返回有效的显示模式');
53       showConnectionStatus('未知显示模式', 'warning');
54     }
55   }else{
56     console.error('USDisplay API返回数据无效,result:',result);
57     showConnectionStatus('数据获取失败', 'error');
58   }
59 }

3.2.2 模式一:rtspurl模式的处理

复制代码
  1 function usrtspmode(url) {
  2   // 获取元素
  3   const videoContainers = document.querySelectorAll('.video-container');//4个视频div容器(各自平等独立)
  4   const modal = document.getElementById('videoModal');//视频弹出框
  5   const modalVideo = document.getElementById('modalVideo');//弹出框显示视频区域
  6   const closeBtn = document.querySelector('.close-btn');//弹出框关闭按钮区域
  7   const modalCameraLabel = document.querySelector('.modal-camera-label');//弹出框底部显示视频名称标识
  8 
  9   var cameraConfigs = [];//重新构建rtsp地址,友好前端显示
 10   url.forEach((item, index) => {
 11     cameraConfigs.push(
 12       {
 13         id: index,
 14         name: "camera" + (index + 1),
 15         rtsp: item
 16       }
 17     );
 18   });
 19 
 20   // 用于存储webrtc实例 (几个视频就需要几个实例)
 21   const webrtcInstances = [];
 22 
 23  // 初始化视频流函数--核心方法
 24   function setupVideoStreams() {
 25 
 26     //遍历4个视频div元素操作
 27     //每个视频div结构如下:
 28     // <div class="video-container" data-camera="1">
 29     //   <video class="video-stream" autoplay muted></video>
 30     //   <div class="camera-label"></div>
 31     // </div>
 32 
 33     videoContainers.forEach((container, index) => {
 34       const videoElement = container.querySelector('.video-stream');//小区域视频本身
 35       const cameraLabel = container.querySelector('.camera-label');//小区域视频标识
 36 
 37       // (1)摄像头名称显示(从配置读取)
 38       if (cameraLabel && cameraConfigs[index]) {
 39         cameraLabel.textContent = cameraConfigs[index].name;//根据后台的摄像头名称(位置标识)进行标识显示
 40       }
 41 
 42       // (2)初始化webrtc-streamer
 43       if (videoElement && cameraConfigs[index]) {
 44         //----2.1 实例化WebRtcStreamer ---固定写法
 45         const webrtc = new WebRtcStreamer(videoElement, WEBRTC_SERVER);
 46 
 47         //----2.2 执行webrtc实例连接rtsp流(地址)    ---固定写法
 48         //webrtc.connect(cameraConfigs[index].rtsp);//优化
 49         //webrtc.connect(cameraConfigs[index].rtsp,null,"rtptransport=tcp&timeout=60&width=320&height=240",null);
 50         webrtc.connect(cameraConfigs[index].rtsp, null, "rtptransport=tcp&timeout=60", null);
 51 
 52         //----2.3 存储实例以便管理
 53         // webrtcInstances.push({
 54         //   id: cameraConfigs[index].id,
 55         //   instance: webrtc,
 56         //   element: videoElement
 57         // });
 58 
 59         //存储到全局数组用于资源管理
 60         globalWebrtcInstances.push({
 61           id: cameraConfigs[index].id,
 62           instance: webrtc,
 63           element: videoElement
 64         });
 65 
 66         // 错误处理
 67         videoElement.onerror = function () {
 68           handleStreamError(container);
 69         };
 70 
 71         //补充:连接成功反馈
 72         videoElement.onloadstart = function(){
 73           console.log(`<video>视频方式${cameraConfigs[index].name}连接成功`);
 74           showConnectionStatus(`<video>视频方式${cameraConfigs[index].name}连接成功`, 'success');
 75         };
 76       }
 77     });
 78   }
 79 
 80   // 处理流错误
 81   function handleStreamError(container) {
 82     const videoElement = container.querySelector('.video-stream');
 83     const label = container.querySelector('.camera-label');
 84 
 85     if (videoElement) {
 86       videoElement.style.display = 'none';
 87     }
 88 
 89     if (label) {
 90       label.style.color = '#ff4d4f';
 91       label.textContent = label.textContent + ' (离线)';
 92     }
 93 
 94     container.style.backgroundColor = '#333';
 95     container.innerHTML += `
 96     <div style="color:white;display:flex;justify-content:center;align-items:center;height:100%;position:absolute;top:0;left:0;right:0;bottom:0;">
 97       视频流无法加载
 98     </div>
 99   `;
100     //showConnectionStatus('视频流连接失败', 'error');
101 
102   }
103 
104   // 监听每个视频区域div的用户点击事件
105   //每个视频div结构如下:
106   // <div class="video-container" data-camera="1">
107   //   <video class="video-stream" autoplay muted></video>
108   //   <div class="camera-label"></div>
109   // </div>
110   videoContainers.forEach(container => {
111     container.addEventListener('click', function () {
112       const videoElement = this.querySelector('.video-stream');
113       const cameraId = this.getAttribute('data-camera');
114       //从配置变量中获取到对应视频的完整配置信息
115       const cameraConfig = cameraConfigs.find(c => c.id === Number(cameraId));
116 
117       if (videoElement && videoElement.srcObject && cameraConfig) {
118         modalVideo.srcObject = videoElement.srcObject;
119         modalCameraLabel.textContent = cameraConfig.name;
120         modal.style.display = 'flex';
121 
122         modalVideo.play().catch(e => console.error('弹窗视频播放失败:', e));
123       }
124     });
125   });
126 
127 
128   // 关闭弹窗
129   if (closeBtn) {
130     closeBtn.addEventListener('click', function () {
131       modal.style.display = 'none';
132       modalVideo.pause();
133       modalVideo.srcObject = null;
134     });
135   }
136 
137 
138   // 通过webrtc-streamer工具显示视频
139   setupVideoStreams();
140 
141   // 页面卸载时清理资源----通过页面事件监听
142   window.addEventListener('beforeunload', function () {
143     // webrtcInstances.forEach(instance => {
144     //   instance.instance.disconnect();//实例断开连接
145     // });
146     //------修订完善
147     globalWebrtcInstances.forEach(instance => {
148       if (instance && instance.instance) {
149         instance.instance.disconnect();
150       }
151     });
152 
153   });
154 
155 
156 
157 }

3.2.3 模式二:websocketurl模式的处理

复制代码
  1 //websocket模式显示逻辑
  2 function uswebsocketmode(url){
  3   //websocket canvas div 待切换新结构梳理
  4   // <div class="chartdata" id="videoGrid">//下面包含4个视频区域
  5     // <div class="video-container" data-camera="1">
  6     //   <canvas class="videoCanvas"></canvas>
  7     //   <div class="camera-label"></div>
  8     // </div>
  9     // <div class="video-container" data-camera="2">
 10     //   <canvas class="videoCanvas"></canvas>
 11     //   <div class="camera-label"></div>
 12     // </div>
 13     // <div class="video-container" data-camera="3">
 14     //   <canvas class="videoCanvas"></canvas>
 15     //   <div class="camera-label"></div>
 16     // </div>
 17     // <div class="video-container" data-camera="4">
 18     //   <canvas class="videoCanvas"></canvas>
 19     //   <div class="camera-label"></div>
 20     // </div>
 21   // </div>
 22 
 23   //原有老结构
 24   // <div class="chartdata" id="videoGrid">
 25   //   <!-- 四个视频区域 -->
 26   //   <div class="video-container" data-camera="1">
 27   //     <video class="video-stream" autoplay muted></video>
 28   //     <div class="camera-label"></div>
 29   //   </div>
 30   //   <div class="video-container" data-camera="2">
 31   //     <video class="video-stream" autoplay muted></video>
 32   //     <div class="camera-label"></div>
 33   //   </div>
 34   //   <div class="video-container" data-camera="3">
 35   //     <video class="video-stream" autoplay muted></video>
 36   //     <div class="camera-label"></div>
 37   //   </div>
 38   //   <div class="video-container" data-camera="4">
 39   //     <video class="video-stream" autoplay muted></video>
 40   //     <div class="camera-label"></div>
 41   //   </div>
 42   // </div>
 43 
 44 
 45   const modal = document.getElementById('videoModal');//视频弹出框  //--------公共操作变量
 46   const modalCameraLabel = document.querySelector('.modal-camera-label');//弹出框底部显示视频名称标识
 47   var modalcanvas = null;
 48   var modalctx = null;
 49   //var cameraId = null;
 50   var currentModalCameraId = null; // 当前弹出框显示的摄像头ID
 51 
 52 
 53   const videoContainers = document.querySelectorAll(".video-container");//获取4个.video-container视频区域元素
 54   //1- 先清掉原有默认页面的div结构内的元素,构建新的canvas元素 
 55   //依次进行替换
 56   videoContainers.forEach(
 57     container => {
 58       //查找原有的<video>元素
 59       const videoElement = container.querySelector(".video-stream");
 60       if (videoElement) {
 61 
 62         //创建<canvas>元素
 63         const canvas = document.createElement("canvas");
 64         canvas.className = 'videoCanvas';
 65         // canvas.width = 320; //设置默认尺寸,即图片的分辨率、画布分辨率(和容器大小没有关系,最终都会在指定容器100%显示)
 66         // canvas.height = 240;
 67         //以上配置不能自动充满div区域
 68 
 69 
 70         // // 根据容器大小动态设置,但保持最小分辨率
 71         // const containerRect = container.getBoundingClientRect();
 72         // canvas.width = Math.max(containerRect.width || 320, 160);
 73         // canvas.height = Math.max(containerRect.height || 240, 120);
 74 
 75         // 自适应容器尺寸:填满容器
 76 
 77         const rect = container.getBoundingClientRect();
 78         canvas.width = Math.max(1, Math.floor(rect.width));
 79         canvas.height = Math.max(1, Math.floor(rect.height));
 80 
 81 
 82         //用<canvas>元素替换<video>元素  --- 通过获取<video>元素的父节点,来将<video>替换为<canvas>
 83         videoElement.parentNode.replaceChild(canvas, videoElement);
 84       }
 85     }
 86   );
 87 
 88   //2- 初始化canvas基础信息
 89   var canvasElementArr = [];
 90   var ctx = [];
 91   var canvasElements = document.querySelectorAll(".videoCanvas");//获取到所有<canvas>  //注意元素是4个,但是后台返回的不一定是4个
 92   canvasElements.forEach((canvas, index) => {
 93     //注意元素是4个,但是后台返回的不一定是4个。只需要根据后端返回的图片流地址个数,按需及可 (后台若超过4个,则只操作前4个)
 94     if ( index < url.length) {
 95       canvasElementArr[index] = canvas;
 96 
 97       ctx[index] = canvas.getContext('2d');
 98       //绘制初始状态 ---似乎没什么用
 99        ctx[index].fillStyle = '#333';
100       ctx[index].fillRect(0, 0, canvas.width, canvas.height);
101       ctx[index].fillStyle = 'white';
102       ctx[index].font = '24px Arial'
103       ctx[index].textAlign = 'center';
104       ctx[index].fillText('正在连接...', canvas.width / 2, canvas.height / 2);
105 
106       console.log("ctx["+index+"]",ctx[index]);
107     }
108   })
109 
110   //3- 构建帧展示逻辑 ---- 若干个区域同时接收图片帧,要考虑异步和实时性
111   function displayFrame(blob,ctx,canvas){
112 
113     //追加:--检查参数有效性
114     if (!blob || !ctx || !canvas) {
115       console.warn('displayFrame: 无效参数');
116       return;
117     }
118 
119     const img = new Image();
120 
121     //追加:--设置超时机制,防止图片加载卡死
122     const loadTimeout = setTimeout(() => {
123       console.warn('图片加载超时');
124       if (img.src) {
125         URL.revokeObjectURL(img.src);
126       }
127       img.onload = null;
128       img.onerror = null;
129     }, 2000); // 2秒超时
130 
131     // 将超时定时器添加到全局管理数组
132     globalTimeouts.push(loadTimeout);
133 
134 
135     // img.onload = function(){//回调函数
136     //   //1.先清除画布信息
137     //   ctx.clearRect(0,0,canvas.width,canvas.height);
138 
139     //   //2.计算缩放比
140     //   const scale = Math.min(canvas.width/img.width,canvas.height/img.height);
141     //   const x = (canvas.width - img.width * scale)/2;
142     //   const y = (canvas.height - img.height * scale)/2;
143 
144     //   //3.绘制图片在画布
145     //   ctx.drawImage(img,x,y,img.width*scale,img.height*scale);
146 
147     //   //4.将图像引用取消
148     //   URL.revokeObjectURL(img.src);
149     // };
150 
151     // //补充图片的加载失败异常事件逻辑
152     // img.onerror = function () {
153     //   console.error('图片帧函数----图片加载失败');
154     //   ctx.fillStyle = '#ff4d4f';
155     //   ctx.fillRect(0, 0, canvas.width, canvas.height);
156     //   ctx.fillStyle = 'white';
157     //   ctx.font = '14px Arial';
158     //   ctx.textAlign = 'center';
159     //   ctx.fillText('图片加载失败', canvas.width / 2, canvas.height / 2);
160     // };
161 
162     //修复:内存管理
163     //--------------重新定义onload事件和onerror事件
164     const onLoadHandler = function(){
165 
166       //追加: --0. 清理超时定时器
167       clearTimeout(loadTimeout);
168       try {
169         //1.先清除画布信息
170         ctx.clearRect(0, 0, canvas.width, canvas.height);
171 
172         //2.计算缩放比
173         const scale = Math.min(canvas.width / img.width, canvas.height / img.height);
174         const x = (canvas.width - img.width * scale) / 2;
175         const y = (canvas.height - img.height * scale) / 2;
176 
177         //3.绘制图片在画布
178         ctx.drawImage(img, x, y, img.width * scale, img.height * scale);
179 
180       } catch (error) {
181         console.error('绘制图片时出错:', error);
182       } finally {
183         //4.清理资源
184         URL.revokeObjectURL(img.src);
185         img.onload = null;
186         img.onerror = null;
187         img.src = ''; // 清空src引用
188       }
189 
190     };
191 
192     const onErrorHandler = function(){
193 
194       // 清理超时定时器
195       clearTimeout(loadTimeout);
196 
197       console.error('图片帧函数----图片加载失败');
198 
199       try {
200         ctx.fillStyle = '#ff4d4f';
201         ctx.fillRect(0, 0, canvas.width, canvas.height);
202         ctx.fillStyle = 'white';
203         ctx.font = '14px Arial';
204         ctx.textAlign = 'center';
205         ctx.fillText('图片加载失败', canvas.width / 2, canvas.height / 2);
206       } catch (error) {
207         console.error('绘制错误状态时出错:', error);
208       } finally {
209         //清理资源
210         URL.revokeObjectURL(img.src);
211         img.onload = null;
212         img.onerror = null;
213         img.src = ''; // 清空src引用
214       }
215 
216     };
217 
218     img.onload = onLoadHandler;//配合内部的资源管理
219     img.onerror = onErrorHandler;//配合内部的资源管理
220     img.src = URL.createObjectURL(blob);
221 
222   }
223 
224 
225 //4- 构建网页客户端连接WebSocket服务端
226 //注意会有多个websocket(每个独立的socket连接一个摄像头数据(一个机器人有1-多个摄像头))
227 
228 var ws=[];//用于存储websocket连接实例(网页客户端连接服务端)
229 function connectWebSocket(){
230   // 实例化websocket,并配置特有的官方监听事件
231   url.forEach((urlitem,index)=>{
232 
233     // //0 检查是否已连接
234     // if(ws[index] && ws[index].readyState === WebSocket.OPEN){
235     //   console.log(`WebSocket[${index}]已经连接,跳过重复连接`);
236     //   return;
237     // }
238 
239     //0 严格检查并清理已存在的连接
240     if (ws[index]) {
241       if (ws[index].readyState === WebSocket.OPEN || ws[index].readyState === WebSocket.CONNECTING) {
242         console.log(`WebSocket[${index}]已经连接或正在连接,跳过重复连接`);
243         return;
244       } else {
245         // 清理无效连接
246         try {
247           ws[index].close();
248           ws[index] = null;
249         } catch (e) {
250           console.log(`清理无效连接时出错: ${e.message}`);
251         }
252       }
253     }
254 
255     try {
256       // 1 实例化
257       ws[index] = new WebSocket(urlitem);
258       globalWebSocketInstances[index] = ws[index];
259 
260       // 2 配置监听事件
261       //-------- 2.1 onopen事件
262       ws[index].onopen = function () {
263         console.log("ws[" + index + "]:" + urlitem + "连接已建立,开始监听服务端WebSocket数据");
264         //showConnectionStatus(`摄像头${index + 1}连接成功`, 'success');//后面换vue框架自带的信息提醒框!
265         reconnectAttempts[index] = 0; //重置重连计数
266       }
267 
268       //-------- 2.2 onmessage事件---核心事件
269       ws[index].onmessage = function (event) {
270         if (event.data instanceof Blob) {
271           //displayFrame(event.data,ctx[index]);//调用帧显示函数----[将帧显示在对应的canvas区域] function displayFrame(blob,ctx) canvasElementArr
272           displayFrame(event.data, ctx[index], canvasElementArr[index]);//始终小窗口需要渲染
273 
274           // 如果当前索引与弹出框显示的摄像头索引匹配,且弹出框正在显示,则同时渲染弹出框
275           if (currentModalCameraId && (index === currentModalCameraId - 1) && modal && modal.style.display === 'flex' && modalctx && modalcanvas) {
276             displayFrame(event.data, modalctx, modalcanvas);
277           }
278         }
279       }
280 
281       //-------- 2.3 onclose事件
282       ws[index].onclose = function (event) {
283         console.log("ws[" + index + "]:" + urlitem + "连接已关闭", event.code, event.reason);
284 
285         //------------补充:自动重连逻辑
286         if (!reconnectAttempts[index]) reconnectAttempts[index] = 0;
287 
288         if (reconnectAttempts[index] < MAX_RECONNECT_ATTEMPTS) {
289           reconnectAttempts[index]++;
290           showConnectionStatus(`摄像头${index + 1}重连中(${reconnectAttempts[index]}/${MAX_RECONNECT_ATTEMPTS})`, 'warning');////后续调用vue自身方法
291 
292           //补充
293           // 清理该连接的旧定时器
294           if (reconnectTimeouts[index]) {
295             clearTimeout(reconnectTimeouts[index]);
296           }
297 
298           const timeoutid = setTimeout(() => {
299             console.log(`尝试重连ws[${index}], 第${reconnectAttempts[index]}次`);
300 
301             //补充
302             // 清理连接状态
303             if (ws[index]) {
304               try {
305                 ws[index].close();
306               } catch (e) { }
307               ws[index] = null;
308             }
309             connectSingleWebSocket(urlitem, index);
310             //补充
311             reconnectTimeouts[index] = null;
312           }, RECONNECT_DELAY);
313 
314           //追加内存管理
315           globalTimeouts.push(timeoutid);
316           reconnectTimeouts[index] = timeoutid;
317 
318         } else {
319           showConnectionStatus(`摄像头${index + 1}连接失败`, 'error');////后续调用vue自身方法
320           //显示连接失败状态
321           if (ctx[index] && canvasElementArr[index]) {
322             ctx[index].fillStyle = '#ff4d4f';
323             ctx[index].fillRect(0, 0, canvasElementArr[index].width, canvasElementArr[index].height);
324             ctx[index].fillStyle = 'white';
325             ctx[index].font = '14px Arial';
326             ctx[index].textAlign = 'center';
327             ctx[index].fillText('ws[index].onclose事件连接失败', canvasElementArr[index].width / 2, canvasElementArr[index].height / 2);
328           }
329         }
330       };//onclose事件
331 
332       //-------- 2.4 onerror事件
333       ws[index].onerror = function (error) {
334         console.log("ws[" + index + "]:" + urlitem + "连接出现错误:" + error);
335         showConnectionStatus(`摄像头${index + 1}连接错误`, 'error');//后续调用vue自身方法
336       };//onerror事件
337     } catch (error) {
338       console.error(`创建WebSocket[${index}]失败:`, error);
339       showConnectionStatus(`摄像头${index + 1}创建失败`, 'error');
340     }
341   });
342 }
343 
344 //5- 构建网页客户端断开连接WebSocket服务端
345 function disconnectWebSocket(){
346   if(ws){
347     ws.forEach((wsitem,index)=>{
348       if(wsitem){
349         wsitem.close();
350         ws[index] = null;//恢复初始状态
351       }
352     });
353     ws = [];//恢复暂存数组初始状态
354   }
355 }
356 
357   //6- 执行连接函数调用 (最多内部连4个websocket)
358   connectWebSocket();
359 
360   //7- 执行调用关闭
361   // 页面卸载时清理资源----通过页面事件监听
362   window.addEventListener('beforeunload', function () {
363     disconnectWebSocket();
364   });
365 
366   //8- 视频区域div点击事件 弹出弹出框放大视频显示   ---- 弹出框<video>也需要替换为<canvas>!
367   videoContainers.forEach(container => {
368     container.addEventListener('click',function(){
369       //1 先把原有弹出框<video>修改为<canvas>
370       //原有结构参考
371       // <div id="videoModal" class="modal">
372       //   <div class="modal-content">
373       //     <span class="close-btn">&times;</span>
374       //     <video id="modalVideo" autoplay controls></video>
375       //     <div class="modal-camera-label"></div>
376       //   </div>
377       // </div>
378 
379       // ---1.1 先查找到要被替换元素本身
380       const videoelement = document.querySelector('#modalVideo');
381       if(videoelement){
382         // ---1.2 再创建一个新的替换元素
383         const popcanvas = document.createElement("canvas");
384         // ---1.3 新元素沿用原来的id--换个新的吧
385         //popcanvas.id = 'modalVideo';
386         popcanvas.id = 'modalCanvas';
387 
388         // //补充:设置canvas内图片的分辨率
389         // popcanvas.width = 800;
390         // popcanvas.height = 600;
391         //以上匹配会导致画布不能充满div区域;
392 
393         // 让弹出框canvas自适应弹窗区域
394         const modalContent = modal.querySelector('.modal-content') || modal;
395         const mrect = modalContent.getBoundingClientRect();
396         popcanvas.width = Math.max(1, Math.floor(mrect.width));
397         popcanvas.height = Math.max(1, Math.floor(mrect.height));
398         // ---1.4 通过被替换元素的直接父元素,将被替换元素替换为新元素
399         videoelement.parentNode.replaceChild(popcanvas,videoelement);
400 
401       }
402 
403       // //2 给弹出框内的新元素<canvas>设置基础配置:canvas、ctx
404       // var modalcanvas = document.getElementById('modalCanvas');
405       // var modalctx = modalcanvas.getContext('2d');
406       // modalctx.fillRect(0,0,modalcanvas.width,modalcanvas.height);
407       // modalctx.fillStyle = 'red';
408       // modalctx.font = '24px Arial';
409       // modalctx.textAlign = 'center';
410       // modalctx.fillText('等待连接...',modalcanvas.width/2,modalcanvas.height/2);
411       //--------------------------------------------------
412       //注意:--------以上这些代码可能后续调试需要放在下方if内部代码:modal.style.display = 'flex'; //视频弹出框整体div显示下方。因为没显示前操作canvas的width和height可能不起作用
413 
414       //3 构建视频配置信息对象
415       const canvasElement = this.querySelector('.videoCanvas'); //被点击的canvas元素
416       //cameraId = this.getAttribute('data-camera');//获取被点击的视频div区域编号(注意,从1开始)
417       const clickedCameraId = this.getAttribute('data-camera');//获取被点击的视频div区域编号(注意,从1开始)
418       currentModalCameraId = clickedCameraId; // 更新当前弹出框显示的摄像头ID
419 
420       // const modal = document.getElementById('videoModal');//视频弹出框
421       // const modalCameraLabel = document.querySelector('.modal-camera-label');//弹出框底部显示视频名称标识
422 
423       //根据点击的下标,获取对应的已有的ws实例,执行图像渲染
424       //if (canvasElement && cameraId && ws[cameraId - 1] != null) {
425       if (canvasElement && clickedCameraId && ws[clickedCameraId - 1] != null) {
426 
427         //modalCameraLabel.textContent = 'camera'+ cameraId; //显示视频编号名称
428         modalCameraLabel.textContent = 'camera'+ clickedCameraId; //显示视频编号名称
429         modal.style.display = 'flex'; //视频弹出框整体div显示
430 
431         //上方外层移到此处
432         //给弹出框内的新元素<canvas>设置基础配置:canvas、ctx
433         modalcanvas = document.getElementById('modalCanvas');
434         modalctx = modalcanvas.getContext('2d');
435 
436         //补充:画布自适应显示 监听窗口尺寸变化,保持弹窗canvas自适应
437         const resizeModalCanvas = () => {
438           const modalContent = modal.querySelector('.modal-content') || modal;
439           const mrect = modalContent.getBoundingClientRect();
440           const w = Math.max(1, Math.floor(mrect.width));
441           const h = Math.max(1, Math.floor(mrect.height));
442           if (modalcanvas.width !== w || modalcanvas.height !== h) {
443             modalcanvas.width = w;
444             modalcanvas.height = h;
445           }
446         };
447 
448         //window.addEventListener('resize', resizeModalCanvas);
449         // 移除之前的resize监听器,避免重复添加
450         if (resizeHandler) {
451           window.removeEventListener('resize', resizeHandler);
452         }
453         resizeHandler = resizeModalCanvas;
454         window.addEventListener('resize', resizeHandler);
455         globalEventListeners.push({element: window, event: 'resize', handler: resizeHandler});
456 
457         resizeModalCanvas();
458         modalctx.fillStyle = '#333';
459         modalctx.fillRect(0, 0, modalcanvas.width, modalcanvas.height);
460         modalctx.fillStyle = 'white';
461         modalctx.font = '20px Arial';
462         modalctx.textAlign = 'center';
463         modalctx.fillText('等待图像...', modalcanvas.width / 2, modalcanvas.height / 2);
464 
465         //canvas 图片帧显示
466         //ws[cameraId - 1].onmessage = function (event) {
467         //修改为同时渲染小窗口和弹出框
468         ws[clickedCameraId - 1].onmessage = function (event) {
469           if (event.data instanceof Blob) {
470             //displayFrame(event.data, modalctx, modalcanvas);//帧显示
471             // 始终渲染小窗口
472             displayFrame(event.data, ctx[clickedCameraId - 1], canvasElementArr[clickedCameraId - 1]);
473             // 如果弹出框显示且是当前摄像头,也渲染弹出框
474             if (modal.style.display === 'flex' && modalctx && modalcanvas && currentModalCameraId == clickedCameraId) {
475               displayFrame(event.data, modalctx, modalcanvas);
476             }
477           }
478         };
479 
480         //console.log("ws["+(cameraId-1)+"]"+"弹出框放大显示已执行!");
481         console.log("ws["+(clickedCameraId-1)+"]"+"弹出框放大显示已执行!");
482       }
483     });
484   }
485   );
486 
487   //9- 弹出框关闭按钮监听事件
488   const closeBtn = document.querySelector('.close-btn');//弹出框关闭按钮区域
489   if (closeBtn) {
490     // closeBtn.addEventListener('click', function () {
491     //   modal.style.display = 'none';
492     //   //-------- 9.1 恢复对应视频区域小窗口的图片帧显示
493     //   if (cameraId != null && ws[cameraId-1]) {
494     //     ws[cameraId - 1].onmessage = function (event) {//重新覆盖onmessage事件,在小窗口上渲染图片帧
495     //       if (event.data instanceof Blob) {
496     //         displayFrame(event.data, ctx[cameraId - 1], canvasElementArr[cameraId - 1]);
497     //       }
498     //     };
499     //   }
500     //优化以上内容
501     // 移除之前的click事件监听器,避免重复添加
502     const existingListeners = globalEventListeners.filter(item =>
503       item.element === closeBtn && item.event === 'click'
504     );
505     existingListeners.forEach(item => {
506       item.element.removeEventListener(item.event, item.handler);
507     });
508 
509     // 定义新的事件处理函数
510     const closeBtnHandler = function () {
511       modal.style.display = 'none';
512       // //-------- 9.1 恢复对应视频区域小窗口的图片帧显示
513       // if (cameraId != null && ws[cameraId - 1]) {
514       //   ws[cameraId - 1].onmessage = function (event) {//重新覆盖onmessage事件,在小窗口上渲染图片帧
515       //     if (event.data instanceof Blob) {
516       //       displayFrame(event.data, ctx[cameraId - 1], canvasElementArr[cameraId - 1]);
517       //     }
518       //   };
519       // }
520       //以上内容不需要特殊恢复了,因为迭代代码后,再弹出弹出框的时候,也是一直保证小窗口也在显示的
521 
522       //-------- 9.2 清除弹出框canvas的图片帧显示
523       if (modalctx != null && modalcanvas != null) {
524         modalctx.clearRect(0, 0, modalcanvas.width, modalcanvas.height);
525       }
526 
527       // 重置弹出框相关变量
528       modalcanvas = null;
529       modalctx = null;
530       currentModalCameraId = null; // 清除当前弹出框摄像头ID
531 
532       //});
533 
534       // 移除resize事件监听器
535       if (resizeHandler) {
536         window.removeEventListener('resize', resizeHandler);
537         // 从全局列表中移除
538         const index = globalEventListeners.findIndex(item =>
539           item.element === window && item.event === 'resize' && item.handler === resizeHandler
540         );
541         if (index !== -1) {
542           globalEventListeners.splice(index, 1);
543         }
544         resizeHandler = null;
545       }
546     };
547 
548     // 添加新的事件监听器并记录
549     closeBtn.addEventListener('click', closeBtnHandler);
550     globalEventListeners.push({ element: closeBtn, event: 'click', handler: closeBtnHandler });
551   }
552 
553   //}
554 
555   //追加: 10- 内存优化管理 
556   // ------------10.1  页面可见性变化时的资源管理
557   document.addEventListener('visibilitychange', function () {
558     if (document.hidden) {
559       // 页面切换到后台时,清理资源但不断开连接
560       console.log('页面切换到后台,清理部分资源');
561 
562       // 清理定时器
563       globalTimeouts.forEach(timeoutId => {
564         clearTimeout(timeoutId);
565       });
566       globalTimeouts = [];
567 
568       // 清理状态提示元素
569       const statusElement = document.getElementById('connection-status');
570       if (statusElement) {
571         statusElement.remove();
572       }
573     } else {
574       // 页面重新可见时
575       console.log('页面重新可见');
576     }
577   });
578 
579   // ------------10.2  页面失去焦点时的额外清理
580   window.addEventListener('blur', function () {
581     // 清理可能残留的定时器
582     globalTimeouts.forEach(timeoutId => {
583       clearTimeout(timeoutId);
584     });
585     globalTimeouts = [];
586   });
587 
588 }

3.2.4 其他辅助变量及函数

复制代码
  1 //图片帧兼容方案
  2 //全局变量用于资源管理
  3 let globalWebrtcInstances = [];
  4 let globalWebSocketInstances = [];
  5 let reconnectAttempts = {};
  6 const MAX_RECONNECT_ATTEMPTS = 3;
  7 const RECONNECT_DELAY = 2000;
  8 
  9 //新增修复:修复图片帧方式显示浏览器内存持续增长问题 -----全局定时器和事件监听器管理
 10 var globalTimeouts = [];
 11 var globalEventListeners = [];
 12 var resizeHandler = null;
 13 var reconnectTimeouts = []; // 管理重连定时器
 14 
 15 //清理之前的连接资源
 16 function cleanupPreviousConnections() {
 17   //清理WebRTC连接
 18   globalWebrtcInstances.forEach(instance => {
 19     if (instance && instance.instance) {
 20       instance.instance.disconnect();
 21     }
 22   });
 23   globalWebrtcInstances = [];
 24 
 25   //清理WebSocket连接
 26   globalWebSocketInstances.forEach((ws, index) => {
 27     if (ws && ws.readyState === WebSocket.OPEN) {
 28       ws.close();
 29     }
 30   });
 31   globalWebSocketInstances = [];
 32 
 33   //-------------------------------------------追加补充部分---开始------------------------------------------------
 34   //清理定时器
 35   globalTimeouts.forEach(timeoutId => {
 36     clearTimeout(timeoutId);
 37   });
 38   globalTimeouts = [];
 39 
 40   //清理事件监听器
 41   globalEventListeners.forEach(({ element, event, handler }) => {
 42     element.removeEventListener(event, handler);
 43   });
 44   globalEventListeners = [];
 45 
 46   //清理resize监听器
 47   if (resizeHandler) {
 48     window.removeEventListener('resize', resizeHandler);
 49     resizeHandler = null;
 50   }
 51 
 52   //清理状态提示元素
 53   const statusElement = document.getElementById('connection-status');
 54   if (statusElement) {
 55     statusElement.remove();
 56   }
 57   //-------------------------------------------追加补充部分---结束------------------------------------------------
 58   
 59   //重置重连计数
 60   reconnectAttempts = {};
 61   
 62   //console.log('已清理所有之前的连接资源');
 63   console.log('已清理所有之前的连接资源、定时器和事件监听器');
 64 }
 65 
 66 //单个WebSocket重连函数
 67 function connectSingleWebSocket(urlitem, index) {
 68   try {
 69     ws[index] = new WebSocket(urlitem);
 70     globalWebSocketInstances[index] = ws[index];
 71 
 72     //重新绑定事件(复用上面的逻辑)
 73     ws[index].onopen = function () {
 74       console.log(`ws[${index}]:${urlitem} 重连成功`);
 75       //showConnectionStatus(`摄像头${index + 1}重连成功`, 'success');
 76       reconnectAttempts[index] = 0;
 77     };
 78 
 79     ws[index].onmessage = function (event) {
 80       if (event.data instanceof Blob) {
 81         // 始终渲染小窗口
 82         displayFrame(event.data, ctx[index], canvasElementArr[index]);
 83 
 84         // 如果当前索引与弹出框显示的摄像头索引匹配,且弹出框正在显示,则同时渲染弹出框
 85         if (currentModalCameraId && (index === currentModalCameraId - 1) && modal && modal.style.display === 'flex' && modalctx && modalcanvas) {
 86           displayFrame(event.data, modalctx, modalcanvas);
 87         }
 88 
 89       }
 90     };
 91 
 92     //重连的onclose和onerror事件处理与初始连接相同
 93     ws[index].onclose = function (event) {
 94       console.log(`ws[${index}] 重连后又关闭了`);
 95       if (reconnectAttempts[index] < MAX_RECONNECT_ATTEMPTS) {
 96         reconnectAttempts[index]++;
 97         setTimeout(() => connectSingleWebSocket(urlitem, index), RECONNECT_DELAY);
 98       }
 99     };
100 
101     ws[index].onerror = function (error) {
102       console.error(`ws[${index}] 重连错误:`, error);
103     };
104 
105   } catch (error) {
106     console.error(`重连WebSocket[${index}]失败:`, error);
107   }
108 }

4 模拟数据集构建(视频切割成图片帧 25fps)

此环节是通过ffmpeg命令,将一个视频按照指定的帧率切割成一张张帧图片,以作为本地模拟服务端程序的模拟图片帧数据源。具体操作步骤命令可参考之前博文:https://www.cnblogs.com/Jesuslovesme/p/18818356

5 模拟websocket服务端程序编写

这个可根据个人擅长的开发语言编写,因为我主要是为了验证前端显示方案是否可以落地,所以后端程序只要能按一定频率取本地的帧图片并实时通过websocket发送给前端显示即可。我通过ai生成了一个验证测试的C#后端程序。

基于.NET 6.0的控制台应用程序代码如下:

复制代码
using Fleck;
using System.Text.Json;
using System.Collections.Concurrent;
using System.Net;
using System.Net.Http;
using System.Text;

namespace WebSocketServerApp
{
    public class Program
    {
        private static readonly ConcurrentDictionary<string, List<IWebSocketConnection>> _connections = new();
        private static readonly ConcurrentDictionary<string, CancellationTokenSource> _cancellationTokens = new();
        private static string _imagePath = @"D:\XX中心\总控系统项目\测试demo\图片帧demo\dash-video-to-img";//图片帧文件夹存放位置
        
        public static async Task Main(string[] args)
        {
            // 启动HTTP服务器------路线1 
            var httpTask = StartHttpServer();//用于处理前端的模式请求确认 
            
            // 启动WebSocket服务器  ----路线2
            var wsTask = StartWebSocketServer(); //对接网页websocket,传输图片帧
            
            Console.WriteLine("=== WebSocket服务器启动完成 ===");
            Console.WriteLine("HTTP API服务: http://localhost:8080");
            Console.WriteLine("WebSocket服务: ws://localhost:8081");
            Console.WriteLine("");
            Console.WriteLine("测试URL:");
            Console.WriteLine("- RTSP模式: http://localhost:8080/api/usdisplay?usid=2");
            Console.WriteLine("- WebSocket模式: http://localhost:8080/api/usdisplay?usid=3");
            Console.WriteLine("- WebSocket连接: ws://localhost:8081/api/websocket?usid=3&cam=0");
            Console.WriteLine("");
            Console.WriteLine("按 Ctrl+C 停止服务器");
            
            // 等待两个服务器
            await Task.WhenAll(httpTask, wsTask);
        }



        //异步函数  启动HTTP服务器
        private static async Task StartHttpServer()
        {
            //1.
            var listener = new HttpListener();
            // 绑定到localhost与127.0.0.1,避免因Host不匹配导致返回系统400且无CORS头

            //2.
            listener.Prefixes.Add("http://localhost:8080/");
            listener.Prefixes.Add("http://127.0.0.1:8080/");
            // 如需对外访问,可尝试开启以下通配符(需要管理员权限并配置urlacl)
            // listener.Prefixes.Add("http://+:8080/");

            //3.
            listener.Start();
            
            Console.WriteLine("HTTP服务器已启动: http://localhost:8080 与 http://127.0.0.1:8080");
            
            //4.持续监控
            while (true)
            {
                try
                {
                    //5.获取访问请求上下文
                    var context = await listener.GetContextAsync(); //等待一个即将到来的请求操作
                    _ = Task.Run(() => HandleHttpRequest(context));//开启一个线程,处理http请求
                }
                catch (Exception ex)
                {
                    Console.WriteLine($"HTTP服务器错误: {ex.Message}");
                }
            }
        }
        
        //处理http请求
        private static async Task HandleHttpRequest(HttpListenerContext context)
        {
            try
            {
                var request = context.Request;//请求上下文的客户端request
                var response = context.Response;//请求上下文的服务端response

                // 设置反馈的CORS头
                response.Headers.Add("Access-Control-Allow-Origin", "*");
                response.Headers.Add("Access-Control-Allow-Methods", "GET, POST, OPTIONS, PUT, DELETE");
                response.Headers.Add("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With, Accept, Origin");
                response.Headers.Add("Access-Control-Allow-Credentials", "true");
                response.Headers.Add("Access-Control-Max-Age", "86400");
                
                if (request.HttpMethod == "OPTIONS")
                {
                    response.StatusCode = 200;
                    response.Close();
                    return;
                }

                //如果请求url不为空,且绝对地址为"/api/usdisplay"
                if (request.Url?.AbsolutePath == "/api/usdisplay")
                {
                    var usid = request.QueryString["usid"];//获取到查询参数usid的值
                    if (string.IsNullOrEmpty(usid))
                    {
                        response.StatusCode = 400;
                        var errorBytes = Encoding.UTF8.GetBytes("Missing usid parameter");
                        await response.OutputStream.WriteAsync(errorBytes, 0, errorBytes.Length);
                    }
                    else
                    {
                        //构建模式反馈json (两种模式,反馈的json模板不一样)
                        var configResponse = GetDisplayConfig(usid);
                        var jsonResponse = JsonSerializer.Serialize(configResponse);
                        var responseBytes = Encoding.UTF8.GetBytes(jsonResponse);
                        
                        response.ContentType = "application/json";//设置返回数据类型
                        response.StatusCode = 200;//设置返回状态码
                        await response.OutputStream.WriteAsync(responseBytes, 0, responseBytes.Length);
                    }
                }
                else
                {
                    response.StatusCode = 404;
                    var notFoundBytes = Encoding.UTF8.GetBytes("Not Found");
                    await response.OutputStream.WriteAsync(notFoundBytes, 0, notFoundBytes.Length);
                }
                
                response.Close();
            }
            catch (Exception ex)
            {
                Console.WriteLine($"处理HTTP请求错误: {ex.Message}");
            }
        }
        
        private static async Task StartWebSocketServer()
        {
            //创建websocket服务端
            var server = new Fleck.WebSocketServer("ws://0.0.0.0:8081");
            
            //服务端socket执行事件监听
            server.Start(socket =>
            {
                //网页端触发socket请求后
                socket.OnOpen = () =>
                {
                    var query = ParseQuery(socket.ConnectionInfo.Path);//获取前端连接服务端的websocket地址(网页端websocket请求连接地址)
                    var usid = query.GetValueOrDefault("usid", "");//获取websocket请求连接地址的usid参数值
                    var cam = query.GetValueOrDefault("cam", "");//获取websocket请求连接地址的cam参数值
                    var connectionKey = $"{usid}-{cam}";//自定义变量,存储连接信息{usid}-{cam}

                    Console.WriteLine($"WebSocket连接建立: usid={usid}, cam={cam}, IP={socket.ConnectionInfo.ClientIpAddress}");
                    //socket.ConnectionInfo.ClientIpAddress 请求连接的客户端ip

                    // 添加连接到管理字典
                    _connections.AddOrUpdate(connectionKey, 
                        new List<IWebSocketConnection> { socket },
                        (key, list) => { list.Add(socket); return list; });
                    
                    // 开始发送图片帧
                    StartSendingFrames(socket, usid, cam, connectionKey);
                };
                
                socket.OnClose = () =>
                {
                    var query = ParseQuery(socket.ConnectionInfo.Path);
                    var usid = query.GetValueOrDefault("usid", "");
                    var cam = query.GetValueOrDefault("cam", "");
                    var connectionKey = $"{usid}-{cam}";
                    
                    Console.WriteLine($"WebSocket连接关闭: usid={usid}, cam={cam}");
                    
                    try
                    {
                        // 从管理字典中移除连接
                        if (_connections.TryGetValue(connectionKey, out var connections))
                        {
                            connections.Remove(socket);
                            if (connections.Count == 0)
                            {
                                _connections.TryRemove(connectionKey, out _);
                                
                                // 停止发送任务并释放资源
                                if (_cancellationTokens.TryRemove(connectionKey, out var cts))
                                {
                                    cts.Cancel();
                                    cts.Dispose();
                                    Console.WriteLine($"已清理连接资源: {connectionKey}");
                                }
                            }
                        }
                        
                        // 强制垃圾回收释放内存
                        GC.Collect();
                        GC.WaitForPendingFinalizers();
                    }
                    catch (Exception ex)
                    {
                        Console.WriteLine($"连接关闭时清理资源出错: {ex.Message}");
                    }
                };
                
                socket.OnError = exception =>
                {
                    Console.WriteLine($"WebSocket错误: {exception.Message}");
                };
            });
            
            // 保持服务器运行
            await Task.Delay(Timeout.Infinite);
            //await Task.Delay(Timeout.Infinite); 的意思是在一个异步方法里"无限等待",也就是说这个任务永远不会完成(除非有外部中断或取消)。
            //这通常用于让一个后台任务保持运行状态、占位、或者在某些调试场景下阻止应用退出。
        }

        private static Dictionary<string, string> ParseQuery(string path)
        {
            var result = new Dictionary<string, string>();
            
            if (string.IsNullOrEmpty(path) || !path.Contains('?'))
                return result;
            
            var queryString = path.Split('?')[1];
            var pairs = queryString.Split('&');
            
            foreach (var pair in pairs)
            {
                var keyValue = pair.Split('=');
                if (keyValue.Length == 2)
                {
                    result[keyValue[0]] = Uri.UnescapeDataString(keyValue[1]);
                }
            }
            
            return result;
        }
        
        private static void StartSendingFrames(IWebSocketConnection socket, string usid, string cam, string connectionKey)
        {
            // 检查是否已有任务在运行,如果有则先取消
            if (_cancellationTokens.TryGetValue(connectionKey, out var existingCts))
            {
                try
                {
                    existingCts.Cancel();
                    existingCts.Dispose();
                    Console.WriteLine($"取消已存在的发送任务: usid={usid}, cam={cam}");
                }
                catch (Exception ex)
                {
                    Console.WriteLine($"取消已存在任务时出错: {ex.Message}");
                }
            }
            
            var cts = new CancellationTokenSource();
            _cancellationTokens[connectionKey] = cts;
            
            // 使用ConfigureAwait(false)避免上下文切换开销
            Task.Run(async () =>
            {
                try
                {
                    if (!Directory.Exists(_imagePath))
                    {
                        Console.WriteLine($"图片目录不存在: {_imagePath}");
                        socket.Close();
                        return;
                    }
                    
                    // 优化:只获取文件路径,不读入内存
                    var imageFiles = Directory.GetFiles(_imagePath, "*.jpg")
                                   .Concat(Directory.GetFiles(_imagePath, "*.jpeg"))
                                   .Concat(Directory.GetFiles(_imagePath, "*.png"))
                                   .OrderBy(f => f)
                                   .ToArray();
                    
                    if (imageFiles.Length == 0)
                    {
                        Console.WriteLine($"图片目录中没有找到图片文件: {_imagePath}");
                        socket.Close();
                        return;
                    }
                    
                    Console.WriteLine($"摄像头{cam}开始发送图片帧,共{imageFiles.Length}个文件");
                    
                    var frameIndex = 0;
                    
                    while (!cts.Token.IsCancellationRequested && socket.IsAvailable)
                    {
                        var currentImageFile = imageFiles[frameIndex % imageFiles.Length];
                        
                        try
                        {
                            // 内存优化:使用using确保资源及时释放
                            using (var fileStream = new FileStream(currentImageFile, FileMode.Open, FileAccess.Read))
                            {
                                var imageBytes = new byte[fileStream.Length];
                                await fileStream.ReadAsync(imageBytes, 0, imageBytes.Length, cts.Token);
                                
                                // 立即发送后释放引用
                                socket.Send(imageBytes);
                                imageBytes = null; // 显式释放引用
                            }
                            
                            Console.WriteLine($"发送图片帧: usid={usid}, cam={cam}, frame={frameIndex}, file={Path.GetFileName(currentImageFile)}");
                            
                            frameIndex++;
                            
                            // 降低帧率减少内存压力:改为10fps,即每100ms发送一帧
                            await Task.Delay(40, cts.Token);
                            
                            // 每100帧强制垃圾回收一次
                            if (frameIndex % 100 == 0)
                            {
                                GC.Collect();
                                GC.WaitForPendingFinalizers();
                                Console.WriteLine($"执行垃圾回收: frame={frameIndex}");
                            }
                        }
                        catch (OperationCanceledException)
                        {
                            break;
                        }
                        catch (Exception ex)
                        {
                            Console.WriteLine($"发送图片帧时出错: {ex.Message}");
                            break;
                        }
                    }
                }
                catch (Exception ex)
                {
                    Console.WriteLine($"图片发送任务异常: {ex.Message}");
                }
                finally
                {
                    Console.WriteLine($"停止发送图片帧: usid={usid}, cam={cam}");
                }
            }, cts.Token);
        }
        
        //获取模式配置的反馈json
        private static object GetDisplayConfig(string usid)
        {
            //模拟数据,模拟两个无人系统,每个1个模式
            switch (usid)
            {
                case "2":
                    // RTSP模式
                    return new
                    {
                        code = 200,
                        success = true,
                        data = new
                        {
                            mode = "rtspurl",
                            url = new string[]
                            {
                                "rtsp://127.0.0.1:8081",
                                "rtsp://127.0.0.1:8082",
                                "rtsp://127.0.0.1:8083"
                            }
                        }
                    };
                    
                case "3":
                    // WebSocket模式
                    return new
                    {
                        code = 200,
                        success = true,
                        data = new
                        {
                            mode = "websocketurl",
                            url = new string[]
                            {
                                "ws://127.0.0.1:8081/api/websocket?usid=3&cam=0",
                                "ws://127.0.0.1:8081/api/websocket?usid=3&cam=1",
                                "ws://127.0.0.1:8081/api/websocket?usid=3&cam=2"
                            }
                        }
                    };
                    
                default:
                    return new
                    {
                        code = 404,
                        success = false,
                        message = "未找到指定的机器人配置"
                    };
            }
        }
    }
}

View Code

6 效果展示

3个小区域的图片帧显示:

点击任意一个小区域,弹出图片帧放大显示弹出框: