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

相关文章

Python与MySQL实现数据库实时同步的详细步骤

《Python与MySQL实现数据库实时同步的详细步骤》在日常开发中,数据同步是一项常见的需求,本篇文章将使用Python和MySQL来实现数据库实时同步,我们将围绕数据变更捕获、数据处理和数据写入这... 目录前言摘要概述:数据同步方案1. 基本思路2. mysql Binlog 简介实现步骤与代码示例1

C#控制台程序同步调用WebApi实现方式

《C#控制台程序同步调用WebApi实现方式》控制台程序作为Job时,需同步调用WebApi以确保获取返回结果后执行后续操作,否则会引发TaskCanceledException异常,同步处理可避免异... 目录同步调用WebApi方法Cls001类里面的写法总结控制台程序一般当作Job使用,有时候需要控制

Android 缓存日志Logcat导出与分析最佳实践

《Android缓存日志Logcat导出与分析最佳实践》本文全面介绍AndroidLogcat缓存日志的导出与分析方法,涵盖按进程、缓冲区类型及日志级别过滤,自动化工具使用,常见问题解决方案和最佳实... 目录android 缓存日志(Logcat)导出与分析全攻略为什么要导出缓存日志?按需过滤导出1. 按

Linux中的HTTPS协议原理分析

《Linux中的HTTPS协议原理分析》文章解释了HTTPS的必要性:HTTP明文传输易被篡改和劫持,HTTPS通过非对称加密协商对称密钥、CA证书认证和混合加密机制,有效防范中间人攻击,保障通信安全... 目录一、什么是加密和解密?二、为什么需要加密?三、常见的加密方式3.1 对称加密3.2非对称加密四、

MySQL中读写分离方案对比分析与选型建议

《MySQL中读写分离方案对比分析与选型建议》MySQL读写分离是提升数据库可用性和性能的常见手段,本文将围绕现实生产环境中常见的几种读写分离模式进行系统对比,希望对大家有所帮助... 目录一、问题背景介绍二、多种解决方案对比2.1 原生mysql主从复制2.2 Proxy层中间件:ProxySQL2.3

python使用Akshare与Streamlit实现股票估值分析教程(图文代码)

《python使用Akshare与Streamlit实现股票估值分析教程(图文代码)》入职测试中的一道题,要求:从Akshare下载某一个股票近十年的财务报表包括,资产负债表,利润表,现金流量表,保存... 目录一、前言二、核心知识点梳理1、Akshare数据获取2、Pandas数据处理3、Matplotl

Linux线程同步/互斥过程详解

《Linux线程同步/互斥过程详解》文章讲解多线程并发访问导致竞态条件,需通过互斥锁、原子操作和条件变量实现线程安全与同步,分析死锁条件及避免方法,并介绍RAII封装技术提升资源管理效率... 目录01. 资源共享问题1.1 多线程并发访问1.2 临界区与临界资源1.3 锁的引入02. 多线程案例2.1 为

python panda库从基础到高级操作分析

《pythonpanda库从基础到高级操作分析》本文介绍了Pandas库的核心功能,包括处理结构化数据的Series和DataFrame数据结构,数据读取、清洗、分组聚合、合并、时间序列分析及大数据... 目录1. Pandas 概述2. 基本操作:数据读取与查看3. 索引操作:精准定位数据4. Group

MySQL中EXISTS与IN用法使用与对比分析

《MySQL中EXISTS与IN用法使用与对比分析》在MySQL中,EXISTS和IN都用于子查询中根据另一个查询的结果来过滤主查询的记录,本文将基于工作原理、效率和应用场景进行全面对比... 目录一、基本用法详解1. IN 运算符2. EXISTS 运算符二、EXISTS 与 IN 的选择策略三、性能对比

MySQL 内存使用率常用分析语句

《MySQL内存使用率常用分析语句》用户整理了MySQL内存占用过高的分析方法,涵盖操作系统层确认及数据库层bufferpool、内存模块差值、线程状态、performance_schema性能数据... 目录一、 OS层二、 DB层1. 全局情况2. 内存占js用详情最近连续遇到mysql内存占用过高导致