乐趣区

关于音视频:ffplay视频播放原理分析

作者 | 赵家祝

FFmpeg 框架由命令行工具和函数库组成,ffplay 是其中的一种命令行工具,提供了播放音视频文件的性能,不仅能够播放本地多媒体文件,还能够播放网络流媒体文件。本文从 ffplay 的整体播放流程登程,借鉴其设计思路,学习如何设计一款繁难的播放器。

一、播放器工作流程

在学习 ffplay 源码之前,为了不便了解,咱们先宏观理解一下播放器在播放媒体文件时的工作流程。

  • 解协定 :媒体文件在网络上传输时,须要通过流媒体协定将媒体数据分段成若干个数据包,这样就能够满足用户一边下载一边观看的需要,而不须要等整个媒体文件都下载实现能力观看。常见的流媒体协定有 RTMP、HTTP、HLS、MPEG-DASH、MSS、HDS 等。因为流媒体协定中不仅仅蕴含媒体数据,还蕴含管制播放的信令数据。因而,解协定是移除协定中的信令数据,输入音视频封装格局数据。
  • 解封装 :封装格局也叫容器,就是将曾经编码压缩好的视频流和音频流依照肯定的格局放到一个文件中,常见的封装格局有 MP4、FLV、MPEG2-TS、AVI、MKV、MOV 等。解封装是将封装格局数据中的音频流压缩编码数据和视频流压缩编码数据拆散,不便在解码阶段应用不同的解码器解码。
  • 解码 :压缩编码数据是在原始数据根底上采纳不同的编码压缩失去的数据,而解码阶段就是编码的逆向操作。常见的视频压缩编码标准有 H.264/H.265、MPEG-2、AV1、V8/9 等,音频压缩编码标准有 AAC、MP3 等。解压后失去的视频图像数据是 YUV 或 RGB,音频采样数据是 PCM。
  • 音视频同步 :解码后的视频数据和音频数据是独立的,在送给显卡和声卡播放前,须要将视频和音频同步,防止播放进度不统一。

二、main 函数

ffplay 的应用非常简单,以 ffplay -i input.mp4 -loop 2 为例,示意应用 ffplay 播放器循环播放 input.mp4 文件两遍。执行该命令时,对应的源码在 fftools/ffplay.c 中,程序入口函数是 main 函数。

注:本文 ffplay 源码基于 ffmpeg 4.4。

2.1 环境初始化

初始化局部次要调用以下函数:

  • init\_dynload:调用 SetDllDirectory(“”) 删除 动态链接库(DLL)搜寻门路中的当前工作目录,是 Windows 平台下的一种平安预防措施。
  • av\_log\_set\_flag:设置 log 打印的标记为 AV\_LOG\_SKIP\_REPEATED,即跳过反复音讯。
  • parse\_loglevel:解析 log 的级别,会匹配命令中的 -loglevel 字段。如果命令中增加 -report,会将播放日志输入成文件。
  • avdevice\_register\_all:注册非凡设施的封装库。
  • avformat\_network\_init:初始化网络资源,能够从网络中拉流。
  • parse\_options:解析命令行参数,示例中的 -i input.mp4 和 -loop 2 就是通过这个函数解析的,反对的选项定义在 options 动态数组中。解析失去的文件名、文件格式别离保留在全局变量 input\_filename 和 file\_iformat 中。

2.2 SDL 初始化

SDL 的全称是 Simple DirectMedia Layer,是一个跨平台的多媒体开发库,反对 Linux、Windows、Mac OS 等多个平台,实际上是对 DirectX、OpenGL、Xlib 再封装,在不同操作系统上提供了雷同的函数。ffplay 的播放显示是通过 SDL 实现的。

