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

前言

最近准备搞新项目了

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

如果按计划来的话,应该可以在一个月内搞定 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,就这样了,完成了一篇工作内容的整理。

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

相关推荐
guojikun13 天前
一键配置 Web 前端开发环境(PowerShell 自动化脚本)
windows·web前端·powershell
程序设计实验室2 个月前
在Next.js中集成swagger文档
web前端·next.js
丁同亚的博客2 个月前
echarts大屏项目指南
echarts·可视化·js·web前端·大屏
程序设计实验室2 个月前
主流 nodejs 包管理器 pnpm vs bun vs npm vs yarn 简单横评
web前端
牧码岛5 个月前
Web前端之隐藏元素方式的区别、Vue循环标签的时候在同一标签上隐藏元素的解决办法、hidden、display、visibility
前端·css·vue·html·web·web前端
牧码岛7 个月前
Web前端之Vue+Element实现表格动态不同列合并多行、localeCompare、forEach、table、push、sort、Map
前端·javascript·elementui·vue·web·web前端
little_kid_pea9 个月前
网站上的图片无法使用右键“图片另存为”
web前端·图片下载
程序设计实验室1 年前
StarBlog博客Vue前端开发笔记:(4)使用FontAwesome图标库
web前端·starblog-vue
程序设计实验室1 年前
StarBlog博客Vue前端开发笔记:(3)SASS与SCSS
web前端·starblog-vue