iOS直播画中画方案调研

画中画简介

画中画是苹果在 iOS 9 和 iOS 14 中分别对 iPad 和 iPhone 开放系统级悬浮窗能力,打开画中画后,直播和视频可以无缝地在系统全局播放。

使用限制

AVPictureInPictureController 最初仅支持一种初始化方式:

  • init?(playerLayer: AVPlayerLayer)

即 AVPLayer,而在大多数情况下由于种种原因我们并不会使用原生的播放器,例如之前提到的 AVPlayer 仅支持 HLS 流直播,因此要使用画中画我们需要通过一些曲线救国的方法。

方案一:RTMP -> HLS

通过服务端将 RTMP 转流为 HLS,输送到一个后台的 AVPlayer,同时旁路 RTMP 流输送给第三方播放器播放,只是要注意保持两个播放器的时间片同步。

优点:兼容性强

这种方案是通过兼容 AVPlayer 达到目的,因此所有支持画中画的设备都可以兼容。

缺点

1. 额外的性能开销

无论转流服务建立在 Nginx 服务器上还是建立在移动端设备上,都不可避免的造成额外开销。

2. 更高的延迟

之前提到过 HLS 延迟源自于起播缓冲以及建立连接等,该种方案的延迟相当于将 RTMP 与 HLS 的延迟合并起来,造成比原本更为严重的延迟。

3. 额外的适配成本

在面对不同协议的流时需要各自将他们都转成 HLS 流。

方案二:AVSampleBufferDisplayLayer

在 iOS 15 中,苹果为画中画推出了一种新的初始化方法:

  • init(contentSource: AVPictureInPictureController.ContentSource)

该方式允许通过一个自定义的视图进行画中画的初始化,包括使用AVSampleBufferDisplayLayer

下面简单介绍一下AVSampleBufferDisplayLayer。

AVSampleBufferDisplayLayer 是 CALayer 的一个子类,它可以用来渲染 CMSampleBuffer

CMSampleBuffer & CVPixelBuffer

简而言之,CVPixelBuffer 就是视频编码前/解码后的数据, CMSampleBuffer 则是视频编码后/解码前的数据。

那么我们只要能将要播放的画面的 CVPixelBuffer 取到,就可以将其播放在画中画上了。

优点:

  1. 无额外延时

由于是直接利用播放器解码得到的 CVPixelBuffer ,因此理论上除了对它的封装之外没有其他的延迟因素,这个延迟也可以忽略不计。

  1. 无需额外适配

由于是利用解码后的内容,因此解码前的视频是何种格式我们并不关心,只需要播放器支持回传 CVPixelBuffer 就行。

  1. 更容易获得第三方支持

很多时候我们是使用第三方的播放器或推流服务,而 CVPixelBuffer 只是单纯的画面数据,不会暴露任何其他服务商信息,因此更容易获得服务商的支持。

缺点:

由于是 iOS 15 之后才支持的 API,覆盖度会比第一种方案稍小,但随着时间过去,这个缺点会逐渐变小直至消失。

综上,由于方案二更具前瞻性和可行性,因此本次调研主要围绕方案二进行。接下来我会以 ijkplayer 为例详细介绍如何实现。

以 ijkplayer 为例的实现

从 ijkplayer 中获取 CVPixelBuffer

ijkplayer 是业界广泛使用的开源播放器,但目前的公开版本中并不会回传 CVPixelBuffer,因此需要对其做一些改造。

这个部分的内容参照了其他大佬的实现:

IJKPlayer获取实时数据(上)- 添加外部接口 IJKPlayer获取实时数据(中)- 添加软解码输出纹理 IJKPlayer获取实时数据(下)- 添加硬解码输出纹理

需要注意的一点是,按照这位大佬的实现获取的 CVPixelBuffer 是无法在屏幕上显示的,究其原因是因为他在生成 CVPixelBuffer 时没有为其添加 IOSurface 的关键字导致 buffer 没有与 IOSurface 产生关联。

详见:

AVSampleBufferDisplayLayer not rendering frames anymore in iOS10

因此对于「添加软解码输出纹理」中 ff_ffplay.c 的

 createCVPixelBuffer(FFPlayer *ffp, AVCodecContext* avctx, AVFrame* frame, CVPixelBufferRef* cvImage)

这一步,我们需要将