main 函数中次要调用了以下三个 SDL 函数:

  • SDL\_Init:初始化 SDL 库,传入的参数 flags,默认反对视频、音频和定时器,如果命令中配置了 -an 则禁用音频,配置了 -vn 则禁用视频。
  • SDL\_CreateWindow:创立播放视频的窗口,该函数能够指定窗口的地位、大小,默认是 640*480 大小。
  • SDL\_CreateRenderer:为指定的窗口创立渲染器上下文,对应的构造体是 SDL\_Render。咱们既能够应用渲染器创立纹理,也能够渲染视图。

2.3 解析媒体流

stream\_open 函数是 ffplay 开始播放流程的终点,该函数传入两个参数,别离是文件名 input\_filename 和文件格式 file\_iformat。上面是函数外部的解决流程:

(1)初始化 VideoState:VideoState 是 ffplay 中最大的构造体,所有的视频信息都定义在其中。初始化 VideoState 时,先定义 VideoState 构造体指针类型的局部变量 is,调配堆内存。而后初始化构造体中的变量,例如视频流、音频流、字幕流的索引,并赋值函数入参 filename 和 iformat。

(2)初始化 FrameQueue:FrameQueue 是解码后的 Frame 队列,Frame 是解码后的数据,例如视频解码后是 YUV 或 RGB 数据,音频解码后是 PCM 数据。初始化 FrameQueue 时,会对 VideoState 中的 pictq(视频帧队列)、subpq(字幕帧队列)、sampq(音频帧队列)顺次调用 frame\_queue\_init 函数进行初始化。FrameQueue 外部是通过数组实现了一个先进先出的环形缓冲区,windex 是写指针,被解码线程应用;rindex 是读指针,被播放线程应用。应用环形缓冲区的益处是,缓冲区内的元素被移除后,其它元素不须要挪动地位,实用于当时晓得缓冲区最大容量的场景。

(3)初始化 PacketQueue:PacketQueue 是解码前的 Packet 队列,用于保留解封装后的数据。初始化 PacketQueue 时,会对 VideoState 中的 videoq(视频包队列)、audio(音频包队列)、subtitleq(字幕包队列)顺次调用 packet\_queue\_init 函数进行初始化。不同于 FrameQueue,PacketQueue 采纳链表的形式实现队列。因为解码前的包大小不可控,无奈明确缓冲区的最大容量,如果应用环形缓冲区,容易触发缓冲区扩容,须要挪动缓冲区内的数据。因而,应用链表实现队列更加适合。

(4)初始化 Clock:Clock 是时钟,在音视频同步阶段,有三种同步办法:视频同步到音频,音频同步到视频,以及音频和视频同步到内部时钟。初始化 Clock 时,会对 VideoState 中的 vidclk(视频时钟)、audclk(音频时钟)、extclk(内部时钟)顺次调用 init\_clock 函数进行初始化。

(5)限度音量范畴 :先限度音量范畴在 0~100 之间,而后再依据 SDL 的音量范畴作进一步限度。

(6)设置音视频同步形式 :ffplay 默认采纳 AV\_SYNC\_AUDIO\_MASTER,即视频同步到音频。

(7)创立读线程 :调用 SDL\_CreateThread 创立读线程,同时设置了线程创立胜利的回调 read\_thread 函数以及接管参数 is(stream\_open 函数最开始创立的 VideoState 指针类型的局部变量)。如果线程创立失败,则调用 stream\_close 做销毁逻辑。

(8)返回值 :将局部变量 is 作为函数返回值返回,用于解决上面的各种 SDL 事件。

2.4 SDL 事件处理

event\_loop 函数外部是一个 for 循环,应用 SDL 监听用户的键盘按键事件、鼠标点击事件、窗口事件、退出事件等。

三、read\_thread 函数

read\_thread 函数的作用是从磁盘或者网络中获取流,包含音频流、视频流和字幕流,而后依据可用性创立对应流的解码线程。因而 read\_thread 所在的线程实际上起到理解协定 / 解封装的作用。外围解决流程能够分为以下步骤:

3.1 创立 AVFormatContext

AVFormatContext 是封装上下文,形容了媒体文件或媒体流的形成和根本信息。avformat\_alloc\_context 函数用于分配内存创立 AVFormatContext 对象 ic。

拿到 AVFormatContext 对象后,在调用 avformat\_open\_input 函数关上文件前,须要设置中断回调函数,用于查看是否应该中断 IO 操作。

‍
ic->interrupt_callback.callback = decode_interrupt_cb;
ic->interrupt_callback.opaque = is;

decode\_interrupt\_cb 外部返回了一个 VideoState 的 abort\_request 变量,该变量在调用 stream\_close 函数敞开流时会被置为 1。

3.2 关上输出文件

在筹备好后面的一些赋值操作后,就能够开始依据 filename 关上文件了。avformat\_open\_input 函数用于关上一个文件,并对文件进行解析。如果文件是一个网络链接,则发动网络申请,在网络数据返回后解析音频流、视频流相干的数据。

3.3 搜寻流信息

搜寻流信息应用 avformat\_find\_stream\_info 函数,该从媒体文件中读取若干个包,而后从其中搜寻流相干的信息,最初将搜寻到的流信息放到 ic->streams 指针数组中,数组的大小为 ic->nb\_streams。

因为在理论播放过程中,用户能够指定是否禁用音频流、视频流、字幕流。因而在解码要解决的流之前,会判断对应的流是否处于不可用状态,如果是可用状态则调用 av\_find\_best\_stream 函数查找对应流的索引,并保留在 st\_index 数组中。

3.4 设置窗口大小

如果找到了视频流的索引,则须要渲染视频画面。因为窗体的大小个别应用默认值 640*480,这个值和视频帧真正的大小可能是不相等的。为了正确显示承载视频画面的窗体,须要计算视频帧的宽高比。调用 av\_guess\_sample\_aspect\_ration 函数猜想帧样本的宽高比,调用 set\_default\_window\_size 函数从新设置显示窗口的大小和宽高比。

3.5 创立解码线程

依据 st\_index 判断音频流、视频流、字幕流的索引是否找到,如果找到了就顺次调用 stream\_component\_open 创立对应流的解码线程。

3.6 解封装解决

接下来是一个 for(;;) 循环:

(1)响应中断进行、暂停 / 持续、Seek 操作;

(2)判断 PacketQueue 队列是否满了,如果满了就休眠 10ms,持续循环;

(3)调用 av\_read\_frame 从码流中读取若干个音频帧或一个视频帧;

(4)从输出文件中读取一个 AVPacket,判断以后 AVPacket 是否在播放工夫范畴内,如果是则调用 packet\_queue\_put 函数,依据类型将其放在音频 / 视频 / 字幕的 PacketQueue 中。

四、stream\_component\_open 函数

3.5 大节讲到,stream\_component\_open 函数负责创立不同流的解码线程。那么它是如何创立解码线程的呢?

4.1 创立 AVCodecContext

AVCodecContext 是编解码器上下文,保留音视频编解码相干的信息。应用 avcodec\_alloc\_context3 函数调配空间,应用 avcodec\_free\_context 函数开释空间。

4.2 查找解码器

依据解码器的 id,调用 avcodec\_find\_decoder 函数,查找对应的解码器。与之类似的一个函数是 avcodec\_find\_encoder,用于查找 FFmpeg 的编码器。两个函数返回的构造体都是 AVCodec。

如果指定了解码器名称,则须要调用 avcodec\_find\_decoder\_by\_name 函数查找解码器。

不论是哪种形式查找解码器,如果没有找到解码器,都会抛异样退出流程。

4.3 解码器初始化

找到解码器后,须要关上解码器,并对解码器初始化,对应的函数是 avcodec\_open2,该函数也反对编码器的初始化。

4.4 创立解码线程

判断解码类型,创立不同的解码线程。

