ijkplayer部分代码解析

decoder_decode_frame(ff_ffplay.c)

int decoder_decode_frame(FFPlayer *ffp, Decoder *d, AVFrame *frame, AVSubtitle *sub)

这部分代码是ffmpeg软解视频和音频pkt的时候被调用的代码块。

  • avcodec_send_packet将pkt发送给ffmpeg
  • avcodec_receive_frame获取解码后的frame

如果视频存在B帧的情况下,avcodec_send_packet与avcodec_receive_frame并不会是严格按交错顺序调用成功(因为B帧要依赖后面的帧才能解码),有可能是avcodec_send_packet多个pkt后avcodec_receive_frame才能获取到帧,也有可能你需要avcodec_receive_frame把帧读出来后,才能继续avcodec_send_packet(这时会返回EAGAIN)。

代码块中的packet_pending就是用来处理上面的情况的。

avcodec_send_packet返回EAGAIN表示当前还无法接受新的packet,还有frame没有取出来:

d->packet_pending = 1;
av_packet_move_ref(&d->pkt, &pkt);

把这个packet存到d->pkt,在下一个循环里,先取frame,再把packet接回来,接着上面的操作:

if (d->packet_pending) {
    av_packet_move_ref(&pkt, &d->pkt);
    d->packet_pending = 0;
}

注意:因为IOS硬解带B帧视频时,回调输出的视频帧并不是按pts排序的,所以接收需要自己排序,ijkplayer里的处理如下:

        pthread_mutex_lock(&ctx->m_queue_mutex);
        volatile sort_queue *queueWalker = ctx->m_sort_queue;
        if (!queueWalker || (newFrame->sort < queueWalker->sort)) { //若队列为空或者newFrame的pts比队列所有的元素都大
            newFrame->nextframe = queueWalker;
            ctx->m_sort_queue = newFrame;
        } else {  //找到合适的位置把newframe插进去
            bool frameInserted = false;
            volatile sort_queue *nextFrame = NULL;
            while (!frameInserted) {
                nextFrame = queueWalker->nextframe;
                if (!nextFrame || (newFrame->sort < nextFrame->sort)) {
                    newFrame->nextframe = nextFrame;
                    queueWalker->nextframe = newFrame;
                    frameInserted = true;
                }
                queueWalker = nextFrame;
            }
        }
        ctx->m_queue_depth++;
        pthread_mutex_unlock(&ctx->m_queue_mutex);

//省略      

//当队列的长度大于max_ref_frames时,才可以开始渲染(max_ref_frames由sps解析得到)
        if ((ctx->m_queue_depth > ctx->fmt_desc.max_ref_frames)) {
            QueuePicture(ctx);
        }

即,使用一个排序队列来接收VTB的输出,当队列的长度超过max_ref_frames时,才从排序队列取出到frame队列。max_ref_frames是从sps里面解析出来的:

num_ref_frames规定了可能在视频序列中任何图像帧间预测的解码过程中用到的短期参考帧和长期参考帧、互补参考场对以及不成对的参考场的最大数量。num_ref_frames 的取值范围应该在0到MaxDpbSize。

sps已经告诉了B帧的最大参考数量为num_ref_frames,所以我们的排序队列只要取这么大,就一定能把输出的帧严格按PTS排序。

音频播放格式协商(IOS端为例)

音频播放使用AudioQueue:

  • 构建AudioQueue:AudioQueueNewOutput
  • 开始AudioQueueStart,暂停AudioQueuePause,结束AudioQueueStop
  • 在回调函数IJKSDLAudioQueueOuptutCallback里,调用下层的填充函数来填充AudioQueue的buffer。
  • 使用AudioQueueEnqueueBuffer把装配完的AudioQueue Buffer入队,进入播放。

上面这些都是AudioQueue的标准操作,特别的是构建AudioStreamBasicDescription的时候,也就是指定音频播放的格式。格式是由音频源的格式决定的,在IJKSDLGetAudioStreamBasicDescriptionFromSpec里看,除了格式固定为pcm之外,其他的都是从底层给的格式复制过来。这样就有了很大的自由,音频源只需要解码成pcm就可以了。

而底层的格式是在audio_open里决定的,逻辑是:

  • 根据源文件,构建一个期望的格式wanted_spec,然后把这个期望的格式提供给上层,最后把上层的实际格式拿到作为结果返回。一个类似沟通的操作,这种思维很值得借鉴
  • 如果上传不接受这种格式,返回错误,底层修改channel数、采样率然后再继续沟通。
  • 但是样本格式是固定为s16,即signed integer 16,有符号的int类型,位深为16比特的格式。位深指每个样本存储的内存大小,16个比特,加上有符号,所以范围是[-2^15, 215-1],215为32768,变化性足够了。

因为都是pcm,是不压缩的音频,所以决定性的因素就只有:采样率、通道数和样本格式。样本格式固定s16,和上层沟通就是决定采样率和通道数。

