8、媒体协商:SDP生成与解析、编解码器优先级设置、Simulcast与SVC

媒体协商,说白了就是两个浏览器在互相喊话:

“我能发 H.264,你能收吗?”

“我能收,但我更想要 VP8,你行不行?”

“行,那就 VP8 吧,分辨率我支持 720p,你呢?”

“我屏幕小,收 360p 就够了。”

……

这段对话,在 WebRTC 里就是通过 SDP(Session Description Protocol)来完成的。我做了这么多年实时通信,见过太多因为 SDP 没协商好导致黑屏、花屏、甚至连不上的情况。嗯,今天我们就来把这层窗户纸捅破。

核心一句话:媒体协商就是两端交换能力,然后选一个双方都支持的组合。

8.1 SDP 长什么样?

SDP 不是二进制协议,它就是一段纯文本。你可以在浏览器里通过 pc.localDescription.sdp 直接打印出来看。我习惯在调试时把它复制出来,用文本编辑器打开,一目了然。

一个典型的 SDP 长这样(简化版):

v=0
o=- 123456 2 IN IP4 0.0.0.0
s=-
t=0 0
m=audio 9 UDP/TLS/RTP/SAVPF 111 103 104
a=rtpmap:111 opus/48000/2
a=rtpmap:103 ISAC/16000
a=rtpmap:104 ISAC/32000
m=video 9 UDP/TLS/RTP/SAVPF 96 97 98
a=rtpmap:96 H264/90000
a=rtpmap:97 VP8/90000
a=rtpmap:98 VP9/90000
a=fmtp:96 packetization-mode=1;profile-level-id=42e01f

你看,m= 开头的是媒体行,a= 开头的是属性行。每一行都有它的含义。我刚开始学的时候,觉得这东西又臭又长,后来发现它其实很规整——只要你掌握了规律。

SDP 字段 含义 常见取值
v= 版本号 0
o= 会话发起者信息 用户名、会话ID、版本、网络类型
m= 媒体描述 audio/video + 端口 + 传输协议 + 编解码器编号
a=rtpmap 编解码器映射 编号 + 编码名 + 时钟频率
a=fmtp 编解码器参数 H.264 的 profile-level-id、packetization-mode
a=ssrc 同步源标识 32位整数,标识一路流

小技巧:调试时可以用 new RTCSessionDescription() 把 SDP 字符串解析成对象,再用 JSON.stringify 打印,结构更清晰。

8.2 SDP 的生成与解析流程

在 WebRTC 里,SDP 的生成和解析是自动的,但你得理解它背后的逻辑。不然出了问题,你连日志都看不懂。

流程大概是这样的:

  1. 创建 Offer:调用 pc.createOffer(),浏览器根据本地音视频设备、编解码器能力生成一份 SDP。
  2. 设置本地描述pc.setLocalDescription(offer),把这份 SDP 存到本地。
  3. 发送给远端:通过信令服务器把 SDP 文本传给对方。
  4. 远端设置远程描述:对方调用 pc.setRemoteDescription(offer)
  5. 远端创建 Answerpc.createAnswer(),根据收到的 Offer 和自己的情况生成 Answer。
  6. 远端设置本地描述pc.setLocalDescription(answer)
  7. 发送 Answer 回来:原路返回。
  8. 本地设置远程描述pc.setRemoteDescription(answer)

嗯,这里要注意:setLocalDescriptionsetRemoteDescription 的顺序不能乱。我曾经在项目中遇到过一个人,他把 Offer 和 Answer 搞反了,结果两端都在等对方先发数据,死锁了半小时。

避坑指南:千万不要手动修改 SDP 字符串里的 a=ssrc 行。SSRC 是随机生成的,你改了会导致 RTP 包无法匹配,直接黑屏。我曾经手贱改过一次,排查了整整一个下午。

8.3 编解码器优先级设置

浏览器默认的编解码器优先级,通常是 VP8 排第一,H.264 排第二。但实际项目中,很多硬件编码器只支持 H.264,比如 iOS 设备。这时候你就得手动调整优先级。

怎么做?很简单,在 createOffercreateAnswer 之前,修改 RTCRtpTransceiversetCodecPreferences

const pc = new RTCPeerConnection();
const transceivers = pc.getTransceivers();

// 获取所有视频编解码器
const videoCodecs = RTCRtpSender.getCapabilities('video').codecs;

// 把 H.264 提到最前面
const h264Codecs = videoCodecs.filter(c => c.mimeType.includes('H264'));
const otherCodecs = videoCodecs.filter(c => !c.mimeType.includes('H264'));
const preferredCodecs = [...h264Codecs, ...otherCodecs];

// 设置优先级
transceivers.forEach(t => {
  if (t.receiver.track.kind === 'video') {
    t.setCodecPreferences(preferredCodecs);
  }
});

// 然后创建 Offer
const offer = await pc.createOffer();

我个人习惯把这段逻辑封装成一个工具函数,每次创建连接前调用一次。你想想看,如果不设置优先级,浏览器可能会选一个对方不支持的编码,那就尴尬了。

重要:setCodecPreferences 必须在 createOffer 之前调用,否则不生效。

8.4 Simulcast:同时发送多路流

