简介:这是一套完整的 RTSP 转 WebRTC 播放方案,面向需要在前端页面实现低延迟实时视频的开发者,适用于视频监控、在线会议、直播推流等场景。包内含预编译的 Windows 版可执行程序,以及配套的前端播放器组件、信令脚本、配置示例和参考页面,共 94 个文件,类型以 JS、HTML、CSS 为主,并附带 wasm、模型文件、图标与说明文档,整体仅 8.77MB,部署轻量。已有 522 人学习。借助其中的可执行程序与项目提供的 JavaScript API,可直接接收 H.264 编码的 RTSP 流,经转码与 SDP 协商后通过 WebRTC 在网页中播放;同时,压缩包内包含 janus、jitsi 等集成示例和 TensorFlow.js 相关体验内容,方便二次开发与功能扩展,是一份理解 RTSP 与 WebRTC 协议转换、快速搭建网页实时播放的实用参考。

1. RTSP转WebRTC:为什么摄像头画面在浏览器里要先过一道“转流网关”

做监控可视化的时候,最常见的场景是:手上一堆海康、大华摄像头,输出只有RTSP拉流协议,产品经理却要求在浏览器里实时预览,不装插件、不做ActiveX、延迟还不能高。很多前端同事第一反应是去找一个“能播RTSP的播放器”,搜一圈发现要么要装客户端,要么延迟离谱,最后又绕回到服务端。RTSP转WebRTC这件事,核心并不是选播放器,而是在中间加一道“翻译层”:一边用RTSP拉协议把摄像头的RTP数据拿过来,另一边用WebRTC的协商和传输方式把它送到浏览器。这个方案能解决免插件、低延迟、多路同时播放的问题,适合手里有RTSP设备、前端要接实时画面的团队。真正动手前最该想清楚的是:转流网关选哪套、编码要不要转、网络要不要TURN,这三件事没定,后面全是坑。

2. 转换链路选型:先搞懂RTSP与WebRTC的协议差异,再决定用哪套网关

2.1 RTSP是“拉流约定”,WebRTC是“协商式会话”,差距在信令

RTSP和WebRTC虽然都走UDP、都用RTP承载媒体,但设计思路完全不同。RTSP是传统客户端-服务器模型:播放器向服务器发DESCRIBE、SETUP、PLAY这些控制命令,服务器在SDP里告诉播放器媒体格式和传输地址,然后一路把RTP包推给播放器。它的特点是“拉流”,服务器只负责发,不管播放器在什么网络环境、中间有没有防火墙。浏览器之所以不能直接播RTSP,不完全是封装格式问题,而是浏览器没有实现RTSP控制协议,也没有对应的音视频解码管道。

WebRTC走的是另一条路:它不是一条单向的流,而是一次“协商式会话”。前端页面先创建一个RTCPeerConnection,生成一个SDP offer,通过WebSocket或HTTP发给服务端;服务端收到offer后创建answer回传;两边还要交换ICE候选,最后才建立媒体通道。这中间的信令交互完全由业务方自定义,WebRTC只负责“接通之后怎么传”。它的传输默认UDP,带DTLS加密,还有拥塞控制和丢包重传机制,延迟能压到几百毫秒甚至几十毫秒。

所以RTSP转WebRTC的“转”,不只是把容器格式换一下,而是把协议模型换一遍。服务端要把RTSP拉回来的RTP数据重新封装成WebRTC能协商的RTP payload,同时维护SDP协商、ICE候选、DTLS握手这些WebRTC会话逻辑。这也是为什么这个标题看起来是前端问题,实际上绝大多数工程量在服务端。

还有一个编码层面的硬约束。WebRTC在浏览器里普遍支持H.264、VP8、VP9、Opus、PCMU等编码,但很多老摄像头默认输出的是H.265(HEVC)或私有编码。H.264可以直转,H.265就得考虑浏览器兼容性,Safari稍微好一点,Chrome和Firefox原生支持非常有限,服务端大概率要转码成H.264。选型之前,先搞清楚摄像头主码流和子码流的编码格式,这一步直接决定要不要购买额外的转码资源。

2.2 两条落地路线:一体化媒体服务器与GStreamer自建链路

业界做RTSP转WebRTC,实践上分成两条主流路线。

第一条是使用一体化媒体服务器,比如ZLMediaKit、SRS、Janus这类开源项目。ZLMediaKit在国内监控场景用得很多,它本身就是一套流媒体服务,支持RTSP拉流、推流,也内置了WebRTC网关。配置好之后,摄像头RTSP地址直接喂给它,它会对外暴露一个WebRTC播放地址,前端拿这个地址播放,中间的信令协商由它自己完成。Janus是更重量级的SFU,适合做多点会议,它也能通过Streaming插件拉RTSP再转WebRTC,但配置和插件机制更复杂,小项目有点杀鸡用牛刀。SRS在WebRTC这块做得也很完整,提供了独立的RTC网关,但和监控生态的结合不如前者自然。

第二条路线是自己搭一条转流链路,常见做法是GStreamer加webrtcbin插件,或者用Python的aiortc库。GStreamer的webrtcbin抽象了WebRTC的音视频传输,你可以用uridecodebin直接拉RTSP,再通过webrtcbin把RTP包交给前端。aiortc则是把FFmpeg的解码能力和WebRTC的RTCPeerConnection绑在一起,代码量很小就能搭出一个原型。这条路线的好处是链路透明,每个环节都能自己控制,坏处是信令、ICE、NAT穿透全要自己处理,踩坑成本高。

选择标准我一般看三个:一是摄像头路数,5路以内自建勉强能撑,10路以上还是建议上一体化网关;二是对延迟的敏感程度,WebRTC本身延迟低,但如果服务端多了一次转码,延迟会从几十毫秒涨到几百毫秒;三是维护边界,团队里有人熟悉GStreamer或FFmpeg就自建,没有就选现成网关。选错了后期返工成本非常高,所以开头多点时间做选型是值得的。

2.3 用一张参数表定方案:延迟、人数、编码、维护成本

我习惯在动手前先画一张对比表,把硬条件摆出来,让产品和后端都能看懂方案边界。

方向 一体化媒体服务器(ZLMediaKit / SRS) GStreamer / aiortc 自建
上手难度 低,配好就有WebRTC播放地址 高,要自己写信令服务
延迟 100ms-500ms,取决于是否转码 50ms-300ms,链路短时可压低
多路并发 好,内部做了连接池和线程复用 差,每路一个管道,资源管理要自己写
编码支持 内置H.264/H.265支持,转码需额外配置 H.264直转,H.265要接FFmpeg转码
NAT穿透 自带TURN/STUN配置 要自己搭STUN/TURN服务
维护成本 低,社区方案成熟 高,每个环节都要自己扛

这张表不是绝对的,比如你用SRS和FFmpeg配合也可以做得非常轻量,但它能帮你在项目一开始就把方案定性。我的选型习惯是:如果摄像头已经能输出H.264子码流,且路数在20路以内,优先选一体化网关,因为它的信令和ICE处理已经打磨过,不太需要关心细节。如果是要做产品级的私有化部署,前后端都自己控制,或者要加一些特殊处理(比如ROI裁剪、AI识别后把标注画到画面上),就自建链路,这样每一步都在自己手里。

还要提醒一点:无论哪条路线,WebRTC为了穿透NAT,基本都依赖STUN和TURN服务。局域网内可以不走公网,但互联网环境下必须把TURN服务准备好,不然后期会发现只有一部分网络能播,另一部分卡死在候选项协商阶段。

3. 搭一套可复现的RTSP转WebRTC链路:测试源、网关配置与最小前端

3.1 先搭一个RTSP测试源:摄像头没有到位也能验证转换链路

开始整链路之前,先准备一个稳定的RTSP测试地址。如果你手边有现成的摄像头,直接用它最真实;如果没有,可以在本地用FFmpeg加一个轻量RTSP服务来模拟。常见做法是启动一个支持RTSP推送的服务端,再用FFmpeg推送合成视频。

# 用FFmpeg往RTSP服务推一路测试视频
ffmpeg -re -f lavfi -i testsrc=size=1280x720:rate=25 \
  -vcodec libx264 -preset veryfast -tune zerolatency -pix_fmt yuv420p \
  -f rtsp rtsp://127.0.0.1:554/test

