Skip to main content

直播推流

最近由于项目需要,研究了一下终端到服务端的视频推流,在此记录分享一下。

业务场景

我遇到的业务场景和视频直播中的推流/拉流很像,典型的架构如图:

对于直播推拉流,已有很多成熟的方案,例如微信视频号直播用的 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 格式。