在 Mac 平台播放 H264 直播流

        虽然做过音视频多年了,但是一直没有了解 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、疑难杂症

  • 没有图像输出

  • 在应用中集成播放器,没有图像输出

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Fighting Horse

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值