这个命令用testsrc生成标准的测试画面,-re参数让推流速度与真实时间一致,避免推得比播放快;tune zerolatency告诉编码器尽量少做缓冲,提升实时性。推流目标指向rtsp://127.0.0.1:554/test,这就是后面所有转换链路的输入源。

如果你用的是真实摄像头,地址一般是海康或大华风格的格式。海康常见的是rtsp://用户名:密码@IP:554/Streaming/Channels/101,其中101代表主码流第一路,102是子码流第一路。子码流分辨率低一档,但编码压力小,适合做WebRTC预览。很多方案里同时拉主码流和子码流,大屏用子码流轮询,点击详情再切主码流,这个思路能明显降低服务端压力。

需要确认一下你手头的RTSP服务端是否允许被推送。很多RTSP服务端默认只做拉流,不做推流,所以如果FFmpeg推流失败,先检查服务端的推流鉴权和路径配置,而不是怀疑命令写错了。

3.2 用一体化网关拉起WebRTC:从RTSP地址到可播放的WebRTC流

选一体化媒体服务器时,ZLMediaKit配置路径相对清晰。启动服务后,把摄像头RTSP地址通过它拉进来,它会自动对外提供RTMP、HLS、WebRTC等多协议播放地址。我这里写一个最小启动流程,只提关键操作,不依赖特定版本。

# 启动媒体服务器,指定配置文件
./MediaServer -c config.ini

启动后可以用ffprobe确认RTSP拉流是否成功:

# 验证服务端能否正常拉取RTSP流
ffprobe rtsp://127.0.0.1:554/live/test

这里把摄像头或测试源地址统一配置成/live/test这个应用名,方便管理和鉴权。能看到媒体信息输出,就说明拉流正常。

WebRTC这块的关键配置有三个:服务端对外的公网IP或域名、WebRTC的UDP端口范围、是否启用TURN。公网IP如果不填,ICE候选里只会出现内网地址,外网前端永远连不上。UDP端口范围要放行给防火墙,一般建议一段连续的端口,比如20000-30000,云主机安全组里也要同步放行。如果是内网私有化部署,还需要在浏览器页面里配置一个可靠的信令地址,ZLM会通过HTTP或WebSocket接口返回WebRTC播放的SDP应答。

配置完以后,前端拿到的播放地址通常是类似webrtc://ip:8000/live/test这样的协议串,但WebRTC不直接播放这种URL,需要先经过网关换取SDP。实际接入时是前端发起offer,网关回answer,整个流程和2.1里描述的协商过程一致。如果你看到配置里有一堆端口和加密参数,先不要动,默认值一般能跑通,跑不通再逐项排查。

3.3 不想用现成网关时:用aiortc自己拉RTSP并转WebRTC

一体化网关适合快速交付,但如果你想完全掌控链路,可以用aiortc来搭一个最小服务。aiortc是Python库,它把FFmpeg的解码能力和WebRTC的RTCPeerConnection揉在了一起,几百行内能把信令和处理逻辑跑通。下面是一个简单的信令服务端核心代码:

import asyncio
from aiohttp import web
from aiortc import RTCPeerConnection, MediaPlayer

async def handler_offer(request):
    pc = RTCPeerConnection()
    # 用FFmpeg拉RTSP,强制走TCP,避免UDP丢包导致花屏
    player = MediaPlayer(
        "rtsp://127.0.0.1:554/live/test",
        options=["-rtsp_transport", "tcp"]
    )
    pc.addTrack(player.audio)
    pc.addTrack(player.video)
    # 收到前端offer,返回本地answer
    data = await request.json()
    await pc.setRemoteDescription({
        "type": "offer",
        "sdp": data["sdp"]
    })
    answer = await pc.createAnswer()
    await pc.setLocalDescription(answer)
    return web.json_response({
        "type": "answer",
        "sdp": pc.localDescription.sdp
    })

app = web.Application()
app.router.add_post("/offer", handler_offer)
web.run_app(app, port=8080)

这段代码里,MediaPlayer直接传RTSP地址,aiortc内部会用FFmpeg把RTSP拉回来并解码成原始帧,addTrack把音视频轨挂到RTCPeerConnection上。重点在options里的-rtsp_transport tcp,它把RTSP传输层改成TCP,虽然会增加一点点延迟,但能避开UDP传输在跨网络时的丢包问题。视觉上画面更稳,WebRTC侧本来就有丢包重传,两者叠加效果更可控。

这只是一个原型,实际生产还要处理ICE候选的转发、连接的关闭、多路的并发问题。当你发现这个原型能在一个网段内跑通时,就可以并行补充信令细节了。

3.4 前端最小播放页面:原生WebRTC把流挂到video上

服务端的offer/answer打通之后,前端其实不需要引入任何播放器库,用浏览器原生RTCPeerConnection就能把画面拉起来。下面是一个最简的播放页面代码。

<video id="preview" autoplay muted playsinline></video>

<script>
const pc = new RTCPeerConnection({
  iceServers: [
    { urls: "stun:stun.cloudflare.com:3478" },
    {
      urls: "turn:turn.example.com:3478",
      username: "user",
      credential: "pass"
    }
  ]
});

pc.ontrack = (event) => {
  const video = document.getElementById("preview");
  video.srcObject = event.streams[0];
};

const sendOffer = async () => {
  const offer = await pc.createOffer();
  await pc.setLocalDescription(offer);
  // 把offer送到信令服务端,并拿到answer
  const res = await fetch("/offer", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ sdp: pc.localDescription.sdp })
  });
  const answer = await res.json();
  await pc.setRemoteDescription(answer);
};

sendOffer();
</script>

ontrack回调在媒体轨道到达时触发,把流赋给video.srcObject就能播放。createOffer之后要立刻setLocalDescription,再通过信令服务把SDP发给网关。iceServers里只写STUN能解决公网地址发现,但遇到严格防火墙还是要靠TURN,这两者同时配置是生产环境的常态。

要注意muted和playsinline这两个属性的作用:muted让视频默认静音,避开浏览器自动播放限制;playsinline保证iOS Safari里不会强制全屏。视频出现黑屏时,优先看浏览器Console里有没有ICE候选相关报错,很多前端页面问题不是代码逻辑,而是服务端的TURN没配置好。

4. 落地避坑:五个让画面黑屏或卡死的真实问题与排查路径

4.1 排查ICE状态:offer/answer完成却一直checking

现象:信令交换正常,前端Console能看到offer和answer都发送成功,但视频一直黑屏,webrtc-internal页面里ICE状态停留在checking,偶尔跳到disconnected。

原因:offer和answer只是“约定”,真正的数据通路要靠ICE候选打通。STUN只负责拿到你本机的公网地址,但如果服务端和前端之间有对称型NAT或防火墙,UDP包进不来,候选就会卡在checking,一直等不到connect。

解决:在RTCPeerConnection的iceServers里同时配置STUN和TURN。TURN的relay候选会通过服务器中转数据,虽然多一跳延迟,但能保证绝大多数网络环境下都能连通。先确认TURN服务地址和账号是否可用,再用chrome://webrtc-internal本地页看候选类型,如果全是srflx没有relay,大概率TURN没生效。

4.2 画面出来了但延迟越拉越高:GOP和TCP拉流的双重影响

现象:刚播放时延迟一两秒,播放十分钟后延迟涨到快十秒,画面不是卡,而是时间轴越差越远。

原因:RTSP拉流端如果走UDP或在推流端GOP过大,播放器需要等关键帧才能解码。WebRTC本身有拥塞控制和重传机制,但在弱网下它会降低码率,而RTSP源是持续以恒定码率推流,两边一碰撞,延迟就会堆积。另一个常见原因是服务端在转流时配置了较大的缓冲,比如JitterBuffer,延迟就被拉上去了。

解决:拉流时强制RTSP走TCP,命令里加-rtsp_transport tcp;把摄像头主码流的GOP调到2秒以内,也就是帧率25的话I帧间隔50帧左右;服务端尽量不要开启多余的缓冲,ZLMediaKit和FFmpeg都提供tune zerolatency或等价参数,能关就关。前端播放时不要依赖video的buffered来做进度控制,让它按实时帧率播放即可。

4.3 H.265摄像头拉流后前端黑屏:编码边界与转码兜底

现象:服务端用ffprobe看流媒体信息一切正常,视频流也能解码出来,但前端页面就是黑屏,连报错都没有。

原因:又回到编码兼容性问题。RTSP源是H.265(HEVC),而Chrome和Firefox对WebRTC里的H.265原生支持非常有限,Safari稍好。服务端如果只是把RTSP转成WebRTC网关,没有把H.265转成H.264,前端拿到流后没有解码器能解码,自然黑屏。

解决:让摄像头输出一路H.264子码流,作为WebRTC的输入源,这是代价最小的做法;如果改不了摄像头配置,必须在服务端做转码兜底。FFmpeg转码命令里加上强制编码和降帧率:

ffmpeg -i rtsp://127.0.0.1:554/live/test_hevc \
  -c:v libx264 -preset veryfast -tune zerolatency -g 50 \
  -an -f rtsp rtsp://127.0.0.1:554/live/test_h264

这里-g 50把GOP固定为50帧,也就是2秒一个关键帧,降低前端解码等待时间。-an表示不要音频,监控场景很多时候不需要声音,这样也能省一部分资源。转码的代价是CPU开销成倍上涨,所以能走子码流就直接走子码流,不要盲目转码。

4.4 多路并发把CPU打满:软解转码的代价

现象:单路播放很流畅,同时播放两路也正常,加到七八路时CPU直接飙到90%以上,服务端日志出现丢帧,前端画面集体卡顿。

原因:很多方案默认每路流都做了一次解码再编码,即使直转也有一个RTP封装的过程。如果摄像头本身是H.265,而网关强制转成H.264,那每一路都要软解H.265再软编H.264,CPU开销非常可观。另外,前端每个Video标签都会创建一个独立的RTCPeerConnection,服务端就要为每个连接维护一个会话,资源占用成倍上升。

解决:尽最大努力直转,不转码;摄像头支持子码流就优先用子码流;服务端启用硬件编码或硬件解码,NVIDIA显卡可以用NVENC,Intel平台可以用QSV;前端按需拉流,只在页面可见时建立WebRTC连接,离开视野就close()。还要注意,不要用四个video标签去播放同一路流,应该在服务端只拉一路RTSP,在WebRTC网关里分发到多个前端连接,而不是每个前端都拉一遍RTSP。

4.5 video有流但不播放:autoplay策略的拦截

现象:Console里没有报错,WebRTC连接状态也正常,video元素上已经设置了srcObject,但画面就是不动。

原因:这是浏览器自动播放策略在起作用。Chrome和Safari都要求有声视频必须在用户手势之后才能播放,如果你的页面在加载时不带声音但却有音频轨道,浏览器会把play()拦截掉。

解决:最常用的办法是把video标签的muted属性设为true,并在代码里显式调用play();或者等用户点击页面后再开始连接WebRTC,把用户点击当作手势。还有一种场景是大屏系统,页面打开后没有任何用户操作,这时唯一的办法就是muted播放,需要声音时再让用户点击“开启声音”按钮。

5. 前端播放页面的参数调优:延迟、多路并发与浏览器策略

5.1 多路同屏时按需建链:离开视图就释放WebRTC连接

大屏类页面动不动就是九宫格、十六路,如果页面刷新或路由切换时忘记关闭RTCPeerConnection,会有大量僵尸连接占用服务端资源。我的做法是建一个React/Vue组件,在组件挂载时建立连接,在组件卸载时主动关闭。

const VideoPlayer = ({ streamUrl }) => {
  const videoRef = useRef(null);
  const pcRef = useRef(null);

  useEffect(() => {
    const pc = new RTCPeerConnection({ iceServers: turnConfig });
    pcRef.current = pc;
    pc.ontrack = (event) => {
      videoRef.current.srcObject = event.streams[0];
    };
    startWebRTC(pc, streamUrl);

    return () => {
      pc.close();
      pcRef.current = null;
    };
  }, [streamUrl]);

  return <video ref={videoRef} muted autoPlay playsInline />;
};

pc.close()会立即停止媒体传输,释放服务端会话,这是最容易忽略的收尾动作。还要注意在组件重渲染时避免重复建立多个连接,useEffect的依赖项要明确指定streamUrl,不要留空数组。多路页面还有一个细节:对不可见的AB面的video,可以直接把它从DOM里去掉或挂起连接,大屏滚动场景效果很明显。

5.2 拉流参数与播放器属性:把延迟压到你能接受的下限

