视频中台解决方案:组织树组件+多路视频直播界面开发

前言

最近准备搞新项目了

这次应该不会咕咕咕了,我编写了完整的计划

如果按计划来的话,应该可以在一个月内搞定 MVP 上线

不过在开始新项目之前,得把我之前的工作整理一下,输出几篇笔记记录一下

在公众号后台回复「树组件」可以获取本文树组件的相关代

介绍

这个项目是中台里的一个子项目,视频中台

主要功能是管理各个项目的监控设备、摄像头,以及查看监控直播

在此之前,我只用 SRS 部署了直播平台,然后使用 RTMP 协议推流实现直播

但这种方式适合的场景更多是像 B 站、抖音这种直播平台

对于视频监控,业内有个更专业的方式:GB28181-2016 标准

也就是常说的 28181 协议

最终我们选择的监控后端是开源的 WVP (https://github.com/648540858/wvp-GB28181-pro)

初见这个项目主页,一股浓浓的国产粗犷风格扑面而来,主打一个凑合看看得了,简单的文档后标明了收费内容和付费咨询渠道。

不过也就是这么个粗看其貌不扬的项目,却意外的…能用?

总之就是这么个项目,支撑起几千个设备的视频监控播放。

主要的设备就是海康、大华、宇视等品牌的 IPC、NVR,一开始我还 NVR(录像机)和 IPC(摄像头)拆分开,没想到这系统里是不拆分的,我后面也发现了不拆分更好,一律按照设备来记录,然后实际视频流再按照通道来区分。

视频中台

视频中台这块的技术方面其实不会很复杂

主要的工作量和复杂度还是在沟通、协调等流程方面,原因有以下几点:

  • 不单单是做这么一个系统,还需要让现场人员去配置摄像头,让管理人员录入摄像头信息
  • 现场人员很多不会操作电脑,如何指导他们配置摄像头(类似配置路由器)
  • 存量设备和新增设备如何管理?
  • 摄像头和录像机如何编码?
  • 编码完成后如何让现场人员知道哪个编码对应哪个设备?
  • ……

这里只列举一部分,实际运行的问题只会更多。

其实这些都还好,只要理清了整个流程,实施起来还是有可行性的。

但一旦涉及得人过多,没有人负责推动,最终就会变成互相推诿,效率低下,一个月都不一定能完成一台设备的接入。

OK,废话太多了,说回正题,先来看看系统界面。

主页

直接上截图吧,这是视频中台的截图(敏感信息和数据已经全部用假数据代替,请放心查看)

视频中台解决方案:组织树组件+多路视频直播界面开发

从界面可以看出,核心功能就是管理视频和播放视频

视频播放

PS: 敏感信息已打码,请放心查看

视频播放界面,就是本文要重点介绍的

视频中台解决方案:组织树组件+多路视频直播界面开发

可以切换 1 路、4 路、9 路、16 路播放,这里再截一个 16 路视频的播放截图吧,其他就不放了,相信聪明的读者们能理解的 😃

视频中台解决方案:组织树组件+多路视频直播界面开发

技术实现

技术方面,我继续发扬之前「Less is more」的思路: 返璞归真!使用 Alpine.js 开发交互式 web 应用,抛弃 node_modules 和 webpack 吧!

使用 Alpine.js + HTMX 来实现整个页面

代码

页面布局

页面布局使用 tailwindcss

交互使用刚才说的 Alpine.js

<main x-data="playApp()">   <div class="grid grid-cols-12 gap-4">     {# 左侧组织/项目树 #}     <div class="col-span-4" id="tree-list">       <div class="bg-white rounded-lg shadow h-full">         <div class="border-b border-gray-200 bg-[#f1f5fa] px-4 py-3">           <div class="flex items-center justify-between gap-2 h-8">             <div class="flex items-center">               <span class="w-1.5 h-4 bg-[#156bd2] rounded mr-2"></span>               <h5 class="text-lg font-medium text-gray-900">组织架构</h5>             </div>             <button                     type="button"                     class="inline-flex gap-2 items-center px-2 py-1 bg-transparent border border-[#0f5cb9] shadow-sm text-sm font-medium rounded-md text-[#0f5cb9] bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"                     x-on:click="refreshTree()"                     x-bind:disabled="isLoading"                     >               <i class="fa-solid fa-arrow-rotate-right"></i>               <span x-text="isLoading ? '加载中...' : '刷新'"></span>             </button>           </div>         </div>         <div class="p-4 h-full flex flex-col gap-4">           <!-- 搜索框 -->           <div x-show="!isLoading">             <div class="relative">               <input                      type="text"                      id="tree-search"                      placeholder="搜索组织或项目..."                      class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"                      x-on:input="tree && tree.search($event.target.value)"                      >               <div class="absolute inset-y-0 right-0 flex items-center pr-3">                 <svg class="h-4 w-4 text-gray-400" fill="none" stroke="currentColor"                      viewBox="0 0 24 24">                   <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"                         d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>                 </svg>               </div>             </div>             <!-- 搜索结果统计 -->             <div id="search-stats" class="mt-2 text-sm text-gray-500" style="display: none;"></div>           </div>            <!-- 加载动画骨架屏 -->           <div x-show="isLoading" class="space-y-3">             <div class="animate-pulse">               <div class="flex items-center space-x-3">                 <div class="w-4 h-4 bg-gray-300 rounded"></div>                 <div class="h-4 bg-gray-300 rounded w-3/4"></div>               </div>             </div>             <div class="animate-pulse ml-6">               <div class="flex items-center space-x-3">                 <div class="w-4 h-4 bg-gray-300 rounded"></div>                 <div class="h-4 bg-gray-300 rounded w-2/3"></div>               </div>             </div>             <div class="animate-pulse ml-12">               <div class="flex items-center space-x-3">                 <div class="w-4 h-4 bg-gray-300 rounded"></div>                 <div class="h-4 bg-gray-300 rounded w-1/2"></div>               </div>             </div>             <div class="animate-pulse ml-6">               <div class="flex items-center space-x-3">                 <div class="w-4 h-4 bg-gray-300 rounded"></div>                 <div class="h-4 bg-gray-300 rounded w-3/5"></div>               </div>             </div>             <div class="animate-pulse">               <div class="flex items-center space-x-3">                 <div class="w-4 h-4 bg-gray-300 rounded"></div>                 <div class="h-4 bg-gray-300 rounded w-4/5"></div>               </div>             </div>             <div class="animate-pulse ml-6">               <div class="flex items-center space-x-3">                 <div class="w-4 h-4 bg-gray-300 rounded"></div>                 <div class="h-4 bg-gray-300 rounded w-1/3"></div>               </div>             </div>           </div>            <!-- 树形结构容器 -->           <div id="tree-container" class="tree-view" x-show="!isLoading"></div>         </div>       </div>     </div>      <!-- 播放器 -->     <div class="col-span-8">       <div class="bg-white rounded-lg shadow h-full">         <div class="border-b border-gray-200 bg-[#f1f5fa] px-4 py-3">           <div class="flex items-center h-8">             <span class="w-1.5 h-4 bg-[#156bd2] rounded mr-2"></span>             <h5 class="text-lg font-medium text-gray-900">视频播放</h5>           </div>         </div>         <div class="p-4">           <div class="bg-blue-50 text-blue-700 px-4 py-3 rounded-lg mb-2" x-show="!selectedProject">             请先选择摄像头           </div>           <!-- 加载摄像头提示 -->           <div class="bg-yellow-50 text-yellow-700 px-4 py-3 rounded-lg mb-2" x-show="isLoadingCameras">             <div class="flex items-center">               <i class="fas fa-spinner fa-spin mr-2"></i>               正在加载摄像头列表...             </div>           </div>           <div class="space-y-4">             <!-- 视频播放控制栏 -->             <div class="flex items-center justify-between bg-gray-50 px-4 py-3 rounded-lg">               <div class="flex items-center space-x-4">                 <span class="text-sm font-medium text-gray-700">播放模式:</span>                 <select x-model="videoLayout" x-on:change="changeVideoLayout()"                         class="px-3 py-1 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">                   <option value="1">1路播放</option>                   <option value="4">4路播放</option>                   <option value="9">9路播放</option>                   <option value="16">16路播放</option>                 </select>               </div>               <div class="flex items-center space-x-2">                 <span class="text-sm text-gray-600"                       x-text="`已播放: ${activeVideos.filter(v => v).length}/${videoLayout}`"></span>                 <button x-on:click="clearAllVideos()"                         class="px-3 py-1 bg-red-500 text-white text-sm rounded hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500">                   清空全部                 </button>               </div>             </div>              <!-- 让tailwind生成样式 -->             <div class="hidden grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"></div>              <!-- 视频播放网格 -->             <div id="video-grid" class="grid gap-2" x-bind:class="getGridClass()">               <template x-for="(video, index) in activeVideos" :key="index">                 <div class="relative bg-black rounded-lg overflow-hidden aspect-video">                   <template x-if="activeVideos[index]">                     <div class="relative w-full h-full">                       <video                              x-bind:id="`video-player-${index}`"                              class="w-full h-full object-cover"                              controls                              muted                              x-bind:data-camera-guid="activeVideos[index].guid"                              ></video>                       <!-- 视频信息覆盖层 -->                       <div class="absolute top-2 left-2 bg-black bg-opacity-70 text-white px-2 py-1 rounded text-xs">                         <span x-text="activeVideos[index].name"></span>                       </div>                       <!-- 控制按钮 -->                       <div class="absolute top-2 right-2 flex space-x-1">                         <button x-on:click="fullscreenVideo(index)"                                 class="bg-black bg-opacity-70 text-white p-1 rounded hover:bg-opacity-90">                           <i class="fas fa-expand text-xs"></i>                         </button>                         <button x-on:click="removeVideo(index)"                                 class="bg-red-500 bg-opacity-70 text-white p-1 rounded hover:bg-opacity-90">                           <i class="fas fa-times text-xs"></i>                         </button>                       </div>                     </div>                   </template>                   <template x-if="!activeVideos[index]">                     <div class="flex items-center justify-center h-full text-gray-400">                       <div class="text-center">                         <i class="fas fa-video text-4xl mb-2"></i>                         <p class="text-sm">空闲位置</p>                       </div>                     </div>                   </template>                 </div>               </template>             </div>           </div>         </div>       </div>     </div>   </div> </main> 

播放器实现

播放器我选择了 mpegts.js - https://github.com/xqq/mpegts.js

mpegts.js 是在 HTML5 上直接播放 MPEG2-TS 流的播放器,针对低延迟直播优化,可用于 DVB/ISDB 数字电视流或监控摄像头等的低延迟回放。

这是 B 站开源的 flv.js 的 fork 版本

B 站和国内其他大厂的尿性一样,管生不管养,flv.js 项目已经四年多没更新了,issues 一大堆也不处理,基本处于废弃状态。

估计这也是 B 站的一个 KPI 开源项目吧…

我一开始看到是 B 站开源的,以为会很好用,用 flv.js 来播放,结果根本没法播放,一看才知道 flv.js 只支持 H.264 编码

而现在摄像头很多都是 H.265 编码了…

WVP 项目的播放使用的是 Jessibuca 这个播放器

不过这个项目的文档比 WVP 还乱,让人根本没有想要使用的欲望…(虽说这个项目可能兼容性和性能都会好一些?)

而且因为用了 wasm,不能使用 npm 安装,集成也麻烦,我还是选择了纯 js 实现的方案。

安装也简单

pnpm i mpegts.js 

经过 gulp 配置后集成到静态文件里

<script src="{% static 'lib/mpegts.js/dist/mpegts.js' %}"></script> 

播放视频流的代码也比较简单

console.log('播放摄像头:', camera.name, 'GUID:', camera.guid);  // 获取摄像头直播地址 const url = window.API_URLS.cameraStreamUrl.replace('__camera_guid__', camera.guid); axios.get(url)   .then(res => {   if (res.data.success && res.data.data && res.data.data.stream_url) {     const streamUrl = res.data.data.stream_url.trim();     console.log('直播地址:', streamUrl);     this.addVideoToGrid(camera, streamUrl);   } else {     alert('获取直播地址失败:无效的响应数据');   } })   .catch(err => {   console.error('获取直播地址失败:', err);   alert('获取直播地址失败,请重试'); }); 

纯 Alpine.js 的树组件实现

使用 react/vue 时,应该有比较多可选的树组件

不过纯 js 的树组件就都是纯一坨,根本没有能用的!

在一番尝试之后,我决定使用 Alpine.js 自己写一个!

效果在前面的截图里也有了,可以实现树节点展开、实时搜索过滤,需要的功能都有,完美~

代码由于篇幅关系就不放了,有兴趣的同学可以在公众号后台回复「树组件」获取相关代码~

小结

OK,就这样了,完成了一篇工作内容的整理。

距离我开启新项目又近了一步!

发表评论

评论已关闭。

相关文章