直播推流
最近由于项目需要,研究了一下终端到服务端的视频推流,在此记录分享一下。
业务场景
我遇到的业务场景和视频直播中的推流/拉流很像,典型的架构如图:
对于直播推拉流,已有很多成熟的方案,例如微信视频号直播用的 RTMP。但这里我只需要将客户端的直播画面推送到服务端,后续由服务端交给大模型进行理解,不需要拉流分发。所以我决定自己实现一下这个过程。
首先是客户端。采集直播画面可以取手机的摄像头画面,也可以取手机的屏幕直播画面。
对于摄像头采集,常规的摄像头代码就不贴了,只需留意添加videoOutput
时,将videoSetting
设置为如下形式:
videoOutput.videoSettings = @{
(id)kCVPixelBufferPixelFormatTypeKey : @(kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange),
};
kCVPixelBufferPixelFormatTypeKey
的设置直接决定了你拿到的视频帧的像素格式、颜色空间和内存布局。它决定了每个像素的颜色数据是如何编码和存储的。主要有两种选择:YUV 和 BGRA。
kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange
是 iOS 摄像头硬件直接输出的格式,由于色度抽样,相比未压缩的 RGB 格式,数据量更小,适合网络传输和存储。硬件视频编码器(如 H.264)的输入格式就是 YUV。对于录制视频或进行视频流传输来说,这是最高效的选择。
kCVPixelFormatType_32BGRA
是未压缩的 RGB 颜色格式。由于摄像头原生输出是 YUV,将每一帧 YUV 数据转换为 BGRA 会增加 CPU/GPU 资源的消耗。适合需要对视频帧进行实时图像处理(如美颜、滤镜、贴纸)的情况。
对于手机屏幕直播画面,使用 Broadcast Upload Extension
可以采集到系统级别的手机屏幕画面。
这两种采集方式都能收到包含 CMSampleBuffer
数据的回调,这个是苹果提供的帧数据结构,包含特定类型(音频、视频、混合等)的媒体样本。
H264 编码
接下来是重点的 H264 编码部分。收到第一帧 CMSampleBuffer
后,开启一个 VTCompressionSession
会话,VTCompressionSession
是苹果 VideoToolbox 提供的硬件编码能力。
CMFormatDescriptionRef formatDesc = CMSampleBufferGetFormatDescription(sampleBuffer);
CMVideoDimensions dims = CMVideoFormatDescriptionGetDimensions(formatDesc);
int width = dims.width;
int height = dims.height;
// 创建视频压缩会话,指定宽、高,指定H264编码格式,指定compressionOutputCallback为编码回调函数
OSStatus status = VTCompressionSessionCreate(NULL, width, height, kCMVideoCodecType_H264, NULL, NULL, NULL, compressionOutputCallback, (__bridge void *)self, &_compressionSession);
创建会话后,使用VTSessionSetProperty
设置会话相关的属性,例如关键帧间隔、ProfileLevel、帧率、比特率等。
关键帧(Instantaneous Decoder Refresh, IDR, 也称为 I 帧) 是一张完整的图像,就像一张可完整显示的图片。解码器只需要这一帧的数据就能完整显示整个画面;而非关键帧 (Predicted Frame, P 帧和 Bi-directional Predicted Frame, B 帧) 也叫“增量帧”或“差异帧”,它们不包含完整的图像信息,只记录了与前后帧相比发生了变化的部分,这样编码可以极大地提高压缩率。
视频流就是由一个关键帧,后面跟着一串非关键帧,然后又一个关键帧……这样组成的。这个关键帧的序列被称为 GOP (Group of Pictures)。kVTCompressionPropertyKey_MaxKeyFrameInterval
就定义了这个 GOP 的最大长度,也就是两个关键帧之间最多允许有多少帧。对于直播推流 30FPS 来说,每 30 帧一个关键帧是比较合适的。
在 H.264 编码标准中,Profile 和 Level 是两个独立的参数。
Profile 定义了编码器在压缩视频时可以使用编码特性:
- Baseline Profile: 最简单,包含的特性最少。不支持 B 帧(双向预测帧),不支持 CABAC 熵编码。压缩率最低,但解码时计算量最小,延迟也最低。
- Main Profile: 在 Baseline 的基础上增加了 B 帧和 CABAC 等,大大提高了压缩效率。
- High Profile: 在 Main 的基础上增加了更多高级特性,如 8x8 内部预测、自定义量化矩阵等。是目前压缩率最高的 Profile。
Level 定义了解码器必须能够处理的视频码率、分辨率、帧率等参数的上限。例如 4.0 表示 1920x1080@30fps(1080p)。
这里我们将kVTCompressionPropertyKey_ProfileLevel
设置为kVTProfileLevel_H264_High_AutoLevel
,意思是使用 High Profile,Level 让框架根据分辨率和帧率自动判断。
kVTCompressionPropertyKey_AverageBitRate
这个属性设置的是平均码率 (Average Bit Rate),对应的是 VBR (Variable Bit Rate) 可变码率,当画面变化小时,它会使用低于 4 Mbps 的码率,从而节省带宽;当画面变化大时,它会瞬时使用高于 4 Mbps 的码率,以保证画面的清晰度。对于直播 1080p 视频来说,设置 4500 Kbps 左右的码率比较合适(大约是1920*1080*2
)。
使用 kVTCompressionPropertyKey_DataRateLimits
可以给编码器增加一个硬性上限,即不管画面瞬时变化有多大,我们希望每个时刻的码率都不超过 6 Mbps。
// 设置帧率
int fps = 30;
CFNumberRef fpsNum = CFNumberCreate(kCFAllocatorDefault, kCFNumberIntType, &fps);
VTSessionSetProperty(self.compressionSession, kVTCompressionPropertyKey_ExpectedFrameRate, fpsNum);
CFRelease(fpsNum);
// 设置关键帧间隔
int keyFrameInterval = 30;
CFNumberRef keyFrameIntervalNum = CFNumberCreate(kCFAllocatorDefault, kCFNumberIntType, &keyFrameInterval);
VTSessionSetProperty(self.compressionSession, kVTCompressionPropertyKey_MaxKeyFrameInterval, keyFrameIntervalNum);
CFRelease(keyFrameIntervalNum);
// 设置Profile Level
VTSessionSetProperty(self.compressionSession, kVTCompressionPropertyKey_ProfileLevel, kVTProfileLevel_H264_Main_AutoLevel);
// 告诉编码器这是一个实时直播的画面,优先保证低延迟和处理速度,可以适当牺牲一些压缩率和画面质量。
VTSessionSetProperty(self.compressionSession, kVTCompressionPropertyKey_RealTime, kCFBooleanTrue);
// 设置平均码率
int32_t averageBitRate = 4500 * 1000; // 4,500,000 bits per second
CFNumberRef bitRateNum = CFNumberCreate(kCFAllocatorDefault, kCFNumberSInt32Type, &averageBitRate);
VTSessionSetProperty(self.compressionSession, kVTCompressionPropertyKey_AverageBitRate, bitRateNum);
CFRelease(bitRateNum);
// 设置码率上限
int dataLimitBytes = 6000 * 1000 / 8; // 6 Mbps in Bytes per second
int dataLimitSeconds = 1.0;
VTSessionSetProperty(self.compressionSession, kVTCompressionPropertyKey_DataRateLimits, (__bridge CFArrayRef)@[@(dataLimitBytes), @(dataLimitSeconds)]);
参数准备就绪后,调用 VTCompressionSessionPrepareToEncodeFrames(self.compressionSession)
准备编码。
然后,将流式输入的 CMSampleBuffer
传给 VTCompressionSessionEncodeFrame
函数进行编码。这是一个异步函数,它会立刻返回,并在编码完成后将结果通过你在创建 VTCompressionSession
时注册的回调函数返回给你。
self.frameCount++;
CVImageBufferRef imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer);
CMTime presentationTimeStamp = CMTimeMake(self.frameCount, 30);
CMTime duration = CMSampleBufferGetDuration(sampleBuffer);
VTCompressionSessionEncodeFrame(self.compressionSession, imageBuffer, presentationTimeStamp, duration, NULL, NULL, NULL);
H264 原始数据流
NALU (Network Abstraction Layer Unit) 是 H.264/HEVC 标准定义的一个基本数据包格式。H.264 视频流就是一个个 NALU 拼接起来的。
一个 NALU 由两部分组成:
- NAL Header: 其中 NALU Type 指明了这个 NALU 的类型(IDR 帧、非 IDR 帧、SPS、PPS)
- NAL Payload: 实际的视频数据。对于 SPS/PPS,这里面是配置信息;对于 IDR/P/B 帧,这里面是压缩后的图像数据。
SPS (Sequence Parameter Set) 和 PPS (Picture Parameter Set) 是 H.264/HEVC 编码标准中定义的两种参数集 NALU。它们不包含像素数据,而是包含解码器正确解析像素数据所必需的元数据。
SPS 是为整个视频文件、一个直播流设定的参数,包括 Profile、Level、图像尺寸、帧率和时序等,在一个视频流中固定不变。如果 SPS 发生改变,通常意味着视频的核心属性如分辨率发生了变化(例如用户在直播中切换了清晰度),此时解码器需要重新进行初始化。
PPS 是被多个图像共享的参数,包括量化、熵编码等,在一个视频序列中可以存在多个不同的 PPS。
H.264 数据在实际应用中主要有两种封装格式:
第一种是 AVCC 格式 (或 Length-Prefixed Format, 长度前缀格式) ,它的结构如下:
[4-byte NALU Length][NALU Data][4-byte NALU Length][NALU Data]...
它的特点是每个 NALU 单元前面都有一个 4 字节的大端、整数,用来指明这个 NALU 的长度。解码器可以先读取 4 个字节来知道接下来要读取多少数据。这是 VideoToolbox 框架默认输出的格式,也是 .mp4
, .mov
等文件容器内部存储 H.264 数据时使用的格式。
第二种是 Annex B 格式 (或 Start Code-Prefixed Format, 起始码前缀格式),它的结构如下:
[Start Code][NALU Data][Start Code][NALU Data]...
它的特点是每个 NALU 单元前面都有一个起始码 (Start Code),通常是 0x00000001
(4 字节)。这种格式使得在传输数据流时,解析方可 以在一个连续的字节流中通过起始码来找到每个 NALU 的边界,被广泛用于流媒体传输协议中。
我们将CMSampleBuffer
提交给VTCompressionSession
压缩后,输出的数据是 AVCC 格式,这里我们需要转换成网络传输所需要的 Annex B 格式。