status = CVPixelBufferCreate(
                                     kCFAllocatorDefault,
                                     frame->width,
                                     frame->height,
                                     kCVPixelFormatType_32BGRA,
                                     NULL,
                                     cvImage
                                     );

修改成

        CFDictionaryRef pixelBufferAttributes;

        CFTypeRef emptyDict = CFDictionaryCreate(NULL, NULL, NULL, 0,
                                                 NULL, NULL);

        pixelBufferAttributes = CFDictionaryCreate(NULL,
            (const void**)&kCVPixelBufferIOSurfacePropertiesKey,
            (const void**)&emptyDict,
            1,
            NULL,
            NULL);
        
        status = CVPixelBufferCreate(
                                     kCFAllocatorDefault,
                                     frame->width,
                                     frame->height,
                                     kCVPixelFormatType_32BGRA,
                                     pixelBufferAttributes,
                                     cvImage
                                     );

将 Pixcelbuffer 封装为 CMSampleBuffer

- (CMSampleBufferRef)sampleBufferFromPixelBuffer:(CVPixelBufferRef)pixelBuffer {
  
    CMSampleBufferRef sampleBuffer = NULL;
    OSStatus err = noErr;
    CMVideoFormatDescriptionRef formatDesc = NULL;
    
    CVPixelBufferRetain(pixelBuffer);
    
    err = CMVideoFormatDescriptionCreateForImageBuffer(kCFAllocatorDefault, pixelBuffer, &formatDesc);
  
    if (err != noErr) {
        return nil;
    }
    
    CMSampleTimingInfo sampleTimingInfo = kCMTimingInfoInvalid;
  
    err = CMSampleBufferCreateReadyWithImageBuffer(kCFAllocatorDefault, pixelBuffer, formatDesc, &sampleTimingInfo, &sampleBuffer);
  
    if (sampleBuffer) {
        CFArrayRef attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, YES);
        CFMutableDictionaryRef dict = (CFMutableDictionaryRef)CFArrayGetValueAtIndex(attachments, 0);
        CFDictionarySetValue(dict, kCMSampleAttachmentKey_DisplayImmediately, kCFBooleanTrue);
    }
  
    if (err != noErr) {
        return nil;
    }
  
    formatDesc = nil;
    CVPixelBufferRelease(pixelBuffer);
  
    return sampleBuffer;
  
}

流程图

Demo 地址

可获取 PixelBuffer 的 ijkplayer

https://github.com/Lavanille777/ijkplayer

PIPDemo

https://github.com/Lavanille777/PIPDemo

知乎教育的支持情况

ZVVideo (KSMediaPlayer)

ZVVideoPlayer 提供了一个 ZVPlayerRenderProtocol 类型的代理

@protocol ZVPlayerRenderProtocol <NSObject>
@optional
- (void)displayPixelbuffer:(CVPixelBufferRef _Nullable )pixelBuffer;
@end

看上去似乎是提供了 CVPixelBufferRef 的回调,但实际上没有调用。

追查到 KSMediaPlayer,并没有发现任何视频纹理相关的回调。

声网

由于 CCLive 同样依赖了声网RTC,当前使用的 AgoraRTCKit 版本是 3.7.2。

它提供了 VideoFrame 回调。

但仅包含yuv分量的数据

__attribute__((visibility("default"))) @interface AgoraVideoDataFrame : NSObject
/** The color video format. See AgoraVideoFrameType.
 */
@property(assign, nonatomic) AgoraVideoFrameType frameType;
/** The width (px) of the video.
 */
@property(assign, nonatomic) NSInteger width;  // width of video frame
/** The height (px) of the video.
 */
@property(assign, nonatomic) NSInteger height;  // height of video frame
/** For YUV data, the line span of the Y buffer; for RGBA data, the total
 data length.
 */
@property(assign, nonatomic) NSInteger yStride;  // stride of Y data buffer
/** For YUV data, the line span of the U buffer; for RGBA data, the value is 0.
 */
@property(assign, nonatomic) NSInteger uStride;  // stride of U data buffer
/** For YUV data, the line span of the V buffer; for RGBA data, the value is 0.
 */
@property(assign, nonatomic) NSInteger vStride;  // stride of V data buffer
/** For YUV data, the pointer to the Y buffer; for RGBA data, the data buffer.
 */
@property(assign, nonatomic) void* _Nullable yBuffer;  // Y data buffer
/** For YUV data, the pointer to the U buffer; for RGBA data, the value is 0.
 */
@property(assign, nonatomic) void* _Nullable uBuffer;  // U data buffer
/** For YUV data, the pointer to the V buffer; for RGBA data, the value is 0.
 */
@property(assign, nonatomic) void* _Nullable vBuffer;  // V data buffer
/** The clockwise rotation angle of the video frame.
 See AgoraVideoRotation.
 */
@property(assign, nonatomic) AgoraVideoRotation rotation;  // rotation of this frame (0, 90, 180, 270)
/** The Unix timestamp (ms) when the video frame is rendered. This timestamp
 can be used to guide the rendering of the video frame. This parameter is
 required.
 */
@property(assign, nonatomic) int64_t renderTimeMs;
/** Reserved parameter.
 */
@property(assign, nonatomic) NSInteger avsync_type;

@end

实际测试得到的是YUV 420的格式的数据

尝试了将 YUV 合成为 CVPixelBuffer ,但画面色彩出现了问题。

func pixelBuffer(fromYUV yBuffer: UnsafeMutableRawPointer,
                     uBuffer: UnsafeMutableRawPointer,
                     vBuffer: UnsafeMutableRawPointer,
                     width: Int,
                     height: Int,
                     videoFrame: AgoraVideoDataFrame) -> CVPixelBuffer? {
        
        let pixelAttributes: [CFString : Any] = [
            kCVPixelBufferIOSurfacePropertiesKey: [:]
        ]
        
        var pixelBuffer: CVPixelBuffer?
        
        let result = CVPixelBufferCreate(kCFAllocatorDefault,
                                         width,
                                         height,
                                         kCVPixelFormatType_420YpCbCr8BiPlanarFullRange,
                                         pixelAttributes as CFDictionary,
                                         &pixelBuffer)
        
        guard result == kCVReturnSuccess, let pixelBuffer = pixelBuffer else {
            return nil
        }
        
        CVPixelBufferLockBaseAddress(pixelBuffer, CVPixelBufferLockFlags(rawValue: 0))
        defer {
            CVPixelBufferUnlockBaseAddress(pixelBuffer, CVPixelBufferLockFlags(rawValue: 0))
        }
        
        let uPlaneSize = width * height / 4
        let vPlaneSize = width * height / 4
        let numberOfElementsForChroma = uPlaneSize + vPlaneSize
        
        let uvPlane = UnsafeMutableRawPointer.allocate(byteCount: Int(numberOfElementsForChroma), alignment: MemoryLayout<UInt8>.alignment)
        defer {
          uvPlane.deallocate()
        }

        memcpy(uvPlane, uBuffer, uPlaneSize)
        memcpy(uvPlane.advanced(by: Int(uPlaneSize)), vBuffer, vPlaneSize)

        let yDestPlane = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 0)
        let uvDestPlane = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 1)
        
       
        
        var yPerRowOfPlane = Int(ceil(Double(width) / 64) * 64)
        
        if videoFrame.yStride == yPerRowOfPlane {
            memcpy(yDestPlane!, yBuffer, videoFrame.yStride * height)
            memcpy(uvDestPlane, uBuffer, videoFrame.uStride * height / 2)
            memcpy(uvDestPlane!.advanced(by: Int(videoFrame.uStride * height / 2)), vBuffer, videoFrame.vStride * height / 2)
        } else {
            for i in 0 ..< videoFrame.height {
                memcpy(yDestPlane!.advanced(by: i * yPerRowOfPlane), yBuffer + i * videoFrame.yStride, yPerRowOfPlane)
                if i < videoFrame.height / 2 {
                    memcpy(uvDestPlane!.advanced(by: i * yPerRowOfPlane / 2), uvPlane + i * videoFrame.yStride, yPerRowOfPlane)
                }
            }
        }
        
        return pixelBuffer
    }

暂时没有解决这个问题,可能需要声网协助

在 4.0.0 及以上的 AgoraRTCSDK 中则是直接提供了 CVPixelBuffer。

ijkplayer

由于 ijkplayer 是开源的,可以通过修改符号的方式解决依赖冲突的问题,且方便定制。风险则是可能需要专人维护。


种一棵树最好的时间是在十年前,而后是现在。

Loading Disqus comments...
Table of Contents