AVPlayer初体验之视频解纹理

AVPlayer是苹果提供的用来管理多媒体播放的控制器,提供了播放所需要的控制接口和支持KVO的属性,支持播放本地和网络视频,以及实时视频流。它一次只能播放一个AVPlayerItem,如果需要切换媒体源,需要使用replaceCurrentItem(with:)函数。如果需要播放多个视频,可以考虑使用AVQueuePlayer。在不同性能的设备上,甚至相同设备的不同iOS版本上,AVPlayer的最大支持清晰度都会不一样,例如在iOS10的某些机器上不支持4k播放,但是到iOS11就支持了,关于测定视频是否可以用AVPlayer来解码,可以直接在safari中输入视频网址来测试。

如果只需要播放视频,可以直接使用CALayer的子类AVPlayerLayer。这里不做过多的说明,可以查看苹果的Demo代码

这里主要说明从AVPlayerOutput中获取视频纹理的以用于OpenGl的下一步处理。

进度、播放状态控制

播放信息监听

利用KVO和通知中心监听以下Key即可,虽然KVO的机制不太推荐使用,但是看了官方文档,确实说这么用。

//已缓存进度
self.playerItem!.addObserver(self, forKeyPath: "loadedTimeRanges", options: NSKeyValueObservingOptions.new, context: nil)
//状态改变
self.playerItem!.addObserver(self, forKeyPath: "status", options: NSKeyValueObservingOptions.new, context: nil)
//缓冲
self.playerItem!.addObserver(self, forKeyPath: "playbackBufferEmpty", options: NSKeyValueObservingOptions.new, context: nil)
//缓冲可播
self.playerItem!.addObserver(self, forKeyPath: "playbackLikelyToKeepUp", options: NSKeyValueObservingOptions.new, context: nil)
//播放完成
NotificationCenter.default.addObserver(self, selector: #selector(didPlayToEnd(notify:)), name: NSNotification.Name.AVPlayerItemDidPlayToEndTime, object: nil)

状态控制

所有的状态控制都需要在AVPlayerItemStatus变成readyToPlay的时候才可以使用,并且只有这个时候可以取到视频的Size,所以在KVO的回调里

if keyPath == "status"{
    switch (object as! AVPlayerItem).status {
        case .readyToPlay:
            // 只有在这个状态下才能播放
            //准备就绪
            let pixelBuffer:CVPixelBuffer? = self.videoOutPut.copyPixelBuffer(forItemTime:(self.playerItem!.currentTime()), itemTimeForDisplay: nil)
            if(pixelBuffer != nil){
                //获取size
                let width:Int = CVPixelBufferGetWidth(pixelBuffer!)
                let height:Int = CVPixelBufferGetHeight(pixelBuffer!)
                self.playerItem?.videoSize = CGSize.init(width: width, height: height)
            }
            self.notify(state: .prepared)
            if(self.shouldPlayAfterPrepared)
            {
                self.play()
            }
        case .unknown:
                self.notify(state: .unknown)
                //print("视频加载未知错误")
        case .failed:
                self.notify(state: .failed,error: self.avPlayer?.error)
                //print("视频加载错误,\(String(describing: self.avPlayer?.error))")
            }
}

如果播放遇到错误可以用self.avPlayer?.error来查看错误类型。

输出纹理

YUV纹理

由于视频的编码格式基本都是YUV420,可以查看苹果的Demo代码 ,通过AVPlayerItemVideoOutput获取Y-PannelUV-Pannel两张纹理,最后在Shader中对两种纹理组合处理。

设置AVPlayerItemVideoOutput的部分代码

NSDictionary *pixBuffAttributes = @{(id)kCVPixelBufferPixelFormatTypeKey: @(kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange)};
self.videoOutput = [[AVPlayerItemVideoOutput alloc] initWithPixelBufferAttributes:pixBuffAttributes];

输出纹理的部分代码

//Y-Plane
glActiveTexture(GL_TEXTURE0);
err = CVOpenGLESTextureCacheCreateTextureFromImage(kCFAllocatorDefault, _videoTextureCache, pixelBuffer, NULL, GL_TEXTURE_2D, GL_RED_EXT, frameWidth, frameHeight, GL_RED_EXT, GL_UNSIGNED_BYTE, 0, &_lumaTexture);
//UV-plane
glActiveTexture(GL_TEXTURE1);
err = CVOpenGLESTextureCacheCreateTextureFromImage(kCFAllocatorDefault, _videoTextureCache, pixelBuffer, NULL, GL_TEXTURE_2D, GL_RG_EXT, frameWidth / 2, frameHeight / 2, GL_RG_EXT, GL_UNSIGNED_BYTE, 1, &_chromaTexture);

其中的kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange是CoreVideo中指定的Pixel Format Identifiers 类型,在OpenGLES2环境下其对应的参数是GL_RED_EXTGL_RG_EXT。视频支持的PixelFormat格式如下

获取纹理之后,还要使用Shader混合两张纹理,片元着色器(.fsh)代码如下

void main()
{
	mediump vec3 yuv;
	lowp vec3 rgb;
	
	// Subtract constants to map the video range start at 0
	yuv.x = (texture2D(SamplerY, texCoordVarying).r - (16.0/255.0))* lumaThreshold;
	yuv.yz = (texture2D(SamplerUV, texCoordVarying).rg - vec2(0.5, 0.5))* chromaThreshold;
	
	rgb = colorConversionMatrix * yuv;

	gl_FragColor = vec4(rgb,1);
}

RGB纹理

首先要明白一点,上图中明确说明,BGRA的输出格式是420v的两倍多带宽(More than 2x bandwidth),并且在该图来源,WWDC的这个视频27:00位置明确说明420v的输出格式效率会明显高于BGRA的输出格式(It does come across if you can avoid using BGRA and doing your work in YUV, it's more efficient from bandwidth standpoint),但是反过来,对于OpenGL来说,两张纹理的性能又会低于一张纹理。而且直接使用使用BGRA毕竟会方便很多,因为输出的直接就是一张纹理,个人认为在iOS5时代可能需要考虑420和BGRA的输出效率,但是现在毕竟都iOS11时代了,所以影响可以忽略不计。

设置AVPlayerItemVideoOutput的代码

NSDictionary *pixBuffAttributes = @{(id)kCVPixelBufferPixelFormatTypeKey: @(kCVPixelFormatType_32BGRA)};
self.videoOutput = [[AVPlayerItemVideoOutput alloc] initWithPixelBufferAttributes:pixBuffAttributes];

输出纹理的代码

CVReturn textureRet = CVOpenGLESTextureCacheCreateTextureFromImage(kCFAllocatorDefault, self.videoTextureCache, pixelBuffer, nil, GL_TEXTURE_2D, GL_RGBA, width, height, GL_BGRA, GL_UNSIGNED_BYTE, 0, &_textureOutput);

BGRA对应的输出格式是kCVPixelFormatType_32BGRA,其对应的从Buffer读纹理的参数是GL_RGBAGL_BGRA

完整的从VideoOutput中获取纹理的代码如下

-(CVOpenGLESTextureRef)getVideoTextureWithOpenGlContext:(EAGLContext *)context{
    if(self.videoOutput == nil){
        NSLog(@"ferrisxie: 输出对象为空");
        return nil;
    }
    //step1:构造缓存
    if(self.videoTextureCache == nil){
        CVReturn ret = CVOpenGLESTextureCacheCreate(kCFAllocatorDefault, nil, context, nil, &_videoTextureCache);
        if(ret != 0){
            NSLog(@"构造缓存失败");
            return nil;
        }
    }
    //step2: 取纹理
    CMTime currentTime = self.currentItem.currentTime;
    if(![self.videoOutput hasNewPixelBufferForItemTime:currentTime]){
        //没有新的纹理 返回上一帧
        return self.textureOutput;
    }
    CVPixelBufferRef pixelBuffer = [self.videoOutput copyPixelBufferForItemTime:currentTime itemTimeForDisplay:nil];
    CGFloat width = CVPixelBufferGetWidth(pixelBuffer);
    CGFloat height = CVPixelBufferGetHeight(pixelBuffer);
    if(CGSizeEqualToSize(CGSizeZero, self.videoSize)){
        self.videoSize = CGSizeMake(width, height);
    }
    CVOpenGLESTextureCacheFlush(self.videoTextureCache, 0);
    if(self.textureOutput != nil){
        //释放上一帧
        CFRelease(self.textureOutput);
        self.textureOutput = nil;
    }
    CVReturn textureRet = CVOpenGLESTextureCacheCreateTextureFromImage(kCFAllocatorDefault, self.videoTextureCache, pixelBuffer, nil, GL_TEXTURE_2D, GL_RGBA, width, height, GL_BGRA, GL_UNSIGNED_BYTE, 0, &_textureOutput);
    if(textureRet != 0){
        NSLog(@"解析纹理失败%u,%@",textureRet,self.textureOutput);
        if(self.textureOutput != nil){
            //解析纹理失败不需要Release
            //CFRelease(self.textureOutput);
            self.textureOutput = nil;
        }
        return nil;
    }
    if(pixelBuffer != nil){
        CVPixelBufferRelease(pixelBuffer);
    }
    
    return self.textureOutput;
}
//usage
CVOpenGLESTextureRef texureRef = [self.player getVideoTextureWithOpenGlContext:[EAGLContext currentContext]];
GLuint target = CVOpenGLESTextureGetTarget(texureRef);
GLuint name = CVOpenGLESTextureGetName(texureRef);
    //用完记得释放
CFRelease(texureRef);

Swift由于取消了CFRelease等CoreFoundation的内存管理接口,在取纹理的时候需要使用Unmanaged对象,利用takeUnretainedValue,可以不需要释放代码了。

if let videoPlayer = self.videoPlayer{
    if let unmangaed:Unmanaged<CVOpenGLESTexture> = videoPlayer.getVideoTexture(withOpen: self.context){
        let testure:CVOpenGLESTexture = unmangaed.takeUnretainedValue()
        let target:GLuint = CVOpenGLESTextureGetTarget(testure)
        let name:GLuint = CVOpenGLESTextureGetName(testure)
    }
}
//不再需要释放了

其他

切换播放源

针对需要切换播放源的场景,重新构造播放器显然是最简单易行的,但是测试发现,频繁的构造和销毁AVPlayer对象虽然不会导致内存增加,但是很奇怪的是,会导致OtherProccesses的内存增大,从而导致Free内存减小,减小到某个值的时候,就会触发didReceiveMemeoryWarning内存警告,暂时还没有发现原因,因此这种方法不可取。

其实AVPlayer本身提供了切换播放源的函数。

func replaceCurrentItem(with item: AVPlayerItem?)

当要切换播放源时,需要指定新的AVPlayerItem,这时候又会面临状态问题,之前说过只有在AVPlayerItemStatus变成readyToPlay的时候才可以调用playseek等函数,可以使用AVUrlAsset来预加载这个Item:

func loadValuesAsynchronously(forKeys keys: [String], completionHandler handler: (() -> Void)? = nil)

通过预加载duration(视频总进度)来判断视频是否可播放,当加载完成后再replaceCurrentItem

// Load the asset's "playable" key
asset.loadValuesAsynchronously(forKeys: ["duration"]) {
    var error: NSError? = nil
    let status = asset.statusOfValue(forKey: "duration", error: &error)
    switch status {
    case .loaded:
    // Sucessfully loaded, continue processing
    //在这里替换播放源,并且直接开始播放
    let playerItem = AVPlayerItem.init(asset: asset)
    self.videoPlayer?.replaceCurrentItem(with: playerItem)
    self.resumePlay()
    case .failed:
    // Examine NSError pointer to determine failure
    case .cancelled:
    // Loading cancelled
    default:
        // Handle all other cases
    }
}

如果实在需要控制多个播放源,可以考虑使用AVQueuePlayer来处理。

声音优先级

默认的声音优先级为视频播放的默认优先级AVAudioSessionCategoryAmbient,静音状态不会有声音,退出后台就停止播放。AudioSessionCategoriesandModes有关于声音优先级的介绍。

使用如下函数切换

AVAudioSession.sharedInstance().setCategory(_ category: String)

一般的,如果需要静音状态下也有声音可以直接使用AVAudioSessionCategoryPlayback这个Value。

硬件加速

iOS6以后可以使用底层框架VideoToolbox来实现硬解码,具体视频工具箱和硬件加速有很清楚的解释,基本的场景,使用AVPlayer即可满足需求。