这里是一个很好的分层架构的例子,底层通用,上层根据平台各有不同。

音视频同步

1、同步钟以及钟时间的修正
同步钟的概念: 音频或者视频,如果把内容正确的完整的播放,某个内容和一个时间是一一对应的(PTS),当前的音频或者视频播放到哪个位置,它就有一个时间来表示,这个时间就是同步钟的时间。所以音频钟的时间表示音频播放到哪个位置,视频钟表示播放到哪个位置。

因为音频和视频是分开表现的,就可能会出现音频和视频的进度不一致,在同步钟上就表现为两个同步钟的值不同,如果让两者统一,就是音视频同步的问题。

因为有了同步钟的概念,音视频内容上的同步就可以简化为更准确的:音频钟和视频钟时间相同。

这时会有一个同步钟作为主钟,也就是其他的同步钟根据这个主钟来调整自己的时间。满了就调快、快了就调慢。

compute_target_delay里的逻辑就是这样,diff = get_clock(&is->vidclk) - get_master_clock(is);这个是视频钟和主钟的差距:

//视频落后超过临界值,缩短下一帧时间
if (diff <= -sync_threshold)
    delay = FFMAX(0, delay + diff);
//视频超前,且超过临界值,延长下一帧时间
else if (diff >= sync_threshold && delay > AV_SYNC_FRAMEDUP_THRESHOLD)
    delay = delay + diff;
else if (diff >= sync_threshold)
    delay = 2 * delay;

为什么不都是delay + diff,即为什么还有第3种1情况:

延时直接加上diff,那么下一帧就直接修正了视频种和主钟的差异,但有可能这个差异已经比较大了,直接一步到位的修正导致的效果就是:画面有明显的停顿,然后声音继续播,等到同步了视频再恢复正常。

而如果采用2*delay的方式,是每一次修正delay,多次逐步修正差异,可能变化上会更平滑一些。效果上就是画面和声音都是正常的,然后声音逐渐的追上声音,最后同步。

至于为什么第2种情况选择一步到位的修正,第3种情况选择逐步修正,这个很难说。因为AV_SYNC_FRAMEDUP_THRESHOLD值为0.15,对应的帧率是7左右,到这个程度,视频基本都是幻灯片了,我猜想这时逐步修正也没意义了。

2、同步钟时间获取的实现

再看同步钟时间的实现:get_clock获取时间, set_clock_at更新时间。

解释一下:return c->pts_drift + time - (time - c->last_updated) * (1.0 - c->speed);,为啥这么写?

上一次显示的时候,更新了同步钟,调用set_clock_at,上次的时间为c->last_updated,则:

c->pts_drift + time = (c->pts - c->last_updated)+time;

假设距离上次的时间差time_diff = time - c->last_updated,则表达式整体可以变为:

c->pts+time_diff+(c->speed - 1)*time_diff

合并后两项变为:

c->pts+c->speed*time_diff.

我们要求得就是当前时间时的媒体内容位置,上次的位置是c->pts,而中间过去了time_diff这么多时间,媒体内容过去的时间就是:播放速度x现实时间,也就是c->speed*time_diff。

举例:现实里过去10s,如果你2倍速的播放,那视频就过去了20s。所以这个表达式就很清晰了。

在set_clock_speed里同时调用了set_clock,这是为了保证从上次更新时间以来,速度是没变的,否则计算就没有意义了。

3、视频显示时的时间控制

static void video_refresh(void *opaque, double *remaining_time)
{
    //……
    //lastvp上一帧,vp当前帧 ,nextvp下一帧

    last_duration = vp_duration(is, lastvp, vp);//计算上一帧的持续时长
    delay = compute_target_delay(last_duration, is);//参考audio clock计算上一帧真正的持续时长

    time= av_gettime_relative()/1000000.0;//取系统时刻
    if (time < is->frame_timer + delay) {//如果上一帧显示时长未满,重复显示上一帧
        *remaining_time = FFMIN(is->frame_timer + delay - time, *remaining_time);
        goto display;
    }

    is->frame_timer += delay;//frame_timer更新为上一帧结束时刻,也是当前帧开始时刻
    if (delay > 0 && time - is->frame_timer > AV_SYNC_THRESHOLD_MAX)
        is->frame_timer = time;//如果与系统时间的偏离太大,则修正为系统时间

    //更新video clock
    //视频同步音频时没作用
    SDL_LockMutex(is->pictq.mutex);
    if (!isnan(vp->pts))
        update_video_pts(is, vp->pts, vp->pos, vp->serial);
    SDL_UnlockMutex(is->pictq.mutex);

    //……

    //丢帧逻辑
    if (frame_queue_nb_remaining(&is->pictq) > 1) {
        Frame *nextvp = frame_queue_peek_next(&is->pictq);
        duration = vp_duration(is, vp, nextvp);//当前帧显示时长
        if(time > is->frame_timer + duration){//如果系统时间已经大于当前帧,则丢弃当前帧
            is->frame_drops_late++;
            frame_queue_next(&is->pictq);
            goto retry;//回到函数开始位置,继续重试(这里不能直接while丢帧,因为很可能audio clock重新对时了,这样delay值需要重新计算)
        }
    }
}

