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

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

基于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会很大,下载的时候比较费时。
  • 谢谢阅读
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,298评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,701评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,078评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,687评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,018评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,410评论 1 211
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,729评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,412评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,124评论 1 239
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,379评论 2 242
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,903评论 1 257
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,268评论 2 251
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,894评论 3 233
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,014评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,770评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,435评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,312评论 2 260

推荐阅读更多精彩内容