虽然做过音视频多年了,但是一直没有了解 Mac(以及 iOS)平台的音视频框架。最近要做一个 H264 纯视频流的播放,有机会研究了一下。
1、方案调研
在 Mac 上播放视频,有好几个方案:
- 方案一:使用 AVPlayer
AVPlayer 支持 .mp4、.mov、.m4v、.3gp、.avi 这些文件格式,但是不支持 raw h264,所以我们需要将 raw h264 封装为上面的一种格式。然而,上面任意一种都是点播格式,没有一个能够描述持续不断的直播流。所以该方案基本不可行。
- 方案二:使用 ffmpeg 解码 + OpenGL 渲染
该方案看起来基本可行,ffmpeg 软解 h264 应该是很成熟的,使用 OpenGL 渲染只需要将图像转化为 GL 纹理就能够显示。
但是,该方案有性能缺陷,首先用 CPU 软解 h264 需要消耗不少的 CPU;其次将 CPU 处理的图像转换为 GPU 处理的 GL 纹理,需要将数据拷贝到 GPU 专用内存,也会有一定的性能消耗。
同时,该方案需要同时熟悉 ffmpeg 解码和 OpenGL 工作原理,对初学者无论哪个都是一个大的挑战。
也许有人会有疑问,直接将解码后的图像用 NSImageView 显示出来,不就行了吗,何必用 OpenGL。
这就需要知道,解码出来的图像其实不是 RGB 色彩空间的颜色值,而是 YUV 色彩空间的颜色值。将 YUV 转换为 RGB是很耗 CPU 的,基本是都是通过 GPU 实现,把数据传过去再传回来,你说消耗是不是更大了。
- 方案三:自定义AVAssetReader/AVAssetReaderOutput
这是一个思路,但是不管从 Apple 的文档,还是参考网上的资料,都说无法自定义,因此该方案也是不可行。
- 方案四:使用 VideoToolbox 解码 + NSImage 渲染
这是我最终使用的方案,详细的实现细节会在下面给出。
- 方案五:使用 AVSampleBufferDisplayLayer 直接播放
2、开发实现
2.1、解码器配置
解码器的配置首先需要创建一个视频描述对象 CMVideoFormatDescriptionRef。
对于 H264,需要先准备好 SPS、PPS 这两个编解码配置数据(下面的 parameterSet),然后调用下面的方法创建视频描述对象。
uint8_t const * parameterSetPointers[2];
size_t parameterSetSizes[2];
OSStatus status = CMVideoFormatDescriptionCreateFromH264ParameterSets(
NULL, 2, parameterSetPointers, parameterSetSizes, 4, &formatDescription);
有了视频描述对象,还需要准备一个解码器回调函数:
static void Decompression_Output_Callback(
void * CM_NULLABLE decompressionOutputRefCon,
void * CM_NULLABLE sourceFrameRefCon,
OSStatus status,
VTDecodeInfoFlags infoFlags,
CM_NULLABLE CVImageBufferRef imageBuffer,
CMTime presentationTimeStamp,
CMTime presentationDuration )
{
MyPlayer * player = (__bridge MyPlayer*) decompressionOutputRefCon;
[player didDecompress: status with: imageBuffer];
}
最后,就是创建解码器了,输出的是一个 VTDecompressionSessionRef:
VTDecompressionOutputCallbackRecord callBackRecord;
callBackRecord.decompressionOutputCallback = &Decompression_Output_Callback;
callBackRecord.decompressionOutputRefCon = (__bridge void *)self;
status = VTDecompressionSessionCreate(NULL, formatDescription, NULL, NULL,
&callBackRecord, &decodeSession);
2.2、图像解码
图像解码需要单独的线程,在这个线程中不断的从网络读取数据(一帧帧 H264 Sample),然后交给解码器解码。
while (playing) {
int result = ReadSample(&session, &sample);
if (result == success) {
[self decode: &sample];
usleep(33000);
} else if (result == would_block) {
usleep(200000);
} else {
dispatch_async(dispatch_get_main_queue(), ^{
[self stop];
});
break;
}
}
解码时,需要将数据封装为解码器可以接受的内存块 CMBlockBufferRef
OSStatus status = CMBlockBufferCreateWithMemoryBlock(kCFAllocatorDefault,
(void *)buf, size, kCFAllocatorNull, NULL, 0, size, 0, &blockBuffer);
然后创建一个待解码的带描述的帧 CMSampleBufferRef
CMSampleBufferRef sampleBuffer = NULL;
status = CMSampleBufferCreateReady(kCFAllocatorDefault, blockBuffer,
formatDescription, 1, 0, NULL, 1, &size, &sampleBuffer);
最后调用下面的函数解码,这里我使用最简单的同步模式解码,解码函数返回后,回调函数已经执行。
VTDecodeFrameFlags flags = kVTDecodeFrame_EnableAsynchronousDecompression;
VTDecodeInfoFlags flagsOut = 0;
OSStatus decodeStatus = VTDecompressionSessionDecodeFrame ( decodeSession,
sampleBuffer, flags, NULL, &flagsOut );
不要忘了释放一些刚刚创建的对象:
CFRelease(sampleBuffer);
CFRelease(blockBuffer);
2.3、图像渲染
在解码回调方法里面完成渲染工作。对于真正的播放器,这里除了将图像显示出来,还要考虑音视频展示的同步。我们做的是实时直播,也没有音频,所以不考虑同步的事情。只要解码出来,就可以显示了。
通过下面的方法可以将 CVImageBufferRef 转换为 CIImage、再转换为 CGImageRef,最后变为 NSImage。我这也是半吊子抄的其他地方的,不明白各种 Image 有什么区别。
最后,需要在 UI 线程将 NSImage 传给 NSImageView,因为所以对 View 的操作必须在 UI 线程调用。这里用到了 dispatch_async ,用来实现跨线程执行。
CIImage *ciImage = [CIImage imageWithCVPixelBuffer: imageBuffer];
CIContext *temporaryContext = [CIContext contextWithOptions:nil];
CGImageRef videoImage = [temporaryContext
createCGImage:ciImage
fromRect:CGRectMake(0, 0,
CVPixelBufferGetWidth(imageBuffer),
CVPixelBufferGetHeight(imageBuffer))];
NSSize size;
size.width = CVPixelBufferGetWidth(imageBuffer);
size.height = CVPixelBufferGetHeight(imageBuffer);
NSImage *image = [[NSImage alloc] initWithCGImage:videoImage size: size];
dispatch_async(dispatch_get_main_queue(), ^{
if (self->playing)
[self->view setImage: image];
});
CGImageRelease(videoImage);
3、性能优化
播放 720p 10 fps 的视频,查看 CPU 消耗,单核 30%,虽然对一个多核 CPU 来说,这样的消耗不算太多。但是优化还是有必要的,毕竟 1080p、25 fps 才是正常的配置。
- 方案一:硬件缩放
首先我们想到的是,视频图像的分辨率,与最终在界面上显示的分辨率,不是一样的,这个缩放现在是在 CPU 上执行,应该可以优化到在硬件完成。
怎么做呢?不难,直接告诉解码器,我们需要的 W x H 的图像就行了,系统自带的解码器一般会用硬件能力来完成图像的缩放。
代码待补充
经过这一优化后,CPU 降至 20% 左右。
- 方案二:硬件渲染
上面我们都是将硬件解码出来的图像,转换为 CPU 处理的图像,这里应该需要数据拷贝,并且 CPU 渲染效率也不高。
所以,能不能直接在硬件层面就将图像渲染了呢?
我们知道 Mac NSView 的 layer 图层是通过硬件渲染的,我们可以直接将解码出来的图像交给 layer,这样既简单有高效。
IOSurfaceRef surface = CVPixelBufferGetIOSurface(imageBuffer);
dispatch_async(dispatch_get_main_queue(), ^{
if (surface == nil)
printf("surface == nil\n");
if (self->playing && surface != nil)
view.layer.contents = (__bridge id) surface;
});
现在,CPU 降至了 10% 左右。
4、直接播放
后来我发现 Mac 平台有一种更加简单的实时直播方案:直接使用 AVSampleBufferDisplayLayer。只要给他待解码的 Sample,后面的事情就全部交给 AVSampleBufferDisplayLayer 处理了。
4.1、初始化
创建 AVSampleBufferDisplayLayer
// create our AVSampleBufferDisplayLayer and add it to the view
videoLayer = [[AVSampleBufferDisplayLayer alloc] init];
videoLayer.frame = self.view.frame;
videoLayer.bounds = self.view.bounds;
videoLayer.videoGravity = AVLayerVideoGravityResizeAspect;
设置时钟
// set Timebase, you may need this if you need to display frames at specific times
// I didn't need it so I haven't verified that the timebase is working
CMTimebaseRef controlTimebase;
CMTimebaseCreateWithMasterClock(CFAllocatorGetDefault(),
CMClockGetHostTimeClock(), &controlTimebase);
//videoLayer.controlTimebase = controlTimebase;
CMTimebaseSetTime(videoLayer.controlTimebase, kCMTimeZero);
CMTimebaseSetRate(videoLayer.controlTimebase, 1.0);
附加到 View 上
[[self.view layer] addSublayer:videoLayer];
4.2、播放
这里使用的 CMSampleBufferRef 与解码方案中创建的 Sample 是一样的。
可以针对该 Sample 额外配置一些参数,比如下面配置的 DisplayImmediately (立即展示)。
同样,需要在 UI 线程访问 UI 的功能,从而使用了 dispatch_async。
CFArrayRef attachments = CMSampleBufferGetSampleAttachmentsArray(
sampleBuffer, YES);
CFMutableDictionaryRef dict = (CFMutableDictionaryRef)CFArrayGetValueAtIndex(
attachments, 0);
CFDictionarySetValue(dict, kCMSampleAttachmentKey_DisplayImmediately,
kCFBooleanTrue);
dispatch_async(dispatch_get_main_queue(), ^{
[self->layer enqueueSampleBuffer: sampleBuffer];
CFRelease(sampleBuffer);
CFRelease(blockBuffer);
});
我最终并没有切换为该方案, 它虽然简单,但是控制力度要弱一些。可能还有其他缺点,但我现在想不起来了。
5、疑难杂症
- 没有图像输出
- 在应用中集成播放器,没有图像输出