Simulcast 的意思是“同时广播”。发送端同时编码多路不同分辨率的视频流,接收端根据网络状况选择最合适的一路。

举个例子:

  • 你开视频会议,发送端编码 720p、360p、180p 三路流。
  • 接收端 A 网络好,收 720p。
  • 接收端 B 网络差,自动切到 180p。

在 SDP 里,Simulcast 是通过 a=simulcasta=rid 属性来描述的。比如:

a=rid:0 send
a=rid:1 send
a=rid:2 send
a=simulcast:send rid=0,1,2

这三行表示发送端有三路流,分别用 rid 0、1、2 标识。接收端在 SDP 里会声明它想收哪一路:

a=rid:0 recv
a=rid:1 recv
a=rid:2 recv
a=simulcast:recv rid=0,1,2

在代码里启用 Simulcast 也很简单,给 addTransceiver 传一个配置就行:

const pc = new RTCPeerConnection();
const stream = await navigator.mediaDevices.getUserMedia({ video: true });

pc.addTransceiver(stream.getVideoTracks()[0], {
  direction: 'sendonly',
  sendEncodings: [
    { rid: '0', maxBitrate: 900000, scaleResolutionDownBy: 1 },   // 720p
    { rid: '1', maxBitrate: 300000, scaleResolutionDownBy: 2 },   // 360p
    { rid: '2', maxBitrate: 100000, scaleResolutionDownBy: 4 },   // 180p
  ]
});

经验之谈:Simulcast 虽然好用,但会显著增加发送端的 CPU 和带宽开销。我在项目中一般只开两路,720p 和 360p,180p 的效果太差了,用户反馈说“马赛克开会”。

8.5 SVC:可伸缩视频编码

SVC 和 Simulcast 的目标一样——适应不同网络条件。但实现方式完全不同。

Simulcast 是编码多路独立的流,SVC 是编码一路流,但这一路流里包含多个“层”。

  • 时间层(Temporal):控制帧率。比如 30fps 的流,可以只解码其中的 15fps。
  • 空间层(Spatial):控制分辨率。比如 720p 的流,可以只解码其中的 360p。
  • 质量层(Quality):控制码率。比如高码率的流,可以只解码低码率部分。

在 SDP 里,SVC 是通过 a=fmtp 参数来声明的。以 VP9 为例:

a=rtpmap:98 VP9/90000
a=fmtp:98 profile-id=0;level-idx=10;temporal-layer-id=0;spatial-layer-id=0

在代码里启用 SVC,需要设置 RTCRtpEncodingParametersscalabilityMode

const pc = new RTCPeerConnection();
const stream = await navigator.mediaDevices.getUserMedia({ video: true });

pc.addTransceiver(stream.getVideoTracks()[0], {
  direction: 'sendonly',
  sendEncodings: [
    {
      scalabilityMode: 'L3T3',  // 3 层空间层 + 3 层时间层
      maxBitrate: 900000,
    }
  ]
});

scalabilityMode 的格式是 L<空间层数>T<时间层数>。常见的模式有:

模式 说明 适用场景
L1T1 单层,无伸缩 最低复杂度
L1T3 单空间层,3 时间层 帧率自适应
L3T3 3 空间层,3 时间层 全功能 SVC

注意:SVC 目前只有 VP9 和 AV1 支持得比较好。H.264 的 SVC 扩展( Annex G)几乎没有浏览器实现。如果你要用 SVC,确保两端都支持 VP9。

8.6 Simulcast vs SVC:怎么选?

这个问题我经常被问到。我的建议是:

  • 选 Simulcast:如果你需要兼容性好,接收端可以独立选择不同分辨率,且发送端 CPU 够用。
  • 选 SVC:如果你带宽有限,希望用更少的码率实现自适应,且两端都支持 VP9。

在实际项目中,我见过的大厂方案都是 Simulcast 为主。为什么?因为 VP9 的硬件编码器还没普及,软件编码又太费电。你想想看,手机开个视频会议,本来就发热,再用 VP9 软编,那温度能煎鸡蛋了。

一句话总结:Simulcast 是“多路流任你选”,SVC 是“一路流层层剥”。没有绝对的好坏,看场景。

8.7 本章知识体系

下面这张图帮你理清本章的核心逻辑:

媒体协商核心知识体系 SDP 媒体协商 SDP 生成与解析 Offer / Answer 流程 setLocalDescription setRemoteDescription 编解码器优先级 setCodecPreferences H.264 / VP8 / VP9 排序 硬件编码器兼容 Simulcast vs SVC 多路流 / 分层编码 a=simulcast / a=rid scalabilityMode 核心:两端交换能力,选择共同支持的组合 Simulcast 兼容性好,SVC 带宽效率高

这张图把本章的三个核心知识点串起来了。SDP 是载体,编解码器优先级是策略,Simulcast 和 SVC 是具体实现手段。三者缺一不可。


好了,这一章的内容就到这里。媒体协商是 WebRTC 里最容易被忽视但又最关键的一环。我见过太多人花大量时间调网络、调音视频参数,结果问题出在 SDP 没协商好。希望你能把这一章吃透,后面遇到类似问题,至少知道从哪里下手。