美文网首页音视频
基于iOS平台的最简单的FFmpeg视频播放器(三)

基于iOS平台的最简单的FFmpeg视频播放器(三)

作者: Aiewing | 来源:发表于2017-10-31 19:42 被阅读652次

    如果说,视频的解码是最核心的一步,那么视频的显示播放,就是最复杂的一步,也是最难的一步。
    接着上一篇文章的激情,这一篇文章主要是讲述解码后的数据是怎么有顺序,有规律地显示到我们的手机屏幕上的。

    基于iOS平台的最简单的FFmpeg视频播放器(一)
    基于iOS平台的最简单的FFmpeg视频播放器(二)
    基于iOS平台的最简单的FFmpeg视频播放器(三)

    正式开始

    • 视频数据显示的步骤和原理,这里我们需要好好地理一理思路。
      1.先初始化一个基于OpenGL的显示的范围。
      2.把准备显示的数据处理好(就是上一篇文章没有讲完的那个部分)。
      3.在 OpenGL上绘制一帧图片,然后删除数组中已经显示过的帧。
      4.计算数组中剩余的还没有解码的帧,如果不够了那就继续开始解码。
      5.通过一开始处理过的数据中获取时间戳,通过定时器控制显示帧率,然后回到步骤3。
      6.所有的视频都解码显示完了,播放结束。

    1.准备活动

    1.1 初始化OpenGL的类

    • 接下来我们使用AieGLView类,都是仿照自Kxmovie中的 KxMovieGLView类。里面的具体实现内容比较多,以后我们单独分出一个模块来讲。
    - (void)setupPresentView
    {
        _glView = [[AieGLView alloc] initWithFrame:CGRectMake(0, self.view.frame.size.height - 200, 300, 200) decoder:_decoder];
        [self.view addSubview:_glView];
        
        self.view.backgroundColor = [UIColor clearColor];
    }
    

    1.2 处理解码后的数据

    • 这里就是上一篇文章中,解码结束之后,应该对数据做的处理。
    - (AieVideoFrame *)handleVideoFrame
    {
        if (!_videoFrame->data[0]) {
            return nil;
        }
        
        AieVideoFrame * frame;
        if (_videoFrameFormat == AieVideoFrameFormatYUV) {
            AieVideoFrameYUV * yuvFrame = [[AieVideoFrameYUV alloc] init];
            
            yuvFrame.luma = copyFrameData(_videoFrame->data[0],
                                          _videoFrame->linesize[0],
                                          _videoCodecCtx->width,
                                          _videoCodecCtx->height);
            
            yuvFrame.chromaB = copyFrameData(_videoFrame->data[1],
                                          _videoFrame->linesize[1],
                                          _videoCodecCtx->width / 2,
                                          _videoCodecCtx->height / 2);
            
            yuvFrame.chromaR = copyFrameData(_videoFrame->data[2],
                                          _videoFrame->linesize[2],
                                          _videoCodecCtx->width / 2,
                                          _videoCodecCtx->height / 2);
            
            frame = yuvFrame;
        }
        
        frame.width = _videoCodecCtx->width;
        frame.height = _videoCodecCtx->height;
        // 以流中的时间为基础 预估的时间戳
        frame.position = av_frame_get_best_effort_timestamp(_videoFrame) * _videoTimeBase;
        
        // 获取当前帧的持续时间
        const int64_t frameDuration = av_frame_get_pkt_duration(_videoFrame);
        
        if (frameDuration) {
            frame.duration = frameDuration * _videoTimeBase;
            frame.duration += _videoFrame->repeat_pict * _videoTimeBase * 0.5;
        }
        else {
            frame.duration = 1.0 / _fps;
        }
        return frame;
    }
    
    • 以上的代码比较多,涉及的只是也比较广,所以我们还是一段一段的来分析。

    1.2.1 AVFrame数据分析

    if (!_videoFrame->data[0]) {
            return nil;
        }
    
    • _videoFrame就是之前存储解码后数据的AVFrame,之前我们只说到AVFrame的定义,现在来说说它的结构。
    • AVFrame有两个最重要的属性datalinesize
      1.data是用来存储解码后的原始数据,对于视频来说就是YUV、RGB,对于音频来说就是PCM,顺便说一下,苹果手机录音出来的原始数据就是PCM。
      2.linesize是data数据中‘一行’数据的大小,一般大于图像的宽度。
    • data其实是个指针数组,所以它存储的方式是随着数据格式的变化而变化的。
      1.对于packed格式的数据(比如RGB24),会存到data[0]中。
      2.对于planar格式的数据(比如YUV420P),则会data[0]存Y,data[1]存U,data[2]存V,数据的大小的比例也是不同的,朋友们可以了解下。

    1.2.2 把数据封装成自己的格式

    AieVideoFrame * frame;
        if (_videoFrameFormat == AieVideoFrameFormatYUV) {
            AieVideoFrameYUV * yuvFrame = [[AieVideoFrameYUV alloc] init];
            
            yuvFrame.luma = copyFrameData(_videoFrame->data[0],
                                          _videoFrame->linesize[0],
                                          _videoCodecCtx->width,
                                          _videoCodecCtx->height);
            
            yuvFrame.chromaB = copyFrameData(_videoFrame->data[1],
                                          _videoFrame->linesize[1],
                                          _videoCodecCtx->width / 2,
                                          _videoCodecCtx->height / 2);
            
            yuvFrame.chromaR = copyFrameData(_videoFrame->data[2],
                                          _videoFrame->linesize[2],
                                          _videoCodecCtx->width / 2,
                                          _videoCodecCtx->height / 2);
            
            frame = yuvFrame;
        }
    
    • AieVideoFrame ,AieVideoFrameYUV是我们自己定义的简单的类,不懂的可以去看代码,结构很简单。现在我们只考虑YUV的存储,暂时不考虑RGB。
    • 上面的luma, chromaB,chromaR正好对应的YUV,从传进去的参数就可以发现。
    static NSData * copyFrameData(UInt8 *src, int linesize, int width, int height)
    {
        width = MIN(linesize, width);
        NSMutableData *md = [NSMutableData dataWithLength: width * height];
        Byte *dst = md.mutableBytes;
        for (NSUInteger i = 0; i < height; ++i)
        {
            memcpy(dst, src, width);
            dst += width;
            src += linesize;
        }
        return md;
    }
    
    • 说好的一行行的看代码就得一行行看,之前我们说过linesize中的一行的数据大小,一般情况下比实际宽度大一点,但是为了避免特殊情况,这里还是需要判断一下,取最小的那个。
    • 下面就是把数据装到NSMutableData这个容器中,显而易见,数据的总大小就是width * height。所以遍历的时候就遍历它的height,然后把整个宽度的数据全部拷贝到目标容器中,由于这里是指针操作,所以我们需要把指针往后便宜到末尾,下一次拷贝的时候才可以继续从末尾添加数据。
    • 有的朋友可能会问,为什么dst偏移的是width, 但是src偏移的是linesize?理由还是之前的那一个linesize可能会比width大一点,我们的最终数据dst应该根据width来计算,但是src(就是之前的data)他的每一行实际大小是linesize,所以才需要分开来偏移。

    1.2.3 解码后数据的信息

        frame.width = _videoCodecCtx->width;
        frame.height = _videoCodecCtx->height;
        // 以流中的时间为基础 预估的时间戳
        frame.position = av_frame_get_best_effort_timestamp(_videoFrame) * _videoTimeBase;
        
        // 获取当前帧的持续时间
        const int64_t frameDuration = av_frame_get_pkt_duration(_videoFrame);
        
        if (frameDuration) {
            frame.duration = frameDuration * _videoTimeBase;
            frame.duration += _videoFrame->repeat_pict * _videoTimeBase * 0.5;
        }
        else {
            frame.duration = 1.0 / _fps;
        }
    
    • AVCodecContext中的长宽,才是视频的实际的长宽。
    • av_frame_get_best_effort_timestamp ()是以AVFrame中的时间为基础,预估的时间戳,然后乘以_videoTimeBase(之前默认是0.25的那个),就是这个视频帧当先的时间位置。
    • av_frame_get_pkt_duration ()是获取当前帧的持续时间。
    • 接下来的一个if语句很刁钻,我也不是很理解,但是查了资料,大致是这样的。如何获取当前的播放时间:当前帧的显示时间戳 * 时基 + 额外的延迟时间,额外的延迟时间进入repeat_pict就会发现官方已经给我们了extra_delay = repeat_pict / (2*fps),转化一下其实也就是我们代码中的格式(因为fps = 1.0 / timeBase)。

    2. 开始播放视频

    2.1 播放逻辑处理

    dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, 0.1 * NSEC_PER_SEC);
        dispatch_after(popTime, dispatch_get_main_queue(), ^(void){
            [self tick];
        });
    
    • 上面代码的意思就是通过GCD的方式延迟0.1秒之后再开始显示,为什么要延迟0.1秒呢?因为这个一段代码是跟在解码视频的后面的,解码一帧视频也是需要时间的,所以需要延迟0.1秒。那么为什么是0.1秒呢?朋友们是否还记得上一篇文章中NSArray * frames = [strongDecoder decodeFrames:0.1];,这里设置的最小的时间也是0.1秒,所以现在就可以共通了。

    2.2 播放视频

    • 做了这么多的铺垫,终于轮到我们的主角出场了,当当当。。。
    - (void)tick
    {
        // 返回当前播放帧的播放时间
        CGFloat interval = [self presentFrame];
        const NSUInteger leftFrames =_videoFrames.count;
        
        // 当_videoFrames中已经没有解码过后的数据 或者剩余的时间小于_minBufferedDuration最小 就继续解码
        if (!leftFrames ||
            !(_bufferedDuration > _minBufferedDuration))  {
            [self asyncDecodeFrames];
        }
        
        // 播放完一帧之后 继续播放下一帧 两帧之间的播放间隔不能小于0.01秒
        const NSTimeInterval time = MAX(interval, 0.01);
        dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, time * NSEC_PER_SEC);
        dispatch_after(popTime, dispatch_get_main_queue(), ^{
            [self tick];
        });
     
    }
    

    2.2.1 绘制图像

    - (CGFloat)presentFrame
    {
        CGFloat interval = 0;
        AieVideoFrame * frame;
        
        @synchronized (_videoFrames) {
            if (_videoFrames.count > 0) {
                frame = _videoFrames[0];
                [_videoFrames removeObjectAtIndex:0];
                _bufferedDuration -= frame.duration;
            }
        }
        
        if (frame) {
            if (_glView) {
                [_glView render:frame];
            }
            interval = frame.duration;
        }
        return interval;
    }
    
    • @synchronized是一个互斥锁,为了不让其他的线程同时访问锁中的资源。
    • 线程里面的内容就很简单了,就是取出解码后的数组的第一帧,然后从数组中删除。
    • _bufferedDuration就是数组中的数据剩余的时间的总和,所以取出数据之后,需要把这一帧的时间减掉。
    • 如果第一帧存在,那就[_glView render:frame],把视频帧绘制到屏幕上,这个函数涉及到OpenGL的很多知识,比较复杂,如果有朋友感兴趣的话,以后可以单独设一个模块仔细的讲一讲。

    2.2.2 再次开始解码

       const NSUInteger leftFrames =_videoFrames.count;
        if (0 == leftFrames) {
            return;
        }
        if (!leftFrames ||
            !(_bufferedDuration > _minBufferedDuration))
        {
            [self asyncDecodeFrames];
        }
    
    • _videoFrames中已经没有可以播放的数据,说明视频已经播放完了,所以可以退出了,停止播放也可遵循一样的原理。
    • 当剩余的时间_bufferedDuration小于_minBufferedDuration时,那就继续开始解码。

    2.2.3 播放下一帧

    const NSTimeInterval time = MAX(interval, 0.01);
        dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, time * NSEC_PER_SEC);
        dispatch_after(popTime, dispatch_get_main_queue(), ^{
            [self tick];
        });
    
    • 这个其实是一个递归函数,只是在中间加了一个正常的延时,两帧之间的播放间隔不能小于0.01秒,这样就可以达到我们看见的播放视频的效果了。

    结尾

    • 到这里我们关于最简单的视频播放器的内容就全部结束了,其实,我只是在Kxmovie的基础上,抽离出其中的核心代码,然后组成这样一系列的代码。如果反应好的话,我会继续把剩下完整的部分也陆续给大家分享出来的,谢谢大家的支持。
    • 有兴趣的朋友也可以仔细的去解读Kxmovie的源码,如果文章中有错误的地方还希望大佬们可以指出。
    • 由于放了FFmpeg库,所以Demo会很大,下载的时候比较费时。
    • 谢谢阅读

    相关文章

      网友评论

        本文标题:基于iOS平台的最简单的FFmpeg视频播放器(三)

        本文链接:https://www.haomeiwen.com/subject/xufipxtx.html