FFplay源码分析-音视频同步2 - 弦外之音

/ 0评 / 0

本系列 以 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 有几个场景的赋值变化,如下。

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 播放一帧。

音频视频
第一帧00
第二帧0.020.04 (14:00:00:09)
第三帧0.040.08
第四帧0.060.12
第五帧0.08 (14:00:00:10)0.16
第六帧0.100.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() 的内部逻辑如下:

  1. frame_queue_peek_last(), 拿到上一帧视频。
  2. calculate_display_rect(),计算 SDL_Rect,里面使用了 sar 这个参数,sar 的含义简单来说就是 把宽高按 sar 的比例拉伸播放,这个是显示设备像素设计不同导致的,推荐阅读:ffmpeg解析出的视频参数PAR,DAR,SAR的意义theory-videoaspectratios
  3. upload_texture(),把 视频帧数据更新到 is->vid_texture,更新到 SDL texture里面。
  4. SDL_RenderCopyEx() ,把 SDL texture 纹理 拷贝到 SDL render 渲染器里面

ffplay 源码分析,event_loop() 视频播放线程分析完毕。

©版权所属:知识星球:弦外之音,QQ:2338195090。

由于笔者的水平有限, 加之编写的同时还要参与开发工作,文中难免会出现一些错误或者不准确的地方,恳请读者批评指正。如果读者有任何宝贵意见,可以加我微信 Loken1。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注