switch (avctx->codec_type) {
case AVMEDIA_TYPE_AUDIO:    // 音频
    ...
    if ((ret = decoder_init(&is->auddec, avctx, &is->audioq, is->continue_read_thread)) < 0)
        goto fail;
    ...
    if ((ret = decoder_start(&is->auddec, audio_thread, "audio_decoder", is)) < 0)
        goto out;
    ...
case AVMEDIA_TYPE_VIDEO:    // 视频
    ...
    if ((ret = decoder_init(&is->viddec, avctx, &is->videoq, is->continue_read_thread)) < 0)
        goto fail;
    if ((ret = decoder_start(&is->viddec, video_thread, "video_decoder", is)) < 0)
        goto out;
    ...
case AVMEDIA_TYPE_SUBTITLE: // 字幕
    ...
    if ((ret = decoder_init(&is->subdec, avctx, &is->subtitleq, is->continue_read_thread)) < 0)
        goto fail;
    if ((ret = decoder_start(&is->subdec, subtitle_thread, "subtitle_decoder", is)) < 0)
        goto out;
    ...
}

线程创立在 decoder\_start 函数中,仍然应用 SDL 创立线程的形式,调用 SDL\_CreateThread 函数。

五、video\_thread 函数

视频解码线程从视频的 PacketQueue 中一直读取 AVPacket,解码实现后将 AVFrame 放入视频 FrameQueue。音频的解码实现和视频相似,这里仅介绍视频的解码过程。

5.1 创立 AVFrame

AVFrame 形容解码后的原始音频数据或视频数据,通过 av\_frame\_alloc 函数分配内存,通过 av\_frame\_free 函数开释内存。

5.2 视频解码

开启 for(;;) 循环,一直调用 get\_video\_frame 函数解码一个视频帧。该函数次要调用了 decoder\_decode\_frame 函数解码,decoder\_decode\_frame 函数对音频、视频、字幕都进行了解决,次要依附 FFmpeg 的 avcodec\_receive\_frame 函数获取解码器解码输入的数据。

拿到解码后的视频帧后,会依据音视频同步的形式和命令行的 -framedrop 选项,判断是否须要抛弃失去同步的视频帧。

  • 命令行带 -framedrop 选项,无论哪种音视频同步机制,都会抛弃失去同步的视频帧。
  • 命令行带 -noframedrop 选项,无论哪种音视频同步机制,都不会抛弃失去同步的视频帧。
  • 命令行不带 -framedrop 或 -noframedrop 选项,若音视频同步机制为同步到视频,则不抛弃失去同步的视频帧,否则会抛弃失去同步的视频帧。

5.3 放入 FrameQueue

调用 queue\_picture 函数,将 AVFrame 放入 FrameQueue。该函数外部调用了 frame\_queue\_push 函数,采纳了环形缓冲区的解决形式,对写指针 windex 累加。

static void frame_queue_push(FrameQueue *f)
{if (++f->windex == f->max_size)
        f->windex = 0;
    SDL_LockMutex(f->mutex);
    f->size++;
    SDL_CondSignal(f->cond);
    SDL_UnlockMutex(f->mutex);
}

六、音视频同步

ffplay 默认采纳将视频同步到音频的形式,分以下三种状况:

  • 如果视频和音频进度统一,不须要同步;
  • 如果视频落后音频,则抛弃以后帧间接播放下一帧,人眼感觉跳帧了;
  • 如果视频超前音频,则反复显示上一帧,期待音频,人眼感觉视频画面进行了,然而有声音在播放;

ffplay 视频同步到音频的逻辑在视频播放函数 video\_refresh 中实现。该函数的调用链是:main()->event\_loop()->refresh\_loop\_wait\_event()->video\_refresh。

6.1 判断播放实现

调用 frame\_queue\_nb\_remaing 函数计算残余没有显示的帧数是否等于 0,如果是,则不须要走剩下的步骤。计算过程比较简单,用 FrameQueue 的 size – rindex\_shown,size 是 FrameQueue 的大小,rindex\_shown 示意 rindex 指向的节点是否曾经显示,如果曾经显示则为 1,否则为 0。

