简介:面向前端与音视频开发者的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 网页播放链路从源到浏览器就全部打通了,剩下的就是按实际场景微调延迟和画质参数。

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

Logo

邀请您加入社区

更多推荐