ffplay.c源码阅读之音频、字幕、视频渲染原理(一)

前言

之前陆续学习了视频渲染相关技术opengl es,视频编解码相关技术(基于ffmpeg封装接口的使用),虽然拥有了这些基础知识,但是离写出一个功能完善的播放器还有一段距离,我觉得可以先学学ffplay.c,ijkplayer等等开源播放器他们是如何实现的,然后学以致用。所以从今天开始将逐步学习ffplay.c的实现方式。我认为阅读源码的方式如果带着问题举一反三的思维去理解代码思路,这样可能印象更加深刻。首先抛出问题”如果让我实现我会怎么做,开源是怎么实现的,它为什么这么做,这么做的优缺点是什么?“ 所以我读ffplay.c的源码也会按照这样的思路来

抛出问题

在实现一个播放器时,会涉及到如下三个部分


image.png

拉流:从本地或者远程读取压缩音视频数据
解码:将读取到的音视频数据进行解码得到未压缩音视频数据
渲染:将解码得到的未压缩视频渲染到屏幕、未压缩音频通过扬声器播放

那么应该创建几个线程来做这些事情?首先拉流肯定要单独的线程,解码和渲染是否应该放到一个线程里面呢,我觉得应该分开,因为不分开的话流程就是,解码成功->渲染-->解码下一帧-->渲染 一直循环。对于I、B、P帧解码所花时间会不一样,那么渲染时间非等间隔的就会导致播放不那么流畅。综上,应该有拉流一个线程(音视频共用),解码两个(音视频独立),渲染两个(音视频独立)

音视频要分开的原因是音视频渲染间隔是是不同的。

今天先从渲染部分开始阅读和学习
对于线程的创建实际上ffplay.c也是这样实现的,它的整个架构设计图如下:
截止到ffmpeg 4.2版本,ffplay.c大概有近四千行代码。整体的流程图架构设计如下:


image.png

ffplay.c的实现

实际上ffplay.c也是按照这样

  • 视频渲染线程的代码

这里只贴出关键代码。这段代码的主要工作流程如下:
1、取视频帧;没有可渲染视频帧就返回睡眠,有则进入步骤2
2、取出上一次已渲染视频帧和当前待渲染视频帧
3、根据音视频同步规则决定当前待渲染视频帧是否立即渲染,即:如果本帧的播放时刻(即上一帧的播放时刻+上一帧的时长)大于当前时刻,代表本帧的播放时刻还未到来,渲染时间未到来则继续播放上一帧(由如下goto语句进行跳转),然后将remain_time赋值为本帧的播放时刻与当前时刻的时间差值(睡眠remain_time时间后)下一个流程再渲染此帧;否则进入步骤4
4、更新视频帧FrameQueue队列相关数据,然后渲染本帧(真正执行视频渲染工作的代码在video_display()函数中)

步骤1-4循环调用

/** 1、用于控制视频的显示
 *  2、如果音视频同步方式为视频同步到音频,则这块逻辑在此实现
 */
