視頻中臺解決方案:組織樹組件+多路視頻直播界面開發(fā)
前言
最近準備搞新項目了
這次應(yīng)該不會咕咕咕了,我編寫了完整的計劃
如果按計劃來的話,應(yīng)該可以在一個月內(nèi)搞定 MVP 上線
不過在開始新項目之前,得把我之前的工作整理一下,輸出幾篇筆記記錄一下
在公眾號后臺回復(fù)「樹組件」可以獲取本文樹組件的相關(guān)代
介紹
這個項目是中臺里的一個子項目,視頻中臺
主要功能是管理各個項目的監(jiān)控設(shè)備、攝像頭,以及查看監(jiān)控直播
在此之前,我只用 SRS 部署了直播平臺,然后使用 RTMP 協(xié)議推流實現(xiàn)直播
但這種方式適合的場景更多是像 B 站、抖音這種直播平臺
對于視頻監(jiān)控,業(yè)內(nèi)有個更專業(yè)的方式:GB28181-2016 標準
也就是常說的 28181 協(xié)議
最終我們選擇的監(jiān)控后端是開源的 WVP (https://github.com/648540858/wvp-GB28181-pro)
初見這個項目主頁,一股濃濃的國產(chǎn)粗獷風格撲面而來,主打一個湊合看看得了,簡單的文檔后標明了收費內(nèi)容和付費咨詢渠道。
不過也就是這么個粗看其貌不揚的項目,卻意外的…能用?
總之就是這么個項目,支撐起幾千個設(shè)備的視頻監(jiān)控播放。
主要的設(shè)備就是海康、大華、宇視等品牌的 IPC、NVR,一開始我還 NVR(錄像機)和 IPC(攝像頭)拆分開,沒想到這系統(tǒng)里是不拆分的,我后面也發(fā)現(xiàn)了不拆分更好,一律按照設(shè)備來記錄,然后實際視頻流再按照通道來區(qū)分。
視頻中臺
視頻中臺這塊的技術(shù)方面其實不會很復(fù)雜
主要的工作量和復(fù)雜度還是在溝通、協(xié)調(diào)等流程方面,原因有以下幾點:
- 不單單是做這么一個系統(tǒng),還需要讓現(xiàn)場人員去配置攝像頭,讓管理人員錄入攝像頭信息
- 現(xiàn)場人員很多不會操作電腦,如何指導(dǎo)他們配置攝像頭(類似配置路由器)
- 存量設(shè)備和新增設(shè)備如何管理?
- 攝像頭和錄像機如何編碼?
- 編碼完成后如何讓現(xiàn)場人員知道哪個編碼對應(yīng)哪個設(shè)備?
- ……
這里只列舉一部分,實際運行的問題只會更多。
其實這些都還好,只要理清了整個流程,實施起來還是有可行性的。
但一旦涉及得人過多,沒有人負責推動,最終就會變成互相推諉,效率低下,一個月都不一定能完成一臺設(shè)備的接入。
OK,廢話太多了,說回正題,先來看看系統(tǒng)界面。
主頁
直接上截圖吧,這是視頻中臺的截圖(敏感信息和數(shù)據(jù)已經(jīng)全部用假數(shù)據(jù)代替,請放心查看)

從界面可以看出,核心功能就是管理視頻和播放視頻
視頻播放
PS: 敏感信息已打碼,請放心查看
視頻播放界面,就是本文要重點介紹的

可以切換 1 路、4 路、9 路、16 路播放,這里再截一個 16 路視頻的播放截圖吧,其他就不放了,相信聰明的讀者們能理解的 ??

技術(shù)實現(xiàn)
技術(shù)方面,我繼續(xù)發(fā)揚之前「Less is more」的思路: 返璞歸真!使用 Alpine.js 開發(fā)交互式 web 應(yīng)用,拋棄 node_modules 和 webpack 吧!
使用 Alpine.js + HTMX 來實現(xiàn)整個頁面
代碼
頁面布局
頁面布局使用 tailwindcss
交互使用剛才說的 Alpine.js
<main x-data="playApp()">
<div class="grid grid-cols-12 gap-4">
{# 左側(cè)組織/項目樹 #}
<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">組織架構(gòu)</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>
<!-- 搜索結(jié)果統(tǒng)計 -->
<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>
<!-- 樹形結(jié)構(gòu)容器 -->
<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>
<!-- 視頻播放網(wǎng)格 -->
<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>
播放器實現(xiàn)
播放器我選擇了 mpegts.js - https://github.com/xqq/mpegts.js
mpegts.js 是在 HTML5 上直接播放 MPEG2-TS 流的播放器,針對低延遲直播優(yōu)化,可用于 DVB/ISDB 數(shù)字電視流或監(jiān)控攝像頭等的低延遲回放。
這是 B 站開源的 flv.js 的 fork 版本
B 站和國內(nèi)其他大廠的尿性一樣,管生不管養(yǎng),flv.js 項目已經(jīng)四年多沒更新了,issues 一大堆也不處理,基本處于廢棄狀態(tài)。
估計這也是 B 站的一個 KPI 開源項目吧…
我一開始看到是 B 站開源的,以為會很好用,用 flv.js 來播放,結(jié)果根本沒法播放,一看才知道 flv.js 只支持 H.264 編碼
而現(xiàn)在攝像頭很多都是 H.265 編碼了…
WVP 項目的播放使用的是 Jessibuca 這個播放器
不過這個項目的文檔比 WVP 還亂,讓人根本沒有想要使用的欲望…(雖說這個項目可能兼容性和性能都會好一些?)
而且因為用了 wasm,不能使用 npm 安裝,集成也麻煩,我還是選擇了純 js 實現(xiàn)的方案。
安裝也簡單
pnpm i mpegts.js
經(jīng)過 gulp 配置后集成到靜態(tài)文件里
<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('獲取直播地址失敗:無效的響應(yīng)數(shù)據(jù)');
}
})
.catch(err => {
console.error('獲取直播地址失敗:', err);
alert('獲取直播地址失敗,請重試');
});
純 Alpine.js 的樹組件實現(xiàn)
使用 react/vue 時,應(yīng)該有比較多可選的樹組件
不過純 js 的樹組件就都是純一坨,根本沒有能用的!
在一番嘗試之后,我決定使用 Alpine.js 自己寫一個!
效果在前面的截圖里也有了,可以實現(xiàn)樹節(jié)點展開、實時搜索過濾,需要的功能都有,完美~
代碼由于篇幅關(guān)系就不放了,有興趣的同學可以在公眾號后臺回復(fù)「樹組件」獲取相關(guān)代碼~
小結(jié)
OK,就這樣了,完成了一篇工作內(nèi)容的整理。
距離我開啟新項目又近了一步!

浙公網(wǎng)安備 33010602011771號