【DangerFFmpeg】第一节、屏幕截图
本文是 《DangerFFmpeg》系列教程第一节,系列完整目录:
《开篇》
《第一节、屏幕截图》
《第二节、输出到屏幕》
《第三节、播放声音》
《第四节、多线程》
《第五节、视频同步》
《第六节、同步音频》
《第七节、快进快退》
《结语》
系列所有代码托管在 GitHub 。
概述
视频文件有一些基本构成成分。首先,视频文件本身叫做 容器(container),容器的类型决定信息在文件中如何存储。AVI 和 Quicktime 就是容器的两个例子。然后,你会遇到一组 流(stream),举例来说,视频文件通常会有 音频流 和 视频流( 流 描述了 “一组按时间先后排列的数据(data element)”)。流中的数据称作 帧(frame)(帧一般会被编码成一个个包,包解开后才是帧)。每个流都是事先使用各种 编解码器(codec) 编码好的。编码器定义了原始数据是何如 编码(COded) 和 解码(DECoed) 的,所以组合起来就叫做 编码器(CODEC) 了。DivX 和 MP3 就是编解码器的两个例子。包(Packet) 紧接着从流中被读取出来。包是可以包含(有的包可能不会携带原始数据)能够被解码原始数据帧的数据块,拿到数据帧后我们就可以根据需要对其进行修改了。对于我们来说,每个包都包含完整的数据帧,音频则是多个数据帧。
对于简单的需求,处理视频和音频的流程非常简单:
1 | OPEN video_stream FROM video.avi |
使用 FFmpeg 处理多媒体文件就和上面的流程差不多,不过一些大型项目的 DO SOMETHING 步骤可能会非常复杂。在本节教程中,我们将打开视频文件并从中读取视频流,然后在 DO SOMETHING 步骤中将帧数据写入 PPM 文件。
PPM 文件是啥?
PPM 是 Portable PixMap 的缩写,是一种用文本格式紧密存储的 24-bit RGB 图像。后面在写 PPM 文件时还会再提到。
打开文件
首先,看下如何打开一个文件。使用 FFmpeg 前,你必须对其进行初始化。
1 |
|
这段代码注册 FFmpeg 中所有可用的文件和编解码器,后面打开对应格式文件时可以自动使用这些编解码器了。你只需要调用 av_register_all
一次,所以上面代码在 main 里调用。如果你不想注册所有的格式和编解码器,也可以单独注册指定的文件格式和编解码器,不过这通常没必要。
在 FFmpeg 4.0 被标记为 deprecated,文件格式和编解码器会自动地进行注册。av_register_all
现在我们可以真正地去打开文件了:
1 | AVFormatContext *pFormatCtx = nullptr; |
我们从第一个输入参数从获取文件名。 avformat_open_input
读取文件头并且将读取到的信息存储在 pFormatCtx
中。最后两个参数分别用来指定文件格式,和格式(解封装)参数,这里都传了 nullptr
,libavformat 会自动检测这些参数值。
因为 avformat_open_input
只读取文件头信息,所以接下来我们还要确认文件中的流信息:
1 | // Retrive stream information |
avformat_open_input
将读取的信息填充到 pFormatCtx->streams
。这里介绍一个非常方便的调试函数,将文件中的信息输出到终端:
1 | // Dump information about the file onto standard error |
pFormatCtx->streams
只是一个指针数组,大小是 pFormatCtx->nb_streams
,我们将遍历这个数组并找到视频流:
1 | AVCodecContext *pCodecCtxOrig = nullptr, *pCodecCtx = nullptr; |
pFormatCtx->streams
与编解码器(codec)相关的信息叫做 “编解码器上下文(codec context)”,上下文里包含了对应 stream 所使用编解码器的所有信息、参数等,而现在我们拿到了上下文的指针。不过我们还是需要根据上下文找到实际的编解码器实现并打开:
1 | AVCodec* pCodec = nullptr; |
注意我们不能直接使用 stream 里的 AVCodecContext
,需要使用 avcodec_parameters_to_context
将 stream 中编解码器参数拷贝到新的 pCodecCtx
中。
原文使用
实现参数拷贝,但在新版 FFmpeg 中已经被标记为 deprecated。avcodec_copy_context
暂存数据
现在还需要有个地方存 帧数据(frames):
1 | // Allocate video frame |
因为我们打算输出 PPM 文件,所以还需要把 FFmpeg 解码出来的原始格式转换成 RGB 格式,FFmpeg 也能帮我们完成这些转换操作。对于大多数项目(包括我们的),都会将原始的数据帧转换成特定格式。现在让我们为新格式分配一个 AVFrame
:
1 | AVFrame* pFrameRGB = av_frame_alloc(); |
分配好 pFrameRGB
后,还需要指定一块内存区域存放转好的 RGB 数据。我们使用 avpicture_get_size
计算缓冲区的大小并申请内存:
1 | // Determine required buffer size and allocate buffer |
av_malloc
是 FFmpeg 对 malloc 函数的简单封装,它可以保证申请到的内存地址是对齐的。不过 av_malloc
不会帮你解决内存泄漏、重复释放以及其它内存分配相关问题。
接着我们使用 avpicture_fill
将帧和新分配好缓冲区绑定。
1 | // Assign appropriate parts of the buffer to iamge planes in pFrameRGB |
终于,我们可以开始从流读取数据了。
读取数据
接下来我们会读取整个视频流,从中读取数据包,并解码到数据帧中,当数据帧被填满后,进行格式转换并输出到 PPM 文件。
1 | // initialize SWS context for software scaling |
整个的过程还是比较简单:av_read_frame
读取数据包并将数据存储在 AVPacket
中。要注意这里的只分配了 packet 结构体,FFmpeg 内部会帮我们分配缓冲区,即 packet.data
,不再使用后通过 av_free_packet
释放。avcodec_send_packet
将 packet 发送给解码器进行解码,avcodec_receive_frame
从解码器中接收帧数据。通常情况下一个 packet 可以解码出一个 frame,也存在一个 packet 解出多个 frame 或多个 packet 解出一个 frame 的情况。这种 send/receive 模式是 FFmpeg 3.3 新引入的,本质上是一个状态机模型,用户根据不同的 send/receive 状态决定下一步操作。
视频编码后的帧(P帧、B帧)会参考其前后的帧进行解码,所以有时候发送一个 packet 之后,由于参考帧还 ready 就需要等包含参考帧的 packet 解码之后才解码这个 packet。
另外有一些编码格式会用 packet 存储一些元数据,它们并不会解码出视频帧,只是为解码器提供参数。
解码出 frame 后我们使用 sws_scale
将原始图像格式(pCodecCtx->pix_fmt)转换成 RGB 格式。最后使用 SaveFrame
将帧数据写入 PPM 文件。
现在剩下的是实现 SaveFrame
将 RGB 格式的帧数据写入 PPM 文件。等会会简单介绍些 PPM 文件格式。
1 | void SaveFrame(AVFrame* pFrame, int width, int height, int iFrame) { |
SaveFrame
使用 out|binary
模式打开文件,然后将 RGB 数据写入,一次写入一行数据。PPM 文件是 RGB 数据排列成一个长字符串的文件格式。如果你了解 HTML 颜色编写方式,PPM 就像是将每个颜色的像素值紧密排列起来,比如 #ff0000#ff0000… 是纯红色(注意实际存储的二进制,并没有 # 等分隔符)。Header 行存储了图像的尺寸和 RGB 色值的最大值。
现在回到 main
函数,当我们读取完视频流后,只需要完成一些清理工作就可以了:
1 | // Free frame |
你应该注意到我们使用 av_free
释放那些使用 avcodec_alloc_frame
和 av_malloc
申请的内存。
程序运行部分就不赘述了,可以下载源码查看,执行后就能得到五张本文的封面图!!!
这些就是本节教程的全部内容,源码已经上传 GitHub 。