转自: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函数主要步骤如下:
前面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
的主要代码是一个主循环,主循环内执行:
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的前置条件。满足以下条件即可显示:
接下来,分析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
的主要流程如下:
先来看下上面流程图中的主流程——即中间一列框图。从框图进一步抽象,video_refresh
的主体流程分为3个步骤:
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。
丢帧的条件是,需要满足:
调用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的显示流程就分析完毕了。做个简单的总结: