简介:针对RTSP与WebRTC协议间的转换需求,这套资源提供了一站式解决思路,面向低延迟实时视频通信的开发者,可应用于视频会议、在线直播与安防监控等场景。核心是基于webrtc-streamer的预编译程序,解决了浏览器无法直接拉取RTSP码流的问题,无需安装任何插件即可在网页内观看实时画面。压缩包共94个文件,以JavaScript和HTML为主,覆盖信令处理、播放器集成、示例页面与调试页面,另含wasm编解码扩展、CSS样式、图标图片及完整配置文件,整体体积仅为8.77MB,方便快速下载与验证。包内自带Windows AMD64位可执行程序,可对RTSP输入完成H.264到VP8/VP9的视频转码与音频适配,配合前端API实现SDP协商、连接建立和媒体渲染;同时提供Janus、Jitsi等信令网关的接入示例,便于架构选型。已有523人学习浏览,适合具备基础Web开发能力或运维经验的工程师,借助这份完整可运行的方案,快速搭建低延迟实时视频播放系统,免去从零构建信令与转码链路的繁琐。
1. RTSP 转 WebRTC:监控视频上网页的这条路,为什么非走不可
RTSP 转 WebRTC 这个问题,十有八九是从监控摄像头引出来的。某天你打开浏览器想直接看摄像头的实时画面,发现<video>标签对 RTSP 束手无策,于是开始找中间层。市面上能搜到的方案不少,但真正能在公网环境跑稳的没几个:延迟 3 秒以上的有,只在局域网有效的也有,还有的把流拉下来转成 HLS 再丢给播放器——画面出来了,实时性没了。
这篇要讲的是我在实际项目里反复用过的链路:媒体网关从摄像头拉取 RTSP,内部完成协议转换,浏览器端通过 RTCPeerConnection 做信令协商后直接播放。媒体面默认走 UDP,端到端延迟通常在 300 到 800 毫秒这个量级,适合安防监控、远程看板,以及需要「秒开」的生产场景。
适合谁来读:被摄像头播放折磨过的前端,需要把网络摄像机的 RTSP 流接入 Web 页面的后端开发,以及想弄懂 WebRTC 信令到底在做什么的入门者。下面从选型理由讲起,一路到网关部署、前端播放、参数调整和踩坑记录,关键命令和代码都能直接照抄。
2. 先定方案再动手:RTSP 到 WebRTC 的路径对比与选型理由
2.1 浏览器为什么吃不掉 RTSP:协议层面的硬限制
RTSP(Real Time Streaming Protocol)本质上是一个控制协议,负责 DESCRIBE、SETUP、PLAY、TEARDOWN 这类会话动作;真正的媒体数据走的是 RTP,通过 UDP 或 TCP 单独传输。浏览器里的<video>标签和 MSE(Media Source Extensions)能处理的输入非常有限:MP4、WebM、HLS 切片,最多再加一个 MPEG-TS。要让浏览器直接解析 RTSP 会话拿到 RTP 包,还得自己处理 SDP、时间戳、RTP 序号乱序和丢包重传——这不是「少一个解码器」的问题,是整个传输模型都和浏览器媒体管线对不上。
所以业界没人费劲往浏览器里塞 RTSP 客户端,而是默契地在中间加一层媒体网关。网关把自己伪装成 RTSP 客户端去连摄像头,拿到流之后重新封装成浏览器和 WebRTC 能协商出来的格式。这层「中间层」就是整条链路的核心,后端几乎全部工作都集中在这里。理解了这一层,后面部署网关和排查问题时就不会犯方向性错误。
2.2 延迟对比:HLS、HTTP-FLV 与 WebRTC 的量化差异
给网关选型之前,先看为什么最终选 WebRTC,而不是更常见的 HLS 或 HTTP-FLV。三者的差异用一张表可以看得很清楚:
| 方案 | 端到端延迟 | 浏览器兼容性 | 首帧等待 | 适合场景 |
|---|---|---|---|---|
| HLS(m3u8 切片) | 5~15 秒 | 全平台原生 | 慢,等第一个切片 | 回看、点播 |
| HTTP-FLV + FLV 播放组件 | 2~5 秒 | 需 MSE 支持 | 中等 | 直播、延迟要求不苛刻 |
| WebRTC | 0.3~1 秒 | Chrome / Firefox / Safari 原生 | 快,等关键帧即可 | 监控、实时互动 |
HLS 的切片通常按 2 到 6 秒一个生成,播放器必须等当前切片完全下载才起播,延迟 5 秒以上是常态。HTTP-FLV 稍好一点,但播放组件要把 FLV 标签逐条解析后喂给 MSE,缓冲区和 seek 逻辑天然多带来 1 到 3 秒延迟。而 WebRTC 的媒体面基于 RTP/SRTP,接收端有独立的抖动缓冲和 NACK 重传机制,丢包不会直接卡死画面,延迟却能压到一秒级以内。对监控这种「晚一秒可能就错过一个事件」的场景,这个差距是决定性的。
还有一层现实因素:WebRTC 是浏览器原生能力,用户不需要装插件,也不依赖某个长期维护的开源播放器。协议自带的 ICE 穿透机制,能省掉大量内网到公网部署的适配工作。所以结论很直接:监控实时预览,优先走 WebRTC;HLS 留给回放和点播。
2.3 直通转封装还是转码:判断标准只有一条
确定走 WebRTC 之后,网关内部还有两条子路线:直通转封装和转码。直通的意思是网关不碰帧内容,只做 RTP 会话层面的搬运和封装调整,把 RTSP 拉到的 H.264 帧重新按 WebRTC 协商好的 Payload Type 打包,走 SRTP 发出去。这条路线 CPU 开销可以忽略,延迟也最低,前提是摄像头的编码格式浏览器能直接解。
浏览器 WebRTC 能解的编码集是明确的:视频侧支持 H.264(另有 VP8/VP9,要看浏览器实现),音频强制要求 Opus,向前兼容 G.711。摄像头这边,视频大多数是 H.264 或 H.265,音频常见 AAC 或 G.711。所以判断标准只有一条:视频是不是 H.264,音频是不是 Opus 或 G.711。是,就走直通;不是,就走转码——用 FFmpeg 把 H.265 转成 H.264、AAC 转成 Opus,代价是 CPU 开销明显上升、延迟增加几十毫秒。
我一般会在接入每一路摄像头之前,先用ffprobe -show_streams rtsp://用户名:密码@IP/stream1把编码参数打出来看一遍,再决定这条路走直通还是转码。这个习惯能替我挡掉后面一半的排障时间,后面避坑章节还会再提。
3. 部署媒体网关:从拉取 RTSP 到输出 WebRTC 的最小可行链路
3.1 网关的四项核心职责与关键配置
媒体网关不是简单「转发」,它要完成四件事:
- 拉流:作为 RTSP 客户端主动连接摄像头,完成 DESCRIBE、SETUP、PLAY,并持续接收 RTP 包。
- 解复用与重封装:把 RTP 里的视频音频流解出来,再按 WebRTC 的 SRTP 会话打包。
- 信令服务:与浏览器完成 SDP 交换,收集并响应 ICE Candidate。
- 会话管理:同一路流可能同时被多个浏览器观看,网关要做多路分发,并维护每路的连接状态。
部署之前,先把四个关键配置想清楚。很多「本地能跑、上公网就废」的问题都出在这一步:
| 配置项 | 建议取值 | 作用与影响 |
|---|---|---|
| extern_ip | 服务器公网 IP 或域名 | 网关写给浏览器的 ICE 候选地址;不配则候选是内网 IP,公网播放直接失败 |
| WebRTC 媒体端口 | 连续 UDP 区间,如 10000-20000 | SRTP 走 UDP,防火墙必须放行整段,只放行 TCP 没有用 |
| 关键帧间隔(GOP) | 1~2 秒 | 新观众加入时等待首帧的时间,越短首帧越快,码流体积略增 |
| 拉流超时 | 5~15 秒 | 摄像头断流后网关重建会话的间隔,太短容易反复重连 |
extern_ip是 WebRTC 公网部署的命门。如果服务器在 NAT 后面,网关自己也分不清自己是公网还是内网,必须显式告诉它「你对外长这样」。这条配置漏了,后面排查黑屏能查一整天。
3.2 Docker 部署:最小可行命令
常见做法是直接跑打包好的开源媒体网关镜像,部署上我一般用 Docker 把网关和宿主机隔离开,但网络模式必须特殊处理:
# 用 host 网络模式启动,避免 Docker 端口映射与 UDP 端口范围冲突 docker run -itd \ --name rtc-gateway \ --network host \ -v /opt/rtc-gateway/config.ini:/opt/media/conf/config.ini \ registry.example.com/rtc-gateway:latest这里--network host不是偷懒。WebRTC 的媒体端口是一整段 UDP 区间,如果按传统方式用-p 10000-20000:10000-20000/udp一个个映射,端口管理会很痛苦,而且 Docker 的 NAT 会干扰 ICE 拿到的地址,导致服务器写进 SDP 的候选和实际网络接口对不上。host 模式让网关直接看到服务器的真实网络接口,extern_ip的配置也能和实际 IP 一一对上。registry.example.com是镜像地址占位,部署时换成你实际能拉到的仓库即可。
启动后立刻验证端口监听:
ss -lntup | grep -E ':(554|80|8000)'554 是 RTSP 对外服务端口,80 是 HTTP API 端口,8000 是 WebRTC 信令和媒体端口。三个都在监听,说明网关本体就绪。如果某个端口缺失,先查容器日志,多半是配置文件里对应段落被注释掉了。
3.3 添加拉流代理,并确认流真的进来了
网关只是个服务容器,它不会主动去连摄像头,需要你告诉它「哪一路 RTSP 要拉」。常见的开源网关都提供 HTTP 管理接口,我一般用 curl 添加拉流代理:
curl -X POST 'http://127.0.0.1/index/api/addStreamProxy' \ -H 'Content-Type: application/json' \ -d '{ "vhost": "__defaultVhost__", "app": "live", "stream": "camera01", "url": "rtsp://monitor:pass123@10.0.0.20:554/stream1", "enable_rtsp": true, "enable_rtmp": false }'这个调用里,url是摄像头原始 RTSP 地址,注意用户名密码要 URL 编码,特殊字符多时会成为隐蔽的坑;app和stream定义流在网关内部的命名空间,后面 WebRTC 播放 URL 就用这两个字段定位流;enable_rtsp打开网关自身的 RTSP 输出,方便先用桌面端验证转发链路。接口路径和字段名以你部署的网关版本为准,不同小版本的命名有差异,翻车时先查对应版本的管理文档。
添加完成后,用管理接口确认流状态:
curl 'http://127.0.0.1/index/api/getMediaList?app=live&stream=camera01'返回 JSON 里code为 0,且data里带tracks字段,就说明流已经成功拉起来了。再用桌面播放器验一次:
ffplay rtsp://127.0.0.1:554/live/camera01ffplay 能出画面,说明「拉流到转发」这一整段没问题,剩下的就全是 WebRTC 层的活了。
4. 前端播放器接入:SDP 交换、answer 解析与三个延迟相关设置
4.1 一个 WebRTC 播放页的最小 JavaScript 实现
前端要做的事可以压缩成三件:创建 RTCPeerConnection、把 offer 发给网关、把网关返回的 answer 设置回本地。下面是可直接用的最小播放函数:
async function playWebRTC(gatewayUrl, videoEl) { // 只接收、不发送,明确这是播放会话 const pc = new RTCPeerConnection({ bundlePolicy: 'max-bundle' }); pc.addTransceiver('video', { direction: 'recvonly' }); pc.addTransceiver('audio', { direction: 'recvonly' }); pc.ontrack = (e) => { videoEl.srcObject = e.streams[0] || new MediaStream([e.track]); videoEl.play(); }; const offer = await pc.createOffer(); await pc.setLocalDescription(offer); // 等 ICE 候选收集完成再发 offer,避免 SDP 里没有 candidate const gatherP = new Promise((resolve) => { if (pc.iceGatheringState === 'complete') return resolve(); pc.addEventListener('icegatheringstatechange', () => { if (pc.iceGatheringState === 'complete') resolve(); }); }); await Promise.race([gatherP, new Promise((r) => setTimeout(r, 3000))]); const form = new FormData(); form.append('offer', JSON.stringify(pc.localDescription)); const resp = await fetch(gatewayUrl, { method: 'POST', body: form }); const data = await resp.json(); if (data.code !== 0) throw new Error(data.codeMsg); const answer = JSON.parse(data.answer); await pc.setRemoteDescription(answer); return pc; }调用方式:playWebRTC('http://203.0.113.10/index/api/webrtc?app=live&stream=camera01&type=play', document.getElementById('video'))。这段代码有四个关键设计。
addTransceiver显式声明只收不发,防止某些网关把会话误判成双向推流而拒绝播放;max-bundle让所有媒体复用同一条传输通道,ICE 只协商一次,连接建立时间明显缩短;ontrack里的兜底写法是因为部分网关实现只发送 track 不挂 stream,直接取e.streams[0]会是 undefined,用new MediaStream([e.track])包一层就不会白屏;最后等待 ICE 收集完成并加 3 秒超时,是为了避免 offer 里一个 candidate 都没有导致网关回不来,同时也防止极端情况下 promise 永久挂起。
4.2 answer 的解析规则与接口参数说明
网关返回的 JSON 结构一般是这样的:
{ "code": 0, "codeMsg": "Success", "answer": "{\"type\":\"answer\",\"sdp\":\"v=0\\r\\no=- ...\"}" }这里藏着三个最容易踩的解析坑。第一,answer是字符串化的 JSON,不是 SDP 纯文本,也不是 JS 对象,必须先用JSON.parse再传给setRemoteDescription,传错类型会直接抛异常。第二,type=play时网关会把它自己的 ICE candidate 写在 answer 的 SDP 里,正常不需要额外的 candidate 交换;如果你发现 answer 里一条a=candidate:都没有,别在代码上纠结,回服务器查extern_ip配置。第三,code非 0 时不要继续处理 answer,网关的codeMsg报错通常比浏览器控制台精确得多,比如它会直接告诉你「流不存在」还是「编码不支持」。
注意:信令接口的字段名和返回值在不同网关版本里有差异,上面这份以最常见的开源网关约定为准。换网关或升级版本后,先抓一次真实响应再改解析逻辑。
4.3 延迟体验相关的三个设置:哪些能调,哪些别动
WebRTC 的抖动缓冲在浏览器内部,没有暴露给脚本层,所以「前端调低延迟」这件事空间非常有限。我有三个反直觉的习惯。
第一,视频元素拿到srcObject之后再调video.play(),不要手贱再用 MSE 包一层。某些播放器组件为了兼容老项目,会把 WebRTC 内部再转成 FLV 喂给 MSE,这一转延迟直接倒退到 1 秒以上,等于白做。第二,不要把playbackRate调成 1.1 或 0.9 去追音画同步,直播流的 A/V 同步由浏览器按 RTP 时间戳处理,手动改速率只会让音画错位。第三,网络抖动加剧时浏览器会自动加重传次数,延迟放大是它在保护流畅度,不是故障。要降低「网络差时的延迟」,正确做法是在服务器侧把 GOP 间隔压到 1 到 2 秒,把接收端等关键帧的等待时间上限控制住,前端不用做额外操作。
说白了,前端在这条链路里就是个「听话的信令客户端」。真正决定延迟上限的旋钮全部在服务端,前端能做的就是别捣乱。
5. 避坑排查:黑屏、卡顿、延迟飙升的五个实操问题
5.1 SDP 协商成功但黑屏:ICE 候选没到齐
现象:setRemoteDescription正常执行,Chrome 的chrome://webrtc-internals里连接状态也显示过connected,但ontrack始终不触发,画面一直黑着。
原因:ICE 看起来通了,实际协商出来的 candidate-pair 是错的。最常见的场景是服务器部署在云上,extern_ip没配,网关在 answer 里给出的候选是内网地址,浏览器侧被迫用一个不可路由的候选对,连接状态短暂显示 connected 后又掉回 failed。
解决:到网关配置里把extern_ip改成公网 IP,重启进程再试。同时打开chrome://webrtc-internals看 ICE candidate 面板:如果候选里全是 10.x、192.168.x 这类私有地址,问题基本实锤。ICE 这块看起来像玄学,实际排查路径是固定的,先看服务器给出去的候选是什么,再看防火墙放行没有,两步走完能覆盖九成黑屏。
5.2 一直转圈或花屏:H.265 与 AAC 的编码兼容问题
现象:WebRTC 连接建立成功,但页面永远停在加载态,偶尔黑屏后闪出几帧花屏。
原因:摄像头默认输出 H.265,或者音频是 AAC。Chrome 桌面版对 H.265 在 WebRTC 里的支持至今是缺失的,AAC 也不在 WebRTC 强制编码器之列。网关如果直通把这些数据包原样发来,浏览器解码器直接罢工,表现就是连接正常但不出画面。
解决:先用ffprobe -show_streams rtsp://用户名:密码@IP:554/stream1看codec_name。出现hevc或aac就改走转码链路:在网关对应拉流代理上开启转码会话,让网关内部调用 FFmpeg 完成 H.265 到 H.264、AAC 到 Opus 的转换;或者独立起一个 FFmpeg 进程转完再推给网关。另外注意 H.264 的 Profile,个别内核较老的浏览器或硬件对 High Profile 支持不完整,转码时显式加-profile:v main更稳妥。
5.3 延迟越拉越大:GOP 与抖动缓冲互相拉扯
现象:刚打开时延迟大约 500 毫秒,播放 20 分钟后延迟缓慢涨到 3 秒以上,但画面依旧流畅,看不出卡顿。
原因:这是 WebRTC 拥塞控制和抖动缓冲的正常反应。轻度丢包时浏览器会请求重传并把播放点后移,以保证画面不碎;如果摄像头的 GOP 间隔是 4 秒甚至更长,浏览器在等待下一个关键帧时继续缓存,两个机制叠在一起,延迟就一点点滚大了。
解决:把网关或摄像头的关键帧间隔调到 1 到 2 秒。拉流代理配置里按时间维度设置 IDR 帧周期;如果摄像头固件不支持改 GOP,就在转码链路里加-force_key_frames "expr:gte(t,n_forced*2)"这类参数强制插帧。这样新观众加入时的首帧等待也从「最长 4 秒」压到「最长 2 秒」,一箭双雕。
5.4 多路并发后 CPU 飙升:转码被偷偷开了 N 份
现象:单路播放完全没问题,同时开 4 路以后服务器 CPU 从 20% 直接顶到 85% 以上,所有画面一起卡顿。
原因:网关对每个新播放会话,只要检测到编码不兼容,就会自动起一个独立转码实例。你以为是 1 路转码,实际是 4 个 WebRTC 会话各自转 4 份,同一路流的转码结果完全没有被复用。直通和转码的 CPU 开销差一个数量级,这个锅不在网络。
解决:在网关的自适应配置里关掉「自动转码」,改成显式指定——只有确认是 H.265 或 AAC 的流,才在那个拉流代理上单独开转码。转码实例数也要设上限,部分网关支持按路配置转码线程池大小。宁可让个别不兼容的流播放失败,也不要让整个服务器被拖垮。监控大屏项目上线前,建议先用两路和八路各压一次,把 CPU 拐点找出来。
5.5 内网秒开、公网打不开:extern_ip 与 UDP 放行一起查
现象:同一套代码,局域网内播放秒开;部署到公网服务器后,浏览器一直停在 connecting 状态,最后超时报错。
原因:两个高频原因经常叠着出现。一是extern_ip没配,公网服务器上网关拿到的本机地址是内网网卡 IP,写进 SDP 的候选全部不可路由。二是 WebRTC 媒体端口在防火墙里没放行,UDP 被挡在门外,DTLS 握手一直完不成。
解决:按顺序检查。第一步,确认配置里的extern_ip是公网 IP。第二步,放行 UDP 端口区间:
iptables -A INPUT -p udp --dport 8000:20000 -j ACCEPT第三步,抓包验证 SRTP 真的在走公网网卡:
tcpdump -i eth0 udp portrange 8000-20000 -c 50能看到持续的 UDP 包进出,说明媒体面已经通了;如果一条包都抓不到,问题在防火墙或路由,别再去怀疑 WebRTC 代码。
6. 进阶:带鉴权的多路播放与一个资源回收习惯
WebRTC 播放 URL 如果不加保护,任何人拿到app和stream这两个名字就能直接拉流观看,这对监控场景是不能接受的。我在生产项目里的做法是:在网关前面加一层轻量鉴权,浏览器先向业务后端换取一个短时播放令牌,令牌有效期 30 秒,并且在签发时绑定 stream 名;然后用令牌拼到信令请求里,网关侧通过鉴权钩子校验,通过后才创建 WebRTC 会话。这样即使令牌在网络传输中被截获,30 秒后自动失效,而且无法拿它去播放其他路流。
多路播放场景还有一个更容易漏掉的资源回收问题。如果每个摄像头画面都长期持有 RTCPeerConnection,二三十路开下来,浏览器内存和 socket 数量都会被打满。我的习惯是把播放能力封装成组件,组件切走时立刻pc.close()并清掉srcObject,绝不把连接藏在全局变量里。配合ontrack里对e.streams[0]为空的兜底处理,这个组件在大多数网关实现下都能稳定出画面。
上线前我还会强制跑一次完整验证:打开chrome://webrtc-internals,重点看三组数据——ICE candidate pair 用的地址是不是公网、framesDecoded是否持续增长、roundTripTime是否稳定在 100 毫秒以下。三项都通过,这条链路才算真的稳了。
我在这条路上翻过最狠的一次车,就是公网部署时漏了extern_ip,客户现场一开播就是黑屏,查了两天才发现罪魁祸首是一个配置项。从那以后,我把extern_ip检查和 UDP 端口放行写进了上线 checklist,每次部署先自检一遍再交付。希望这套排查顺序和代码习惯能帮到你,少走我走过的这段弯路。
本文还有配套的精品资源,点击获取