龙空技术网

零基础读懂视频播放器控制原理: ffplay 播放器源代码分析

音视频开发老舅 138

前言:

现在我们对“播放设计原理是什么”大概比较珍视,你们都想要知道一些“播放设计原理是什么”的相关知识。那么小编也在网摘上搜集了一些对于“播放设计原理是什么””的相关内容,希望朋友们能喜欢,看官们一起来学习一下吧!

视频播放器原理其实大抵相同,都是对音视频帧序列的控制。只是一些播放器在音视频同步上可能做了更为复杂的帧预测技术,来保证音频和视频有更好的同步性。

ffplay 是 FFMpeg 自带的播放器,使用了 ffmpeg 解码库和用于视频渲染显示的 sdl 库,也是业界播放器最初参考的设计标准。本文对 ffplay 源码进行分析,试图用更基础而系统的方法,来尝试解开播放器的音视频同步,以及播放/暂停、快进/后退的控制原理。

由于 FFMpeg 本身的跨平台特性,相比在移动端看音视频代码,在 PC 端利用 VS 查看和调试代码,分析播放器原理,要高效迅速很多。

由于 FFMpeg 官方提供的 ffmplay 在 console 中进行使用不够直观,本文直接分析 上将 ffplay 移植到 VC 的代码(ffplay for MFC)进行分析。

文章目录:一、初探mp4文件二、以最简单播放器开始:FFmpeg解码 + SDL显示三、先抛五个问题四、ffplay代码总体结构五、视频播放器的操作控制六、 这次分析ffplay代码的反省总结一、初探mp4文件

为了让大家对视频文件有一个初步认识,首先来看对一个MP4文件的简单分析,如图1。

图1 对MP4文件解参

从图一我们知道,每个视频文件都会有特定的封装格式、比特率、时长等信息。视频解复用之后,就划分为video_stream和audio_stream,分别对应视频流和音频流。

解复用之后的音视频有自己独立的参数,视频参数包括编码方式、采样率、画面大小等,音频参数包括采样率、编码方式和声道数等。

对解复用之后的音频和视频Packet进行解码之后,就变成原始的音频(PWM)和视频(YUV/RGB)数据,才可以在进行显示和播放。

其实这已经差不多涉及到了,视频解码播放的大部分流程,整个视频播放的流程如图2所示。

图2 视频播放流程

二、以最简单播放器开始:FFmpeg解码 + SDL显示

为将问题简单化,先不考虑播放音频,只播放视频,代码流程图如图3所示:

图3 播放器流程图(图源见水印)

流程图说明如下:

1.FFmpeg初始化的代码比较固定,主要目的就是为了设置 AVFormatContext 实例中相关成员变量的值,调用av_register_all、avformat_open_input av_find_stream_info和avcodec_find_decoder等函数。

如图4所示,初始化之后的AVFormatContext实例里面具体的值,调用av_find_stream_info就是找到文件中的音视频流数据,对其中的streams(包含音频、视频流)变量进行初始化。

图4 AVFormatContext初始化实例

2.av_read_frame不断读取stream中的下一帧,对其进行解复用得到视频的AVPacket,随后调用avcodec_decode_video2是视频帧AVPacket进行解码,得到图像帧AVFrame。

3.得到AVFrame之后,接下来就是放到SDL中进行渲染显示了,也很简单,流程见下面代码注释:

SDL_Overlay *bmp;//将解析得到的AVFrame的数据拷贝到SDL_Overlay实例当中SDL_LockYUVOverlay(bmp);bmp->pixels[0]=pFrameYUV->data[0];bmp->pixels[2]=pFrameYUV->data[1];bmp->pixels[1]=pFrameYUV->data[2];    bmp->pitches[0]=pFrameYUV->linesize[0];bmp->pitches[2]=pFrameYUV->linesize[1];  bmp->pitches[1]=pFrameYUV->linesize[2];SDL_UnlockYUVOverlay(bmp);//设置SDL_Rect,因为涉及到起始点和显示大小,用rect进行表示。SDL_Rect rect;rect.x = 0;   rect.y = 0;   rect.w = pCodecCtx->width; rect.h = pCodecCtx->height;   //将SDL_Overlay数据显示到SDL_Surface当中。SDL_DisplayYUVOverlay(bmp, &rect);//延时40ms,留足ffmpeg取到下一帧并解码该帧的时间,随后继续读取下一帧SDL_Delay(40);

由上面的原理可知,从帧流中获取到AVPacket,并且解码得到AVFrame,渲染到SDL窗口中。

图5 视频播放状态图

对视频播放的流程总结一下就是:读取下一帧——>解码——>播放——>不断往复,状态图如图5所示。

领C++音视频学习资料→音视频开发(资料文档+视频教程+项目源码)(FFmpeg+WebRTC+RTMP+RTSP+HLS+RTP)

三、先抛五个问题

本文还是以问题抛问题的思路,以逐步对每个问题进行原理性分析,加深对音视频解码和播放的认识。以下这些问题也是每一个播放器所需要面对的基础问题和原理:

1.我们在观看电影时发现,电影可以更换不同字幕,甚至不同音频,比如中英文字幕和配音,最后在同一个画面中进行显示,视频关于画面、字幕和声音是如何组合的? 其实每一个视频文件,读取出来之后发现,都会被区分不同的流。为了让大家有更具体的理解,以FFMpeg中的代码为例,AVMediaType定义了具体的流类型:

enum AVMediaType {    AVMEDIA_TYPE_VIDEO,  //视频流    AVMEDIA_TYPE_AUDIO, //音频流    AVMEDIA_TYPE_SUBTITLE, //字幕流};

利用av_read_frame读取出音视频帧之后,随后就利用avcodec_decode_video2对视频捷星解码,或者调用avcodec_decode_audio4对音频进行解码,得到可以供渲染和显示的音视频原始数据。

图像和字幕都将会以Surface或者texture的形式,就像Android中的SurfaceFlinger,将画面不同模块的显示进行组合,生成一幅新的图像,显示在视频画面中。

2.既然视频有帧率的概念,音频有采样率的概念,是否直接利用帧率就可以控制音视频的同步了呢? 每一个视频帧和音频帧在时域上都对应于一个时间点,按道理来说只要控制每一个音视频帧的播放时间,就可以实现同步。

但实际上,对每一帧显示的时间上的精确控制是很难的,更何况音频和视频的解码所需时间不同,极容易引起音视频在时间上的不同步。

所以,播放器具体是如何做音视频同步的呢?

3.视频的音频流、视频流和字幕流,他们在时间上是连续的还是离散的?不同流的帧数相同吗?

由于计算机只能数字模拟离散的世界,所以在时间上肯定是离散的。那既然是离散的,他们的帧数是否相同呢?

视频可以理解为诸多音频帧、视频帧和字幕帧在时间上的序列,他们在时间上的时长,跟视频总时长是相同的,但是由于每个帧解码时间不同,必然会导致他们在每帧的时间间隔不相同。

音频原始数据本身就是采样数据,所以是有固定时钟周期。但是视频假如想跟音频进行同步的话,可能会出现跳帧的情况,每个视频帧播放时间差,都会起伏不定,不是恒定周期。

所以结论是,三者在视频总时长上播放的帧数肯定是不一样的。

4.视频播放就是一系列的连续帧不停渲染。对视频的控制操作包括:暂停和播放、快进和后退。那有没有想过,每次快进/后退的幅度,以时间为量度好,还是以每次跳跃的帧数,就是每次快进是前进多长时间,还是前进多少帧。 时间 VS 帧数?

由上面问题分析,我们知道,视频是以音频流、视频流和字幕流进行分流的,假如以帧数为基础,由于不同流的帧数量不一定相同,以帧数为单位,很容易导致三个流播放的不一致。

因此以时间为量度,相对更好,直接搜寻mp4文件流,当前播放时间的前进或后退时长的seek时间点,随后重新对文件流进行分流解析,就可以达到快进和后退之后的音视频同步效果。

我们可以看到绝大部分播放器,快进/倒退都是以时长为步进的,我们可以看看ffplay是怎么样的,以及是如何实现的。

5.上一节中,实现的简单播放器,解码和播放都是在同一个线程中,解码速度直接影响播放速度,从而将直接造成播放不流畅的问题。那如何在解码可能出现速度不均匀的情况下,进行流畅的视频播放呢?

很容易想到,引入缓冲队列,将视频图像渲染显示和视频解码作为两个线程,视频解码线程往队列中写数据,视频渲染线程从队列中读取数据进行显示,这样就可以保证视频是可以流程播放的。

因此需要采用音频帧、视频帧和字幕帧的三个缓冲队列,那如何保证音视频播放的同步呢?

PTS是视频帧或者音频帧的显示时间戳,究竟是如何利用起来的,从而控制视频帧、音频帧以及字幕帧的显示时刻呢?

那我们就可以探寻ffplay,究竟是如何去做缓冲队列控制的。

所有以上五个问题,我们都将在对ffplay源代码的探寻中,逐步找到更具体的解答。

四、ffplay代码总体结构

图6 ffplay代码总体流程

网上有人做了ffplay的总体流程图,如图6。有了这幅图,代码看起来,就会轻松了很多。流程中具体包含的细节如下:

1.启动定时器Timer,计时器40ms刷新一次,利用SDL事件机制,触发从图像帧队列中读取数据,进行渲染显示;

2.stream_componet_open函数中,av_read_frame()读取到AVPacket,随后放入到音频、视频或字幕Packet队列中;

3.video_thread,从视频packet队列中获取AVPacket并进行解码,得到AVFrame图像帧,放到VideoPicture队列中。

4..audio_thread线程,同video_thread,对音频Packet进行解码;

5.subtitle_thread线程,同video_thread,对字幕Packet进行解码。

五、视频播放器的操作控制

视频播放器的操作包括播放/暂停、快进/倒退、逐帧播放等,这些操作的实现原理是什么呢,下面对其从代码层面逐个进行分析。

5.1 ffplay所定义的关键结构体VideoState

与FFmpeg解码类似,定义了一个AVFormatContext结构体,用于存储文件名、音视频流、解码器等字段,供全局进行访问。

ffplay也定义了一个结构体VideoState,通过对VideoState的分析,就可以大体知道播放器基本实现原理。

typedef struct VideoState {       // Demux解复用线程,读视频文件stream线程,得到AVPacket,并对packet入栈       SDL_Thread *read_tid;         //视频解码线程,读取AVPacket,decode 爬出可以成AVFrame并入队       SDL_Thread *video_tid;       //视频播放刷新线程,定时播放下一帧       SDL_Thread *refresh_tid;       int paused;  //控制视频暂停或播放标志位       int seek_req;  //进度控制标志       int seek_flags;       AVStream *audio_st;   //音频流       PacketQueue audioq;  //音频packet队列       double audio_current_pts;  //当前音频帧显示时间       AVStream *subtitle_st; //字幕流       PacketQueue subtitleq;//字幕packet队列        AVStream *video_st; //视频流       PacketQueue videoq;//视频packet队列       double video_current_pts; ///当前视频帧pts       double video_current_pts_drift;         VideoPicture pictq[VIDEO_PICTURE_QUEUE_SIZE];  //解码后的图像帧队列}

从VideoState结构体中可以看出:

1.解复用、视频解码和视频刷新播放,分属三个线程中,并行控制;

2.音频流、视频流、字幕流,都有自己的缓冲队列,供不同线程读写,并且有自己的当前帧的PTS;

3.解码后的图像帧单独放在pictq队列当中,SDL利用其进行显示。

其中PTS是什么呢,这在音视频中是一个很重要的概念,直接决定视频帧或音频帧的显示时间,下面具体介绍一下。

5.2 补充基础知识——PTS和DTS

图7 音视频解码分析

图7为输出的音频帧和视频帧序列,每一帧都有PTS和DTS标签,这两个标签究竟是什么意思呢? DTS(Decode Time Stamp)和PTS(Presentation Time Stamp)都是时间戳,前者是解码时间,后者是显示时间,都是为视频帧、音频帧打上的时间标签,以更有效地支持上层应用的同步机制。

也就是说,视频帧或者音频在解码时,会记录其解码时间,视频帧的播放时间依赖于PTS。

对于声音来说 ,这两个时间标签是相同的;但对于某些视频编码格式,由于采用了双向预测技术,DTS会设置一定的超时或延时,保证音视频的同步,会造成DTS和PTS的不一致。

5.3 如何控制音视频同步

我们已经知道,视频帧的播放时间其实依赖pts字段的,音频和视频都有自己单独的pts。但pts究竟是如何生成的呢,假如音视频不同步时,pts是否需要动态调整,以保证音视频的同步?

下面先来分析,如何控制视频帧的显示时间的:

static void video_refresh(void *opaque){   //根据索引获取当前需要显示的VideoPicture  VideoPicture *vp = &is->pictq[is->pictq_rindex];  if (is->paused)      goto display; //只有在paused的情况下,才播放图像  // 将当前帧的pts减去上一帧的pts,得到中间时间差  last_duration = vp->pts - is->frame_last_pts;  //检查差值是否在合理范围内,因为两个连续帧pts的时间差,不应该太大或太小  if (last_duration > 0 && last_duration < 10.0) {    /* if duration of the last frame was sane, update last_duration in video state */    is->frame_last_duration = last_duration;  }  //既然要音视频同步,肯定要以视频或音频为参考标准,然后控制延时来保证音视频的同步,  //这个函数就做这个事情了,下面会有分析,具体是如何做到的。  delay = compute_target_delay(is->frame_last_duration, is);  //获取当前时间  time= av_gettime()/1000000.0;   //假如当前时间小于frame_timer + delay,也就是这帧改显示的时间超前,还没到,就直接返回  if (time < is->frame_timer + delay)       return;  //根据音频时钟,只要需要延时,即delay大于0,就需要更新累加到frame_timer当中。  if (delay > 0)       /更新frame_timer,frame_time是delay的累加值       is->frame_timer += delay * FFMAX(1, floor((time-is->frame_timer) / delay));  SDL_LockMutex(is->pictq_mutex);  //更新is当中当前帧的pts,比如video_current_pts、video_current_pos 等变量  update_video_pts(is, vp->pts, vp->pos);  SDL_UnlockMutex(is->pictq_mutex);display:  /* display picture */  if (!display_disable)    video_display(is);}

函数compute_target_delay根据音频的时钟信号,重新计算了延时,从而达到了根据音频来调整视频的显示时间,从而实现音视频同步的效果。

static double compute_target_delay(double delay, VideoState *is){    double sync_threshold, diff;   //因为音频是采样数据,有固定的采用周期并且依赖于主系统时钟,要调整音频的延时播放较难控制。所以实际场合中视频同步音频相比音频同步视频实现起来更容易。   if (((is->av_sync_type == AV_SYNC_AUDIO_MASTER && is->audio_st) ||     is->av_sync_type == AV_SYNC_EXTERNAL_CLOCK)) {       //获取当前视频帧播放的时间,与系统主时钟时间相减得到差值       diff = get_video_clock(is) - get_master_clock(is);       sync_threshold = FFMAX(AV_SYNC_THRESHOLD, delay);      //假如当前帧的播放时间,也就是pts,滞后于主时钟      if (fabs(diff) < AV_NOSYNC_THRESHOLD) {         if (diff <= -sync_threshold)             delay = 0;      //假如当前帧的播放时间,也就是pts,超前于主时钟,那就需要加大延时      else if (diff >= sync_threshold)        delay = 2 * delay;      }   }   return delay;}

图8 音视频帧显示序列

所以这里的流程就很简单了,图8简单画了一个音视频帧序列,想表达的意思是,音频帧数量和视频帧数量不一定对等,另外每个音频帧的显示时间在时间上几乎对等,每个视频帧的显示时间,会根据具体情况有延时显示,这个延时就是有上面的compute_target_delay函数计算出来的。

计算延迟后,更新pts的代码如下:

static void update_video_pts(VideoState *is, double pts, int64_t pos) {    double time = av_gettime() / 1000000.0;    /* update current video pts */    is->video_current_pts = pts;    is->video_current_pts_drift = is->video_current_pts - time;    is->video_current_pos = pos;    is->frame_last_pts = pts;}

整个流程可以概括为:

显示第一帧视频图像;

根据音频信号,计算出第二帧的delay时间,更新该帧的pts;

当pts到达后,显示第二帧视频图像;

重复以上步骤,到最后一帧。

也许在这里仍然会让人很困惑,为什么单单根据主时钟,就可以播放下一帧所需要的延时呢?

其实视频是具备一定长度的播放流,具体可以分为音频流、视频流和字幕流,三者同时在一起播放形成了视频,当然他们总的播放时间是跟视频文件的播放时长是一样的。

由于音频流本身是pwm采样数据,以固定的频率播放,这个频率是跟主时钟相同或是它的分频,从时间的角度来看,每个音频帧是自然均匀流逝。

所以音频的话,直接按照主时钟或其分频走就可以了。

视频,要根据自己的显示时间即pts,跟主时钟当前的时间进行对比,确定是超前还是滞后于系统时钟,从而确定延时,随后进行准确的播放,这样就可以保证音视频的同步了。

那接下来,还有一个问题,计算出延时之后,难道需要sleep一下做延迟显示吗?

其实并不是如此,上面分析我们知道delay会更新到当前需要更新视频帧的pts (video_current_pts),对当前AVFrame进行显示前,先检测其pts时间,假如还没到,就不进行显示了,直接return。直到下一次刷新,重新进行检测(ffplay采用的40ms定时刷新)。

代码如下,未到更新后的pts时间( is->frame_timer + dela),直接return:

if (av_gettime()/1000000.0 < is->frame_timer + delay)      return;

那接下来就是分析如何播放视频帧,就很简单了,只是这里多加了一个字幕流的处理:

static void video_image_display(VideoState *is){    VideoPicture *vp;   SubPicture *sp;   AVPicture pict;   SDL_Rect rect;   int i;   vp = &is->pictq[is->pictq_rindex];   if (vp->bmp) {       //字幕处理       if (is->subtitle_st) {}                     }   //计算图像的显示区域   calculate_display_rect(&rect, is->xleft, is->ytop, is->width, is->height, vp);   //显示图像   SDL_DisplayYUVOverlay(vp->bmp, &rect);   //将pic队列的指针向前移动一个位置   pictq_next_picture(is);}

VIDEO_PICTURE_QUEUE_SIZE 只设置为4,很快就会用完了。数据满了如何重新更新呢?

一旦检测到超出队列大小限制,就处于等待状态,直到pictq被取出消费,从而避免开启播放器,就把整个文件全部解码完,这样会代码会很吃内存。

static int queue_picture(VideoState *is, AVFrame *src_frame, double pts1, int64_t pos){/* keep the last already displayed picture in the queue */while (is->pictq_size >= VIDEO_PICTURE_QUEUE_SIZE - 2 &&      !is->videoq.abort_request) {    SDL_CondWait(is->pictq_cond, is->pictq_mutex);   }   SDL_UnlockMutex(is->pictq_mutex);}

5.4 如何控制视频的播放和暂停?

static void stream_toggle_pause(VideoState *is){    if (is->paused) {       //由于frame_timer记下来视频从开始播放到当前帧播放的时间,所以暂停后,必须要将暂停的时间( is->video_current_pts_drift - is->video_current_pts)一起累加起来,并加上drift时间。     is->frame_timer += av_gettime() / 1000000.0 + is->video_current_pts_drift - is->video_current_pts;     if (is->read_pause_return != AVERROR(ENOSYS)) {     //并更新video_current_pts        is->video_current_pts = is->video_current_pts_drift + av_gettime() / 1000000.0;       }    //drift其实就是当前帧的pts和当前时间的时间差    is->video_current_pts_drift = is->video_current_pts - av_gettime() / 1000000.0;    }    //paused取反,paused标志位也会控制到图像帧的展示,按一次空格键实现暂停,再按一次就实现播放了。    is->paused = !is->paused;}

特别说明:paused标志位控制着视频是否播放,当需要继续播放的时候,一定要重新更新当前所需要播放帧的pts时间,因为这里面要加上已经暂停的时间。

5.5 逐帧播放是如何做的?

在视频解码线程中,不断通过stream_toggle_paused,控制对视频的暂停和显示,从而实现逐帧播放:

static void step_to_next_frame(VideoState *is){   //逐帧播放时,一定要先继续播放,然后再设置step变量,控制逐帧播放   if (is->paused)      stream_toggle_pause(is);//会不断将paused进行取反   is->step = 1;}

其原理就是不断的播放,然后暂停,从而实现逐帧播放:

static int video_thread(void *arg){  if (is->step)    stream_toggle_pause(is);      ……………………  if (is->paused)    goto display;//显示视频  }}

5.6 快进和后退

关于快进/后退,首先抛出两个问题:

1. 快进以时间为维度还是以帧数为维度来对播放进度进行控制呢?

2.一旦进度发生了变化,那么当前帧,以及AVFrame队列是否需要清零,整个对stream的流是否需要重新来进行控制呢?

ffplay中采用以时间为维度的控制方法。对于快进和后退的控制,都是通过设置VideoState的seek_req、seek_pos等变量进行控制。

do_seek: //实际上是计算is->audio_current_pts_drift + av_gettime() / 1000000.0,确定当前需要播放帧的时间值 pos = get_master_clock(cur_stream); pos += incr; //incr为每次快进的步进值,相加即可得到快进后的时间点 stream_seek(cur_stream, (int64_t)(pos AV_TIME_BASE), (int64_t)(incrAV_TIME_BASE), 0); 关于stream_seek的代码如下,其实就是设置VideoState的相关变量,以控制read_tread中的快进或后退的流程:
/* seek in the stream */static void stream_seek(VideoState *is, int64_t pos, int64_t rel, int seek_by_bytes){  if (!is->seek_req) {  is->seek_pos = pos;  is->seek_rel = rel;  is->seek_flags &= ~AVSEEK_FLAG_BYTE;  if (seek_by_bytes)    is->seek_flags |= AVSEEK_FLAG_BYTE;  is->seek_req = 1;}}

stream_seek中设置了seek_req标志,就直接进入前进/后退控制流程了,其原理是调用avformat_seek_file函数,根据时间戳控制索引点,从而控制需要显示的下一帧:

static int read_thread(void *arg){//当调整播放进度以后if (is->seek_req) {   int64_t seek_target = is->seek_pos;   int64_t seek_min    = is->seek_rel > 0 ? seek_target - is->seek_rel + 2: INT64_MIN;   int64_t seek_max    = is->seek_rel < 0 ? seek_target - is->seek_rel - 2: INT64_MAX;  //根据时间抽查找索引点位置,定位到索引点之后,下一帧的读取直接从这里开始,就实现了快进/后退操作  ret = avformat_seek_file(is->ic, -1, seek_min, seek_target, seek_max, is->seek_flags);  if (ret < 0) {     fprintf(stderr, "s: error while seeking\n", is->ic->filename);  } else {  //查找成功之后,就需要清空当前的PAcket队列,包括音频、视频和字幕     if (is->audio_stream >= 0) {        packet_queue_flush(&is->audioq);        packet_queue_put(&is->audioq, &flush_pkt);     }     if (is->subtitle_stream >= 0) {//处理字幕stream        packet_queue_flush(&is->subtitleq);        packet_queue_put(&is->subtitleq, &flush_pkt);    }    if (is->video_stream >= 0) {       packet_queue_flush(&is->videoq);       packet_queue_put(&is->videoq, &flush_pkt);    }  }  is->seek_req = 0;  eof = 0;  }}

另外从上面代码中发现,每次快进后退之后都会对audioq、videoq和subtitleq进行flush清零,也是相当于重新开始,保证缓冲队列中的数据的正确性。

对于音频,开始仍然有些困惑,因为在暂停的时候,没有看到对音频的控制,是如何控制的呢?

后来发现,其实暂停的时候设置了is->paused变量,解复用和音频解码和播放都依赖于is->paused变量,所以音频和视频播放都随之停止了。

六、 这次分析ffplay代码的反省总结:

1.基础概念和原理积累,最开始接触FFmpeg,因为其涉及的概念很多,看起来有种无从下手的感觉。这时候必须从基本模块入手,逐步理解更多,一定的量积累,就会产生一些质变,更好的理解视频编解码机制;

2.一定要首先看懂代码总体架构和流程,随后针对每个细节点进行深入分析,会极大提高看代码效率。会画一些框图是非常重要的,比如下面这张,所以简要的流程图要比注重细节的uml图要方便得多;

3.看FFmpeg代码,在PC端上调试,会快捷很多。假如要在Android上,调用jni来看代码,效率就会很低。

标签: #播放设计原理是什么