4

1. 引言

常见的音视频会话中,一端将本地的音视频数据传输给对端将至少经历3个步骤:采集->编码->传输,将数据从采集模块到发送模块的流动称为音视频数据的流水线。接下来几篇文章中将以视频数据为本来讨WebRTC是如何建立此视频流水线的:数据如何采集,如何从采集模块一步步流向网络发送模块,最终传输出去的。

2. 采集

视频采集模块是数据流水线的起始点,负责从视频源采集原始视频帧,推送给流水线的下一站:可以是本地渲染模块进行本地回显,也可以是编码模块进行数据编码压缩。

视频源可以是摄像头,也可以是桌面、窗口抓屏(远程桌面,基于视频流的电子白板等应用),甚至可以是磁盘上的视频文件,图片文件。WebRTC中提供了基于摄像头的视频采集框架,是本文要讨论的重点。当然WebRTC也提供了桌面,窗口抓屏框架,这套框架对外所提供的接口与基于摄像头的采集接口有所不同。整个视频流水线建立是以摄像头采集接口为基础的,从而导致这么个问题:当需要将抓屏数据当做视频源往外推送时,需要使用适配器模式来实现一套基于摄像头的视频采集接口。在基于视频流的互动白板中曾如此实现过,但不是本文讨论重点,因此,将放在别的文章中进行阐述。

视频采集模块是平台相关的模块,MacOS/IOS一般使用AVFoundation框架或者QuikTime框架,Linux平台一般使用V4L2库,Android上一般使用Camera1或者Camera2框架,Windows平台则使用DS(DirectShow)或者是MF(MediaFoundation)。由于WebRTC是个非常活跃的工程,代码架构一直在不停的变动之中,比如2019年4月份的代码还有VideoCaptureMF的代码,并且还注释着Vista及以上的版本建议使用MediaFoundation采集框架,而2019年11月份的代码MediaFoundation相关的代码已经被移除。再比如MacOs/IOS,Android的相关代码已经被移动到sdk/objc和sdk/android目录下。本文以modules/video_capture下的代码来做阐述,平台无关的代码在该直接目录下,平台相关的实现在modules/video_capture/windows,modules/video_capture/linux目录下,如图所示:

1.png

2.1 视频采集相关UML

2.png

DeviceInfo接口提供了设备枚举相关功能,其平台相关子类实例以组合的形式提供给VideoCapture。

  • 枚举设备个数,获取某个设备名称。
  • 枚举某个设备所支持的所有能力(VideoCaptureCapability: 分辨率,最大帧率,颜色空间,是否逐行扫描)
  • 获取某个设备的所有能力中与外部设置的能力最匹配的那个能力。

VideoCaptureModule视频采集模块的虚基类,它定义一系列视频采集的通用接口函数:

  • Start/StopCapture用来开始/结束视频采集(平台相关);
  • CaptureStarted用来判断当前capture运行状态(平台相关);
  • Register/DeCaptureDataCallback用来注册/注销数据回调模块(平台无关);
  • Set/GetApplyRotation用来设置视频旋转角度(平台无关)。

VideoCaptureImpl类是VideoCaptureModule的实现子类。做了3个事:

  • 声明静态Ctreate方法,用于创建平台相关的VideoCaptureImpl子类,在Windows平台上为VideoCaptureDS,在Linux平台上实现的子类是VideoCaptureV4L2。该方法一处声明,多处实现,在相应平台编译时,只会加载对应平台的实现代码;
  • 平台相关的接口,留待平台相关的子类中实现,主要是开始/结束视频采集;
  • 实现平台无关的接口:注册视频数据回调,应用视频旋转相关函数。其中注册数据回调将一个实现了VideoSinkInterface<VideoFrame>接口的对象赋予VideoCaptureImpl::_dataCallBack成员。当采集模块得到一帧视频数据,就可以通过该对象的OnFrame()方法推送出来。

2.2 采集模块的内部数据流

1. 以VideoCaptureDS为例,平台相关的采集模块采集到一帧视频后,平台相关的函数ProcessCapturedFrame()方法进行处理。ProcessCapturedFrame()将视频帧直接传递给VideoCaptureImpl::IncomingFrame()方法

2. VideoCaptureImpl::IncomingFrame()方法将对视频帧按需求进行旋转,并利用libyuv库转换成I420类型,再给视频帧加上ntp时间戳。经过上述处理后,IncomingFrame()将视频帧进一步传递给VideoCaptureImpl::DeliverCapturedFrame()

3. VideoCaptureImpl::DeliverCapturedFrame()将调用VideoSinkInterface::OnFrame(),将视频帧传递给回调对象_dataCallBack,即数据的下一站,从而将视频帧推送出采集模块。

3 流水线建立

视频采集模块作为底层模块,需要和上层模块协作才能把采集到的视频数据发送到上层的显示和编码模块,为数据流水线提供源源不断的视频数据。从控制流来讲,视频采集模块在初始化阶段由上层模块进行创建并开启视频采集,在结束的时候由上层模块停止视频采集并销毁模块。从数据流来讲,采集到的视频数据通过回调接口传递到上层模块,进行数据流水线上的下一步处理。

3.1 VideoCapture->VideoTrack的流水线

不论视频流最终目的地是流向本地渲染模块还是要流向编码器,首先都要经过VideoTrack这个对象。从控制流上来讲:一个VideoTrack对象的创建过程就是VideoCapture->VideoTrack流水线建立过程:

3.png

从数据流来讲:而视频数据流动方向正好和创建的方向相反:

image.png

相关的类图如下:

5.png

1. VideoCaptureModule->VideoSource

VideoCaptureModule作为数据源头组合到VideoSource对象中,同时VideoSource又实现了VideoSinkInterface<VideoFrame>接口,可以把自己注册到VideoCaptureModule中。从而实现了视频帧从VideoCaptureModule->VideoSource的流动。

VideoSource持有一个非常重要的成员VideoBroadcaster对象,该对象的UML类图如下。
6.png

一方面VideoBroadcaster实现了VideoSinkInterface接口,成为一个Sink,这样VideoSource得到采集模块的视频帧后,首先会流入到内部的VideoBroadcaster成员对象,而非直接从VideoSource流出;另一方面VideoSource和VideoBroadcaster都实现了VideoSourceInterface接口,对外VideoSource作为视频源存在,向数据流下一站提供注册方法AddOrUpdateSink();该方法内部调用VideoBroadcaster的AddOrUpdateSink(),从而将数据流下一站VideoSink注册到VideoBroadcaster,存入成员std::vector<SinkPair> sinks_中。到此,应该不难想到VideoBroadcaster既有了数据流入,还知道数据的下一站(可能多个),那么VideoBroadcaster::OnFrame()中就可以通过循环调用下一站的OnFrame方法将视频帧广播出去。

为什么要如此设计?因为,在WebRTC 1.0的官方规范中说明了一个视频源是可以被多个视频轨共用的。通过上述方式可以实现共用的概念。

2. VideoSource->VideoTrackSource

VideoTrackSource没有实现VideoSinkInterface接口,因此,实质上视频数据是不会流入到VideoTrackSource中的,但其组合了VideoSource对象,并且实现了VideoSourceInterface接口,添加到VideoTrackSource中的VideoSink会被添加到VideoSource,然后进一步添加到VideoBroadcast中。对外部来说,VideoTrackSource就是视频源。

VideoTrackSource另外实现了视频源状态相关的接口,以及状态通告相关的接口NotifierInterface,用于向更高一层(VideoTrack)通告视频源的状态。由于与数据流的讨论无关,此处只提及,不详述。

3. VideoTrackSource->VideoTrack

如同VideoTrackSource一般,VideoTrack也没有实现VideoSinkInterface接口,因此,视频数据也不会流入到VideoTrack中,但其组合了VideoTrackSource,并且间接实现了VideoSourceInterface接口。想要从VideoTrack中获取视频流的站点,只要实现VideoSinkInterface接口,通过VideoTrack的AddOrUpdateSink()注册进来即可,因为该VideoSink会经过VideoTrackSource->VideoSource->VideoBroadcaster,最终可以从VideoBroadcaster获得视频流。

VideoTrack另外实现了ObserverInterface接口,用于以观察者的身份来接收响应VideoTrackSource关于视频源状态的报告。

VideoTrack还实现了VideoTrackInterface接口,其中提供了一个重要的属性:ContentHint。这个属性告知编码器在码率降低时,应该如何应对:降低帧率?降低分辨率?对于桌面采集应用来说,我们应该设置该属性为kDetailed或者是kText,这样编码器编码该视频流的时候不会降低分辨率,量化参数qp值也不会设置的过大。

3.2 VideoTrack到本地渲染

从之前的描述,我们很清楚的知道视频帧是如何流动到VideoTrack的(虽然实质上并没有流动到VideoTrack类),我们也知道该如何从VideoTrack中获取视频数据:1)实现VideoSinkInterface接口,2)通过VideoTrack的AddOrUpdateSink()注册进去即可。事实上,本地渲染就是如此做的:要么直接使用WebRTC提供的平台相关的渲染类,这些类都实现了VideoSinkInterface接口;要么可以自己实现Renderer类,并实现VideoSinkInterface接口,在OnFrame方法中获取视频帧,并进行渲染操作。render通过VideoTrack的AddOrUpdateSink()注册进去时,会一直被投递到VideoBroadcaster被其持有,从VideoBroadcaster处直接得到视频帧。

