FrameSync帧同步—framesync_advance
作者:罗上文,微信:Loken1,公众号:FFmpeg弦外之音
在了解 帧同步算法 之前,我们需要先熟悉 两个数据结构,FFFrameSync 跟 FFFrameSyncIn。
我习惯把 FFFrameSync 称为 帧同步器,然后 FFFrameSyncIn 是 帧同步器 里面的输入流。
这里读者可能会有疑问,帧同步器的 输入流 跟 滤镜(AVFilterContext)的 输入流 是怎么绑定、怎么关联在一起的?
答:它们是通过数组下标 [i] 关联的,代码如下:

也就是 FFFrameSync 的第一个流 对应 AVFilterContext 的第一个流,第二个流 对应 第二个流。如下图:

虽然数据结构 FFFrameSync 跟 FFFrameSyncIn 有很多字段,但是目前我们只需要理解 同步级别 与 同步流,就能去看帧同步最核心的逻辑 framesync_advance() 了
FFFrameSyncIn 里的 sync 字段就是同步级别,每个输入流都有一个同步级别,同步级别最高的流被称为 同步流。
以 overlay 滤镜为例,第一个输入流(main)的 sync 是 2,第二个流(second)的 sync 是 1,所以同步流是 main 那个流。代码如下:

FFFrameSync 里也有一个 sync_level,这个字段存储的是 最大的 FFFrameSyncIn::sync,也就是在这么多个输入流里,同步级别最高是多少。
我们可以用不同帧率的输入流,来理解 FFFrameSync 同步算法。
假设某个滤镜支持 3 个输入流,分别是 A、B、C。其中 A 是同步流,B、C 是其他的输入流。A 的帧率是小于 B 的帧率的。如下图:

FFFrameSync 帧同步器 需要选出 3 帧数据丢给滤镜进行处理,他是根据怎样的算法来挑选各个流的帧的呢?如下:
首先,同步流的每一帧数据都不能丢,也不能重复进行滤波处理。同步流是逐帧进行滤波处理的。
所以他会选出 A1 帧,然后从其他流中选出那些紧挨着 A1 的帧。输入流B 里 紧挨着 A1 的是 第一帧 B1,输入流C 里 紧挨着 A1 的是 第一帧 C1,所以第一次滤波处理的组合是 A1+B1+C1。
第二次滤波处理,就会选择同步流的第二帧 A2,然后从其他流中选出那些紧挨着 A2 的帧。输入流B 里 紧挨着 A2 的是 B3,输入流C 里 紧挨着 A2 的还是 C1,所以第一次滤波处理的组合是 A2+B3+C1。
因此第二次滤波处理的同步过程中,丢弃了 B2 帧,重复使用了 C1 帧。
因此如果一个输入流不是同步流,他的帧就可能因为同步的需要而丢弃,或者被重复使用。
紧挨着 这个词就是同步算法的核心,他每次都从同步流里面取一个帧出来,这个叫同步帧,然后在其他流里面找紧挨着这个同步帧的帧。
因此在进行 滤波处理 的帧列表里面,同步流的帧的 pts 是最大的。
framesync_advance() 里面的复杂逻辑,fs->in[i].frame 跟 fs->in[i].frame_next 移来移去、等等,就是为了实现 紧挨着 这个需求。
framesync_advance() 的逻辑包在一个大的循环 while (!(fs->frame_ready || fs->eof)) {...} 里面,如下:

主要有两种情况可以跳出这个 while(...) 循环:
- 帧同步完成的时候,也就是
fs->frame_ready设置为 1 的时候。 AVFilterLink上没有数据可读了,也就是consume_from_fifos()的返回值小于或等于 0
consume_from_fifos() 函数内部会循环读取所有 AVFilterLink,当它返回 1 的时候,代表所有输入流的 frame_next 都有数据了。
因此,也可以说 framesync_advance() 函数逻辑是在 循环消耗 AVFilterLink::fifo 上的数据,直到完成帧同步。
framesync_advance() 函数的第一步是选出 frame_next 列表里面最小的 pts,如下:
pts = INT64_MAX;
for (i = 0; i < fs->nb_in; i++)
if (fs->in[i].have_next && fs->in[i].pts_next < pts)
pts = fs->in[i].pts_next;
然后再把 最小 pts 的 frame_next 放进去 frame 变量里面。代码如下:

提醒一下,因为每个流都有一个 frame_next 跟 frame,多个流的 frame_next 是可以看成一个列表的。所以我习惯把 frame_next 叫做一个列表,frame 也是一个列表。
上图的逻辑非常绕,首先需要注意,他判断了 fs->in[i].pts_next == pts,所以除非所有输入流里面的 pts_next 都相等,要不他这个循环只是处理了其中一个输入流的 frame_next。
他只是把其中一个输入流的 frame_next 赋值给了 frame,并不是所有输入流的 frame_next 都赋值给了 frame,这个要注意一下,容易看错。
这个被赋值的 frame 的 pts 是最小的,但是,但是他同时也是最大的。这是什么意思呢?
就是这个 pts 在 FFFrameSyncIn::frame_next 列表里面是最小的,但是在 FFFrameSyncIn::frame 列表里面是最大的,因为 frame 列表的数据就是从最小 pts 一个一个加进去的,所以后来加入的 pts 最大。
每个输入流都有一个 frame,这个 frame 就是准备给滤镜进行滤波处理的帧。前面说过,在进行滤波处理的时候,同步流的帧的 PTS 必须是最大的。
所以 framesync_advance() 就要判断,这个最大的 frame 是不是同步流的帧,如果是,就可以进行滤波处理了。代码如下:
if (fs->in[i].sync == fs->sync_level && fs->in[i].frame)
fs->frame_ready = 1;
然后,frame 列表的数据恰恰就是紧挨着 pts 最大的那个帧的。因为 frame 列表的数据是从最小 pts 一个一个加进去的。
所以当确定 frame 列表里 pts 最大的帧是同步帧的时候,就可以把 frame 列表的数据丢给 滤镜进行滤波处理了。
下面让我们通过命令实战,调试代码来理解 紧挨着 这个逻辑。
ffmpeg -an -i juren-30s.mp4 -an -i juren-30s-small.mp4 -filter_complex "[0:v][1:v]overlay=18:20" out.mp4
juren-30s.mp4 的帧率是 23.9 fps,juren-30s-small.mp4 的帧率是 9 fps。
通过调试,我们可以发现,第一个输入流的 第1、第2、第3 帧都是跟 第二个输入流的 第1帧 进行 overlay 叠加,所以第二个流的第1帧 重复使用了3 次,这是符合 紧挨着 的逻辑的。

读者也可以把 juren-30s-small.mp4 从 9 fps 转成 120 fps,这时候会发现第二个流会跳帧,把一些帧丢弃,不进行 overlay 操作。丢帧也是在 framesync_advance() 的循环里面做的。不断把 frame_next 转给 frame,直到 frame_next 列表里面 pts 最小值是同步帧。这个过程中就会丢帧。
最后还有一个重点,就是 overlay 滤镜从同步器里面取 Frame 的时候,他只拿走了同步帧,其他流的帧是保留下来的,如下:

这也是为什么 非同步流 的帧可以重复使用的原因,因为他滤镜在取的时候,没有把 FFFrameSyncIn::frame 置为 NULL,里面的数据还留着。
ff_framesync_dualinput_get() 里面的 dual 是 两个,双的意思。
这个函数不是只给 overlay 用的,alphamerge、paletteuse 滤镜是两个输入,也会用到 ff_framesync_dualinput_get()
