ffplay video显示线程分析

转自:https://zhuanlan.zhihu.com/p/44122324

ffplay显示部分代码包括了video、audio、subtitle的显示(输出)。

我们知道要显示画面或者输出声音,在windows、linux、macos等不同平台上的接口都不尽相同,对于这个问题,ffplay选择了sdl作为显示SDK,以实现跨平台支持。

在阅读本文前,需要读者具备初步的sdl知识,可以参考这篇文章及其系列:https://www.jianshu.com/p/c2255397d91e,写的很好。

在分析显示流程的过程中,为了方便理解,对于如何进行音视频同步将另外写一篇文章分析,本文只关注如何将FrameQueue中已经解码好的音视频进行输出。显示部分实现的好坏直接关系到用户观影的体验,因此,代码篇幅和分析的篇幅也都较长,故决定分3篇文章,分别分析video、audio、subtitle的显示(输出).

 

下面,我们分析video的显示。

因为使用了SDL,而video的显示也依赖SDL的窗口显示系统,所以先从main函数的SDL初始化看起(节选):

int main(int argc, char **argv)
{
    //……

    //1. SDL_Init
    flags = SDL_INIT_VIDEO | SDL_INIT_AUDIO | SDL_INIT_TIMER;
    if (SDL_Init (flags)) {
        av_log(NULL, AV_LOG_FATAL, "Could not initialize SDL - %s\n", SDL_GetError());
        av_log(NULL, AV_LOG_FATAL, "(Did you set the DISPLAY variable?)\n");
        exit(1);
    }

    //2. SDL_CreateWindow
    window = SDL_CreateWindow(program_name, SDL_WINDOWPOS_UNDEFINED,SDL_WINDOWPOS_UNDEFINED, default_width, default_height, flags);

    if (window) {
        //3. SDL_CreateRender
        renderer = SDL_CreateRenderer(window, -1, SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC);
        if (!renderer) {
            av_log(NULL, AV_LOG_WARNING, "Failed to initialize a hardware accelerated renderer: %s\n", SDL_GetError());
            renderer = SDL_CreateRenderer(window, -1, 0);
        }
        if (renderer) {
            if (!SDL_GetRendererInfo(renderer, &renderer_info))
                av_log(NULL, AV_LOG_VERBOSE, "Initialized %s renderer.\n", renderer_info.name);
        }
    }

    //4. stream_open
    is = stream_open(input_filename, file_iformat);//这里创建了read_thread
    if (!is) {
        av_log(NULL, AV_LOG_FATAL, "Failed to initialize VideoState!\n");
        do_exit(NULL);
    }

    //5. event_loop
    event_loop(is);
}

main函数主要步骤如下:

  1. SDL_Init
  2. SDL_CreateWindow
  3. SDL_CreateRender
  4. stream_open
  5. event_loop

前面3个步骤几乎是SDL的标配了,初始化SDL后创建了一个窗口,然后创建Render用于渲染窗口和其他内容。接着调用stream_open,创建read_thread,read_thread会打开文件,解析封装,获取AVStream信息,启动解码器(创建解码线程),并开始读取文件。(这部分的分析可以参考本人的前几篇文章)

event_loop开始处理SDL事件:

static void event_loop(VideoState *cur_stream)
{
    SDL_Event event;
    double incr, pos, frac;

    for (;;) {
        double x;
        refresh_loop_wait_event(cur_stream, &event);//video是在这里显示的
        switch (event.type) {
            //……
            case SDLK_SPACE://按空格键触发暂停/恢复
                toggle_pause(cur_stream);
                break;
            case SDL_QUIT:
            case FF_QUIT_EVENT://自定义事件,用于出错时的主动退出
                do_exit(cur_stream);
                break;
        }
    }
}

event_loop的主要代码是一个主循环,主循环内执行:

  1. refresh_loop_wait_event
  2. 处理SDL事件队列中的事件。比如按空格键可以触发暂停/恢复,关闭窗口可以触发do_exit销毁播放现场。

video的显示主要在refresh_loop_wait_event

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)) {//调用SDL_PeepEvents无阻塞取事件
        if (!cursor_hidden && av_gettime_relative() - cursor_last_shown > CURSOR_HIDE_DELAY) {//鼠标自动隐藏逻辑
            SDL_ShowCursor(0);
            cursor_hidden = 1;
        }
        if (remaining_time > 0.0)//sleep控制画面输出间隔
            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();//驱动SDL事件队列
    }
}

SDL_PeepEvents通过参数SDL_GETEVENT非阻塞查询队列中是否有事件。如果返回值为0,表示没有事件发生,那么函数就会返回,接着让event_loop处理事件;否则,就调用video_refresh显示画面,并通过输出参数remaining_time获取下一轮应当sleep的时间,以保持稳定的画面输出。

