前言
最近准备搞新项目了
这次应该不会咕咕咕了,我编写了完整的计划
如果按计划来的话,应该可以在一个月内搞定 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,就这样了,完成了一篇工作内容的整理。
距离我开启新项目又近了一步!