主要思路就是如果视频播放过快,则重复播放上一帧,以等待音频;如果视频播放过慢,则丢帧追赶音频。实现的方式是,参考audio clock,计算上一帧(在屏幕上的那个画面)还应显示多久(含帧本身时长),然后与系统时刻对比,是否该显示下一帧了。

这里与系统时刻的对比,引入了另一个概念——frame_timer。可以理解为帧显示时刻,如更新前,是上一帧的显示时刻;对于更新后(is->frame_timer += delay),则为当前帧显示时刻。

上一帧显示时刻加上delay(还应显示多久(含帧本身时长))即为上一帧应结束显示的时刻。具体原理看如下示意图:

1.jpg

这里给出了3种情况的示意图:

  • time1:系统时刻小于lastvp结束显示的时刻(frame_timer+dealy),即虚线圆圈位置。此时应该继续显示lastvp
  • time2:系统时刻大于lastvp的结束显示时刻,但小于vp的结束显示时刻(vp的显示时间开始于虚线圆圈,结束于黑色圆圈)。此时既不重复显示lastvp,也不丢弃vp,即应显示vp
  • time3:系统时刻大于vp结束显示时刻(黑色圆圈位置,也是nextvp预计的开始显示时刻)。此时应该丢弃vp。

至此,基本上分析完了视频同步音频的过程,简单总结下:

  • 基本策略是:如果视频播放过快,则重复播放上一帧,以等待音频;如果视频播放过慢,则丢帧追赶音频。
  • 这一策略的实现方式是:引入frame_timer概念,标记帧的显示时刻和应结束显示的时刻,再与系统时刻对比,决定重复还是丢帧。
  • lastvp的应结束显示的时刻,除了考虑这一帧本身的显示时长,还应考虑了video clock与audio clock的差值。
  • 并不是每时每刻都在同步,而是有一个“准同步”的差值区域。

seek的处理

seek就是调整进度条到新的地方开始播,这个操作会打乱原本的数据流,一些播放秩序要重新建立。需要处理的问题包括:

  • 缓冲区数据的释放,而且要重头到位全部释放干净
  • 播放时间显示
  • “加载中”的状态的维护,这个影响着用户界面的显示问题
  • 剔除错误帧的问题

流程
1、外界seek调用到ijkmp_seek_to_l,然后发送消息ffp_notify_msg2(mp->ffplayer, FFP_REQ_SEEK, (int)msec);,消息捕获到后调用到stream_seek,然后设置seek_req为1,记录seek目标到seek_pos。

2、在读取函数read_thread里,在is->seek_req为true时,进入seek处理,几个核心处理:

  • ffp_toggle_buffering关闭解码,packet缓冲区静止
  • 调用avformat_seek_file进行seek
  • 成功之后用packet_queue_flush清空缓冲区,并且把flush_pkt插入进去,这时一个标记数据
  • 把当前的serial记录下来
if (pkt == &flush_pkt)
        q->serial++;

所以serial的意义就体现出来了,每次seek,serial+1,也就是serial作为一个标记,相同代表是同一次seek里的。

3、到decoder_decode_frame里:

  • 因为seek的修改是在读取线程里,和这里的解码线程不是一个,所以seek的修改可以在这里代码的任何位置出现。

  • if (d->queue->serial == d->pkt_serial)这个判断里面为代码块1,while (d->queue->serial != d->pkt_serial)这个循环为代码块2,if (pkt.data == flush_pkt.data)这个判断为true为代码块3,false为代码块4.

  • 如果seek修改出现在代码块2之前,那么就一定会进代码块2,因为packet_queue_get_or_buffering会一直读取到flush_pkt,所以也就会一定进代码块3,会执行avcodec_flush_buffers清空解码器的缓存。

  • 如果seek在代码块2之后,那么就只会进代码块4,但是再循环回去时,会进代码块2、代码块3,然后avcodec_flush_buffers把这个就得packet清掉了。

  • 综合上面两种情况,只有seek之后的packet才会得到解码。

如果整个数据流是一条河流,那flush_pkt就像一个这个河流的一个浮标,遇到这个浮标,后面水流的颜色都变了。

4、播放处
视频video_refresh里:

if (vp->serial != is->videoq.serial) {
       frame_queue_next(&is->pictq);
       goto retry;
   }

音频audio_decode_frame里:

do {
       if (!(af = frame_queue_peek_readable(&is->sampq)))
           return -1;
       frame_queue_next(&is->sampq);
    } while (af->serial != is->audioq.serial);

都根据serial把旧数据略过了。

所以整体看下来,seek体系里最厉害的东西的东西就是使用了serial来标记数据,从而可以很明确的知道哪些是旧数据,哪些是新数据。

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

推荐阅读更多精彩内容