6.2  播放序列匹配 **

别离调用 frame\_queue\_peek\_last 和 frame\_queue\_peek 函数从 FrameQueue 中获取上一帧和以后帧,上一帧是上次曾经显示的帧,以后帧是以后待显示的帧。

(1)比拟以后帧和以后 PacketQueue 的播放序列 serial 是否相等:

  • 如果不等,重试视频播放的逻辑;
  • 如果相等,则进入(2)流程判断;

注:serial 是用来辨别是不是间断的数据,如果产生了 seek,会开始一个新的播放序列,

(2)比拟上一帧和以后帧的播放序列 serial 是否相等:

  • 如果不相等,则将 frame\_timer 更新为以后工夫;
  • 如果相等,不解决并进入下一流程

6.3 判断是否反复上一帧

(1)将上一帧 lastvp 和以后帧 vp 传入 vp\_duration 函数,通过 vp->pts – lastvp->pts 计算上一帧的播放时长。

注:pts 全称是 Presentation Time Stamp,显示工夫戳,示意解码后失去的帧的显示工夫。

(2)在 compute\_target\_delay 函数中,调用 get\_clock 函数获取视频时钟,调用 get\_master\_clock 函数获取同步时钟,计算两个时钟的差值,依据差值计算须要 delay 的工夫。

(3)如果以后帧播放时刻(is->frame\_timer + delay)大于以后时刻(time),示意以后帧的播放工夫还没有到,相当于以后视频超前音频了,则须要将上一帧再播放一遍。

last_duration = vp_duration(is, lastvp, vp);
delay = compute_target_delay(last_duration, is);

time= av_gettime_relative()/1000000.0;
if (time < is->frame_timer + delay) {*remaining_time = FFMIN(is->frame_timer + delay - time, *remaining_time);
    goto display;
}

6.4 判断是否抛弃未播放的帧

如果以后队列中的帧数大于 1,则须要思考丢帧,只有一帧的时候不思考丢帧。

(1)调用 frame\_queue\_peek\_next 函数获取下一帧(下一个待显示的帧),依据以后帧和下一帧计算以后帧的播放时长,计算过程和 6.3 雷同。

(2)满足以下条件时,开始丢帧:

  • 以后播放模式不是步进模式;
  • 丢帧策略失效:framedrop>0,或者以后音视频同步策略不是音频到视频。
  • 以后帧 vp 还没有来得及播放,然而下一帧的播放时刻(is->frame\_timer + duration)曾经小于以后零碎时刻(time)了。

(3)丢帧时,将 is->frame\_drops\_late++,并调用 frame\_queue\_next 函数将上一帧删除,更新 FrameQueue 的读指针 rindex 和 size。

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;    }}

七、渲染

ffplay 最终的图像渲染是由 SDL 实现的,在 video\_display 中调用了 SDL\_RenderPresent(render) 函数,其中 render 参数是最开始在 main 函数中创立的。在渲染之前,须要将解码失去的视频帧数据转换为 SDL 反对的图像格式。转换过程在 upload\_texture 函数中实现,细节不在此处剖析。

音频相似,如果解码失去的音频不能被 SDL 反对,须要对音频进行重采样,将音频帧格局转换为 SDL 反对的格局。

八、小结

本文从整体播放流程登程,介绍了 ffplay 播放器播放媒体文件的次要流程,不深陷于代码细节。同时,对 FFmpeg 的一些罕用函数有了一些理解,对咱们本人手写一个简略的播放器有很大的帮忙。

———-  END  ———-

举荐浏览【技术加油站】系列

百度工程师眼中的云原生可观测性追踪技术

应用百度开发者工具 4.0 搭建专属的小程序 IDE

百度工程师教你玩转设计模式(观察者模式)

揭秘百度智能测试在测试主动执行畛域实际

H.265 编码原理入门

小程序启动性能优化实际

退出移动版