这里还有一个判断是否要调用video_refresh的前置条件。满足以下条件即可显示:
  1. 显示模式不为SHOW_MODE_NONE(如果文件中只有audio,也会显示其波形或者频谱图等)
  2. 或者,当前没有被暂停
  3. 或者,当前设置了force_refresh

接下来,分析video显示的关键函数video_refresh(经简化):

static void video_refresh(void *opaque, double *remaining_time)
{
    VideoState *is = opaque;
    double time;

    Frame *sp, *sp2;

    if (is->video_st) {
retry:
        if (frame_queue_nb_remaining(&is->pictq) == 0) {
            // nothing to do, no picture to display in the queue
        } else {
            double last_duration, duration, delay;
            Frame *vp, *lastvp;

            /* dequeue the picture */
            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);
            delay = compute_target_delay(last_duration, is);

            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;
            if (delay > 0 && time - is->frame_timer > AV_SYNC_THRESHOLD_MAX)
                is->frame_timer = time;

            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(!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;
                }
            }

            if (is->subtitle_st) {//显示字幕
                //……
            }

            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;

}

video_refresh比较长,即使已经经过了简化,去掉了次要分支。

函数中涉及到FrameQueue中的3个节点是lastvp, vp, nextvp:

其中vp这次将要显示的目标帧,lastvp是已经显示了的帧(也是当前屏幕上看到的帧),nextvp是下一次要显示的帧。取出其前面一帧与后面一帧,是为了通过pts准确计算duration。duration的计算通过函数vp_duration完成:

static double vp_duration(VideoState *is, Frame *vp, Frame *nextvp) {
    if (vp->serial == nextvp->serial) {//序列连续的情况下
        double duration = nextvp->pts - vp->pts;
        if (isnan(duration) || duration <= 0 || duration > is->max_frame_duration)
            return vp->duration;//如果pts差计算的duration无效,就直接返回Frame中的duration字段
        else//使用两帧pts差值计算duration,一般情况下也是走的这个分支
            return duration;
    } else {//序列不连续,直接返回0
        return 0.0;
    }
}
更多FrameQueue相关分析,参考我的另一篇文章: https://zhuanlan.zhihu.com/p/43564980

video_refresh的主要流程如下:

ffplay video显示线程分析_第1张图片

 

先来看下上面流程图中的主流程——即中间一列框图。从框图进一步抽象,video_refresh的主体流程分为3个步骤:

  1. 计算上一帧应显示的时长,判断是否继续显示上一帧
  2. 估算当前帧应显示的时长,判断是否要丢帧
  3. 调用video_display进行显示

video_display会调用frame_queue_peek_last获取上次显示的frame,并显示。所以在video_refresh中如果流程直接走到video_display就会显示lastvp,如果先调用frame_queue_next再调用video_display,那么就会显示vp.

下面我们具体分析这3个步骤,并和流程图与代码进行对应阅读。

 

计算上一帧应显示的时长,判断是否继续显示上一帧

首先检查pictq是否为空(调用frame_queue_nb_remaining判断队列中是否有未显示的帧),如果为空,则调用video_display(显示上一帧)。

在进一步准确计算上一帧应显示时间前,需要先判断frame_queue_peek获取的vp是否是最新序列——即if (vp->serial != is->videoq.serial),如果条件成立,说明发生过seek等操作,流不连续,应该抛弃vp。故调用frame_queue_next抛弃vp后,返回流程开头重试下一轮。

接下来可以计算准确的lastvp应显示时长了。计算应显示时间的代码是:

last_duration = vp_duration(is, lastvp, vp);
delay = compute_target_delay(last_duration, is);

直观理解,主要基于vp_duration计算两帧pts差,即帧持续时间,即可。但是,如果考虑到同步,比如视频同步到音频,则还需要考虑当前与主时钟的差距,进而决定是重复上一帧还是丢帧,还是正常显示下一帧。关于音视频同步,我们在专门一篇文章分析。这里只需要理解通过以上两步就可以计算出准确的上一帧应显示时长了。

最后,根据上一帧应显示时长(delay变量),确定是否继续显示上一帧:

time= av_gettime_relative()/1000000.0;//获取当前系统时间(单位秒)
if (time < is->frame_timer + delay) {
    *remaining_time = FFMIN(is->frame_timer + delay - time, *remaining_time);
    goto display;
}

frame_timer可以理解为帧显示时刻,对于更新前,可以理解为上一帧的显示时刻;对于更新后,可以理解为当前帧显示时刻。time < is->frame_timer + delay,如果当前系统时刻还未到达上一帧的结束时刻,那么还应该继续显示上一帧。

 

估算当前帧应显示的时长,判断是否要丢帧

这个步骤执行前,还需要一点准备工作:更新frame_timer和更新vidclk。

is->frame_timer += delay;//更新frame_timer,现在表示vp的显示时刻
if (delay > 0 && time - is->frame_timer > AV_SYNC_THRESHOLD_MAX)
    is->frame_timer = time;//如果和系统时间差距太大,就纠正为系统时间

