本文还是以问题抛问题的思路,以逐步对每个问题进行原理性分析,加深对音视频解码和播放的认识。以下这些问题也是每一个播放器所需要面对的基础问题和原理:
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)(incr * AV_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来看代码,效率就会很低。
参考文章:
雷神的文章(多媒体入门开发必看):
http://blog.csdn.net/leixiaohua1020/article/details/15811977
新闻热点
疑难解答