static void video_refresh(void *opaque, double *remaining_time)
{
    VideoState *is = opaque;
    double time;

    Frame *sp, *sp2;
    ....省略代码.....

    // rtsp 等实时流则is->realtime的值为1,本地文件的播放则为0
    if (!is->paused && get_master_sync_type(is) == AV_SYNC_EXTERNAL_CLOCK && is->realtime)
        check_external_clock_speed(is);


    if (is->video_st) {
retry:
        if (frame_queue_nb_remaining(&is->pictq) == 0) { // 步骤1、取视频帧;没有可渲染视频帧就返回睡眠,
            // nothing to do, no picture to display in the queue
        } else {
            double last_duration, duration, delay;
            Frame *vp, *lastvp;

            /* dequeue the picture */
            // 步骤2、取出上一次已渲染视频帧和当前待渲染视频帧
            // 首次进入此方法,由于f->rindex_shown是默认值,所以得到的lastvp和vp是同一个Frame
            lastvp = frame_queue_peek_last(&is->pictq);
            vp = frame_queue_peek(&is->pictq);

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

            if (lastvp->serial != vp->serial)
                is->frame_timer = av_gettime_relative() / 1000000.0;

            if (is->paused)
                goto display;

            
            /* compute nominal last_duration */
            last_duration = vp_duration(is, lastvp, vp);
            /** 这里实现了视频同步音频或者同步系统时钟的关键代码
             *  本帧视频是否能够播放的条件为 is->frame_timer (上一帧视频的播放时间) + delay(本帧视频的播放延迟) >= 当前系统时间
             *  delay是基于音频时钟或者系统时钟计算出来的播放延迟时间(它的理论值就是本帧pts-上帧pts)
             *  delay>=0 值越小代表视频播的太慢了,本帧越需要尽快播放,越大则代表视频播的太快了,本帧需要延后播放 为0 则视频有可能慢了音频至少一个帧
             */
            delay = compute_target_delay(last_duration, is);
            
            // frame_timer表示上一帧的播放时刻(这个时刻比非实际显示到屏幕的时刻提前一点点时间)
            time= av_gettime_relative()/1000000.0;
            if (time < is->frame_timer + delay) {   // 步骤3、 如果本帧的播放时刻(即上一帧的播放时刻+上一帧的时长)大于当前时刻,代表本帧的播放时刻还未到来
                // 渲染时间未到来则继续播放上一帧(由如下goto语句进行跳转),然后将remain_time赋值为本帧的播放时刻与当前时刻的时间差值
                *remaining_time = FFMIN(is->frame_timer + delay - time, *remaining_time);
                goto display;   //继续播放上一帧
            }
            
            // 同步上一帧的播放时间
            /** 疑问:这里is->frame_timer += delay;为什么是这样写的,而不是直接is->frame_timer = time;呢?
             *  分析:如果直接用is->frame_timer = time;进行赋值,那么视频帧因为某种原因累积了很多帧未播放时,那么会导致多出来的视频帧无法丢弃
             *  实际上ffplay有两条时钟,一条时钟音视频时钟,用于音视频同步用,即计算这里的delay值,另一条时钟frame_timer用来记录上一帧的播放时间
             *  同时用于计算是否满足丢帧的条件
             */
            is->frame_timer += delay;
            if (delay > 0 && time - is->frame_timer > AV_SYNC_THRESHOLD_MAX)    // 处理首帧播放和音视频帧同时出现解码时间过程导致的抖动,这里就需要重新更新上一帧播放时间为当前时间了
                is->frame_timer = time;
            
            // 步骤4、以下都是
            /** ffplay.c里面有三个时间
             *  1、frame_timer:保存在VideoState结构体里面,用以记录视频播放的时间点,该时间点基于系统时钟
             *  2、pts:保存在视频Clock结构体里面,等同于视频帧的pts
             *  3、pts_drift:视频帧的pts与视频播放时刻的时间差
             */
            SDL_LockMutex(is->pictq.mutex);
            if (!isnan(vp->pts))
                update_video_pts(is, vp->pts, vp->pos, vp->serial);
            SDL_UnlockMutex(is->pictq.mutex);

            // 如果本帧的pts+duration < time(当前时间)则丢弃该帧(说明队列中有大量还未渲染的视频帧,必须得丢掉一些了)
            if (frame_queue_nb_remaining(&is->pictq) > 1) {
                Frame *nextvp = frame_queue_peek_next(&is->pictq);
                duration = vp_duration(is, vp, nextvp);
                if(!is->step && (framedrop>0 || (framedrop && get_master_sync_type(is) != AV_SYNC_VIDEO_MASTER)) && time > is->frame_timer + duration){
                    is->frame_drops_late++;
                    frame_queue_next(&is->pictq);
                    goto retry;
                }
            }

            // FrameQueue队列的当前读取指针rindex的值+1(即指向本帧的索引),并且删除上一帧的Frame数据(因为已经不需要了)
            frame_queue_next(&is->pictq);
            is->force_refresh = 1;

            if (is->step && !is->paused)
                stream_toggle_pause(is);
        }
display:
        /* display picture */
        // 执行渲染本帧的工作
        if (!display_disable && is->force_refresh && is->show_mode == SHOW_MODE_VIDEO && is->pictq.rindex_shown)
            video_display(is);
    }
    is->force_refresh = 0;
    }
    ....省略代码.....
}

/* display the current picture, if any */
static void video_display(VideoState *is)
{
    if (!is->width)
        video_open(is);

    SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255);
    SDL_RenderClear(renderer);
    if (is->audio_st && is->show_mode != SHOW_MODE_VIDEO)
        video_audio_display(is);
    else if (is->video_st)
        video_image_display(is);
    SDL_RenderPresent(renderer);
}

还有一个很重要的就是如何保证前面的video_refresh()函数循环调用呢,通过如下的refresh_loop_wait_event()函数,它通过SDL库检测是否有鼠标和键盘等事件,如果没有则一直循环,有则退出循环去处理事件。处理事件又在event_loop()函数中,它是在main()函数中启动的。

