本系列 以 ffmpeg4.2 源码为准,下载地址:链接:百度网盘 提取码:g3k8
FFplay 源码分析系列以一条简单的命令开始,ffplay -i a.mp4
。a.mp4下载链接:百度网盘,提取码:nl0s 。
上一篇文章已经讲解完 音频播放线程函数 sdl_audio_callback() 内部逻辑,本篇文章开始讲解 视频播放线程的内部逻辑。
音视频同步方式是 AV_SYNC_AUDIO_MASTER,以音频为主时钟,本文基于这种同步方式做讲解。
视频播放线程,实际上就是就是 main() -> event_loop()。就是主线程。
下面开始分析 event_loop() 的内部逻辑,流程图如下:
event_loop() 里面的逻辑是这样的,在死循环里面等待键盘事件出现,如果没有键盘事件,就执行 video_refresh() 播放视频帧。事件处理的逻辑比较简单,这里就不仔细分析了。下面直接从 video_refresh() 视频播放 开始讲解。
video_refresh() 里面播放视频帧的逻辑比较奇怪,它是先用 frame_queue_next() 偏移 FrameQueue 队列的读索引 rindex,再执行 video_display() 来显示上一帧的数据。因为已经偏移了 rindex,所以待播放帧就变成了上一帧。
ffplay 它不是先播放视频帧,再偏移 读索引 rindex。它这样是为了可以通用一些逻辑,例如暂停的时候,缩小播放窗口,也需要调 video_display() 来刷新上一帧数据到SDL texture内存,但是却不用偏移 读索引rindex,可以跳过 偏移rindex。
video_refresh() 里面还有两个重点需要讲解:
1,is->frame_timer 跟 is->force_refresh 的作用。
is->frame_timer 可以理解为 窗口正在显示的帧 的播放时刻,就是说这帧是在什么系统时间播放的,音视频同步会用到系统时间作为刻度表。这个 is->frame_timer 表示的就是 在 14:00 (下午2点)的时候,播放了 第二帧视频,第二帧的 pts 是2。这里为什么不用 clock 里面的时间?我再想想。
is->force_refresh 控制是否需要调用 video_display()。video_display 的作用是 用 AVFrame:: data 重新渲染SDL render。在 reflesh_loop_wait_event() 里面循环的时候,remaining_time 被赋值为 REFRESH_RATE,也就说至少经历 0.01s 循环一次。但为了降低渲染频率,不是每 0.01s 秒就取上一帧渲染到SDL。在调 video_display() 之前,是用了一个判断,我流程图没画出来,请看代码:
if (!display_disable && is->force_refresh && is->show_mode == SHOW_MODE_VIDEO && is->pictq.rindex_shown)
video_display(is);
上面的 if 条件,最重要的是 force_refresh,是否需要渲染。force_refresh 有几个场景的赋值变化,如下。
- video_reflesh 里面 顺利拿到 下一帧,force_reflesh 置为1。
ffplay.c 1680行
frame_queue_next(&is->pictq);
is->force_refresh = 1;
- 窗口缩放事件,需要重新渲染。
case SDL_WINDOWEVENT_EXPOSED:
cur_stream->force_refresh = 1;
然后在 video_display() 之后,还会把 force_refresh 改回去成0,减少不必要的渲染。在暂停状态下,窗口大小没变,视频播放线程 event_loop 是不会每隔0.01s 就取上一帧渲染SDL的,因为没必要。这也就是之前文章所说的,FrameQueue 里面的 keep_last 跟 index_shown 的作用,保留上一帧在队列不销毁。
2,compute_target_delay(),计算出窗口正在显示的帧需要持续显示多久。
compute_target_delay 是 video_refresh() 里面最重要的函数,视频向音频同步的算法,就是在这里实现的。
本文命令是以音频为主时钟的,compute_target_delay 的代码不过几十行,但每一行代码都比较复杂。下面开始讲解:
diff = get_clock(&is->vidclk) - get_master_clock(is);
上面这行代码 是计算 视频时钟 与 音频时钟的差异, diff 大于 0 代表 视频 比 音频 播放快了, diff 小于 0 代表 视频 比 音频 播放慢了,diff 的单位是秒。
为什么这两个时钟相减就能得出,视频比音频快多少,或者慢多少呢?这个问题需要仔细研究一下 struct Clock 的结构以及赋值场景。
static void set_clock(Clock *c, double pts, int serial)
{
double time = av_gettime_relative() / 1000000.0; //注意这里
set_clock_at(c, pts, serial, time);
}
typedef struct Clock {
double pts; /* clock base */
double pts_drift; /* clock base minus time at which we updated the clock */
double last_updated;
double speed;
int serial; /* clock is based on a packet with this serial */
int paused;
int *queue_serial; /* pointer to the current packet queue serial, used for obsolete clock detection */
} Clock;
struct Clock 里面最重要的字段就是 pts 跟 pts_drift,他们的单位都是秒,pts_drift = pts - last_updated。
pts 存的是视频帧AVFrame的pts,last_updated 是通过 av_gettime_relative() 得到的,所以 pts_drift 计算出来的值是一个很大的负数。
一个很大的负数,其实看不出 pts_drift 要实现的功能。我改动一下 pts_drift 的实现,让他的功能更容易明白。
在main() 入口的时候,加一句代码 strat_time = av_gettime_relative() ,用 av_gettime_relative() 获取一个程序启动的时间。
然后 pts_drift = pts - last_updated - strat_time ,因为 last_updated 是用 av_gettime_relative() 获取的最新的时间。所以还可以简写成这样。
pts_drift = pts - (av_gettime_relative() - strat_time) , 也就是 pts_drift = pts - 系统消逝的时间。
没错, pts_drift 的实际意义就是 视频帧的 pts 与当前系统消逝时间的差距。视频中的pts的单位是秒。
什么是系统消逝时间,还是用 a.mp4 举例,a.mp4 是24帧的视频,也就是每隔 0.04 秒播放一帧,也就是说,在main()开始执行后,过了0.04s 就应该播放第一帧视频,过了 0.08s 就应该播放第二帧视频。0.04 跟 0.08 就是 av_gettime_relative() - strat_time 得到的。视频帧的AVFrame的pts肯定是 0.04s,0.08s的值,但是,但是 av_gettime_relative() - strat_time 不一定就是 0.04 或者 0.08 之类的,因为系统卡顿等等因素,可能 在0.05秒的时候才播放第一帧,也就是 av_gettime_relative() - strat_time = 0.05s 的时候播放第一帧,此时此刻,视频的 pts_drift 是不是等于 0.04 -0.05 = -0.01,也就是说视频播放比系统时间慢了 0.01s 秒。没错,音视频同步里面 系统时间是一个基准。
音频的 pts_drift 同理,也是代表音频帧pts跟系统时间的差距,当前播放的音频帧比系统时间慢多少或者快多少。
如果 音频的 pts_drift 是 0.03s,音频帧pts比系统时间快0.03s秒。
如果 视频的 pts_drift 是 -0.01s,视频帧pts比系统时间慢0.01s秒。
diff = get_clock(&is->vidclk) - get_master_clock(is);
那此时 compute_target_delay() 里面计算的 diff 就是 -0.04s,视频比音频慢 0.04s 秒。
再举个例子,还是 播放 a.mp4,音频每隔 0.02s 播放一帧,视频每隔 0.04s 播放一帧。
音频 | 视频 | |
---|---|---|
第一帧 | 0 | 0 |
第二帧 | 0.02 | 0.04 (14:00:00:09) |
第三帧 | 0.04 | 0.08 |
第四帧 | 0.06 | 0.12 |
第五帧 | 0.08 (14:00:00:10) | 0.16 |
第六帧 | 0.10 | 0.20 |
第七帧 | 0.12(14:00:00:14) | 0.24 |
在 14:00:00 (下午2点)的时候开始执行main()函数,过了0.1s 到 14:00:00:10 的时候,音频已经播放到第5帧,音频的第5帧的pts是0.08s,所以音频比系统时间慢了0.02s,为什么慢是因为系统卡顿。然后视频的第3帧的pts也是0.08,也就是说,音视频完全同步的情况下,播放完音频的第5帧之后,需要立即播放视频的第三帧,这样才是完全同步,但是 视频的第二帧是在 14:00:00:09 的时候才播放的,按帧率播放,视频第二帧本来应该在 14:00:00:04 的时候播放的,在 00:09 才播放第二帧,说明视频的播放慢于系统时间 0.05s,
视频的播放慢于系统时间 0.05s,音频比系统时间慢了0.02s,那就是 视频比音频慢 0.03s秒,所以在 14:00:00:09 播放第二帧视频的时候,第二帧视频本来应该持续显示 0.04s 的,因为按帧率算嘛,一帧视频显示 0.04s秒再显示下一帧。但是因为视频比音频慢了0.03s,第二帧视频不能显示 0.04s这么长时间,他只能显示 0.01s秒,因为到 14:00:00:10 的时候已经开始播放第五帧音频了,所以需要立即显示第三帧视频。
这个就是 compute_target_delay() 函数做的事情,传进去的参数 delay 是 0.04s,delay是上一帧应该持续显示的时长,然后计算出视频慢于音频多少,就是 diff 等于负多少。
如果视频比音频慢,diff 就会是负数,delay +diff 就会导致delay减少,例如上面的例子从 0.04s 减少到 0.01s。
如果视频比音频快,diff 就会是正数,delay +diff 就会导致delay拉长,拉长会导致重复播放上一帧视频。
我上面在 main() 加的 start_time,在 diff = get_clock(&is->vidclk) - get_master_clock(is); 的时候其实是可以对消掉的,实际上等于什么都没改,只是方便理解。
现在已经知道怎么计算出diff,然后也知道diff的作用,但是 compute_target_delay 用 diff的方式有点复杂,还是需要继续讲解一下,请看下面代码。
sync_threshold = FFMAX(AV_SYNC_THRESHOLD_MIN, FFMIN(AV_SYNC_THRESHOLD_MAX, delay));
if (!isnan(diff) && fabs(diff) < is->max_frame_duration) {
if (diff <= -sync_threshold)
delay = FFMAX(0, delay + diff);
else if (diff >= sync_threshold && delay > AV_SYNC_FRAMEDUP_THRESHOLD)
delay = delay + diff;
else if (diff >= sync_threshold)
delay = 2 * delay;
}
上面这些逻辑判断的意思是。
1,如果音视频差距大于 max_frame_duration 不进行同步,不管。
2,sync_threshold = FFMAX(AV_SYNC_THRESHOLD_MIN, FFMIN(AV_SYNC_THRESHOLD_MAX, delay));
这段代码的意思是,让 sync_threshold 在 0.04 ~ 0.1 之间取值,具体看delay的值。
3,在本文的命令里,sync_threshold 的值是 0.04166,也就是一帧视频的时间。如果视频慢于音频 0.03s ,diff = -0.03,那 diff <= -sync_threshold 就是假。
如果视频慢于音频 0.03s ,那上面的任何 if 条件都不会跑进去,就是不进行同步。这里同步逻辑就是,如果视频慢于音频的时间,比一帧的时间还短,不进行同步。所以我上面的那个14:00:00 (下午2点) 0.03s,减少到 0.01s的那个例子的时间假设有点问题,不要介意,原理就是那样。
4,如果视频慢于音频 0.05s,那 diff <= -sync_threshold 就是真,就会跑进去 delay = FFMAX(0, delay + diff),这样delay 就等于 0,也就是说,如果视频慢于音频的时间,比一帧的时间长,就设置delay为0,正在显示的帧马上消失,立即显示下一帧视频。
5,如果视频快于音频 0.03s,上面的任何 if 条件都不会跑进去。不进行同步,也就是说,如果视频快于音频的时间,比一帧视频的时间还短,不进行同步。
6,如果视频快于音频 0.05s,就会跑进去 delay = delay + diff;,也就是说,如果视频快于音频的时间,比一帧视频的时间长,长多少时间就延长多少上一帧的显示时间。这个判断还有个 AV_SYNC_FRAMEDUP_THRESHOLD 限制,AV_SYNC_FRAMEDUP_THRESHOLD 等于 0.1,1秒10帧,也就是低帧率这个条件不生效。
6,如果视频快于音频 0.05s,同时,视频流又是1秒5帧那种低帧率视频,就会跑进去 delay = 2 * delay,直接double。
上面已经举例把视频同步的所有情况都列举完了。
视频同步,计算出 delay 的值之后,还有两个逻辑,决定是重复显示上一帧,还是播放刚刚取出来的当前帧,还是丢弃刚刚取出来的当前帧。如图:
ffplay.c 1622行
if (time < is->frame_timer + delay) {
*remaining_time = FFMIN(is->frame_timer + delay - time, *remaining_time);
goto display;
}
如果上一帧还没显示完,不需要取下一帧来播放。
代码继续跑。
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;
}
}
这里的逻辑需要注意,因为之前计算出来的 delay 是一个 大于等于 0 的数,但是有可能视频已经落后于音频2个视频帧的时间了,
例如 音频播放线程 已经播放到 第七帧 0.12(14:00:00:14) 了,但视频播放线程因为一些原因卡住了,才播放到 第二帧 0.04 (14:00:00:14),视频比音频慢了0.08s,2个帧的时间,但是delay只能返回 0,delay不能是负数。所以需要上面那段代码判断。
判断如果队列里面有超过 2 个帧,而且视频比音频慢 2 个帧时间以上,为什么是2个帧,是因为 frame_timer 已经重新赋值了,那刚刚取出来的当前帧不用去播放,直接丢弃,继续取队列的下一帧。
这个逻辑是为了处理视频慢于音频太多时间,用来丢视频帧的。
所以 video_reflesh() 里面的视频同步逻辑重点总结如下。
1,视频如果慢于音频一个视频帧以内的时间,不进行同步。为什么差距一个视频帧之内不同步?也是非常容易推理到的。
例如 14:00:00:10 的时候播放第五帧音频,但是到 14:00:00:12 的时候才播放第三帧视频,第三帧视频跟第五帧音频应该同时间播放,但是视频慢了 0.02s秒,那 0.02秒的时间差距需要同步吗?视频一帧的时间都需要显示0.04s秒,这个0.02s连一帧都不够,太小了,察觉不出来。所以不同步是情有可原的。
2,视频如果慢于音频1个视频帧的时间,但是不慢于2个视频帧的时间,delay 置为 0,窗口现在播放的帧立即消失。下一帧显示。
3,视频如果慢于音频2个视频帧的时间,delay 置为 0,窗口现在播放的帧立即消失。下一帧丢弃,取下下一帧显示。以此类推。
4,视频如果快于音频,拉长上一帧的显示时间。
还有一个函数没有讲解,就是 video_display() 里面的video_image_display()。
video_image_display() 的内部逻辑如下:
- frame_queue_peek_last(), 拿到上一帧视频。
- calculate_display_rect(),计算 SDL_Rect,里面使用了 sar 这个参数,sar 的含义简单来说就是 把宽高按 sar 的比例拉伸播放,这个是显示设备像素设计不同导致的,推荐阅读:ffmpeg解析出的视频参数PAR,DAR,SAR的意义 跟 theory-videoaspectratios
- upload_texture(),把 视频帧数据更新到 is->vid_texture,更新到 SDL texture里面。
- SDL_RenderCopyEx() ,把 SDL texture 纹理 拷贝到 SDL render 渲染器里面
ffplay 源码分析,event_loop() 视频播放线程分析完毕。
©版权所属:知识星球:弦外之音,QQ:2338195090。
由于笔者的水平有限, 加之编写的同时还要参与开发工作,文中难免会出现一些错误或者不准确的地方,恳请读者批评指正。如果读者有任何宝贵意见,可以加我微信 Loken1。