找回密码
 立即注册
首页 业界区 业界 通过Canvas在网页中将后端发来的一帧帧图片渲染成“视频 ...

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

刘凤 7 天前
1.背景

最近有这样的场景,网页端需要显示现场无人系统(机器人)的摄像头数据(图片)。值得注意的是,一个无人系统(机器人)它身上可能挂载若干个摄像头,这若干个摄像头都需要在前端的若干个小区域内显示;另外不同的用户访问前端网页,每个用户都访问他自己想关注的无人系统(机器人)摄像头数据。而前端直接和现场的无人系统对接是不合适的:因为对于同一个无人系统,可能不同的用户同一时间或相近时间都访问它,导致该无人系统要处理反馈多份资源请求,并且很容易导致超过机器人的处理负荷;另外对于前端来讲,他并不知知道应该和现场的哪一个无人系统进行对接(因为前端并没有现场的无人系统相关身份数据,无法做识别)。
为此,设计了如下方案,现场的无人系统统一和数据中转服务器对接,每个机器人都只给一份实时摄像头数据给数据中转服务器。数据中转服务器建立websocket服务端程序,并处理网页端的请求(请求获取特定机器人的所有摄像头信息),数据中转服务器根据网页端的请求,对请求信息进行解析,并创建特定的websocket服务实例。具体通信示意图如下:
1.png

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

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

针对以上内容进行分析,并为了兼容已有实现的功能,约定如下大屏与数据中转器的接口方式:
网页端通过GET请求,调用数据中转服务器接口,请求接口地址为:
http://ip:port/api/usdisplay?usid=2 。其中请求参数usid代表前端给数据中转服务器(后端)传递的无人系统id.
数据中转服务器需要根据无人系统id,判断该无人系统摄像头数据传递是使用的哪种方式?并根据特定的方式返回前端结果,前端根据不同的模式,执行不同的渲染方式。
数据中转服务器(后端)返回前端的结果格式为:
 

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

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

3.1 div结构设计
  1. 1     
  2. 2             <span>态势总览</span>
  3. 3            
  4. 4               
  5. 5               
  6. 6                 <video class="video-stream" autoplay muted></video>
  7. 7                 
  8. 8               
  9. 9               
  10. 10                 <video class="video-stream" autoplay muted></video>
  11. 11                 
  12. 12               
  13. 13               
  14. 14                 <video class="video-stream" autoplay muted></video>
  15. 15                 
  16. 16               
  17. 17               
  18. 18                 <video class="video-stream" autoplay muted></video>
  19. 19                 
  20. 20               
  21. 21            
  22. 22           
复制代码
主要是在一个区域内预先占用4个小区域,每个小区域用于显示同一个无人系统的一个摄像头信息,最多支持显示同一个无人系统的4个摄像头信息(实际显示其中的1-4个小区域是以实际同一个无人系统的摄像头个数而定的)。
以上的html结构最先是为了支持rtsp视频流而设计的,对于当前的图片帧显示使用的Canvas技术不适用,所以如果是在图片帧显示的模式下,后续需要通过js动态的修改html结果,切换为相关标签结构。
以上现有的html结构对应的CSS样式如下:
  1. 1 .chartarea {
  2. 2   width: 95%;
  3. 3   height: 31%;
  4. 4   margin-top: 3.5%;
  5. 5 }
  6. 6 .innerright .chartarea {
  7. 7   margin-left: 3%;
  8. 8   margin-right: 2%;
  9. 9 }
  10. 10 .charttitle {
  11. 11   width: 100%;
  12. 12   height: 15%;
  13. 13   background-image: url("/img/visualImages/20_chart_title.png");
  14. 14   background-size: 100% 100%;
  15. 15 }
  16. 16 .charttitle>span {
  17. 17   height: 100%;
  18. 18   margin-left: 5%;
  19. 19   display: flex;
  20. 20   align-items: center;
  21. 21   font-size: 0.8vw;
  22. 22   color: #fff;
  23. 23   font-weight: 700;
  24. 24 }
  25. 25 .chartdata {
  26. 26   width: 100%;
  27. 27   height: 85%;
  28. 28   /* background-image: url("/img/visualImages/21_chart_background.png");
  29. 29     background-size: cover;
  30. 30     background-repeat: no-repeat;
  31. 31     background-position:top left; */
  32. 32
  33. 33   /* 当背景图片无法完整铺满整个div,但自己又想即时图片变形不合比例拉伸,也要铺满,这是种好方式! */
  34. 34   /* 这种方法会将背景图片拉伸以完全覆盖div的宽度和高度,可能会导致图片变形,特别是如果图片的原始宽高比与div的宽高比不匹配时。 */
  35. 35   background-image: url("/img/visualImages/21_chart_background.png");
  36. 36   background-size: 100% 100%;
  37. 37 }
  38. 38 #videoGrid {
  39. 39   flex: 1;
  40. 40   display: grid;
  41. 41   grid-template-columns: 0.48fr 0.48fr;
  42. 42   grid-template-rows: 0.49fr 0.49fr;
  43. 43   /* gap: 5px; */
  44. 44   gap: 2%;
  45. 45   padding: 1.5%;
  46. 46 }
  47. 47
  48. 48 .video-container {
  49. 49   position: relative;
  50. 50   background-color: #000;
  51. 51   border-radius: 4px;
  52. 52   overflow: hidden;
  53. 53 }
  54. 54 .video-stream
  55. 55  {
  56. 56   width: 100%;
  57. 57   height: 100%;
  58. 58   object-fit: cover;
  59. 59 }
  60. 60
  61. 61 .camera-label {
  62. 62   position: absolute;
  63. 63   bottom: 5px;
  64. 64   left: 5px;
  65. 65   color: white;
  66. 66   background-color: rgba(0, 0, 0, 0.5);
  67. 67   padding: 2px 5px;
  68. 68   border-radius: 3px;
  69. 69   font-size: 12px;
  70. 70 }
复制代码
在上面的4个小视频区域,当用户点击其中任意一个有视频的小区域时,会弹出一个视频放大显示的弹出框,其对应的html结构和css如下:
  1. 1
  2. 2           
  3. 3            
  4. 4               <span class="close-btn">×</span>
  5. 5               <video id="modalVideo" autoplay controls></video>
  6. 6               
  7. 7            
  8. 8           
复制代码
 
  1. 1 /* 弹窗样式 */
  2. 2 .modal {
  3. 3   display: none;
  4. 4   position: fixed;
  5. 5   z-index: 1100;
  6. 6   left: 0;
  7. 7   top: 0;
  8. 8   width: 100%;
  9. 9   height: 100%;
  10. 10   background-color: rgba(0, 0, 0, 0.8);
  11. 11   justify-content: center;
  12. 12   align-items: center;
  13. 13 }
  14. 14 .modal-content {
  15. 15   position: relative;
  16. 16   width: 70vw;
  17. 17   height: 75vh;
  18. 18   background-color: #000;
  19. 19   border-radius: 5px;
  20. 20   overflow: hidden;
  21. 21 }
  22. 22
  23. 23 .close-btn {
  24. 24   position: absolute;
  25. 25   top: 10px;
  26. 26   right: 15px;
  27. 27   color: white;
  28. 28   font-size: 28px;
  29. 29   font-weight: bold;
  30. 30   cursor: pointer;
  31. 31   z-index: 1001;
  32. 32 }
  33. 33 .close-btn:hover {
  34. 34   color: #ccc;
  35. 35 }
  36. 36 .close-btn {
  37. 37   font-size: 24px;
  38. 38   font-weight: bold;
  39. 39   color: #999;
  40. 40   cursor: pointer;
  41. 41 }
  42. 42 #modalVideo{
  43. 43   width: 100%;
  44. 44   height: 100%;
  45. 45   object-fit: contain;
  46. 46 }
  47. 47 .modal-camera-label {
  48. 48   position: absolute;
  49. 49   bottom: 10px;
  50. 50   left: 10px;
  51. 51   color: white;
  52. 52   background-color: rgba(0, 0, 0, 0.5);
  53. 53   padding: 5px 10px;
  54. 54   border-radius: 3px;
  55. 55   font-size: 14px;
  56. 56 }
复制代码
3.2 js函数设计

