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

前言

最近准备搞新项目了

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

如果按计划来的话,应该可以在一个月内搞定 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

html 复制代码
<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 实现的方案。

安装也简单

bash 复制代码
pnpm i mpegts.js

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

html 复制代码
<script src="{% static 'lib/mpegts.js/dist/mpegts.js' %}"></script>

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

javascript 复制代码
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,就这样了,完成了一篇工作内容的整理。

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

相关推荐
牧码岛1 个月前
Web前端之隐藏元素方式的区别、Vue循环标签的时候在同一标签上隐藏元素的解决办法、hidden、display、visibility
前端·css·vue·html·web·web前端
牧码岛3 个月前
Web前端之Vue+Element实现表格动态不同列合并多行、localeCompare、forEach、table、push、sort、Map
前端·javascript·elementui·vue·web·web前端
little_kid_pea5 个月前
网站上的图片无法使用右键“图片另存为”
web前端·图片下载
程序设计实验室7 个月前
StarBlog博客Vue前端开发笔记:(4)使用FontAwesome图标库
web前端·starblog-vue
程序设计实验室7 个月前
StarBlog博客Vue前端开发笔记:(3)SASS与SCSS
web前端·starblog-vue
程序设计实验室7 个月前
StarBlog博客Vue前端开发笔记:(2)页面路由
web前端·starblog-vue
程序设计实验室7 个月前
StarBlog博客Vue前端开发笔记:(1)准备篇
web前端·starblog-vue
.NET快速开发框架7 个月前
一文搞懂flex(弹性盒布局)
c#·.netcore·web前端·开发技术·rdif·rdiframework.net
程序设计实验室7 个月前
返璞归真!使用 alpinejs 开发交互式 web 应用,抛弃 node_modules 和 webpack 吧!
web前端