RTSP转WebRTC:浏览器低延迟播放监控视频流实战
简介:面向前端与音视频开发者的RTSP网页播放实现资源,聚焦浏览器无法直接播放RTSP流的典型难题,借助Streamedian等工具完成协议转换与前端播放适配。资源涵盖流媒体代理配置思路、HTML5播放页面示例、样式文件及核心脚本,并包含H.265格式的解码播放支持,可作为搭建网页实时视频监控、在线课堂等场景的参考起点。压缩包共14个文件,以JavaScript脚本、HTML页面、CSS样式和XML配置为主,整体约403KB,结构清晰,便于按需查看和改动。目前已有6845人学习下载,适合具备一定前端基础、希望快速理解RTSP到浏览器可播格式转换流程的开发者。
1. 为什么浏览器不能直接播 RTSP,问题到底卡在哪
做过视频接入的人基本都撞过这堵墙:后端从大华或者海康的 NVR 里拿 RTSP 拉流地址,丢给前端去播放,结果 Chrome、Edge、Firefox 全都不认这个协议。浏览器地址栏里直接输 rtsp:// 开头的地址,页面只会提示找不到应用。这不是前端代码写得不对,而是浏览器底层压根没有实现 RTSP 的解复用和传输逻辑。RTSP 走的是 TCP/UDP 上的实时流控制协议,而浏览器只认 HTTP(S) 和 WebSocket,再加上 H.264、H.265 这些编码裸流需要封装成 fMP4 或 FLV 之后才能交给 MSE(Media Source Extensions)去喂给 video 标签。所以要在网页里播放 RTSP 流,必须有一条明确的转封装和传输链路:把 RTSP 拉下来,转成浏览器能吃的协议,再推到页面上。目前线上用得最多的方案是 RTSP 转 WebRTC,其次是 RTSP 转 FLV 走 WebSocket 播放。这篇文就把整个链路拆开,从服务端选型到前端 SDK 接入,把 RTSP 流在网页端的完整实现在本地跑通。
2. 协议选型与网关部署:RTSP 转 WebRTC 还是 RTSP 转 FLV
2.1 为什么优先考虑 WebRTC,而不是把 RTSP 直接推到 video 标签
浏览器播放视频流有三条常规路线: <video> 标签直接播、MSE 加 fMP4、WebRTC。 <video> 标签能播的是 HTTP 渐进式文件或者 HLS 分片,RTSP 不在支持列表里。MSE 这条路需要把 RTSP 转成 fMP4 或 FLV,再通过 WebSocket 推给前端,延迟通常在 1 到 3 秒之间,这个量级在监控预览场景能接受,但要用来做远程摇操或者双向对讲就不太够看了。WebRTC 是 UDP 传输,SRTP 加密,配合优化的 JitterBuffer,端到端延迟可以压到 200 到 500 毫秒。实际部署时,多数团队选的方案是:服务端用 ZLMediaKit 或 MediaMTX 做 RTSP 拉流代理,对外提供 WebRTC 的 WHEP 接口,前端拿到 SDP 之后直接 PeerConnection 播放。这里面要注意一个容易踩的坑:公网环境下 WebRTC 需要走 STUN 打洞,如果设备在 NAT 后面,必须自己部署 TURN 服务做媒体中继,否则纯内网测试是通的,一到公网就黑屏。
2.2 服务端网关选型对比:ZLMediaKit 与 MediaMTX
我在本地搭 RTSP 转流服务器,一般直接上 Docker,省去编译依赖的麻烦。两个常见的开源方案:
| 特性 | ZLMediaKit | MediaMTX |
|---|---|---|
| 协议支持 | RTSP、RTMP、HLS、WebRTC、GB28181 | RTSP、RTMP、HLS、WebRTC、SRT |
| 转 WebRTC | 支持,走 WHEP 拉流 | 支持,走 WHEP 拉流 |
| H.265 支持 | WebRTC 下需 TURN 或特定端到端协商 | 部分浏览器不支持 H.265 WebRTC 解码 |
| 配置复杂度 | 中,需要理解配置文件结构 | 低,单 YAML 文件 |
| 社区活跃度 | 高,国内安防行业常用 | 高,流媒体社区常用 |
对我来说,如果只做 RTSP 转网页播放这一个场景,MediaMTX 更轻,一个二进制文件加一个 YAML 配置就能跑起来。但要接大华 NVR 的多路取流,或者未来要接 GB28181 国标平台,ZLMediaKit 更稳,它的并发处理能力和断流重连机制比 MediaMTX 更成熟。
2.3 拉流网关配置与验证
我用 MediaMTX 举例,因为它的配置最短,适合先跑通链路。默认配置文件里有一段是 rtsp: path: 配置,把远端 RTSP 地址映射到本地 HTTP 端口上:
docker run --rm -it -p 8554:8554 -p 8888:8888 -p 8889:8889 \
-v $(pwd)/mediamtx.yml:/mediamtx.yml \
bluenviron/mediamtx:latest
配置文件里关键的参数是 rtspAddress: :8554 ,表示本地 RTSP 服务端口; hlsEnabled: false ,用不到 HLS 就关掉; webrtcEnabled: true ,开启 WebRTC 出口。拉取一个 RTSP 源是这么写的:
paths:
cam1:
source: rtsp://admin:password@192.168.1.64:554/Streaming/Channels/101
source 字段直接写了海康或大华 NVR 的取流地址。 /Streaming/Channels/101 是大华的主码流路径,如果希望降低带宽,可以换成子码流路径 /Streaming/Channels/102 。这里要注意,部分大华 NVR 的 RTSP 地址带 ? 查询参数,比如 rtsp://admin:pass@ip:554/cam/realmonitor?channel=1&subtype=0 ,YAML 里必须给 source 值加引号,否则 ? 会被解析成特殊字符导致取流失败。
配置写完后重启容器,检查服务是否启动成功:
curl http://localhost:8888/streams
这个接口会返回当前流列表。看到 cam1 的状态是 ready ,说明拉流成功。此时浏览器打开 http://localhost:8888/cam1 ,MediaMTX 自带一个简易播放器,能直接通过 WebRTC 看到视频画面。这一步通了,后面前端的工作就是写一个通用的 WebRTC 播放器去接这个地址。
3. 前端 WebRTC 接入实现:把 SDP 玩明白
3.1 WHEP 拉流协议的协商过程
MediaMTX 对外暴露的 WebRTC 拉流接口遵循 WHEP 协议。前端做的事本质上只有三步:请求一个 SDP offer、把远端 answer 塞给 PeerConnection、处理候选地址。流程很清晰:先 GET 请求 http://localhost:8888/cam1/whep ,拿到一个 application/sdp 的 offer 描述;然后 new RTCPeerConnection() ,把 offer 设置成 setRemoteDescription ;接着 createAnswer() 生成自己的 answer,POST 回同一个地址;最后等 ontrack 事件触发,把流挂到 <video> 上。
3.2 完整播放器代码
下面这段代码是我在项目里直接用的简化版本,处理了 WebRTC 连接创建、SDP 交换和错误重连:
class RtspWebRtcPlayer {
constructor(videoEl, whepUrl) {
this.video = videoEl;
this.whepUrl = whepUrl;
this.pc = null;
this.reconnectTimer = null;
}
async play() {
try {
// 1. 创建 RTCPeerConnection,配置 STUN 服务器
this.pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
// 2. 收到远端媒体轨后,直接交给 video 元素播放
this.pc.ontrack = (event) => {
this.video.srcObject = event.streams[0];
this.video.play();
};
// 3. 请求 WHEP 拉流地址,拿到 SDP offer
const resp = await fetch(this.whepUrl);
if (!resp.ok) throw new Error(`WHEP request failed: ${resp.status}`);
const offerSdp = await resp.text();
// 4. 把远端 offer 设置为 remoteDescription,并生成本地 answer
await this.pc.setRemoteDescription({ type: 'offer', sdp: offerSdp });
const answer = await this.pc.createAnswer();
await this.pc.setLocalDescription(answer);
// 5. 把本地 answer 回传给 WHEP 服务,完成协商
await fetch(this.whepUrl + '/answer', {
method: 'POST',
headers: { 'Content-Type': 'application/sdp' },
body: answer.sdp
});
// 6. 监听连接断开,触发自动重连
this.pc.onconnectionstatechange = () => {
if (this.pc.connectionState === 'failed' ||
this.pc.connectionState === 'disconnected') {
this.reconnect();
}
};
} catch (err) {
console.error('WebRTC play failed:', err);
this.reconnect();
}
}
reconnect() {
if (this.reconnectTimer) return;
this.reconnectTimer = setTimeout(async () => {
this.reconnectTimer = null;
this.pc && this.pc.close();
await this.play();
}, 3000);
}
stop() {
clearTimeout(this.reconnectTimer);
this.pc && this.pc.close();
}
}
代码里有几个关键点需要交代清楚。 setRemoteDescription 传入的 type 是 offer ,因为 MediaMTX 的 WHEP 端点是服务端主动生成 offer,和标准 WebRTC 里面 caller 发 offer 的方向相反。 fetch 拿到的 SDP 文本直接作为字符串传给 setRemoteDescription ,中间不要做任何字符串截取。 ontrack 事件里 event.streams[0] 拿到的是 MediaStream 对象,直接把 srcObject 指过去即可,不需要再调 createObjectURL ,那已经是废弃的写法了。 /answer 端点返回的响应不需要处理,服务端收完 answer 后媒体流会自动开始传输,大概几百毫秒后黑屏就会出画面。这里有一个比较常见的坑: pc.createAnswer() 必须在 setRemoteDescription 完成之后调用,否则生成的 SDP 里缺少远端 codec 协商信息,浏览器会报 InvalidStateError 。
3.3 页面接入与生命周期管理
页面上的调用方式很直接,创建一个 video 标签, new RtspWebRtcPlayer() 之后调 play() 即可。需要注意组件卸载或者页面切换时要调 stop() 释放连接,否则 PeerConnection 一直挂着,视频标签会被占用。实际项目里,如果同一页面要播放多个摄像头画面,建议每个 camera 实例都持有独立的 RtspWebRtcPlayer 对象,不要共用 PeerConnection,这样单个画面卡顿或者断开时不会互相影响。
4. 延迟优化与画质调优:GOP、缓冲与编码参数
4.1 编码参数是延迟的地基
WebRTC 播放延迟高,很多时候不是播放器的问题,而是摄像头编码端参数没调好。摄像头输出 H.264 编码流时,有两个参数直接决定 WebRTC 首帧延迟和卡顿程度。第一个是 I 帧间隔(GOP 大小),也就是每隔多少帧插入一个关键帧。WebRTC 从关键帧开始解码,GOP 要是 100 帧,播放器加入直播时最坏情况要等 100 帧的时间才能解出画面,按 25fps 算就是 4 秒。第二个是码率控制模式,VBR 在画面变化剧烈时码率会突然拉高,而 WebRTC 的拥塞控制会直接丢帧降码率,导致画面模糊。建议把摄像头编码模式改成 CBR,固定码率,这样码率波动小,WebRTC 接收端的 JitterBuffer 不会频繁因为积压数据而加大缓冲。
4.2 帧率与分辨率的取舍
如果 WebRTC 播放卡顿,先看摄像头推的是不是 4K 或者 3MP 的高分辨率流。浏览器端 WebRTC 解码 1080p 的 H.264 基本没有压力,但 4K 在部分集成显卡上会掉帧。而且分辨率高意味着码率大,公网传输时丢包率明显上升,重传机制会导致延迟增大。监控预览场景通常主码流给本地录像,网页端看子码流就够了。大华摄像头子码流默认是 Channel/102 ,海康是 /ch1/sub/av_stream ,分辨率一般是 704×576 或者 1280×720,拉这个流对服务器带宽和前端解码压力的影响小很多。下面是两个典型场景的参数参考:
| 场景 | 分辨率 | 帧率 | 码率 | GOP |
|---|---|---|---|---|
| 公网远程监控 | 1280x720 | 15fps | 1.5 Mbps | 30 |
| 内网摇操控制 | 1920x1080 | 25fps | 4 Mbps | 50 |
4.3 服务端缓冲参数调整
MediaMTX 的 WebRTC 播放延迟受 writeQueueSize 参数影响。这个参数控制发送队列里最多缓存多少个 RTSP 包,默认值设得偏大,是为了在弱网下避免花屏,但本地内网拉流时会把延迟推到 1 秒以上。改成 16 到 32 之间,延迟能明显降下来:
rtsp:
writeQueueSize: 16
writeQueueSize: 16 的含义是发送队列最多缓存 16 个 RTP 包,超出就丢掉。内网环境丢包率很低,这个值设小一点不会导致画面损坏,因为 WebRTC 的 NACK 重传机制会在接收端把丢掉的包补回来。公网环境建议保守一点,设置成 64 或者 128,否则网络抖动时画面会频繁卡顿,虽然延迟大了,但起码看得清内容。
4.4 视频编码与音频轨处理
RTSP 源如果同时带 G.711 或 AAC 音频轨,WebRTC 协商时会同时传输音视频,前端 ontrack 事件里需要判断 track.kind 来决定绑定到哪个元素。很多摄像头默认开启音频,如果网页端不需要听声音,最简单的方式是在配置里只拉视频轨。MediaMTX 支持按需禁用音频,配置 path 下面加一行 sourceProtocol: udp 不够,正确的写法是指定拉流参数:
paths:
cam1:
source: rtsp://admin:pass@192.168.1.64:554/Streaming/Channels/101
sourceOnDemand: yes
runOnDemand: ffmpeg -rtsp_transport tcp -i $RTSP_URL -an -f mpegts $MTX_OUT
-an 参数表示丢弃音频轨,只转视频。 rtsp_transport tcp 的作用是强制走 TCP 传输,避免 UDP 拉流被防火墙拦或者因为丢包导致花屏。这里有个容易忽略的细节: runOnDemand 模式下源地址 source 字段不再直接取流,而是交给 ffmpeg 命令处理, $RTSP_URL 是 MediaMTX 自动注入的环境变量。如果摄像头不强制要求 TCP,可以直接用 source 字段,它会自动协商传输方式。
5. 多路并发与长时间断流重连:128 路不是简单加机器
5.1 并发播放时资源释放机制
监控平台的痛点从来不是跑通一路流,而是多路流的稳定性和资源占用。每路 WebRTC 播放会建立一个 PeerConnection,服务器端对应一路 UDP 媒体发送线程。如果前端 10 个页面同时开着摄像头画面,浏览器本身要维护 10 个 PeerConnection,每一个都持有独立的解码器实例,GPU 和内存占用会很明显。这里有一个常被忽略的优化点:当摄像头画面切到后台 Tab 或者被关闭时,页面要主动调用 pc.close() 释放连接,并且把 video.srcObject 置空,否则浏览器不会自动回收 PeerConnection,摄像头侧也会持续占用编码资源。
5.2 摄像头端最大连接数与网关拉流限制
大华和海康的普通 IPC 通常限制最大同时取流路数为 2 到 6 路,超过这个数字会直接拒绝新的 RTSP 连接。NVR 的限制会更高一些,通常在 32 到 64 路之间。这意味着前端 10 个用户同时看同一路摄像头,不能直接把 RTSP 地址发给浏览器去拉,因为摄像头扛不住。正确的做法是服务端只保留一条 RTSP 拉流,网关内部做流复制,多个用户同时看的是同一条上行流的副本,把对摄像头的压力降到最低。MediaMTX 默认支持这种模式,多个 WHEP 请求同一个 path 时,不会重复向摄像头拉流,而是复用现有的那条连接,这个行为可以在日志里验证:只有第一次拉流会有 READY 记录,后续并发请求都是直接复用。
5.3 断流重连的边界情况
长时间运行时摄像头容易因为网络闪断、NVR 重启等原因断开 RTSP 连接。MediaMTX 的重连机制默认是每隔几秒重新尝试拉流,一旦源恢复会自动出画面,前端 Play 的 WebRTC 连接不需要重建。但有个反直觉的细节:摄像头重新上线后,SDP 协商里可能重新协商了编码器或者分辨率,原有的 PeerConnection 不一定能正常接收新格式的码流。我在实际项目里遇到过 H.264 转 H.265 的情况,画面黑屏但连接状态正常。这种情况下前端必须在检查到持续 5 秒没有视频帧时强制重建 PeerConnection,而不是傻等 onconnectionstatechange 触发,因为这个状态变化在纯媒体层面判断不到。判断方式是在 ontrack 之后开一个定时器,每 5 秒检查 video.currentTime 是否变化,没变化就重建连接。
5.4 服务端并发上限配置
MediaMTX 的 WebRTC 并发连接数受 webrtcPortMin 和 webrtcPortMax 范围的限制,每个 PeerConnection 会占用一个 UDP 端口。默认范围通常是 8000 到 9000,也就是最多同时 1000 路 WebRTC 会话。单台服务器扛不住的话,要么横向扩容,要么在前端限制同时打开的摄像头预览画面数。比较实用的做法是限制页面同时最大预览路数为 16,超出部分排队,等前面的关闭后再打开新的。毕竟监控场景下一个人同时看 16 路已经超出了注意力覆盖范围,限制并发对实际使用体验几乎无感知,但服务端压力少很多。
5.5 公网部署时的 TURN 服务配置
RTSP 源在办公室内网,网站部署在云服务器上,用户在公网访问时 WebRTC 媒体流大概率打洞失败,因为两边都在对称 NAT 后面。这种情况下必须在 PeerConnection 的 iceServers 里配置 TURN 服务器。coturn 是主流开源 TURN 服务,部署命令大概是:
coturn -n --lt-cred-mech \
--realm=example.com \
--user=username:password \
--listening-port=3478 \
--fingerprint --no-cli
--lt-cred-mech 是长期凭证机制, --user=username:password 设置 TURN 认证账号密码。前端配置方法:
this.pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.example.com:3478' },
{ urls: 'turn:turn.example.com:3478', username: 'username', credential: 'password' }
]
});
这里有个细节需要提醒:TURN 服务器的 3478 端口同时监听了 UDP 和 TCP,但媒体中继一般走 UDP,如果云服务器安全组只开了 TCP 端口,媒体流会一直处于 connecting 状态。验证方法是在浏览器 Console 里看 pc.iceConnectionState 的状态,如果长时间卡在 checking ,大概率是 UDP 端口没放通。还要注意浏览器对混合内容的安全策略:HTTPS 页面下,WebRTC 只能连接 wss 或者 TURN/TLS,所以线上环境要么给页面开 HTTPS,要么给 TURN 服务加 TLS 证书,否则会报 IceTransportsDisabled 这类安全错误。
6. 一张图验证链路与性能定位
6.1 用浏览器原生工具定位是哪一段出了问题
在实际排查 RTSP 网页播放问题时,最常遇到的现象是黑屏、无限播放加载中、卡顿几秒后恢复。定位思路是把链路拆成三段:源——网关——前端拉流。第一步在服务器上检查 MediaMTX 日志,如果日志里有 rtsp: incoming stream closed 或者 republish error ,说明网关和摄像头之间断了,问题在源。第二步请求网关的 /streams 接口确认流是否 ready,如果 ready 但前端仍然黑屏,打开 Chrome 的 chrome://webrtc-internals 页面看 WebRTC 的收发统计。重点关注 framesDecoded 是否在增长,如果不增长说明数据根本没到达解码器,要么是 SDP 协商阶段出问题,要么是 TURN 中继没配好。如果 framesDecoded 在涨但是画面黑,那基本就是编码格式不支持,比如摄像头推了 H.265 而当前浏览器不支持 WebRTC 解码 HEVC。
6.2 拉流时段统计与播放质量指标
做一个简单的前端指标统计,每 10 秒采集一次 video 的播放状态:
setInterval(() => {
console.log(JSON.stringify({
currentTime: video.currentTime,
buffered: video.buffered.end(video.buffered.length - 1),
readyState: video.readyState,
networkState: video.networkState,
paused: video.paused,
width: video.videoWidth,
height: video.videoHeight
}));
}, 10000);
readyState 等于 4 表示 HAVE_ENOUGH_DATA ,播放器缓冲足够,可以正常播;长期处于 2 说明数据源不足,等待中。 buffered.end() 和 currentTime 的差值就是当前的缓冲深度,这个值在 WebRTC 场景下应该接近 0,因为 WebRTC 不做大于 500ms 的缓冲,如果差值明显超过这个数,说明浏览器端的 MSE 路径正在被使用,而不是 WebRTC,排查方向就要转向是不是服务端实际返回了 HLS 或 FLV 流。
6.3 最后一招:强制 TCP 拉流排查 UDP 端口受限
如果摄像头在跨网段或者有防火墙限制的环境下,UDP 拉流经常失败,表现出来就是网关日志里有 udp timeout 。这种情况下在 MediaMTX 的源地址前加 rtsp_transport=tcp 参数强制走 TCP。大华 NVR 的具体写法是:
rtsp://admin:password@192.168.1.64:554/Streaming/Channels/101?transport_mode=2
transport_mode=2 是 TCP 模式, transport_mode=1 是 UDP 模式。海康则是通过 URL 里的 tcp 关键字控制:
rtsp://admin:password@192.168.1.64:554/Streaming/Channels/101?tcp
修改后重启容器,观察日志确认协商成功。TCP 拉流带宽利用率低于 UDP,但内网传输没有太大感知差异,稳定性的收益明显高于性能损耗。这一步做完,整个 RTSP 网页播放链路从源到浏览器就全部打通了,剩下的就是按实际场景微调延迟和画质参数。
更多推荐

所有评论(0)