视音频的基本概念
我们常说的视频文件(例如 avi 文件,MP4 文件等)本质上是一种“容器”,其内部存放一帧帧的视频信息和音频信息。因此,视频文件内部常常包含不止一个“信息流”,而是包含一组“信息流”(若干视频流和若干音频流)。
所谓的“信息流”,其实就是随时间分布的信息而已。比如视频可以看成是一组随时间分布的“图片”。
视频流中的一个数据元通常被称作“一帧(frame)”,每一种视频流都有属于自己的编解码器(enCOder/DECoder,在FFmpeg中被简写为 codec
),用于说明该种视频流是如何编码和解码的。数据包(packets)则常常指从裸数据帧解析而来的数据片段。
总体来说,处理音视频流是非常简单的,通常包含以下几个步骤:
step1. 打开音视频文件,获取音视频流
step2. 从数据流读取数据帧
step3. 如果数据帧不完整,就回到 step2
step4. 处理数据帧
step5. 回到 step2
事实上,使用 FFmpeg 处理多媒体音视频的基本步骤和上述“伪代码”没有太多不同,当然了,“step4. 处理数据帧”
是一个暧昧的说法,毕竟这短短几个字背后的工作量可能非常巨大。
本节将尝试使用 FFmpeg 处理一段视音频文件,这里所谓的“处理”,其实就是将视频分解为若干个 ppm 图片,并存储到磁盘。
打开文件
首先,我们来看看如何打开一个视音频文件。使用 FFmpeg 之前,首先需要注册相关的库,这一过程是简单的,请参考下面的C语言代码:
#include <libavcodec/avcodec.h>
#include <libavformat/avformat.h>
#include <libswscale/swscale.h>
...
int main(int argc, char *argv[])
{
if (argc < 2){
printf("usage:\n\t %s filename\n", argv[0]);
return -1;
}
av_register_all();
...
av_register_all()
函数可以注册 FFmpeg 中所有可用的文件格式和编解码库 codecs,因为这个函数在项目中只需要也只应该调用一次,所以将其放在 main() 函数中了,这不是必须的,当然也可以将其放在项目中的其他地方。
现在我们可以打开相应的文件了:
AVFormatContext *pctx = NULL;
// 打开文件
if (avformat_open_input(&pctx, argv[1], NULL, NULL)!=0) {
return -1;
}
从这段C语言代码可以看出,我们将要打开的文件名通过程序的第一个参数(argv1)指定,avformat_open_input() 函数可以读取文件头信息,并将其放在 pctx 中。后面的两个参数用于指定视频文件的格式,以及选项配置信息的,我们将其设置为 NULL,FFmpeg 库将自动探测这些信息。
只获取视频文件的头信息是不够的,因此需要进一步的探测视频文件的流信息,这一步可以通过下面这个函数实现,请看相关C语言代码:
// 进一步探测信息
assert(avformat_find_stream_info(pctx, NULL)>=0);
这个函数主要填充 pctx->streams 成员,可以使用下面这个函数显示 FFmpeg 的一些中间过程信息到终端:
// 显示中间过程信息
av_dump_format(pctx, 0, argv[1], 0);
下图是一个中间过程信息实例:
pctx->streams 本质上是一组指针,每一个指针都对应着视频容器中存储的一种流,它的 size 等于 pctx->nb_streams,所以可以通过遍历对比的方式从这一组流中找到视频流,相关的C语言代码可以如下写:
int i, video_stream = -1;
for (i=0; i<pctx->nb_streams; i++) {
// 查找第一个视频流
if (pctx->streams[i]->codec->codec_type==AVMEDIA_TYPE_VIDEO) {
video_stream =i;
break;
}
}
if (-1==video_stream) {
printf("no video stream detected\n");
return -1;
}
// pcodec_ctx 指向第一个视频流
AVCodecContext *pcodec_ctx =
pctx->streams[video_stream]->codec;
流信息的编解码器 codec 就存放在我们称作“codec context(编解码上下文)”中,它包含对应流信息使用的 codec 的所有信息,上述代码的最后定义了pcodec_ctx
指针,并让其指向了对打开视频容器中的第一个视频流的 codec 上下文,现在可以根据上下文查找对应视频流的实际编解码器 codec 了,相应的C语言代码可以如下写:
AVCodec *pcodec = NULL;
// 查找视频流对应的解码器
pcodec = avcodec_find_decoder(pcodec_ctx->codec_id);
if (NULL == pcodec) {
printf("unsupported codec.\n");
return -1;
}
// 拷贝上下文
AVCodecContext *pcodec_ctx_orig =
avcodec_alloc_context3(pcodec);
if (avcodec_copy_context(pcodec_ctx_orig, pcodec_ctx) != 0) {
printf("couldn't copy codec context\n");
return -1;
}
// 打开编解码器
if (avcodec_open2(pcodec_ctx, pcodec, NULL) < 0) {
printf("couldn't open codec\n");
return -1;
}
应注意,我们一定不能直接使用视频流的 AVCodecContext,所以不得不使用 avcodec_copy_context()
拷贝了一份上下文。当然了,在拷贝之前,需要先调用 avcodec_alloc_context3()
为其分配相应的内存。
存储数据帧
存储数据帧之前,肯定需要先分配一块内存,这一过程的C语言代码可以如下写:
AVFrame *pframe = av_frame_alloc();
AVFrame *pframe_rgb = av_frame_alloc();
assert(pframe && pframe_rgb);
既然我们计划输出 24-bit RGB 格式的 PPM 文件,那么必须先将打开的输入视频文件从它原来的格式转换为 RGB 格式,因此上面的C语言代码还预先分配了额外的一块内存,用于存储转换后的数据。
上面的C语言代码分配的是输出数据的内存,我们还需要分配一块内存供原始数据使用,为此,首先要现知道需要多少内存,这一过程可以调用 avpicture_get_size()
函数得到,相关的C语言代码如下,请看:
int num_bytes = avpicture_get_size(AV_PIX_FMT_RGB24,
pcodec_ctx->width, pcodec_ctx->height);
uint8_t *buffer = av_malloc(num_bytes * sizeof(uint8_t));
av_malloc()
函数是 FFmpeg 的内存分配函数,它其实不过是 malloc() 函数的简单封装而已,只不过确保了内存地址对齐以提升程序的效率。使用它和使用 malloc() 是类似的,应注意避免内存泄漏,多重释放等问题。
现在我们可以使用 avpicture_fill()
函数将视频帧数据填充到新分配的 buffer 里了,这一过程的C语言代码是简单的:
avpicture_fill(
(AVPicture *)pframe_rgb,
buffer,
AV_PIX_FMT_RGB24,
pcodec_ctx->width,
pcodec_ctx->height
);
终于,我们准备好从视频流里读取数据了!
读取数据
现在要做的就是从视频流中读取数据到 packet,然后解码成帧,将其转换为我们需要的格式,再保存到磁盘,相应的C语言代码如下,请看:
int frame_finished;
AVPacket pkt;
// 初始化 sws 上下文,用于转换数据格式
struct SwsContext *sws_ctx = sws_getContext(
pcodec_ctx->width,
pcodec_ctx->height,
pcodec_ctx->pix_fmt,
pcodec_ctx->width,
pcodec_ctx->height,
AV_PIX_FMT_RGB24,
SWS_BILINEAR,
NULL,
NULL,
NULL
);
i = 0; // 作为实例,只保存前 5 帧
while (av_read_frame(pctx, &pkt) >= 0) {
if (pkt.stream_index != video_stream) {
continue;
}
avcodec_decode_video2(pcodec_ctx, pframe, &frame_finished, &pkt);
if (!frame_finished)
continue;
sws_scale(sws_ctx, pframe->data, pframe->linesize,
0, pcodec_ctx->height, pframe_rgb->data, pframe_rgb->linesize);
if (++i<=5) {
save_frame(pframe_rgb, pcodec_ctx->width, pcodec_ctx->height,i);
}
}
av_free_packet(&pkt);
这一过程的代码虽然稍稍长了点,但是很简单:av_read_frame()
函数读取视频流信息,并将其存放到 AVPacket 结构的 pkt 变量中,应注意,我们只需分配 AVPacket 结构体的内存,数据(pkt->data)的内存则由 FFmpeg 在其内部自动分配,不过使用完毕后,要调用 av_free_packet()
函数释放。
avcodec_decode_video()
函数可以将 packet 转换成 frame,不过,解码一个 packet 不一定能够获得 frame 的全部信息,所以需要借助 frame_finished 标志位用于判断这一过程。
得到一个 frame 后,便可调用 sws_scale()
函数将 frame 从其原始的格式(pctx->pix_fmt)转换到我们期望的 RGB 格式,转换完毕后,就可以调用 save_frame()
函数将其保存到磁盘了。
save_frame()
是一个自己定义的函数,它的相关C语言代码可以按照下面这样写,请看:
void save_frame(AVFrame *pframe, int width, int height, int iframe)
{
char filename[32];
int y;
sprintf(filename, "frame%d.ppm", iframe);
FILE *fp = fopen(filename, "w+");
assert(fp!=NULL);
fprintf(fp, "P6\n%d %d\n255\n", width, height); // header
for (y=0; y<height; y++)
fwrite(pframe->data[0]+y*pframe->linesize[0], 1, width*3, fp);
fclose(fp);
}
save_frame()
函数的C语言代码大都是基础库的使用,唯一需要说明的是下面这行代码:
fprintf(fp, "P6\n%d %d\n255\n", width, height);
它为 PPM 文件添加了固定的头部信息。
关闭使用完毕的资源
现在文章开头计划的工作完成了,可以关闭所有使用完毕的资源了,具体的C语言代码如下,请看:
// 释放内存
av_free(buffer);
av_free(pframe_rgb);
av_free(pframe);
// 关闭 codec
avcodec_close(pcodec_ctx);
avcodec_close(pcodec_ctx_orig);
// 关闭打开的文件
avformat_close_input(&pctx);
编译并执行
相应的 FFmpeg 库的编译安装请参考上一节FFmpeg的编译安装,编译时应指定 FFmpeg 的头文件以及库所在路径:
$ gcc t.c -I <FFmpeg安装目录>/include/ -L <FFmpeg安装目录>/lib/ -lavutil -lavformat -lavcodec -lavutil -lm -g -lswscale
在执行编译生成的C语言程序时,在命令行指定视频文件所在的路径,我在工程目录里放入了一个名为“test.avi”的视频文件,因此可以如下执行程序:
$ a.out ./test.avi
最终输出如下:
这说明程序正常运行了,查看程序所在目录,的确有若干 PPM 文件生成,并且可以通过图片浏览器打开: