WebRTC Native 源码导读(九):iOS 视频硬编码实现分析
原文链接 http://blog.piasy.com/2018/05/04/WebRTC-iOS-HW-Encode-Video/
注:以下为加速网络访问所做的原文缓存,经过重新格式化,可能存在格式方面的问题,或偶有遗漏信息,请以原文为准。
和对 WebRTC Android 的分析一样,继采集和渲染之后,现在让我们分析一下 WebRTC iOS 的视频硬编码实现。
iOS 的视频硬编码用到的是 VideoToolbox 库,除了编码,VideoToolbox 还提供了解码、转码等功能。我们先了解一下 VideoToolbox 编码的基本工作流程,再看看 WebRTC 对它的使用。
本文的分析基于 WebRTC 的 #23295 提交。
VideoToolbox
使用 VideoToolbox 进行编码的基本工作流程如下:
- 调用
VTCompressionSessionCreate
创建编码 session; - 调用
VTSessionSetProperty
或VTSessionSetProperties
配置 session,例如 profile, level, 码率,关键帧间隔等等; - 调用
VTCompressionSessionEncodeFrame
送入未编码数据,在VTCompressionOutputCallback
回调里接收已编码数据; - 调用
VTCompressionSessionCompleteFrames
「刷出」所有正在编码的数据,类似于 Android MediaCodec 的 EndOfStream; - 调用
VTCompressionSessionInvalidate
销毁 session,调用CFRelease
释放内存;
不得不承认,iOS 的硬编码比 Android 要方便的多。
官方文档也没有给出示例,那我们就闲话少说,直接看 WebRTC 的代码吧。
创建 session
创建 session 时通常需要指定宽高、码流类型、未编码数据属性、编码数据回调。
// Set source image buffer attributes. These attributes will be present on
// buffers retrieved from the encoder's pixel buffer pool.
const size_t attributesSize = 3;
CFTypeRef keys[attributesSize] = {
kCVPixelBufferOpenGLESCompatibilityKey,
kCVPixelBufferIOSurfacePropertiesKey,
kCVPixelBufferPixelFormatTypeKey
};
CFDictionaryRef ioSurfaceValue = CreateCFTypeDictionary(nullptr, nullptr, 0);
int64_t pixelFormatType = kCVPixelFormatType_420YpCbCr8BiPlanarFullRange;
CFNumberRef pixelFormat = CFNumberCreate(nullptr, kCFNumberLongType, &pixelFormatType);
CFTypeRef values[attributesSize] = {kCFBooleanTrue, ioSurfaceValue, pixelFormat};
CFDictionaryRef sourceAttributes = CreateCFTypeDictionary(keys, values, attributesSize);
OSStatus status =
VTCompressionSessionCreate(nullptr, // use default allocator
_width,
_height,
kCMVideoCodecType_H264,
nullptr,
sourceAttributes,
nullptr, // use default compressed data allocator
compressionOutputCallback,
nullptr,
&_compressionSession);
// 检查 status,释放 ioSurfaceValue, pixelFormat, sourceAttributes
WebRTC iOS demo 输入编码器的数据格式是 kCVPixelFormatType_420YpCbCr8BiPlanarFullRange
,宽高可以在 demo 里面设置。
这里需要指出的是,VideoToolbox 编码时,需要输入图像尺寸和输出图像尺寸的宽高比保持一致,否则画面就会出现拉伸或压缩。其实 Android 的 MediaCodec 本身也是不支持裁剪的,要么我们对 YUV 数据自行裁剪,要么我们在绘制到 Surface 里时进行裁剪。
此外,回调函数(第八个参数)传入的是一个函数指针,回调发生时是没有 self 指针的,但第九个参数允许我们传入一个指针,每次回调发生时会作为回调的第一个参数,所以我们可以用它来传递 self 指针。但 WebRTC 这里传的是 nullptr,那如何获取 self 指针呢?答案稍后揭晓。
配置 session
WebRTC 对 session 的配置代码如下:
SetVTSessionProperty(_compressionSession, kVTCompressionPropertyKey_RealTime,
true);
SetVTSessionProperty(_compressionSession,
kVTCompressionPropertyKey_ProfileLevel, _profile);
SetVTSessionProperty(_compressionSession,
kVTCompressionPropertyKey_AllowFrameReordering, false);
SetVTSessionProperty(_compressionSession,
kVTCompressionPropertyKey_AverageBitRate, bitrateBps);
// TODO(tkchin): Add a helper method to set array value.
int64_t dataLimitBytesPerSecondValue =
static_cast<int64_t>(bitrateBps * 1.5 / 8);
CFNumberRef bytesPerSecond = CFNumberCreate(
kCFAllocatorDefault, kCFNumberSInt64Type, &dataLimitBytesPerSecondValue);
int64_t oneSecondValue = 1;
CFNumberRef oneSecond =
CFNumberCreate(kCFAllocatorDefault, kCFNumberSInt64Type, &oneSecondValue);
const void* nums[2] = {bytesPerSecond, oneSecond};
CFArrayRef dataRateLimits =
CFArrayCreate(nullptr, nums, 2, &kCFTypeArrayCallBacks);
OSStatus status = VTSessionSetProperty(
_compressionSession, kVTCompressionPropertyKey_DataRateLimits,
dataRateLimits);
// Set a relatively large value for keyframe emission (7200 frames or 4
// minutes).
SetVTSessionProperty(_compressionSession,
kVTCompressionPropertyKey_MaxKeyFrameInterval, 7200);
SetVTSessionProperty(_compressionSession,
kVTCompressionPropertyKey_MaxKeyFrameIntervalDuration,
240);
SetVTSessionProperty
是 WebRTC 里的一个辅助函数,封装了对 VTSessionSetProperty
的调用。
kVTCompressionPropertyKey_RealTime
用来设置编码器的工作模式是实时还是离线,实时会编得快些,延迟更低,但压缩效率会差一些,离线则编得慢些,延迟更大,但压缩效率会更高。本地录制视频文件可以使用离线模式,RTC 场景下为了降低延迟,则需要使用实时模式了。
kVTCompressionPropertyKey_ProfileLevel
用来设置 H.264/H.265 的 profile 和 level,具体定义可以查阅相关规范。iOS 的 demo 可以选择使用 H.264 Baseline 或者 H.264 High profile,选择 Baseline 时使用的是 kVTProfileLevel_H264_Baseline_3_1
,选择 High profile 时使用的是 kVTProfileLevel_H264_High_3_1
。
kVTCompressionPropertyKey_AllowFrameReordering
用来设置是否编 B 帧,High profile 支持 B 帧,但 WebRTC 禁用了 B 帧,因为 B 帧会加大延迟。
kVTCompressionPropertyKey_AverageBitRate
用来设置平均码率,单位是 bps,这是一个柔性的指标,实际输出码率允许在其上下浮动。kVTCompressionPropertyKey_DataRateLimits
用来设置硬性码率限制,别看这段代码很冗长,实际做的就是设置码率的硬性限制是每秒码率不超过平均码率的 1.5 倍,这也正是 WebRTC 封装 SetVTSessionProperty
工具函数的原因,没看到注释里还有个 TODO 嘛 :)
kVTCompressionPropertyKey_MaxKeyFrameInterval
和 kVTCompressionPropertyKey_MaxKeyFrameIntervalDuration
共同设置关键帧间隔,在 WebRTC 里,每 7200 帧要编一个关键帧,或者每 240s 要编一个关键帧。
更多配置选项,可以查阅 Apple 官方文档。
数据输入
数据输入就比较简单了,直接把 YUV 数据送入 session 即可:
OSStatus status =
VTCompressionSessionEncodeFrame(_compressionSession,
pixelBuffer,
presentationTimeStamp,
kCMTimeInvalid,
frameProperties,
encodeParams,
nullptr);
第二个参数 pixelBuffer
是 YUV 数据,当然 WebRTC 在把 YUV 数据送入编码器之前,支持做一个裁剪。此外,如果数据格式是 I420,还需要做一个 I420 转 NV12 的操作,格式转换的同时,也支持先裁剪一番。
第三个参数 presentationTimeStamp
是这一帧的时间戳,单位是毫秒,第四个参数是这一帧的 presentation duration,这个概念我在 H.264 标准里没有看见过,WebRTC 也并没有实际传入此参数。
第五个参数 frameProperties
用来指定这一帧的属性,WebRTC 用它来实现请求关键帧。
第六个参数 encodeParams
就很有意思了,编码器允许每一帧数据携带一个自定义指针,编码完成后我们可以拿到这个指针,这样我们就可以让每帧数据携带一些独特的信息了,例如一个额外的时间戳,Android MediaCodec 可没有这个功能。在这里 WebRTC 把 RTCVideoEncoderH264
对象的 self 指针作为这个独特信息的一部分传了进去,所以即便创建 session 时没有传 self 指针,我们也照样玩得转。
数据输出
前面我们在创建 session 时设置了编码完成的回调,这个回调是一个函数指针,它接收的第一个参数,就是我们创建 session 时传入的第八个参数,它接收的第二个参数,就是我们在输入数据时传的第六个参数,后三个参数比较简单,这里就不展开解释了。
// This is the callback function that VideoToolbox calls when encode is
// complete. From inspection this happens on its own queue.
void compressionOutputCallback(void *encoder,
void *params,
OSStatus status,
VTEncodeInfoFlags infoFlags,
CMSampleBufferRef sampleBuffer) {
if (!params) {
// If there are pending callbacks when the encoder is destroyed, this can happen.
return;
}
std::unique_ptr<RTCFrameEncodeParams> encodeParams(
reinterpret_cast<RTCFrameEncodeParams *>(params));
[encodeParams->encoder frameWasEncoded:status
flags:infoFlags
sampleBuffer:sampleBuffer
codecSpecificInfo:encodeParams->codecSpecificInfo
width:encodeParams->width
height:encodeParams->height
renderTimeMs:encodeParams->render_time_ms
timestamp:encodeParams->timestamp
rotation:encodeParams->rotation];
}
编码数据以及这一帧的基本信息都在 CMSampleBufferRef
中,相关信息的获取示例代码如下:
// 检查是否关键帧
BOOL isKeyframe = NO;
CFArrayRef attachments =
CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, 0);
if (attachments != nullptr && CFArrayGetCount(attachments)) {
CFDictionaryRef attachment =
static_cast<CFDictionaryRef>(CFArrayGetValueAtIndex(attachments, 0));
isKeyframe =
!CFDictionaryContainsKey(attachment, kCMSampleAttachmentKey_NotSync);
}
// 获取 pts, dts
CMTime pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer);
CMTime dts = CMSampleBufferGetDecodeTimeStamp(sampleBuffer);
// 获取 SPS
CMVideoFormatDescriptionRef description =
CMSampleBufferGetFormatDescription(sampleBuffer);
const uint8_t* sps = nullptr;
size_t sps_size = 0;
CMVideoFormatDescriptionGetH264ParameterSetAtIndex(
description, 0, &sps, &sps_size, nullptr, nullptr);
// 获取 PPS
const uint8_t* pps = nullptr;
size_t pps_size = 0;
CMVideoFormatDescriptionGetH264ParameterSetAtIndex(
description, 1, &pps, &pps_size, nullptr, nullptr);
// 获取帧数据
CMBlockBufferRef data_buffer = CMSampleBufferGetDataBuffer(sampleBuffer);
size_t data_size = CMBlockBufferGetDataLength(data_buffer);
uint8_t* data = new uint8_t[data_size];
CMBlockBufferCopyDataBytes(data_buffer, 0, data_size, data);
需要指出的是,编码器输出的数据是 AVCC 格式,WebRTC 将其转换为 Annex-B 格式后,再进行封装和发送。它们是两种 H.264 码流 NALU 存储的方式,简单来说 Annex-B 是 start code + NALU + start code + NALU 的形式,而 AVCC 则是 NALU length + NALU + NALU length + NALU 的形式,具体可以查阅规范,或者这篇中文博客。
调节码率
码率调节和配置 session 时设置码率的方法一样。
说到码率调节,一年多以前我还在 Stack Overflow 上问过 Android MediaCodec 有没有像 iOS 这样设置平均码率 + 最大码率的接口,现在结论已经很明确了,就是没有。
iOS 有个东西倒是没有:bitrate mode。~~根据文档描述来看,应该是 VBR,因为允许码率在平均码率上下波动嘛。~~ 根据实际测试,从效果上看,应该是 CBR 才对,毕竟 CBR 也不是码率完全不变,小幅度波动还是存在的。
之前在 WebRTC Native 源码导读(三):安卓视频硬编码实现分析中我对 VBR CBR 如何选择给出了一点看法,这里我想补充几点:
- VBR 在画面内容保持静止时,码率会降得很低,一旦画面内容开始动起来,码率上升速度会跟不上,就会导致画面质量很差;
- VBR 上调码率后,有可能导致中间网络路径的丢包/延迟增加,进而导致问题;
- CBR 会存在关键帧后的几帧内容模糊的问题,如果关键帧间隔较短,可以观察到明显的「呼吸效应」;
- WebRTC 使用的方案是 CBR + 长关键帧间隔,这样「呼吸效应」就不是那么明显,而 CBR 确实能增强画面质量;
前两点援引自 Twitch blog。
Update 2018.05.10:在分析 Android MediaCodec 视频硬编时,我对各种模式的码率稳定性以及码率调节做了测试,iOS VideoToolbox 虽然没有多种模式可以选择,但仍可以对仅有的一种模式进行测试,下面是测试结果(iPhone 6),完整项目可以在 GitHub 获取。
上图是不调节码率时的码率变化曲线,对比 MediaCodec 的测试结果,我们发现 VideoToolbox 的码率比 MediaCodec 的 VBR 还是要稳得多,和 CBR 差不多。
上图是调节码率时的码率变化曲线,可以看到,表现也和 MediaCodec 的 CBR 差不多。
关键帧
前面我们已经提到了 kVTCompressionPropertyKey_MaxKeyFrameInterval
和 kVTCompressionPropertyKey_MaxKeyFrameIntervalDuration
共同控制关键帧间隔。此外,在编码过程中,我们也可以在编某帧时,主动请求编出关键帧:
CFDictionaryRef frameProperties = nullptr;
CFTypeRef keys[] = {kVTEncodeFrameOptionKey_ForceKeyFrame};
CFTypeRef values[] = {kCFBooleanTrue};
CFDictionaryRef frameProperties = CreateCFTypeDictionary(keys, values, 1);
OSStatus status =
VTCompressionSessionEncodeFrame(_compressionSession,
pixelBuffer,
presentationTimeStamp,
kCMTimeInvalid,
frameProperties,
encodeParams,
nullptr);
总结
至此,iOS 的视频采集、渲染、编码都已经分析完毕了,原本我打算和 Android 一样把 WebRTC 这块代码摘出来,形成 iOS VideoCRE 库,但头文件实在太多,而且依赖关系太复杂,所以只得作罢 :(
接下来我会尝试把完整的 WebRTC 带到 Flutter 的世界里,形成 FlutterRTC,毕竟我还是比较看好 Flutter 的,敬请期待 :)