3.2.1 设计统一的入口函数

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

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

 这个可根据个人擅长的开发语言编写,因为我主要是为了验证前端显示方案是否可以落地,所以后端程序只要能按一定频率取本地的帧图片并实时通过websocket发送给前端显示即可。我通过ai生成了一个验证测试的C#后端程序。
基于.NET 6.0的控制台应用程序代码如下:
2.gif
3.gif
  1. using Fleck;
  2. using System.Text.Json;
  3. using System.Collections.Concurrent;
  4. using System.Net;
  5. using System.Net.Http;
  6. using System.Text;
  7. namespace WebSocketServerApp
  8. {
  9.     public class Program
  10.     {
  11.         private static readonly ConcurrentDictionary<string, List<IWebSocketConnection>> _connections = new();
  12.         private static readonly ConcurrentDictionary<string, CancellationTokenSource> _cancellationTokens = new();
  13.         private static string _imagePath = @"D:\XX中心\总控系统项目\测试demo\图片帧demo\dash-video-to-img";//图片帧文件夹存放位置
  14.         
  15.         public static async Task Main(string[] args)
  16.         {
  17.             // 启动HTTP服务器------路线1
  18.             var httpTask = StartHttpServer();//用于处理前端的模式请求确认
  19.             
  20.             // 启动WebSocket服务器  ----路线2
  21.             var wsTask = StartWebSocketServer(); //对接网页websocket,传输图片帧
  22.             
  23.             Console.WriteLine("=== WebSocket服务器启动完成 ===");
  24.             Console.WriteLine("HTTP API服务: http://localhost:8080");
  25.             Console.WriteLine("WebSocket服务: ws://localhost:8081");
  26.             Console.WriteLine("");
  27.             Console.WriteLine("测试URL:");
  28.             Console.WriteLine("- RTSP模式: http://localhost:8080/api/usdisplay?usid=2");
  29.             Console.WriteLine("- WebSocket模式: http://localhost:8080/api/usdisplay?usid=3");
  30.             Console.WriteLine("- WebSocket连接: ws://localhost:8081/api/websocket?usid=3&cam=0");
  31.             Console.WriteLine("");
  32.             Console.WriteLine("按 Ctrl+C 停止服务器");
  33.             
  34.             // 等待两个服务器
  35.             await Task.WhenAll(httpTask, wsTask);
  36.         }
  37.         //异步函数  启动HTTP服务器
  38.         private static async Task StartHttpServer()
  39.         {
  40.             //1.
  41.             var listener = new HttpListener();
  42.             // 绑定到localhost与127.0.0.1,避免因Host不匹配导致返回系统400且无CORS头
  43.             //2.
  44.             listener.Prefixes.Add("http://localhost:8080/");
  45.             listener.Prefixes.Add("http://127.0.0.1:8080/");
  46.             // 如需对外访问,可尝试开启以下通配符(需要管理员权限并配置urlacl)
  47.             // listener.Prefixes.Add("http://+:8080/");
  48.             //3.
  49.             listener.Start();
  50.             
  51.             Console.WriteLine("HTTP服务器已启动: http://localhost:8080 与 http://127.0.0.1:8080");
  52.             
  53.             //4.持续监控
  54.             while (true)
  55.             {
  56.                 try
  57.                 {
  58.                     //5.获取访问请求上下文
  59.                     var context = await listener.GetContextAsync(); //等待一个即将到来的请求操作
  60.                     _ = Task.Run(() => HandleHttpRequest(context));//开启一个线程,处理http请求
  61.                 }
  62.                 catch (Exception ex)
  63.                 {
  64.                     Console.WriteLine($"HTTP服务器错误: {ex.Message}");
  65.                 }
  66.             }
  67.         }
  68.         
  69.         //处理http请求
  70.         private static async Task HandleHttpRequest(HttpListenerContext context)
  71.         {
  72.             try
  73.             {
  74.                 var request = context.Request;//请求上下文的客户端request
  75.                 var response = context.Response;//请求上下文的服务端response
  76.                 // 设置反馈的CORS头
  77.                 response.Headers.Add("Access-Control-Allow-Origin", "*");
  78.                 response.Headers.Add("Access-Control-Allow-Methods", "GET, POST, OPTIONS, PUT, DELETE");
  79.                 response.Headers.Add("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With, Accept, Origin");
  80.                 response.Headers.Add("Access-Control-Allow-Credentials", "true");
  81.                 response.Headers.Add("Access-Control-Max-Age", "86400");
  82.                
  83.                 if (request.HttpMethod == "OPTIONS")
  84.                 {
  85.                     response.StatusCode = 200;
  86.                     response.Close();
  87.                     return;
  88.                 }
  89.                 //如果请求url不为空,且绝对地址为"/api/usdisplay"
  90.                 if (request.Url?.AbsolutePath == "/api/usdisplay")
  91.                 {
  92.                     var usid = request.QueryString["usid"];//获取到查询参数usid的值
  93.                     if (string.IsNullOrEmpty(usid))
  94.                     {
  95.                         response.StatusCode = 400;
  96.                         var errorBytes = Encoding.UTF8.GetBytes("Missing usid parameter");
  97.                         await response.OutputStream.WriteAsync(errorBytes, 0, errorBytes.Length);
  98.                     }
  99.                     else
  100.                     {
  101.                         //构建模式反馈json (两种模式,反馈的json模板不一样)
  102.                         var configResponse = GetDisplayConfig(usid);
  103.                         var jsonResponse = JsonSerializer.Serialize(configResponse);
  104.                         var responseBytes = Encoding.UTF8.GetBytes(jsonResponse);
  105.                         
  106.                         response.ContentType = "application/json";//设置返回数据类型
  107.                         response.StatusCode = 200;//设置返回状态码
  108.                         await response.OutputStream.WriteAsync(responseBytes, 0, responseBytes.Length);
  109.                     }
  110.                 }
  111.                 else
  112.                 {
  113.                     response.StatusCode = 404;
  114.                     var notFoundBytes = Encoding.UTF8.GetBytes("Not Found");
  115.                     await response.OutputStream.WriteAsync(notFoundBytes, 0, notFoundBytes.Length);
  116.                 }
  117.                
  118.                 response.Close();
  119.             }
  120.             catch (Exception ex)
  121.             {
  122.                 Console.WriteLine($"处理HTTP请求错误: {ex.Message}");
  123.             }
  124.         }
  125.         
  126.         private static async Task StartWebSocketServer()
  127.         {
  128.             //创建websocket服务端
  129.             var server = new Fleck.WebSocketServer("ws://0.0.0.0:8081");
  130.             
  131.             //服务端socket执行事件监听
  132.             server.Start(socket =>
  133.             {
  134.                 //网页端触发socket请求后
  135.                 socket.OnOpen = () =>
  136.                 {
  137.                     var query = ParseQuery(socket.ConnectionInfo.Path);//获取前端连接服务端的websocket地址(网页端websocket请求连接地址)
  138.                     var usid = query.GetValueOrDefault("usid", "");//获取websocket请求连接地址的usid参数值
  139.                     var cam = query.GetValueOrDefault("cam", "");//获取websocket请求连接地址的cam参数值
  140.                     var connectionKey = $"{usid}-{cam}";//自定义变量,存储连接信息{usid}-{cam}
  141.                     Console.WriteLine($"WebSocket连接建立: usid={usid}, cam={cam}, IP={socket.ConnectionInfo.ClientIpAddress}");
  142.                     //socket.ConnectionInfo.ClientIpAddress 请求连接的客户端ip
  143.                     // 添加连接到管理字典
  144.                     _connections.AddOrUpdate(connectionKey,
  145.                         new List<IWebSocketConnection> { socket },
  146.                         (key, list) => { list.Add(socket); return list; });
  147.                     
  148.                     // 开始发送图片帧
  149.                     StartSendingFrames(socket, usid, cam, connectionKey);
  150.                 };
  151.                
  152.                 socket.OnClose = () =>
  153.                 {
  154.                     var query = ParseQuery(socket.ConnectionInfo.Path);
  155.                     var usid = query.GetValueOrDefault("usid", "");
  156.                     var cam = query.GetValueOrDefault("cam", "");
  157.                     var connectionKey = $"{usid}-{cam}";
  158.                     
  159.                     Console.WriteLine($"WebSocket连接关闭: usid={usid}, cam={cam}");
  160.                     
  161.                     try
  162.                     {
  163.                         // 从管理字典中移除连接
  164.                         if (_connections.TryGetValue(connectionKey, out var connections))
  165.                         {
  166.                             connections.Remove(socket);
  167.                             if (connections.Count == 0)
  168.                             {
  169.                                 _connections.TryRemove(connectionKey, out _);
  170.                                 
  171.                                 // 停止发送任务并释放资源
  172.                                 if (_cancellationTokens.TryRemove(connectionKey, out var cts))
  173.                                 {
  174.                                     cts.Cancel();
  175.                                     cts.Dispose();
  176.                                     Console.WriteLine($"已清理连接资源: {connectionKey}");
  177.                                 }
  178.                             }
  179.                         }
  180.                         
  181.                         // 强制垃圾回收释放内存
  182.                         GC.Collect();
  183.                         GC.WaitForPendingFinalizers();
  184.                     }
  185.                     catch (Exception ex)
  186.                     {
  187.                         Console.WriteLine($"连接关闭时清理资源出错: {ex.Message}");
  188.                     }
  189.                 };
  190.                
  191.                 socket.OnError = exception =>
  192.                 {
  193.                     Console.WriteLine($"WebSocket错误: {exception.Message}");
  194.                 };
  195.             });
  196.             
  197.             // 保持服务器运行
  198.             await Task.Delay(Timeout.Infinite);
  199.             //await Task.Delay(Timeout.Infinite); 的意思是在一个异步方法里“无限等待”,也就是说这个任务永远不会完成(除非有外部中断或取消)。
  200.             //这通常用于让一个后台任务保持运行状态、占位、或者在某些调试场景下阻止应用退出。
  201.         }
  202.         private static Dictionary<string, string> ParseQuery(string path)
  203.         {
  204.             var result = new Dictionary<string, string>();
  205.             
  206.             if (string.IsNullOrEmpty(path) || !path.Contains('?'))
  207.                 return result;
  208.             
  209.             var queryString = path.Split('?')[1];
  210.             var pairs = queryString.Split('&');
  211.             
  212.             foreach (var pair in pairs)
  213.             {
  214.                 var keyValue = pair.Split('=');
  215.                 if (keyValue.Length == 2)
  216.                 {
  217.                     result[keyValue[0]] = Uri.UnescapeDataString(keyValue[1]);
  218.                 }
  219.             }
  220.             
  221.             return result;
  222.         }
  223.         
  224.         private static void StartSendingFrames(IWebSocketConnection socket, string usid, string cam, string connectionKey)
  225.         {
  226.             // 检查是否已有任务在运行,如果有则先取消
  227.             if (_cancellationTokens.TryGetValue(connectionKey, out var existingCts))
  228.             {
  229.                 try
  230.                 {
  231.                     existingCts.Cancel();
  232.                     existingCts.Dispose();
  233.                     Console.WriteLine($"取消已存在的发送任务: usid={usid}, cam={cam}");
  234.                 }
  235.                 catch (Exception ex)
  236.                 {
  237.                     Console.WriteLine($"取消已存在任务时出错: {ex.Message}");
  238.                 }
  239.             }
  240.             
  241.             var cts = new CancellationTokenSource();
  242.             _cancellationTokens[connectionKey] = cts;
  243.             
  244.             // 使用ConfigureAwait(false)避免上下文切换开销
  245.             Task.Run(async () =>
  246.             {
  247.                 try
  248.                 {
  249.                     if (!Directory.Exists(_imagePath))
  250.                     {
  251.                         Console.WriteLine($"图片目录不存在: {_imagePath}");
  252.                         socket.Close();
  253.                         return;
  254.                     }
  255.                     
  256.                     // 优化:只获取文件路径,不读入内存
  257.                     var imageFiles = Directory.GetFiles(_imagePath, "*.jpg")
  258.                                    .Concat(Directory.GetFiles(_imagePath, "*.jpeg"))
  259.                                    .Concat(Directory.GetFiles(_imagePath, "*.png"))
  260.                                    .OrderBy(f => f)
  261.                                    .ToArray();
  262.                     
  263.                     if (imageFiles.Length == 0)
  264.                     {
  265.                         Console.WriteLine($"图片目录中没有找到图片文件: {_imagePath}");
  266.                         socket.Close();
  267.                         return;
  268.                     }
  269.                     
  270.                     Console.WriteLine($"摄像头{cam}开始发送图片帧,共{imageFiles.Length}个文件");
  271.                     
  272.                     var frameIndex = 0;
  273.                     
  274.                     while (!cts.Token.IsCancellationRequested && socket.IsAvailable)
  275.                     {
  276.                         var currentImageFile = imageFiles[frameIndex % imageFiles.Length];
  277.                         
  278.                         try
  279.                         {
  280.                             // 内存优化:使用using确保资源及时释放
  281.                             using (var fileStream = new FileStream(currentImageFile, FileMode.Open, FileAccess.Read))
  282.                             {
  283.                                 var imageBytes = new byte[fileStream.Length];
  284.                                 await fileStream.ReadAsync(imageBytes, 0, imageBytes.Length, cts.Token);
  285.                                 
  286.                                 // 立即发送后释放引用
  287.                                 socket.Send(imageBytes);
  288.                                 imageBytes = null; // 显式释放引用
  289.                             }
  290.                            
  291.                             Console.WriteLine($"发送图片帧: usid={usid}, cam={cam}, frame={frameIndex}, file={Path.GetFileName(currentImageFile)}");
  292.                            
  293.                             frameIndex++;
  294.                            
  295.                             // 降低帧率减少内存压力:改为10fps,即每100ms发送一帧
  296.                             await Task.Delay(40, cts.Token);
  297.                            
  298.                             // 每100帧强制垃圾回收一次
  299.                             if (frameIndex % 100 == 0)
  300.                             {
  301.                                 GC.Collect();
  302.                                 GC.WaitForPendingFinalizers();
  303.                                 Console.WriteLine($"执行垃圾回收: frame={frameIndex}");
  304.                             }
  305.                         }
  306.                         catch (OperationCanceledException)
  307.                         {
  308.                             break;
  309.                         }
  310.                         catch (Exception ex)
  311.                         {
  312.                             Console.WriteLine($"发送图片帧时出错: {ex.Message}");
  313.                             break;
  314.                         }
  315.                     }
  316.                 }
  317.                 catch (Exception ex)
  318.                 {
  319.                     Console.WriteLine($"图片发送任务异常: {ex.Message}");
  320.                 }
  321.                 finally
  322.                 {
  323.                     Console.WriteLine($"停止发送图片帧: usid={usid}, cam={cam}");
  324.                 }
  325.             }, cts.Token);
  326.         }
  327.         
  328.         //获取模式配置的反馈json
  329.         private static object GetDisplayConfig(string usid)
  330.         {
  331.             //模拟数据,模拟两个无人系统,每个1个模式
  332.             switch (usid)
  333.             {
  334.                 case "2":
  335.                     // RTSP模式
  336.                     return new
  337.                     {
  338.                         code = 200,
  339.                         success = true,
  340.                         data = new
  341.                         {
  342.                             mode = "rtspurl",
  343.                             url = new string[]
  344.                             {
  345.                                 "rtsp://127.0.0.1:8081",
  346.                                 "rtsp://127.0.0.1:8082",
  347.                                 "rtsp://127.0.0.1:8083"
  348.                             }
  349.                         }
  350.                     };
  351.                     
  352.                 case "3":
  353.                     // WebSocket模式
  354.                     return new
  355.                     {
  356.                         code = 200,
  357.                         success = true,
  358.                         data = new
  359.                         {
  360.                             mode = "websocketurl",
  361.                             url = new string[]
  362.                             {
  363.                                 "ws://127.0.0.1:8081/api/websocket?usid=3&cam=0",
  364.                                 "ws://127.0.0.1:8081/api/websocket?usid=3&cam=1",
  365.                                 "ws://127.0.0.1:8081/api/websocket?usid=3&cam=2"
  366.                             }
  367.                         }
  368.                     };
  369.                     
  370.                 default:
  371.                     return new
  372.                     {
  373.                         code = 404,
  374.                         success = false,
  375.                         message = "未找到指定的机器人配置"
  376.                     };
  377.             }
  378.         }
  379.     }
  380. }
复制代码
View Code6 效果展示

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

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

 

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
您需要登录后才可以回帖 登录 | 立即注册