WebRTC本身的延迟通常在一两百毫秒,但整个链路里RTSP拉流的参数会把这个数字放大。我在实际调试时主要盯三个参数:拉流传输方式、GOP间隔、转码preset。

RTSP拉流建议固定走TCP,UDP在跨网络环境下丢包率高,WebRTC收包后要触发重传,延迟反而增加。GOP间隔是影响解码延迟的大头,摄像头配置里把帧率25、GOP设为50,前端拿到关键帧的最长等待时间就控制在2秒内。转码时用veryfast加zerolatency,能明显减少编码端延迟,代价是稍微多占一点带宽。

前端video元素上的autoplay、muted、playsInline这三个属性同时设置,至少在移动端能减少80%的意外问题。如果没有特殊需求,不要给video加controls,原生的控制条在低延迟直播场景里没什么用,点暂停之后还会让流堆积在缓冲里。

5.3 video被浏览器策略拦截的问题:muted与用户手势

这个坑在第4章提过,前端页面真正做产品时还要再细分两种场景:一种是大屏监控,另一种是后台管理系统。大屏系统没有用户点击,想播声音就要在系统初始化时模拟一次用户手势,或者干脆用muted。后台管理页面则可以等用户点击某一路画面后再建立连接,把点击当作autoplay的手势入口。

如果业务上必须有声自动播放,还有一个办法是先用一个静音视频让页面进入播放状态,然后再切换到目标流,这个操作比较取巧,但在部分浏览器里会触发奇怪的竞态,能不用就不用。最稳妥的还是muted加“点击开启声音”。

5.4 截屏与水印:WebRTC流比HLS流好处理的地方

WebRTC流通过srcObject挂载到video上,它不受Canvas的跨域污染限制,可以直接截帧,这对做AI识别和监控留存很有价值。截帧代码只要在video播放中调用canvas.drawImage,再把canvas转成base64或上传Blob即可。

function captureFrame(video) {
  const canvas = document.createElement("canvas");
  canvas.width = video.videoWidth;
  canvas.height = video.videoHeight;
  const ctx = canvas.getContext("2d");
  ctx.drawImage(video, 0, 0);
  return canvas.toDataURL("image/jpeg", 0.8);
}

注意要在video.videoWidth大于0之后再截,否则拿到的是空图。加上水印则是在drawImage之后再画一层文字或logo,WebRTC流的实时性和可控性让这个场景做起来比HLS顺利得多。

6. 端到端验证:用chrome://webrtc内部页确认流没有走错路

调试RTSP转WebRTC链路时,我最常打开的就是chrome://webrtc-internal这个内部页。它能实时显示每个RTCPeerConnection的状态,包括ICE候选类型、RTT、丢包率、接收的编码格式。你可以在页面播放时打开它,看Connected候选是不是srflx或relay。如果只有host,说明候选没走出内网,那公网用户当然播不了;如果看到relay,说明数据在走TURN中转,延迟会偏高,但至少能通。还要看InboundRTPStream里video的编码是不是H.264。如果显示的是VP8但摄像头源明明是H.264,说明中间有人做了转码,这时候就要权衡延迟和兼容性。

延迟测量有一个土方法很实用:在摄像头监视范围内放一个手机或网页秒表,前端页面同时截一帧,和真实秒表画面对比秒数差,一次就能量出端到端延迟。如果延迟超过500ms,就往GOP和缓冲上排查;如果延迟很低但画面频繁卡顿,就往丢包和TURN上排查。另一个验证手段是抓包,在服务端用tcpdump看RTSP拉流端口和WebRTC媒体端口是否都有流量进出,能确认数据是在服务端被转发的,而不是前端直接直连了某个地址。

这套链路调试到后期,我习惯把“摄像头编码、网络是否公网可达、前端要播放的路数”三件事写在方案第一页。这仨问题不问清楚,后面无论用哪套网关都要翻车。现在每次接到类似项目,我都会先推演一遍完整数据路径:RTSP拉流、RTP解包、SDP协商、ICE穿透、浏览器解码,每个环节对应一个排查入口。链路越透明,出问题时越好定位。先把这些常见问题摸一遍,再去做自己的业务代码,你会发现RTSP转WebRTC其实没那么玄乎,希望帮到你。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

Logo

邀请您加入社区

更多推荐