WebRtc Video Receiver(五)-设置参考帧
1)前言
- 经过前面4篇文章的分析,针对WebRtc视频接收模块从创建接收模块、到对RTP流接收处理、关键帧请求的时机、丢包判断以及丢包重传、frame组帧等已经有了一定的概念和认识。
- 基于以上本文分析rtp包组包后聚合帧发送给解码器前的处理流程,在将一帧完整的帧发送给解码模块之前需要进行一定的预处理,如需要查找参考帧,本文着重分析解码前的参考帧查找原理。
- 承接上文的分析,rtp包组包成功后会将一帧完整的数据帧投递到
RtpVideoStreamReceiver2
模块由其OnAssembledFrame
函数来进行接收处理。 - 其实现如下:
void RtpVideoStreamReceiver2::OnAssembledFrame(
std::unique_ptr<video_coding::RtpFrameObject> frame) {
RTC_DCHECK_RUN_ON(&worker_task_checker_);
RTC_DCHECK(frame);
.....
//该模块默认未开启,新特性值得研究,顾名思义为丢包通知控制模块
// 可通过WebRTC-RtcpLossNotification/Enable开启,但是默认只支持VP8
// SDP需要实现goog-lntf feedback
if (loss_notification_controller_ && descriptor) {
loss_notification_controller_->OnAssembledFrame(
frame->first_seq_num(), descriptor->frame_id,
absl::c_linear_search(descriptor->decode_target_indications,
DecodeTargetIndication::kDiscardable),
descriptor->dependencies);
}
// If frames arrive before a key frame, they would not be decodable.
// In that case, request a key frame ASAP.
if (!has_received_frame_) {
if (frame->FrameType() != VideoFrameType::kVideoFrameKey) {
// |loss_notification_controller_|, if present, would have already
// requested a key frame when the first packet for the non-key frame
// had arrived, so no need to replicate the request.
if (!loss_notification_controller_) {
RequestKeyFrame();
}
}
has_received_frame_ = true;
}
// Reset |reference_finder_| if |frame| is new and the codec have changed.
if (current_codec_) {
//每帧之间的时间戳不一样,当前帧的时间戳大于前一帧的时间戳(未环绕的情况下)
bool frame_is_newer =
AheadOf(frame->Timestamp(), last_assembled_frame_rtp_timestamp_);
if (frame->codec_type() != current_codec_) {
if (frame_is_newer) {
// When we reset the |reference_finder_| we don't want new picture ids
// to overlap with old picture ids. To ensure that doesn't happen we
// start from the |last_completed_picture_id_| and add an offset in case
// of reordering.
reference_finder_ =
std::make_unique<video_coding::RtpFrameReferenceFinder>(
this, last_completed_picture_id_ +
std::numeric_limits<uint16_t>::max());
current_codec_ = frame->codec_type();
} else {
// Old frame from before the codec switch, discard it.
return;
}
}
if (frame_is_newer) {
last_assembled_frame_rtp_timestamp_ = frame->Timestamp();
}
} else {
current_codec_ = frame->codec_type();
last_assembled_frame_rtp_timestamp_ = frame->Timestamp();
}
if (buffered_frame_decryptor_ != nullptr) {
buffered_frame_decryptor_->ManageEncryptedFrame(std::move(frame));
} else if (frame_transformer_delegate_) {
frame_transformer_delegate_->TransformFrame(std::move(frame));
} else {
reference_finder_->ManageFrame(std::move(frame));
}
}
- 首先该函数第一次接收到一帧数据的时候,需要判断是否是在关键帧之前收到,如果在未收到关键帧之前收到的话是不能解码的,所以此时需要发送
关键帧请求
使用RequestKeyFrame()
函数发送关键帧请求。 - 其次、根据不同帧之间的时间戳不一样的原则,判断是否为新的一帧,首次接收到一帧之后会实例化
reference_finder_
成员,后续对参考帧的查找处理在未加密的情况下,都基于该实例完成。 - 如果为新的一帧,每帧数据查找参考帧后都会更新
last_assembled_frame_rtp_timestamp_
。 - 最后调用根据是否加密选择
reference_finder_
或者buffered_frame_decryptor_
对视频帧调用ManageFrame
或者ManageEncryptedFrame
函数进行参考帧查找处理。 - 本文的核心就是分析
ManageFrame
函数。
2)ManageFrame工作流程
-
在分析该函数之前先了解
WebRtc_Video_Stream_Receiver_05_01.pngRtpFrameReferenceFinder
,RtpVideoStreamReceiver2
,OnCompleteFrameCallback
之间的关系。
-
根据上图的关系图,在
RtpFrameReferenceFinder
模块中对video_coding::RtpFrameObject
数据帧进行处理,如果处理成功最终会生成video_coding::EncodedFrame
视频帧,接着回调OnCompleteFrameCallback
的OnCompleteFrame
函数将视频帧返回到RtpVideoStreamReceiver2
模块。 -
ManageFrame()
函数的代码如下:
void RtpFrameReferenceFinder::ManageFrame(
std::unique_ptr<RtpFrameObject> frame) {
// If we have cleared past this frame, drop it.
if (cleared_to_seq_num_ != -1 &&
AheadOf<uint16_t>(cleared_to_seq_num_, frame->first_seq_num())) {
return;
}
FrameDecision decision = ManageFrameInternal(frame.get());
switch (decision) {
case kStash:
if (stashed_frames_.size() > kMaxStashedFrames)
stashed_frames_.pop_back();
stashed_frames_.push_front(std::move(frame));
break;
case kHandOff:
HandOffFrame(std::move(frame));
RetryStashedFrames();
break;
case kDrop:
break;
}
}
-
cleared_to_seq_num_
变量记录的是已经清除的seq,比如说如果一帧数据已经发送到解码模块,或解码完成,那么需要将对应的seq进行清除,在这里的作用就是判断当前待解码的数据帧的首个包的seq和cleared_to_seq_num_
大小进行比较,在未环绕的情况下,如果cleared_to_seq_num_
大于frame->first_seq_num()
则说明该帧数据之前的帧已经解码了,此帧应该放弃解码,所以直接返回。 -
cleared_to_seq_num_
变量的更新通过调用ClearTo(uint16_t seq_num)
函数来更新,调用流程后续会分析到。 - 调用
ManageFrameInternal
函数对当前帧进行决策处理,结果返回三种,kStash
表示当前帧解码时机未到需要存储、kHandOff
可以解码、kDrop
表示放弃该帧。 - 对于可以解码的决策直接调用
HandOffFrame
函数进行后处理,而kStash
的决策使用stashed_frames_
容器将当前帧插入到容器头部,该容器的最大容量为100。 -
ManageFrameInternal
函数的实现如下:
RtpFrameReferenceFinder::FrameDecision
RtpFrameReferenceFinder::ManageFrameInternal(RtpFrameObject* frame) {
........
switch (frame->codec_type()) {
case kVideoCodecVP8:
return ManageFrameVp8(frame);
case kVideoCodecVP9:
return ManageFrameVp9(frame);
case kVideoCodecGeneric:
if (auto* generic_header = absl::get_if<RTPVideoHeaderLegacyGeneric>(
&frame->GetRtpVideoHeader().video_type_header)) {
return ManageFramePidOrSeqNum(frame, generic_header->picture_id);
}
ABSL_FALLTHROUGH_INTENDED;
default:
return ManageFramePidOrSeqNum(frame, kNoPictureId);
}
}
- 该函数根据当前帧数据的codec类型使用不同的实现来对当前帧进行决策,本文以H264为例进行分析讨论。
-
ManageFrameH264
函数分成两部分,一部分可以理解成对方是使用硬编编码出来的数据,此时tid=0xff,这种情况把任务交给了ManageFramePidOrSeqNum
函数。 - 另一种情况针对openh264软编的数据此时tid不为0xff。
- 首先对tid=0xff的情况进行分析。
- 如果要支持H265的话需要在这里新增对H265视频帧的决策处理函数。
3)ManageFramePidOrSeqNum设置参考帧
RtpFrameReferenceFinder::FrameDecision RtpFrameReferenceFinder::ManageFrameH264(
RtpFrameObject* frame) {
const FrameMarking& rtp_frame_marking = frame->GetFrameMarking();
uint8_t tid = rtp_frame_marking.temporal_id;
bool blSync = rtp_frame_marking.base_layer_sync;
/*android 硬编的情况收到的tid位0xff,传入的kNoPictureId=-1,这是h264的特性*/
if (tid == kNoTemporalIdx)
return ManageFramePidOrSeqNum(std::move(frame), kNoPictureId);
....
}
-
根据tid=0xff,直接调用ManageFramePidOrSeqNum对当前帧进行参考帧查找处理。
-
在分析
WebRtc_Video_Stream_Receiver_05_02.pngManageFramePidOrSeqNum()
函数之前首先介绍编码数据gop的概念。
-
以上以h264为例,在H264数据中idr帧可以单独解码,而P帧需要前向参考,在一个GOP内的帧都需要前向参考帧才能顺利解码。
-
WebRtc_Video_Stream_Receiver_05_03.pngRtpFrameReferenceFinder
通过last_seq_num_gop_
容器来维护最近的GOP表,收到P帧后,RtpFrameReferenceFinder
需要找到P帧所属的GOP,将P帧的参考帧设置为GOP内该帧的上一帧,之后传递给FrameBuffer
模块。
-
该容器是以当前待解码的帧所属的
gop
(由于IDR
关键帧是gop
的开始)关键帧的最后一个包的seq位key,以当前帧最后一个包的seq组成的std::pair<seq,seq>为value的容器(当前帧也有可能是padding包。 -
下面开始分析
ManageFramePidOrSeqNum()
函数原理如下
RtpFrameReferenceFinder::FrameDecision
RtpFrameReferenceFinder::ManageFramePidOrSeqNum(RtpFrameObject* frame,
int picture_id) {
// If |picture_id| is specified then we use that to set the frame references,
// otherwise we use sequence number.
// 1)确保非h264帧gop内维护的帧的连续性
if (picture_id != kNoPictureId) {
frame->id.picture_id = unwrapper_.Unwrap(picture_id);
frame->num_references =
frame->frame_type() == VideoFrameType::kVideoFrameKey ? 0 : 1;
frame->references[0] = frame->id.picture_id - 1;
return kHandOff;
}
//2)判断是否为关键帧,其中frame_type在组帧的时候进行设置的
if (frame->frame_type() == VideoFrameType::kVideoFrameKey) {
last_seq_num_gop_.insert(std::make_pair(
frame->last_seq_num(),//当前gop最后一个包的seq为key
std::make_pair(frame->last_seq_num(), frame->last_seq_num())));
}
//3)如果到此为止还没有收到一帧关键帧,则存储该帧
// We have received a frame but not yet a keyframe, stash this frame.
if (last_seq_num_gop_.empty())
return kStash;
// Clean up info for old keyframes but make sure to keep info
// for the last keyframe.
// 4)清除老的gop frame->last_seq_num() - 100之前的所有都清除掉,但至少确保有一个。
auto clean_to = last_seq_num_gop_.lower_bound(frame->last_seq_num() - 100);
for (auto it = last_seq_num_gop_.begin();
it != clean_to && last_seq_num_gop_.size() > 1;) {
it = last_seq_num_gop_.erase(it);
}
// Find the last sequence number of the last frame for the keyframe
// that this frame indirectly references.
// 函数能走到这一步,gop 容器中是一定有存值的
//5.1) 如果关键帧的序号是大于该帧的序号的(未环绕的情况),那么该帧需要丢弃掉。
// 假设last_seq_num_gop_中存的是34号包,而本次来的帧的序号是10~16(非关键帧)。
//5.2) 还有一种情况假设当前帧就是关键帧frame->last_seq_num()=34,假设事先last_seq_num_gop_存的是56号seq,由last_seq_num_gop_定义的排序规则,34号包被插入的时候会在头部,最终下面的条件依然成立。
auto seq_num_it = last_seq_num_gop_.upper_bound(frame->last_seq_num());
if (seq_num_it == last_seq_num_gop_.begin()) {
RTC_LOG(LS_WARNING) << "Generic frame with packet range ["
<< frame->first_seq_num() << ", "
<< frame->last_seq_num()
<< "] has no GoP, dropping frame.";
return kDrop;
}
//如果上述条件不成立这里则返回last_seq_num_gop_最后一个元素对应的迭代器
//如果当前帧为关键帧的话那么seq_num_it为last_seq_num_gop_.end(),进行--操作后旧对应了最后一个关键帧
seq_num_it--;
// Make sure the packet sequence numbers are continuous, otherwise stash
// this frame.
// 6) 该步用来判断该帧和上一帧的连续性
// last_picture_id_gop得到的是当前gop所维护的当前帧的上一帧(前向参考帧)的最后一个包的seq。
uint16_t last_picture_id_gop = seq_num_it->second.first;
// last_picture_id_with_padding_gop得到的也是上一帧的最后一个包的seq。
// 当前GOP的最新包的序列号,可能是last_picture_id_gop, 也可能是填充包.
uint16_t last_picture_id_with_padding_gop = seq_num_it->second.second;
// 非关键帧判断seq连续性,
if (frame->frame_type() == VideoFrameType::kVideoFrameDelta) {
//得到上一帧最后一个包的seq,当前帧的第一个包的seq -1 得到上一帧的最后一个seq
uint16_t prev_seq_num = frame->first_seq_num() - 1;
// 如果不相等说明不连续,如果正常未丢包的情况下是一定会相等的。
if (prev_seq_num != last_picture_id_with_padding_gop)
return kStash;
}
//检查当前帧最后一个seq是否大于所属gop 关键帧的最后一个seq
RTC_DCHECK(AheadOrAt(frame->last_seq_num(), seq_num_it->first));
// Since keyframes can cause reordering we can't simply assign the
// picture id according to some incrementing counter.
//7) 给RtpFrameObject的id.picture_id赋值
// 如果为关键帧num_references为false,否则为true
frame->id.picture_id = frame->last_seq_num();
frame->num_references =
frame->frame_type() == VideoFrameType::kVideoFrameDelta;
//上一帧最后一个包号
frame->references[0] = rtp_seq_num_unwrapper_.Unwrap(last_picture_id_gop);
//这一步确保第6步的逻辑能跑通,否则第6不逻辑是跑不通的last_picture_id_表示的是当前帧的上一个关键帧的最后一个包的seq,frame->id.picture_id为当前帧的最后一个包的seq,正常情况AheadOf函数是会返回true的。
if (AheadOf<uint16_t>(frame->id.picture_id, last_picture_id_gop)) {
//这里修改了容器last_seq_num_gop_对应关键帧的second变量,将当前帧最后一个包号的seq 赋值给他们
//正因为有这个操作,第6步才能顺利跑通
seq_num_it->second.first = frame->id.picture_id;
seq_num_it->second.second = frame->id.picture_id;
}
last_picture_id_ = frame->id.picture_id;
//更新填充包状态
UpdateLastPictureIdWithPadding(frame->id.picture_id);
frame->id.picture_id = rtp_seq_num_unwrapper_.Unwrap(frame->id.picture_id);
return kHandOff;
}
- 1)确保
gop
内帧的连续性,对于google vpx系列的编码数据,只需要判断picture_id是否连续即可,num_references
表示参考帧数目,对于IDR关键帧可以单独解码,不需要参考帧,所以num_references
为0,若gop
内任一帧丢失则该gop
内的剩余时间都将处于卡顿状态。 - 2)判断当前帧是否是关键帧,如果是则直接将其该关键帧的最后一个包的seq 生成相应的键值对插入到
gop
容器last_seq_num_gop_
,关键帧是gop
的开始。 - 3)如果
last_seq_num_gop_
为空表示到此目前为止没收到关键帧,同时当前帧又不是关键帧所以没有参考帧,不能解码,需要缓存该帧。为什么不是直接丢弃? - 4)将
last_seq_num_gop_
容器维护的太旧的关键帧清除掉,规则是当前帧最后一个包seq即[frame->last_seq_num() - 100]
之前的关键帧都清理掉,但是至少保留一个(假设规则之前一共就维护了一个gop那么不清除)。 - 5)以当前帧的最后一个包的seq使用
last_seq_num_gop_.upper_bound(frame->last_seq_num())
查询,该查询返回last_seq_num_gop_
容器中第一个大于frame->last_seq_num()
的位置,假设查出的位置就是last_seq_num_gop_
的首部,则丢弃该帧,为什么呢?来举个例子,假设last_seq_num_gop_
此时存在的seq为34而此时传入的包的seq->first_seq_num() = 10,seq->last_seq_num() =16,而且当前传入的帧为非关键帧,这说明什么意思呢?在传输的过程中可能由于10~16号包这一帧数据中有几个包丢了,而又由于丢包重传发送了PLI请求,也或者是对端主动发送了关键帧,该关键帧的的最后一个包的序号恰好是34,在上文的分析中提到了组包流程,如果组包过程中出现了关键帧,它是不管该关键帧前面的帧的死活的,直接会将该关键帧投递到RtpVideoStreamReceiver2
进行处理,而当该关键帧处理之后10~16号包之间被丢失的包又被恢复了,同理会传递到该函数进行处理,此时上述的假设条件就成立了,那么对于这种情况下,该帧应该丢弃掉,因为他后面的关键帧已经被处理了。 - 6)根据
last_seq_num_gop_
来判断当前帧和上一帧的连续性,如果不连续(说明没有前向参考帧,不能进行解码)则返回kStash
,进行缓存操作。 - 7)设置
picture_id
,对于H264数据用一帧的最后一个seq来作为picture_id
,设置当前帧的参考帧数目,对于关键帧不需要参考帧所以为0,对于P帧,参考帧数目为1(前向参考)。 - 更新
gop
容器last_seq_num_gop_
的value值,它也是一个std::pair<seq,seq>
,这两个值被设置成当前帧的最后一个包的seq,同时也更新RtpFrameObject
的id成员,最后返回kHandOff
。 - 此处
RtpFrameObject
父类有3个重要的成员变量id、num_references、references[0]被赋值,其中num_references表示的意思应该为当前帧的和上一帧是参考关系,h264的前向参考。
WebRtc_Video_Stream_Receiver_05_04.png - 该函数的决策主要是通过判断seq的连续性(是否有参考帧)或者是否是关键帧,来决定当前帧是否要发到解码模块,或者是进行存储,当出现丢帧现象的时候,需要缓存当前帧然后等待丢失的帧重传。
- 到此为止,
gop
容器last_seq_num_gop_
的数据成员如下:
WebRtc_Video_Stream_Receiver_05_05.png
WebRtc_Video_Stream_Receiver_05_06.png
4) UpdateLastPictureIdWithPadding更新填充包状态
void RtpFrameReferenceFinder::UpdateLastPictureIdWithPadding(uint16_t seq_num) {
//取第一个大于seq_num的对应的gop
auto gop_seq_num_it = last_seq_num_gop_.upper_bound(seq_num);
// If this padding packet "belongs" to a group of pictures that we don't track
// anymore, do nothing.
if (gop_seq_num_it == last_seq_num_gop_.begin())
return;
--gop_seq_num_it;
// Calculate the next contiuous sequence number and search for it in
// the padding packets we have stashed.
uint16_t next_seq_num_with_padding = gop_seq_num_it->second.second + 1;
auto padding_seq_num_it =
stashed_padding_.lower_bound(next_seq_num_with_padding);
// While there still are padding packets and those padding packets are
// continuous, then advance the "last-picture-id-with-padding" and remove
// the stashed padding packet.
while (padding_seq_num_it != stashed_padding_.end() &&
*padding_seq_num_it == next_seq_num_with_padding) {
gop_seq_num_it->second.second = next_seq_num_with_padding;
++next_seq_num_with_padding;
padding_seq_num_it = stashed_padding_.erase(padding_seq_num_it);
}
// In the case where the stream has been continuous without any new keyframes
// for a while there is a risk that new frames will appear to be older than
// the keyframe they belong to due to wrapping sequence number. In order
// to prevent this we advance the picture id of the keyframe every so often.
if (ForwardDiff(gop_seq_num_it->first, seq_num) > 10000) {
auto save = gop_seq_num_it->second;
last_seq_num_gop_.clear();
last_seq_num_gop_[seq_num] = save;
}
}
5) ManageFrame函数业务处理
void RtpFrameReferenceFinder::ManageFrame(
std::unique_ptr<RtpFrameObject> frame) {
.....
FrameDecision decision = ManageFrameInternal(frame.get());
switch (decision) {
case kStash:
if (stashed_frames_.size() > kMaxStashedFrames)//最大100
stashed_frames_.pop_back();
stashed_frames_.push_front(std::move(frame));
break;
case kHandOff:
HandOffFrame(std::move(frame));
RetryStashedFrames();
break;
case kDrop:
break;
}
}
- 在2.1中分析了ManageFrameInternal的原理,该函数会返回三种不同的决策。
- 当返回
kStash
的时候会将当前待解码的帧插入到stashed_frames_
容器,等待合适的时机获取,如果容器满了先将末尾的清除掉,然后从头部插入,同时根据上面的分析我们可以得知,出现这种情况是要等待前面的帧完整。所以在kHandOff
的情况下先处理当前帧然后再通过RetryStashedFrames获取stashed_frames_
中存储的帧进行解码。 - 当返回
kHandOff
的时候调用HandOffFrame函数进行再处理。 - 当返回
kDrop
的时候直接丢弃该帧数据。 -
stashed_frames_
为一个std::deque<std::unique_ptr<RtpFrameObject>>
队列。
void RtpFrameReferenceFinder::HandOffFrame(
std::unique_ptr<RtpFrameObject> frame) {
//picture_id_offset_为0
frame->id.picture_id += picture_id_offset_;
for (size_t i = 0; i < frame->num_references; ++i) {
frame->references[i] += picture_id_offset_;
}
frame_callback_->OnCompleteFrame(std::move(frame));
}
- 调用OnCompleteFrame将RtpFrameObject传递到
RtpVideoStreamReceiver
模块当中。
void RtpFrameReferenceFinder::RetryStashedFrames() {
bool complete_frame = false;
do {
complete_frame = false;
for (auto frame_it = stashed_frames_.begin();
frame_it != stashed_frames_.end();) {
FrameDecision decision = ManageFrameInternal(frame_it->get());
switch (decision) {
case kStash:
++frame_it;
break;
case kHandOff:
complete_frame = true;
HandOffFrame(std::move(*frame_it));
RTC_FALLTHROUGH();
case kDrop:
frame_it = stashed_frames_.erase(frame_it);
}
}
} while (complete_frame);
}
- 对
stashed_frames_
容器进行遍历,重新调用ManageFrameInternal
进行决策,最后如果决策可解码的话回调HandOffFrame
进行处理。 - 如果决策结果为
kDrop
直接释放。
6)总结
-
RtpFrameReferenceFinder
模块的核心作用就是决策当前帧是否要进入到解码模块。 - 决策的依据依然是根据seq的连续性,以及是否有关键帧等性质。
- 在决策为
kHandOff
的情况下会通过其成员变量frame_callback_
将数据重新传递到RtpVideoStreamReceiver
模块的OnCompleteFrame
函数。 - 接下来的处理就是解码前的操作了如将数据放到
jitterbuffer
模块等。