基于 WebRTC 的无人机实时直播流接入实践
背景
项目需要在浏览器端直接播放无人机摄像头的实时画面,延迟要求尽可能低。最终选型走了 WebRTC 协议,流媒体服务端用的 ZLMediaKit。整套链路跑下来踩了不少坑,这里把核心实现和细节整理出来。
整体链路
信令交互没有走 WebSocket,而是用了一次简单的 HTTP 请求完成 SDP 交换:
核心实现
创建 PeerConnection 并声明媒体需求
拿到播放地址后,先关掉上一次的连接,再创建新的 RTCPeerConnection 实例。这里
iceServers传了空数组,因为无人机直播场景下浏览器和流媒体服务器在同一内网,不需要 STUN / TURN 做穿透。tsconst pc = new RTCPeerConnection({ iceServers: [] }) livePC = pc // 只接收,不发送 pc.addTransceiver('video', { direction: 'recvonly' }) pc.addTransceiver('audio', { direction: 'recvonly' })用
addTransceiver而不是addTrack,因为浏览器端是纯接收方,不需要往回推流。direction: 'recvonly'明确告诉对端:我只要收。监听媒体轨道
RTCPeerConnection 的ontrack回调在 ICE 协商完成、媒体流到达时触发。这里拿到streams[0]直接塞给<video>元素:tspc.ontrack = (ev: RTCTrackEvent) => { if (videoRef.value && ev.streams && ev.streams[0]) { videoRef.value.srcObject = ev.streams[0] const playPromise = videoRef.value.play() if (playPromise !== undefined) { playPromise .then(() => { liveStatus.value = '直播中' }) .catch((error) => { console.warn('[WebRTC] 自动播放失败:', error) liveStatus.value = '播放失败' }) } } }play()返回 Promise,浏览器自动播放策略可能拦截,所以必须catch。实际部署时给<video>加了muted属性来规避限制。SDP 交换:
Offer → HTTP POST → Answer这是整条链路里最关键的一步。等 ICE 候选收集完成(或超时 1 秒兜底),把本地 SDP 通过 HTTP POST 发给 ZLMediaKit:
tspc.createOffer() .then((offer) => pc.setLocalDescription(offer)) .then(() => { // 等 ICE 收集完成,超时 1s 兜底 return new Promise<void>((resolve) => { if (pc.iceGatheringState === 'complete') { resolve() } else { pc.onicegatheringstatechange = () => { if (pc.iceGatheringState === 'complete') resolve() } setTimeout(resolve, 1000) } }) }) .then(() => { return fetch(playUrl, { method: 'POST', headers: { 'Content-Type': 'application/sdp', Accept: 'application/sdp', }, body: pc.localDescription?.sdp, }) }) .then((resp) => { if (!resp.ok) throw new Error('HTTP ' + resp.status) return resp.text() }) .then((sdp) => { const answerDesc = new RTCSessionDescription({ type: 'answer', sdp }) return pc.setRemoteDescription(answerDesc) })几个细节:
- ZLMediaKit 的 WebRTC 播放接口要求
Content-Type为application/sdp,不是application/json。 - ICE 收集加了 1 秒超时。内网环境下候选收集通常很快,但偶尔会卡在
gathering状态,不兜底的话整个链路就挂住了。 - 返回的
Answer是纯文本,直接resp.text()读取,不需要JSON解析。
- ZLMediaKit 的 WebRTC 播放接口要求
连接状态监听
iceConnectionState的变化用来驱动 UI 上的状态文案:tspc.oniceconnectionstatechange = () => { if (['disconnected', 'failed', 'closed'].includes(pc.iceConnectionState)) { liveStatus.value = '连接断开' } else if (['connected', 'completed'].includes(pc.iceConnectionState)) { liveStatus.value = '直播中' } }资源清理
组件卸载时必须关闭 RTCPeerConnection 并清空
srcObject,否则连接会一直占着,浏览器控制台能看到大量CLOSED状态的残留连接:tsconst closeWebRTC = () => { if (livePC) { livePC.close() livePC = null } if (videoRef.value) { videoRef.value.srcObject = null } liveStatus.value = '未连接' } onUnmounted(() => { closeOldConnect() // 关 WebSocket closeWebRTC() // 关 WebRTC })
模板侧
<video> 标签保留了原生 controls,音量、全屏这些不用自己写:
<div v-if="playUrl" class="w-40% relative bg-black">
<video
ref="videoRef"
class="w-full h-full object-contain"
autoplay
playsinline
muted
controls
></video>
<div
class="absolute top-2 left-2 bg-black/60 text-white text-xs px-2 py-1 rounded"
>
{{ liveStatus }}
</div>
</div>v-if="playUrl" 确保只有拿到播放地址后才渲染视频容器,避免空 DOM 节点。
踩坑记录
| 问题 | 原因 | 处理 |
|---|---|---|
| 画面出不来,控制台无报错 | iceServers 配了公网 STUN 但内网不通 | 改为空数组 |
play() 被浏览器拦截 | 自动播放策略要求静音 | <video> 加 muted |
| 切换设备后旧画面残留 | 没有 close() 旧的 PeerConnection | 切换前先调 closeWebRTC() |
Answer 设置报错 InvalidStateError | Offer 还没 setLocalDescription 就发了请求 | 严格按 createOffer → setLocal → POST → setRemote 顺序走 |
| 全屏状态在 Vue 里不响应 | document.fullscreenElement 不是响应式的 | 监听 fullscreenchange 事件手动同步 |