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 将使用特定的输入格式,传 nullptr 将自动检测输入格式;
options:包含解封装上下文和解封装器特有的参数的字典。
最后不要忘记使用函数 avformat_close_input 关闭解封装上下文,使用函数 avformat_close_input 就不需要再调用函数 avformat_free_context 了,其内部帮我们调用了函数 avformat_free_context

// 源码片段 ffmpeg-4.3.2/libavformat/utils.c
int avformat_open_input(AVFormatContext **ps, const char *filename,
                        ff_const59 AVInputFormat *fmt, AVDictionary **options)
{
    AVFormatContext *s = *ps;
    int i, ret = 0;
    AVDictionary *tmp = NULL;
    ID3v2ExtraMeta *id3v2_extra_meta = NULL;

    if (!s && !(s = avformat_alloc_context()))
        return AVERROR(ENOMEM);
    if (!s->av_class) {
        av_log(NULL, AV_LOG_ERROR, "Input context has not been properly allocated by avformat_alloc_context() and is not NULL either\n");
        return AVERROR(EINVAL);
    }
    if (fmt)
        s->iformat = fmt;

    if (options)
        av_dict_copy(&tmp, *options, 0);

    if (s->pb) // must be before any goto fail
        s->flags |= AVFMT_FLAG_CUSTOM_IO;

    if ((ret = av_opt_set_dict(s, &tmp)) < 0)
        goto fail;

    // 省略代码... 

     if (options) {
        av_dict_free(options);
        *options = tmp;
    }
    *ps = s;
    return 0;

close:
    if (s->iformat->read_close)
        s->iformat->read_close(s);
fail:
    ff_id3v2_free_extra_meta(&id3v2_extra_meta);
    av_dict_free(&tmp);
    if (s->pb && !(s->flags & AVFMT_FLAG_CUSTOM_IO))
        avio_closep(&s->pb);
    avformat_free_context(s);
    *ps = NULL;
    return ret;
}

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 中还需要调用 fflush(stderr) 才能够将信息输出到控制台。fflush 会强迫将缓冲区内容清空,就会立即输出所有在缓冲区中的内容。stderr 是指标准错误输出设备,输出的文本内容一般是红色的,默认向屏幕输出内容。

打印的信息如下,这和我们在终端看到的信息是一样的:

Input #0, mov,mp4,m4a,3gp,3g2,mj2, from '/Users/mac/Downloads/pic/in.mp4':
  Metadata:
    major_brand     : isom
    minor_version   : 512
    compatible_brands: isomiso2avc1mp41
    title           : www.lggzs.com
    encoder         : Lavf58.45.100
  Duration: 00:00:10.04, bitrate: N/A
    Stream #0:0(und): Video: h264 (avc1 / 0x31637661), none, 640x480, 355 kb/s, SAR 1:1 DAR 4:3, 23.98 fps, 23.98 tbr, 24k tbn (default)
    Metadata:
      handler_name    : VideoHandler
    Stream #0:1(und): Audio: aac (mp4a / 0x6134706D), 48000 Hz, 2 channels, 129 kb/s (default)
    Metadata:
      handler_name    : SoundHandler

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:目前没有定义;

流类型枚举:

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() << "audio / video streams is empty.";
    return -1;
}

5、查找解码器

我们通过 stream->codecpar->codec_id 可以查找到对应的解码器:

AVCodec *avcodec_find_decoder(enum AVCodecID id);

5、创建解码上下文

创建解码上下文,需要传递上面查找到的解码器(也可以不传,但解码上下文不会包含解码器):

AVCodecContext *avcodec_alloc_context3(const AVCodec *codec);

参数说明:
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 获取;

6、打开解码器

int avcodec_open2(AVCodecContext *avctx, const AVCodec *codec, AVDictionary **options);

参数说明:
avctx:需要初始化的解码上下文;
codec:解码器;
options:包含解封装上下文和解封装器特有的参数的字典。

7、从音视频流中读取压缩帧

我们可以通过 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 内存空间。

8、音视频解码

首先使用函数 avcodec_send_packet 发送压缩数据到解码器:

int avcodec_send_packet(AVCodecContext *avctx, const AVPacket *avpkt);

然后使用函数 avcodec_receive_frame 从解码器中读取解码后的数据:

int avcodec_receive_frame(AVCodecContext *avctx, AVFrame *frame);

9、保存音视频输出参数

我们定义了两个结构体分别保存音频输出参数和视频输出参数:

// 音频输出参数
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 格式直接写入文件即可:

立体声 Planar 格式音频原始数据写入 PCM 文件

void Demuxer::writeAudioFrame()
{
    if (av_sample_fmt_is_planar(_aDecodeCtx->sample_fmt)) { // planar
        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 { // non-planar
        _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] 有值,frame->linesize[1] 是没有值的。linesize 是指缓冲区大小,有可能 frame 中的样本数量并不足以填满缓冲区,所以在写入文件时,写入文件数据大小需要使用下面方式计算,函数 av_get_bytes_per_sample 获取到的是每个样本所占字节数,再乘以声道数,就得到了每个音频样本帧的大小:

// _sampleSize 每个音频样本的大小
_sampleSize = av_get_bytes_per_sample(aDecodeCtx->sampleFmt);
// _sampleFrameSize 每个音频样本帧的大小
_sampleFrameSize = sampleSize * _aDecodeCtx->channels;

10.2、视频原始数据写入文件

// 创建视频原始数据缓冲区,为了兼容多种原始数据格式
_imageSize = av_image_alloc(_imageBuf, _imageLinesize, _vDecodeCtx->width, _vDecodeCtx->height, _vDecodeCtx->pix_fmt, 1);
void Demuxer::writeVideoFrame()
{
    // 拷贝 frame 中数据到 _imageBuf
    av_image_copy(_imageBuf, _imageLinesize, (const uint8_t **)(_frame->data), _frame->linesize, _vDecodeCtx->pix_fmt, _vDecodeCtx->width, _vDecodeCtx->height);
    //qDebug() << _imageBuf << _imageSize;
    _vOutFile->write((char *)_imageBuf[0], _imageSize);
}

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 out_terminal.yuv -f f32le out_terminal.pcm

和使用 FFmpeg 命令行生成的 PCM 和 YUV 文件大小对比:

$ ls -al
-rw-r--r--   1 mac  staff          614562 Apr 20 12:38 in.mp4
-rw-r--r--   1 mac  staff         3850240 Apr 20 16:25 out_code.pcm
-rw-r--r--   1 mac  staff       109670400 Apr 20 16:25 out_code.yuv
-rw-r--r--   1 mac  staff         3850240 Apr 20 16:18 out_terminal.pcm
-rw-r--r--   1 mac  staff       110592000 Apr 20 16:18 out_terminal.yuv

通过对比发现使用代码得到的 yuv 文件丢失了部分数据,通过排查发现是由于最后忘记刷新解码器缓冲区导致的,刷新解码器数据缓冲区后代码和命令行生成的 PCM 和 YUV 文件大小完全一样。

四、总结

初始化解码器的流程(红框中)音频和视频都是一样的,仅仅 AVMediaType 不同,解码流程(绿框中)也是一样的。这部分代码音视频是可以共用的。整体流程参考下图:

流程总结

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,457评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,837评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,696评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,183评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,057评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,105评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,520评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,211评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,482评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,574评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,353评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,213评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,576评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,897评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,174评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,489评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,683评论 2 335

推荐阅读更多精彩内容