static void refresh_loop_wait_event(VideoState *is, SDL_Event *event) {
    double remaining_time = 0.0;
    SDL_PumpEvents();
    while (!SDL_PeepEvents(event, 1, SDL_GETEVENT, SDL_FIRSTEVENT, SDL_LASTEVENT)) { // 没有捕捉到事件就去渲染视频,捕捉到了视频则先处理事件
        if (!cursor_hidden && av_gettime_relative() - cursor_last_shown > CURSOR_HIDE_DELAY) {
            SDL_ShowCursor(0);
            cursor_hidden = 1;
        }
        if (remaining_time > 0.0)
            av_usleep((int64_t)(remaining_time * 1000000.0));
        remaining_time = REFRESH_RATE;
        if (is->show_mode != SHOW_MODE_NONE && (!is->paused || is->force_refresh))
            video_refresh(is, &remaining_time);
        SDL_PumpEvents();
    }
}

static void event_loop(VideoState *cur_stream)
{
    SDL_Event event;
    double incr, pos, frac;
    /** 学习:线程的事件循环队列
     *  分析:时间循环队列的组成一定是 for/while循环+usleep()休眠+事件捕捉器组成,如果没有usleep()休眠那么会导致cpu空转,造成大量浪费
     *  这里休眠时间由播放视频的帧率(间隔)根据一定的规则计算而来,然后如果捕捉到事件则优先处理事件,接着再去播放视频
     */
    for (;;) {
        double x;
        refresh_loop_wait_event(cur_stream, &event);
        switch (event.type) {
        case SDL_KEYDOWN:
         .......各种键盘和鼠标事件....省略
        .....
    }
}

学习:线程的事件循环队列
分析:事件循环队列的组成一定是 for/while循环+usleep()休眠+事件捕捉器组成,如果没有usleep()休眠那么会导致cpu空转,造成大量浪费这里休眠时间由播放视频的帧率(间隔)根据一定的规则计算而来,然后如果捕捉到事件则优先处理事件,接着再去播放视频

这一套事件循环队列机制就保证了视频持续播放又能响应用户键盘和鼠标事件。以上就是视频渲染线程的工作流程和机制

  • 音频渲染线程的代码
    音频渲染线程,它是通过SDL内部自驱动的一个回调函数,被周期性的回调,只需要不停的往里面填充音频即可进行音频的渲染了。每一次调用称为一个音频渲染周期
    len:代表需要填充的音频数据长度;stream代表填充音频的buffer地址
/* prepare a new audio buffer */
static void sdl_audio_callback(void *opaque, Uint8 *stream, int len)
{
    VideoState *is = opaque;
    int audio_size, len1;

    audio_callback_time = av_gettime_relative();
    
    /** 学习:音频渲染线程,它是通过SDL内部自驱动的一个回调函数,被周期性的回调,只需要不停的往里面填充音频即可进行音频的渲染了。每一次调用称为一个音频渲染周期
     *  len:代表需要填充的音频数据长度;stream代表填充音频的buffer地址
     *
     *  is->audio_buf_index:表示当前渲染周期内已拷贝的音频数据字节的索引,即下一块音频数据放入stream+is->audio_buf_index的位置
     *  is->audio_buf_size:表示当前音频Frame的字节数
     */
    while (len > 0) {
        if (is->audio_buf_index >= is->audio_buf_size) {
           audio_size = audio_decode_frame(is);
           if (audio_size < 0) {
                /* if error, just output silence */
               is->audio_buf = NULL;
               is->audio_buf_size = SDL_AUDIO_MIN_BUFFER_SIZE / is->audio_tgt.frame_size * is->audio_tgt.frame_size;
           } else {
               if (is->show_mode != SHOW_MODE_VIDEO)
                   update_sample_display(is, (int16_t *)is->audio_buf, audio_size);
               is->audio_buf_size = audio_size;
           }
           is->audio_buf_index = 0;
        }
        len1 = is->audio_buf_size - is->audio_buf_index;
        if (len1 > len)
            len1 = len;
        if (!is->muted && is->audio_buf && is->audio_volume == SDL_MIX_MAXVOLUME)
            memcpy(stream, (uint8_t *)is->audio_buf + is->audio_buf_index, len1);
        else {
            memset(stream, 0, len1);
            if (!is->muted && is->audio_buf)
                SDL_MixAudioFormat(stream, (uint8_t *)is->audio_buf + is->audio_buf_index, AUDIO_S16SYS, len1, is->audio_volume);
        }
        len -= len1;
        stream += len1;
        is->audio_buf_index += len1;
    }
    is->audio_write_buf_size = is->audio_buf_size - is->audio_buf_index;
    /* Let's assume the audio driver that is used by SDL has two periods. */
    if (!isnan(is->audio_clock)) {
        set_clock_at(&is->audclk, is->audio_clock - (double)(2 * is->audio_hw_buf_size + is->audio_write_buf_size) / is->audio_tgt.bytes_per_sec, is->audio_clock_serial, audio_callback_time / 1000000.0);
        // 如果音视频同步方式为同步外部时钟,则调用此方法会有用
        sync_clock_to_slave(&is->extclk, &is->audclk);
    }
}

该函数的开启过程是随着拉流线程开启的,在拉流线程内通过stream_component_open()开启音频流处理

static int read_thread(void *arg){
.....省略代码.....
/* open the streams */
    if (st_index[AVMEDIA_TYPE_AUDIO] >= 0) {
        stream_component_open(is, st_index[AVMEDIA_TYPE_AUDIO]);
    }
.....省略代码.....
}

接下来在音频流处理流程中开启音频渲染相关代码

static int stream_component_open(VideoState *is, int stream_index){
.....省略代码.....
 /* prepare audio output */
        if ((ret = audio_open(is, channel_layout, nb_channels, sample_rate, &is->audio_tgt)) < 0)
            goto fail;
....省略代码......
}

然后就是初始化SDL音频渲染相关,在这里指定音频渲染回调

static int stream_component_open(VideoState *is, int stream_index)
{
....省略代码......
wanted_spec.format = AUDIO_S16SYS;
wanted_spec.silence = 0;
wanted_spec.samples = FFMAX(SDL_AUDIO_MIN_BUFFER_SIZE, 2 << av_log2(wanted_spec.freq / SDL_AUDIO_MAX_CALLBACKS_PER_SEC));
wanted_spec.callback = sdl_audio_callback;
wanted_spec.userdata = opaque;
....省略代码......
}

  • 字幕渲染线程的代码
    实际上字幕渲染没有单独的线程,它与视频共用一个线程,可以看到这段代码和视频渲染在同一个函数中的,前面说道,视频渲染流程到了步骤4之后,就代表着即将渲染本帧视频,而字幕的渲染则是在本帧视频渲染之前进行渲染,即下面这段代码是在视频帧渲染之前执行
static void video_refresh(void *opaque, double *remaining_time){
    .....省略代码.....
    if (is->subtitle_st) {
                // 步骤1、字幕FrameQueue队列中是否有字幕帧,没有则退出循环
                while (frame_queue_nb_remaining(&is->subpq) > 0) {
                    // 步骤2、获取当前待渲染字幕帧sp以及下一个待渲染字幕帧sp2(如果有的话)
                    sp = frame_queue_peek(&is->subpq);

                    if (frame_queue_nb_remaining(&is->subpq) > 1)
                        sp2 = frame_queue_peek_next(&is->subpq);
                    else
                        sp2 = NULL;
                    
                    // 步骤3、决定当前字幕帧是否需要被渲染。一帧字幕开始显示时间=pts+start_display_time,结束显示时间=pts+end_display_time
                    /** 学习:视频和字幕同步
                     *  分析:视频帧和字幕帧的同步主要以视频的时钟为准进行同步,这里is->vidclk.pts表示即将要渲染的视频帧的时间,当它大于(晚于)当前要渲染字
                     *  幕帧结束时间或者下一个要渲染字幕帧开始时间表示字幕显示已经落后于视频了,赶紧渲染当前字幕帧;否则就退出字幕帧渲染循环
                     */
                    // sp->serial != is->subtitleq.serial 用于首帧字幕渲染
                    /** 疑问:既然当前字幕帧都落后于即将要渲染的字幕帧了直接丢弃不就好了么?为撒要渲染上去呢?
                     *  分析:知悉分析就发现,下面这个if语句写法保证字幕帧在其显示时间内只被渲染一次。这样有利于效率提升
                     */
                    if (sp->serial != is->subtitleq.serial
                            || (is->vidclk.pts > (sp->pts + ((float) sp->sub.end_display_time / 1000)))
                            || (sp2 && is->vidclk.pts > (sp2->pts + ((float) sp2->sub.start_display_time / 1000))))
                    {
                        if (sp->uploaded) {
                            int i;
                            for (i = 0; i < sp->sub.num_rects; i++) {
                                AVSubtitleRect *sub_rect = sp->sub.rects[i];
                                uint8_t *pixels;
                                int pitch, j;

                                if (!SDL_LockTexture(is->sub_texture, (SDL_Rect *)sub_rect, (void **)&pixels, &pitch)) {
                                    for (j = 0; j < sub_rect->h; j++, pixels += pitch)
                                        memset(pixels, 0, sub_rect->w << 2);
                                    SDL_UnlockTexture(is->sub_texture);
                                }
                            }
                        }
                        frame_queue_next(&is->subpq);
                    } else {
                        break;
                    }
                }
            }
    .....省略代码.....
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,772评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,458评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,610评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,640评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,657评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,590评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,962评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,631评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,870评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,611评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,704评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,386评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,969评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,944评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,179评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,742评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,440评论 2 342

推荐阅读更多精彩内容