SDL_LockMutex(is->pictq.mutex);
if (!isnan(vp->pts))
    update_video_pts(is, vp->pts, vp->pos, vp->serial);//更新vidclk
SDL_UnlockMutex(is->pictq.mutex);

关于vidclk的作用在音视频同步一文中分析。

接下来就可以判断是否要丢帧了:

if (frame_queue_nb_remaining(&is->pictq) > 1) {//只有有nextvp才会丢帧
    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){//“ffplay”风格条件……
        is->frame_drops_late++;
        frame_queue_next(&is->pictq);//这里实现的丢帧
        goto retry;
    }
}

丢帧的代码也比较简单,只需要frame_queue_next,然后retry。

丢帧的条件是,需要满足:

  1. 不处于step状态。换言之,如果当前是step状态,不会触发丢帧逻辑。(step用于pause状态下进行seek操作时,于seek操作结束后显示seek后的一帧画面,用于直观体现seek生效了)
  2. 启用framedrop,或当前video不是主时钟。(这里丢帧条件与解码线程如出一辙,见https://zhuanlan.zhihu.com/p/43948483)
  3. 系统时刻已大于frame_timer+duration,即当前这一帧永无出头日

 

调用video_display进行显示

如果既不需要重复上一帧,也不需要抛弃当前帧,那么就可以安心显示当前帧了。之前有顺带提过video_display中显示的是frame_queue_peek_last,所以需要先调用frame_queue_next,移动pictq内的指针,将vp变成shown,确保frame_queue_peek_last取到的是vp。

接下来看下video_display

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);
}

假设读者已初步了解SDL接口的使用,我们直接看video_image_display

static void video_image_display(VideoState *is)
{
    Frame *vp;
    Frame *sp = NULL;
    SDL_Rect rect;

    vp = frame_queue_peek_last(&is->pictq);//取要显示的视频帧
    if (is->subtitle_st) {
        //字幕显示逻辑
    }

    //将帧宽高按照sar最大适配到窗口
    calculate_display_rect(&rect, is->xleft, is->ytop, is->width, is->height, vp->width, vp->height, vp->sar);

    if (!vp->uploaded) {//如果是重复显示上一帧,那么uploaded就是1
        if (upload_texture(&is->vid_texture, vp->frame, &is->img_convert_ctx) < 0)
            return;
        vp->uploaded = 1;
        vp->flip_v = vp->frame->linesize[0] < 0;
    }

    SDL_RenderCopyEx(renderer, is->vid_texture, NULL, &rect, 0, NULL, vp->flip_v ? SDL_FLIP_VERTICAL : 0);
    if (sp) {
        //字幕显示逻辑
    }
}

如果了解了SDL的显示,video_image_display的逻辑不算复杂,即先frame_queue_peek_last取要显示帧,然后upload_texture更新到SDL_Texture,最后通过SDL_RenderCopyEx拷贝纹理给render显示。

最后,了解下upload_texture具体是如何将AVFormat的图像数据传给sdl的纹理:

static int upload_texture(SDL_Texture **tex, AVFrame *frame, struct SwsContext **img_convert_ctx) {
    int ret = 0;
    Uint32 sdl_pix_fmt;
    SDL_BlendMode sdl_blendmode;
    //获取frame对应的sdl fmt和blendmode
    get_sdl_pix_fmt_and_blendmode(frame->format, &sdl_pix_fmt, &sdl_blendmode);
    //根据frame的sdl fmt和blendmode,重建纹理(当然,内容实现只会在纹理不同或无效时才会重建)
    if (realloc_texture(tex, sdl_pix_fmt == SDL_PIXELFORMAT_UNKNOWN ? SDL_PIXELFORMAT_ARGB8888 : sdl_pix_fmt, frame->width, frame->height, sdl_blendmode, 0) < 0)
        return -1;

    //根据sdl_pix_fmt从AVFrame中取数据填充纹理
    switch (sdl_pix_fmt) {//以下代码被精简
        case SDL_PIXELFORMAT_UNKNOWN:
        case SDL_PIXELFORMAT_IYUV:
            if (frame->linesize[0] > 0 && frame->linesize[1] > 0 && frame->linesize[2] > 0) {//比如IYUV数据的填充
    ret = SDL_UpdateYUVTexture(*tex, NULL, frame->data[0], frame->linesize[0],
                                           frame->data[1], frame->linesize[1],
                                           frame->data[2], frame->linesize[2]);
            }
        default:
    }
    return ret;
}

 

至此,video的显示流程就分析完毕了。做个简单的总结:

  1. 基于SDL实现绘图,绘图逻辑实际在主线程中
  2. 主要控制绘图流程在video_refresh。主要处理了重复上帧画面、丢帧显示、正常显示的逻辑
  3. 画面呈现,在upload_texture中。通过判断不同Frame format,将Frame中的图像数据更新给 SDL texture显示
  4. subtitle显示逻辑交杂在video显示逻辑中

你可能感兴趣的:(音视频)