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

2024-06-24 01:58

本文主要是介绍FFplay源码分析-音视频同步2,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

《FFmpeg原理》的社群来了,想加入社群的朋友请购买 VIP 版,VIP 版有更高级的内容与答疑服务。


本系列 以 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 播放一帧。

音频视频
第一帧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() 视频播放线程分析完毕。

©版权所属:弦外之音。

由于笔者的水平有限, 加之编写的同时还要参与开发工作,文中难免会出现一些错误或者不准确的地方,恳请读者批评指正。

这篇关于FFplay源码分析-音视频同步2的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/1088873

相关文章

Redis主从/哨兵机制原理分析

《Redis主从/哨兵机制原理分析》本文介绍了Redis的主从复制和哨兵机制,主从复制实现了数据的热备份和负载均衡,而哨兵机制可以监控Redis集群,实现自动故障转移,哨兵机制通过监控、下线、选举和故... 目录一、主从复制1.1 什么是主从复制1.2 主从复制的作用1.3 主从复制原理1.3.1 全量复制

Redis主从复制的原理分析

《Redis主从复制的原理分析》Redis主从复制通过将数据镜像到多个从节点,实现高可用性和扩展性,主从复制包括初次全量同步和增量同步两个阶段,为优化复制性能,可以采用AOF持久化、调整复制超时时间、... 目录Redis主从复制的原理主从复制概述配置主从复制数据同步过程复制一致性与延迟故障转移机制监控与维

Redis连接失败:客户端IP不在白名单中的问题分析与解决方案

《Redis连接失败:客户端IP不在白名单中的问题分析与解决方案》在现代分布式系统中,Redis作为一种高性能的内存数据库,被广泛应用于缓存、消息队列、会话存储等场景,然而,在实际使用过程中,我们可能... 目录一、问题背景二、错误分析1. 错误信息解读2. 根本原因三、解决方案1. 将客户端IP添加到Re

详谈redis跟数据库的数据同步问题

《详谈redis跟数据库的数据同步问题》文章讨论了在Redis和数据库数据一致性问题上的解决方案,主要比较了先更新Redis缓存再更新数据库和先更新数据库再更新Redis缓存两种方案,文章指出,删除R... 目录一、Redis 数据库数据一致性的解决方案1.1、更新Redis缓存、删除Redis缓存的区别二

Java汇编源码如何查看环境搭建

《Java汇编源码如何查看环境搭建》:本文主要介绍如何在IntelliJIDEA开发环境中搭建字节码和汇编环境,以便更好地进行代码调优和JVM学习,首先,介绍了如何配置IntelliJIDEA以方... 目录一、简介二、在IDEA开发环境中搭建汇编环境2.1 在IDEA中搭建字节码查看环境2.1.1 搭建步

Redis主从复制实现原理分析

《Redis主从复制实现原理分析》Redis主从复制通过Sync和CommandPropagate阶段实现数据同步,2.8版本后引入Psync指令,根据复制偏移量进行全量或部分同步,优化了数据传输效率... 目录Redis主DodMIK从复制实现原理实现原理Psync: 2.8版本后总结Redis主从复制实

锐捷和腾达哪个好? 两个品牌路由器对比分析

《锐捷和腾达哪个好?两个品牌路由器对比分析》在选择路由器时,Tenda和锐捷都是备受关注的品牌,各自有独特的产品特点和市场定位,选择哪个品牌的路由器更合适,实际上取决于你的具体需求和使用场景,我们从... 在选购路由器时,锐捷和腾达都是市场上备受关注的品牌,但它们的定位和特点却有所不同。锐捷更偏向企业级和专

Spring中Bean有关NullPointerException异常的原因分析

《Spring中Bean有关NullPointerException异常的原因分析》在Spring中使用@Autowired注解注入的bean不能在静态上下文中访问,否则会导致NullPointerE... 目录Spring中Bean有关NullPointerException异常的原因问题描述解决方案总结

python中的与时间相关的模块应用场景分析

《python中的与时间相关的模块应用场景分析》本文介绍了Python中与时间相关的几个重要模块:`time`、`datetime`、`calendar`、`timeit`、`pytz`和`dateu... 目录1. time 模块2. datetime 模块3. calendar 模块4. timeit

python-nmap实现python利用nmap进行扫描分析

《python-nmap实现python利用nmap进行扫描分析》Nmap是一个非常用的网络/端口扫描工具,如果想将nmap集成进你的工具里,可以使用python-nmap这个python库,它提供了... 目录前言python-nmap的基本使用PortScanner扫描PortScannerAsync异