WebRTC中提供的渲染类相关的UML类图:

7.png

3.3 VideoTrack到编码器

要说清楚VideoTrack中的视频帧如何到达编码器的,首要问题是搞清楚在WebRTC中哪个类代表了编码器,这才好研究视频数据的流向。

在WebRTC中VideoStreamEncoder类表征着一个视频编码器,接收原始视频帧作为输入,产生编码后的比特流作为输出。该类位于src/video/video_stream_encoder.h中,如下截图为该类的说明:

8.png

搞清楚了目的地后,接下来就是分析视频流如何从VideoTrack一步步流向VideoStreamEncoder,这条流水线又是如何建立起来的。

从数据流来讲,数据从VideoTrack->VideoStreamEncoder过程中大概经历了这么几个对象:
9.png

这几个对象的UML类图及其关系如下所示:按照之前的分析,我们知道要正真获得视频帧,该类需要实现VideoSinkInterface接口,在OnFrame()在该方法中得到上一站传来的视频帧。通过下面类图,我们可以看到实质上只有VideoStreamEncoder是一个VideoSink对象。而VideoTrack通过以对象成员的方式一直被传递到VideoStreamEncoder。由于VideoTrack实现了VideoSourceInterface,VideoStreamEncoder又可以反向设置到VideoTrack中,根据之前的结论,VideoStreamEncoder最终会存储在VideoBroadcaster中,由VideoBroadcaster将视频帧直接传递给VideoStreamEncoder。

10.png

从控制流来讲,如果不深入研究细节,仅从WebRTC的外层API来看,通过PeerConnection->AddTrack();PeerConnection->CreateOffer();PeerConnection->SetLocalDescription()这三步就建立起了这条流水线。后续简要分析这3个方法内部对建立上述视频流水线做出的贡献。

1. AddTrack()

在创建出VideoTrack后,通过PeerConnection->AddTrack()接口会为每个要发送的视频Track创建一个VideoRtpSender对象,视频Track成为VideoRtpSender的成员,实现逻辑上视频流向VideoTrack->VideoRtpSender流动。 另外,如果SDP使用kUnifiedPlan方式,还会为每个track创建一个独立的

RtpTranceiver对象,组合包含该track的VideoRtpSender,并添加到PC的成员RtpTranceiver数组中。

VideoRtpSender对象有两个重要的成员是与本文的讨论相关的track_和media_channel_。分别就是VideoTrack和WebRtcVideoChannel对象,是视频流的上一站和下一站。执行AddTrack()并不会将二者关联起来,只会将VideoTrack添加到VideoRtpSender中。但最终VideoRtpSender->SetSsrc()方法被调用时完成二者绑定。

  • VideoRtpSender->SetSsrc()被调用的时机?
  • 如果SDP使用kUnifiedPlan方式,VideoRtpSender被创建时,media_channel_并没有跟随一起被创建,那么何时何地media_channel_会被创建。

2. CreateOffer()

PeerConnection->CreateOffer()方法的详细过程是非常复杂的,它收集本地的音视频能力和网络层传输能力形成SDP描述结构。虽然该方法没有直接参与视频流水线构建,但是其为下一步PeerConnection->SetLocalDescription()操作提供了必要信息,使得其能完成视频流水线的构建。

下面简要分析PeerConnection->CreateOffer()的过程中与视频相关的部分,大致的调用过程如下:

11.png

图中特殊标记有两个函数:

PeerConnection::GetOptionsForUnifiedPlanOffer()会遍历PC中所有的RtpTransceiver,为每个RtpTransceiver创建一个媒体描述信息对象MediaDescriptionOptions,在最终的生成的SDP对象中,一个MediaDescriptionOptions就是一个m-line。 根据由于之前的分析,一个Track对应一个RtpTransceiver,实质上在SDP中一个track就会对应到一个m-line。上述遍历形成所有媒体描述信息MediaDescriptionOptions会存入到MediaSessionOptions对象中,该对象在后续过程中一路传递,最终在MediaSessionDescriptionFactory::CreateOffer()方法中被用来完成SDP创建。

另外MediaSessionDescriptionFactory::CreateOffer()创建SDP过程中,会为每个媒体对象,即每个track:audio、video、data创建对应的MediaContent。上图右边展示了为视频track创建VideoContent过程,标黄的静态方法CreateStreamParamsForNewSenderWithSsrcs()会为每个RtpSender生成唯一的ssrc值。ssrc是个关键信息,正如之前分析,但需要说明的一点是此处并不会调用RtpSender->SetSsrc()方法,ssrc当前只存在于SDP信息中,等待SetLocalDescription()的解析。

3. SetLocalDescription()

在CreateOffer()成功的回调中,一方面,我们会通过信令将Offer SDP发送给对端;另一方面调用SetLocalDescription()进行本地设置操作。

SetLocalDescription()的大致步骤如下:

12.png

如上图, SetLocalDescription()过程是相当复杂的,我们抓住视频流水线上关键节点的创建以及关联过程来进行重点描述。重点函数在上图中都标黄显示。

流水线上对象的创建:

1) PeerConnection::UpdateTransceiverChannel()方法中检查PC中的每个RtpTranceiver是存在MediaChannel,不存在的会调用WebRtcVideoEngine::CreateMediaChannel()创建WebRtcVideoChannel对象,并赋值给RtpTranceiver的RtpSender和RtpReceiver,这儿解决了VideoRtpSender的media_channel_成员为空的问题;

2) PeerConnection::UpdateSessionState()方法中,将SDP中的信息应用到上一步创建的视频媒体通道对象WebRtcVideoChannel上,调用WebRtcVideoChannel::AddSendStream()方法为通道创建WebRtcVideoSendStream,如果有多个视频Track,会有多个WebRtcVideoSendStream分别与之对应。WebRtcVideoSendStream对象存入WebRtcVideoChannel的std::map<uint32_t, WebRtcVideoSendStream*> send_streams_成员,以ssrc为key。创建WebRtcVideoSendStream,其构造函数中会进一步创建VideoSendStream,VideoSendStream的构造中会进一步创建

VideoStreamEncoder对象。 到此,所有有关的对象都已经创建完成。

流水线的建立:

之前就分析过VideoRtpSender->SetSsrc()方法非常重要,该方法在PeerConnection::ApplyLocalDescription()中最后被调用。会触发Track被传递,从VideoRtpSender传递到WebRtcVideoChannel,再传递到WebRtcVideoSendStream,成为WebRtcVideoSendStream的成员source_。 从而实现了逻辑上VideoRtpSender->WebRtcVideoChannel->WebRtcVideoSendStream流水线的建立;

WebRtcVideoSendStream::SetVideoSend()方法紧接着又触发调用VideoSendStream的SetSource()方法,以WebRtcVideoSendStream为视频源参数(看之前的类图,WebRtcVideoSendStream实现了VideoSourceInterface接口)一路传递给VideoStreamEncoder的成员VideoSourceProxy。在这个VideoSourceProxy::SetSource方法中,反向调用WebRtcVideoSendStream::AddOrUpdateSink()方法将VideoStreamEncoder作为VideoSink(看之前的类图,VideoStreamEncoder实现了VideoSinkInterface接口)添加到了WebRtcVideoSendStream。注意,在WebRtcVideoSendStream::AddOrUpdateSink()中会调用source_->AddOrUpdateSink()进一步将VideoStreamEncoder添加到了VideoTrack(如之前的描述VideoTrack已经被传递到WebRtcVideoSendStream成为WebRtcVideoSendStream的成员source_)。在逻辑上实现了视频流从WebRtcVideoSendStream->VideoSendStream->VideoStreamEncoder这段流水线。

至此,以发送端角度来看,从采集到编码器的整个流水线都已建立完毕。

4 总结

1. 从WebRTC提供的API角度看,从CreateVideoTrack(),AddTrack(),CreateOffer(),SetLocalDescription()这四步就建立起了发端从采集到编码器的视频流水线。当然具体细节比较复杂。

2. 虽然涉及的类很多,实质上一个视频帧从采集模块开始,流向编码器模块并没有经过太多的对象。接收数据的对象都实现了VideoSinkInterface接口,视频帧就在这几个对象的OnFrame方法中源源不断流动。WebRTC中数据总是从Source向Sink流动。

end
                                                             

                                                       

作者简介

                                    黎意为好未来高级C/C++工程Ⅲ

招聘信息

好未来技术团队正在热招测试、后台、运维、客户端等各个方向高级开发工程师岗位,大家可扫描下方二维码或微信搜索关注“好未来技术”公众号,点击“技术招聘”栏目了解详情,欢迎感兴趣的伙伴加入我们!

也许你还想看

浅析深度知识追踪如何助力智能教育

轻量型TV端遥控器交互类库最佳实践

"考试"背后的科学:教育测量中的理论与模型(IRT篇)

用技术助力教育 | 一起感受榜样的力量

想了解一个异地多校平台的架构演进过程吗?让我来告诉你!

摩比秀换装游戏系统设计与实现(基于Egret+DragonBones龙骨动画)

如何实现一个翻页笔插件

产研人的疫情战事,没有一点儿的喘息


好未来技术团队
416 声望1.3k 粉丝

好未来作为一家科技驱动的教育企业,始终坚持“爱和科技让教育更美好”的使命。