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 的生成和解析是自动的,但你得理解它背后的逻辑。不然出了问题,你连日志都看不懂。
流程大概是这样的:
- 创建 Offer:调用
pc.createOffer(),浏览器根据本地音视频设备、编解码器能力生成一份 SDP。 - 设置本地描述:
pc.setLocalDescription(offer),把这份 SDP 存到本地。 - 发送给远端:通过信令服务器把 SDP 文本传给对方。
- 远端设置远程描述:对方调用
pc.setRemoteDescription(offer)。 - 远端创建 Answer:
pc.createAnswer(),根据收到的 Offer 和自己的情况生成 Answer。 - 远端设置本地描述:
pc.setLocalDescription(answer)。 - 发送 Answer 回来:原路返回。
- 本地设置远程描述:
pc.setRemoteDescription(answer)。
嗯,这里要注意:setLocalDescription 和 setRemoteDescription 的顺序不能乱。我曾经在项目中遇到过一个人,他把 Offer 和 Answer 搞反了,结果两端都在等对方先发数据,死锁了半小时。
避坑指南:千万不要手动修改 SDP 字符串里的 a=ssrc 行。SSRC 是随机生成的,你改了会导致 RTP 包无法匹配,直接黑屏。我曾经手贱改过一次,排查了整整一个下午。
8.3 编解码器优先级设置
浏览器默认的编解码器优先级,通常是 VP8 排第一,H.264 排第二。但实际项目中,很多硬件编码器只支持 H.264,比如 iOS 设备。这时候你就得手动调整优先级。
怎么做?很简单,在 createOffer 或 createAnswer 之前,修改 RTCRtpTransceiver 的 setCodecPreferences。
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=simulcast 和 a=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,需要设置 RTCRtpEncodingParameters 的 scalabilityMode:
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 是载体,编解码器优先级是策略,Simulcast 和 SVC 是具体实现手段。三者缺一不可。
好了,这一章的内容就到这里。媒体协商是 WebRTC 里最容易被忽视但又最关键的一环。我见过太多人花大量时间调网络、调音视频参数,结果问题出在 SDP 没协商好。希望你能把这一章吃透,后面遇到类似问题,至少知道从哪里下手。