28_FFmpeg音视频解封装格式
一、什么是封装格式
封装格式也称为容器,用于打包音频、视频以及字幕等等,比如常见的容器有MP4、MOV、WMV、FLV、AVI、MKV
等等。容器里面装的是音视频的压缩帧,但是不是所有类型的压缩帧都可以装入容器中,不同的容器对于压缩帧的格式是有要求的,有一些容器的兼容性要好一些,有一些容器的兼容性就会差一些。
我们平时看到的文件后缀名mp4
或者mov
是指文件格式,它的作用是让我们知道它是何种类型的文件,让操作系统知道打开文件时应该用哪个应用打开。正常来讲文件后缀名是和封装格式是有对应关系的,每个容器都有一个或多个文件后缀名。虽然说我们可以随意修改文件后缀名,但是封装格式属于文件的内部结构,而文件格式是文件外在表现,所以修改文件扩展名是无法修改容器原封装格式的,修改后播放器一般情况下也是可以播放的,因为播放器在播放时会打开文件判断是哪种容器。
二、使用 FFmpeg 实现解封装
现在对封装格式有了一个简单了解,接下来了解一下封装格式数据是如何被播放出来的,首先要对封装格式数据解封装,可以得到音频压缩数据和视频压缩数据,然后再对音频压缩数据和视频压缩数据分别进行解码,就得到了音频原始数据和视频原始数据,最后对音频原始数据进行处理送到扬声器,对视频数据进行处理送到屏幕,并且还要进行音视频同步处理。本文主要分享的是如何从封装格式数据中拿到音频原始数据和视频原始数据各自生成相对于的文件,音视频同步处理先不讨论。封装格式数据播放大致实现流程图如下:
下面开始使用FFmpeg的libavformat
库(它是一个包含用于多媒体容器格式的解复用器和复用器的库)从MP4封装格式中解码出YUV数据(原始视频数据)和PCM数据(原始音频数据)。
1、创建解封装上下文打开流媒体文件
int avformat_open_input(AVFormatContext **ps, const char *url, ff_const59 AVInputFormat *fmt, AVDictionary **options);
参数说明:
- ps:指向解封装上下文的指针,由
avformat_alloc_context
创建。如果传nullptr,函数avformat_open_input
内部会帮我们创建解封装上下文(注意:函数调用失败时,会释放开发者手动创建的解封装上下文); - url:要打开的流的
url
,也就是要打开的流媒体文件(此处也可以传入设备名称和设备序号,我们在音视频录制时传入的就是设备序号); - fmt:如果非
nulllptr
将使用特定的输入格式,传nulllptr
将自动检测输入格式; - options:包含解封装上下文和解封装器特有的参数的字典。
最后不要忘记使用函数avformat_close_input
关闭解封装上下文,使用函数avformat_close_input
就不需要再调用函数avformat_free_context
了,其内部帮我们调用了函数avformat_free_context
。
2、检索流信息
2.1、检索流信息
该函数可以读取一部分音视频数据并且获得一些相关的信息:
int avformat_find_stream_info(AVFormatContext *ic, AVDictionary **options);
参数说明:
- ic:需要读取信息的解封装上下文;
- options:额外一些参数。
2.2、导出流信息到控制台
我们可以使用下面函数打印检索到的详细信息到控制台,包括音频流的采样率、通道数等,视频流包括视频的width、height、pixel format、码率、帧率
等信息:
void av_dump_format(AVFormatContext *ic,
int index,
const char *url,
int is_output);
参数说明:
- ic:需要打印分析的解封装上下文;
- index:需要导出信息的流索引;
- url:需要打印的输入或者输出流媒体文件 url。
- is_output:是否输出,0 = 输入 / 1 = 输出;
在QT中直接调用这个方法,还不能打印出信息,通过看ffmpeg的官方例子里的信息可以了解到是要dump到stderr的控制台:
因此在Qt中还需要调用fflush(stderr)
才能够将信息输出到控制台。fflush会强迫将缓冲区内容清空,就会立即输出所有在缓冲区中的内容。stderr是指标准错误输出设备,输出的文本内容一般是红色的,默认向屏幕输出内容。
打印的信息如下,这和我们在终端看到的信息是一样的:
看这个打印是不是很眼熟,跟我们在命令行中敲ffmpeg相关命令时打印的一样的
3、初始化音频解码器查找合适的音视流和视频流信息
读取多媒体文件音频流和视频流信息,函数av_find_best_stream
是在FFmpeg新版本中添加的,老版本只可通过遍历的方式读取,我们可以通过stream->codecpar->codec_type
判断流类型,可以取得同样的效果:
int av_find_best_stream(AVFormatContext *ic,
enum AVMediaType type,
int wanted_stream_nb,
int related_stream,
AVCodec **decoder_ret,
int flags);
参数说明:
- ic:需要处理的流媒体文件,解封装上下文中包含流媒体文件信息;
- type:要检索的流类型,比如音频流、视频流和字幕流等等;
- wanted_stream_nb:请求的流序号,传 -1 自动选择;
- related_stream:查找相关流,不查找传 -1;
- decoder_ret:返回当前流对应的解码器。函数调用成功,并且参数 decoder_ret 不为 nullptr,将通过参数 decoder_ret 返回一个对应的解码器;
- flags:目前没有定义;
AVMediaType流类型枚举:
enum AVMediaType {
AVMEDIA_TYPE_UNKNOWN = -1, ///< Usually treated as AVMEDIA_TYPE_DATA
AVMEDIA_TYPE_VIDEO,
AVMEDIA_TYPE_AUDIO,
AVMEDIA_TYPE_DATA, ///< Opaque data information usually continuous
AVMEDIA_TYPE_SUBTITLE,
AVMEDIA_TYPE_ATTACHMENT, ///< Opaque data information usually sparse
AVMEDIA_TYPE_NB
};
函数调用成功返回流序号,如果未找到请求类型的流返回AVERROR_STREAM_NOT_FOUND
,如果找到了请求的流但是没有对应的解码器将返回AVERROR_DECODER_NOT_FOUND
。
4、检验流
我们成功的查找到流后最好要检验一下流是否真的存在;
AVStream *stream = _fmtCtx->streams[*streamIdx];
if (!stream) {
qDebug() << "stream is empty";
return -1;
}
5、查找解码器
我们通过stream->codecpar->codec_id
可以查找到对应的解码器:
AVCodec *avcodec_find_decoder(enum AVCodecID id);
参数说明:
- codec:解码器;
最后需要使用函数avcodec_free_context
释放解码上下文。
6、拷贝流参数到解码器
在FFmpeg旧版本中保存流信息参数是AVStream
结构体中的codec
字段。新版本中已经将AVStream
结构体中的codec
字段定义为废弃属性。因此无法像以前旧版本中直接通过参数codec
获取流信息。当前版本保存流信息的参数是AVStream
结构体中的codecpar
字段,FFmpeg提供了函数avcodec_parameters_to_context
将流信息拷贝到新的解码器中:
int avcodec_parameters_to_context(AVCodecContext *codec,
const AVCodecParameters *par);
参数说明:
- codec:解码器;
- par:流中的参数,通过
stream->codecpar
获取;
7、打开解码器
int avcodec_open2(AVCodecContext *avctx, const AVCodec *codec, AVDictionary **options);
参数说明:
- avctx:需要初始化的解码上下文;
- codec:解码器;
- options:包含解封装上下文和解封装器特有的参数的字典。
8、从音视频流中读取压缩帧
我们可以通过pkt->stream_index
判断读取到的压缩帧是音频压缩帧还是视频压缩帧等等,然后分别对音视频压缩数据进行解码:
int av_read_frame(AVFormatContext *s, AVPacket *pkt);
参数说明:
- s:解封装上下文;
- pkt:读取到的压缩帧数据;
在调用函数avcodec_send_packet
之前我们需要创建一个AVPacket
。在FFmpeg版本4.4中av_init_packet
函数已经过期,实际上FFmpeg不建议我们把AVPacket
放到栈空间了。建议使用函数av_packet_alloc
来创建,av_packet_alloc
创建的AVPacket
是在堆空间的。下面写法不提倡:
// pkt 是在函数中定义,pkt 内存在栈空间,所以 pkt 内存不需要我们去申请和释放
AVPacket pkt;
// init 仅仅是初始化,并不会分配内存
av_init_packet(&pkt);
pkt.data = nullptr;
pkt.size = 0;
最后需要使用函数av_packet_free
释放AVPacket
,注意函数av_packet_unref
仅仅是把AVPacket
指向的一些额外内存释放掉,并不会释放AVPacket
内存空间。
9、音视频解码
9.1发送压缩数据到解码器
首先使用函数avcodec_send_packet
发送压缩数据到解码器:
int avcodec_send_packet(AVCodecContext *avctx, const AVPacket *avpkt);
9.2获取解码后的数据
然后使用函数avcodec_receive_frame
从解码器中读取解码后的数据:
int avcodec_receive_frame(AVCodecContext *avctx, AVFrame *frame);
10、保存音视频输出参数
我们定义了两个结构体分别保存音频输出参数和视频输出参数:
// 音频输出参数
typedef struct {
const char *filename; // 文件名
int sampleRate; // 采样率
AVSampleFormat sampleFmt; // 采样格式
int chLayout; // 声道布局
} AudioDecodeSpec;
// 视频输出参数
typedef struct {
const char *filename; // 文件名
int width; // 宽
int height; // 高
AVPixelFormat pixFmt; // 像素格式
int fps; // 帧率
} VideoDecodeSpec;
保存音频参数:
_aOut->sampleRate = _aDecodeCtx->sample_rate;
_aOut->sampleFmt = _aDecodeCtx->sample_fmt;
_aOut->chLayout = _aDecodeCtx->channel_layout;
保存视频参数:
_vOut->width = _vDecodeCtx->width;
_vOut->height = _vDecodeCtx->height;
_vOut->pixFmt = _vDecodeCtx->pix_fmt;
_vOut->fps = _vDecodeCtx->framerate.num / _vDecodeCtx->framerate.den;
通过上面方法获取到的帧率有可能是0,我们需要使用函数av_guess_frame_rate
获取帧率:
AVRational framerate = av_guess_frame_rate(_fmtCtx, _fmtCtx->streams[_vStreamIdx], nullptr);
_vOut->fps = framerate.num / framerate.den;
10、音视频原始数据写入文件
10.1、音频原始数据写入文件
我们的最终目的是将音频原始数据写入到PCM文件并使用ffplay
命令进行播放。因为播放器是不支持播放planar格式数据的,所以要求写入文件的数据为非planar
格式。我们可以通过函数av_sample_fmt_is_planar
来判断当前音频原始数据是否为planar
格式,对于planar
格式数据,我们要把每个声道中的音频样本交错写入文件。非planar
格式直接写入文件即可:
// _sampleSize 每个音频样本的大小
_sampleSize = av_get_bytes_per_sample(_aOut->sampleFmt);
// _sampleFrameSize 每个音频样本帧的大小
_sampleFrameSize = _sampleSize * _aDecodeCtx->channels;
void Demuxer::writeAudioFrame(){
// libfdk_aac解码器,解码出来的PCM格式:s16
// aac解码器,解码出来的PCM格式:ftlp
if(av_sample_fmt_is_planar(_aOut->sampleFmt)){// 是planar格式
// 外层循环:每一个声道的样本数
// si = sample index
for (int si = 0; si < _frame->nb_samples; si++) {
for (int ci = 0; ci < _aDecodeCtx->channels; ci++) {
uint8_t *begin = (uint8_t *)(_frame->data[ci] + _sampleSize * si);
_aOutFile.write((char *)begin, _sampleSize);
}
}
}else{// 非planar格式
// _aOutFile.write((char *)_frame->data[0],_frame->linesize[0]);
// int v1 = _frame->linesize[0];
// int v2 = _frame->nb_samples // 多少个样本
// * av_get_bytes_per_sample(_aOut->sampleFmt) // 每个样本多大
// * _aDecodeCtx->channels;// 声道
// if(v1 != v2){
// qDebug() <<"_frame->linesize[0]"<<v1 <<"_frame->nb_samples "<< v2;
// }
_aOutFile.write((char *)_frame->data[0],_frame->nb_samples * _sampleFrameSize);
}
}
函数av_sample_fmt_is_planar
内部会去sample_fmt_info
表中查询当前采样格式是否为planar
格式:
// 源码片段 ffmpeg-4.3.2/libavutil/samplefmt.c
/** this table gives more information about formats */
static const SampleFmtInfo sample_fmt_info[AV_SAMPLE_FMT_NB] = {
[AV_SAMPLE_FMT_U8] = { .name = "u8", .bits = 8, .planar = 0, .altform = AV_SAMPLE_FMT_U8P },
[AV_SAMPLE_FMT_S16] = { .name = "s16", .bits = 16, .planar = 0, .altform = AV_SAMPLE_FMT_S16P },
[AV_SAMPLE_FMT_S32] = { .name = "s32", .bits = 32, .planar = 0, .altform = AV_SAMPLE_FMT_S32P },
[AV_SAMPLE_FMT_S64] = { .name = "s64", .bits = 64, .planar = 0, .altform = AV_SAMPLE_FMT_S64P },
[AV_SAMPLE_FMT_FLT] = { .name = "flt", .bits = 32, .planar = 0, .altform = AV_SAMPLE_FMT_FLTP },
[AV_SAMPLE_FMT_DBL] = { .name = "dbl", .bits = 64, .planar = 0, .altform = AV_SAMPLE_FMT_DBLP },
[AV_SAMPLE_FMT_U8P] = { .name = "u8p", .bits = 8, .planar = 1, .altform = AV_SAMPLE_FMT_U8 },
[AV_SAMPLE_FMT_S16P] = { .name = "s16p", .bits = 16, .planar = 1, .altform = AV_SAMPLE_FMT_S16 },
[AV_SAMPLE_FMT_S32P] = { .name = "s32p", .bits = 32, .planar = 1, .altform = AV_SAMPLE_FMT_S32 },
[AV_SAMPLE_FMT_S64P] = { .name = "s64p", .bits = 64, .planar = 1, .altform = AV_SAMPLE_FMT_S64 },
[AV_SAMPLE_FMT_FLTP] = { .name = "fltp", .bits = 32, .planar = 1, .altform = AV_SAMPLE_FMT_FLT },
[AV_SAMPLE_FMT_DBLP] = { .name = "dblp", .bits = 64, .planar = 1, .altform = AV_SAMPLE_FMT_DBL },
};
Planar格式
_frame->linesize[0]
如果是视频表示的是0号平面它每一行的大小,而音频没有行的概念,是表示一个平面有多大。在音频中,planar
格式每个声道的大小都是一样的,所有只有linesize[0]
有值,linesize[1]
是没有值的。
注意:
libfdk_aac解码器,解码出来的PCM格式:s16(非Planar格)
aac解码器,解码出来的PCM格式:ftlp(Planar格)
非Planar格式
在之前的AAC解码实战那里是按照的方式进行文件的写入:
_aOutFile.write((char *)_frame->data[0],_frame->linesize[0]);
但是这里如果使用linesize[0]
来写入数据,会出现写入的文件大小比ffmpeg生成的文件大一些,相差了3072
,由于是双声道的s16
,所以一个样本帧是4个字节,有3072/4 = 768
个样本帧。
可以通过下面的打印知道4096 -1024
正好是3072
int v1 = _frame->linesize[0];
int v2 = _frame->nb_samples // 多少个样本
* av_get_bytes_per_sample(_aOut->sampleFmt) // 每个样本多大
* _aDecodeCtx->channels;// 声道
if(v1 != v2){
qDebug() <<"_frame->linesize[0]"<<v1 <<"_frame->nb_samples "<< v2;
}
打印结果:
linesize
是指缓冲区大小,而_frame->nb_samples
是指frame
里面解码出来有多少个音频样本,有可能frame
中的样本数量并不足以填满缓冲区,所以它的大小不一定等于linesize[0]
,而是小于等于linesize[0]
。所以inesize[0]
还是有些不靠谱的.
10.2、视频原始数据写入文件
// 创建用于存放一帧解码图片的缓冲区
_imgSize = av_image_alloc(_imgBuf,_imgLinesizes,_vOut->width,_vOut->height,_vOut->pixFmt,1);
// _imgSize = av_image_get_buffer_size(_vOut->pixFmt,_vOut->width,_vOut->height,1);
void Demuxer::writeVideoFrame()
{
// 拷贝frame的数据到_imgBuf缓冲区
av_image_copy(_imgBuf,_imgLinesizes,(const uint8_t **)(_frame->data),_frame->linesize,
_vOut->pixFmt,_vOut->width,_vOut->height);
// 将缓冲区的数据写入文件
_vOutFile.write((char *)_imgBuf[0],_imgSize);
}
我们可以使用av_image_get_buffer_size
方法来获取一张图片大小,但是这里的av_image_alloc
方法的返回值就是一张图片大小,所以可以直接使用av_image_alloc
方法的返回值。
11、关闭文件 & 释放资源
_aOutFile->close();
_vOutFile->close();
avcodec_free_context(&_aDecodeCtx);
avcodec_free_context(&_vDecodeCtx);
avformat_close_input(&_fmtCtx);
av_packet_free(&_pkt);
av_frame_free(&_frame);
av_freep(&_imgBuf[0]);
三、使用 FFmpeg 命令行解封装
$ ffmpeg -c:v h264 -c:a aac -i in.mp4 cmd_out.yuv -f f32le cmd_out.pcm
和使用FFmpeg命令行生成的PCM和YUV文件大小对比:
$ ls -al
-rw-r--r-- 1 mac staff 860790 Apr 20 12:38 in.mp4
-rw-r--r-- 1 mac staff 3459072 Apr 20 16:25 cmd_out.pcm
-rw-r--r-- 1 mac staff 125798400 Apr 20 16:25 cmd_out.yuv
-rw-r--r-- 1 mac staff 3459072 Apr 20 16:18 out.pcm
-rw-r--r-- 1 mac staff 125798400 Apr 20 16:18 out.yuv
四、总结
初始化解码器的流程(红框中)音频和视频都是一样的,仅仅AVMediaType
不同,解码流程(绿框中)也是一样的。这部分代码音视频是可以共用的。整体流程参考下图: