ffplay 播放器源代码分析

字节流动

共 13848字,需浏览 28分钟

 ·

2021-06-22 15:37

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


ffplay 是 FFMpeg 自带的播放器,使用了 ffmpeg 解码库和用于视频渲染显示的 sdl 库,也是业界播放器最初参考的设计标准


本文对 ffplay 源码进行分析,试图用更基础而系统的方法,来尝试解开播放器的音视频同步,以及播放/暂停、快进/后退的控制原理。


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


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


一、初探mp4文件


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

图1 对MP4文件解参

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


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


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


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

图2 视频播放流程(图摘自http://blog.csdn.net/leixiaohua1020/article/details/50534150)

二、以最简单播放器开始: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所示。

三、先抛五个问题

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


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来看代码,效率就会很低。


来源:https://cloud.tencent.com/developer/article/1004559

作者:  张坤



推荐:

Android FFmpeg 实现带滤镜的微信小视频录制功能

全网最全的 Android 音视频和 OpenGL ES 干货,都在这了

一文掌握 YUV 图像的基本处理

浏览 55
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报