熟悉图像处理和计算机视觉的同学们应该都用过 OpenCV,当我们要处理视频读写的时候一般都会采用 OpenCV 自带的 cv::VideoCapture
和 cv::VideoWriter
。这两个类本质上是对音视频编解码库 FFmpeg 的封装,并且屏蔽了编解码操作的细节,使用很简单。例如:
cv::VideoCapture reader;
reader.open("in.mp4");
CV_Assert(reader.isOpened());
cv::Mat frame;
reader.read(frame);
cv::VideoWriter writer;
writer.open("out.avi", CV_FOURCC('M', 'J', 'P', 'G'), 25, frame.size(), frame.type() == CV_8UC3);
CV_Assert(writer.isOpened());
writer.write(frame);
while (reader.read(frame))
{
cv::imshow("frame", frame);
writer.write(frame);
int key = cv::waitKey(20);
if (key == 'q')
break;
}
对于一般的视频处理而言,cv::VideoCapture
和 cv::VideoWriter
已经够用了。但是,如果我们要对视频文件做更精细或者深入的处理,这两个类就无法满足需求了。比如:
cv::VideoCapture::read
函数返回的帧指向的是一块内部内存,如果需要一块深拷贝的帧,需要调用cv::Mat::clone()
或者cv::Mat::copyTo()
函数进行操作。cv::VideoCapture
读出的帧只能是 BGR 格式,我们无法获得内部 FFmpeg AVCodecContext 解码后直接得到的 YUV420P 格式的帧。cv::VideoWriter
写的视频无法指定编码器的详细参数,无法指定码率。cv::VideoWriter
只能写入灰度帧或者 BGR 格式的帧,无法写入 YUV420P 格式的帧。cv::VideoWriter
在 Windows 操作系统下要输出 H.264 格式编码的视频,需要重编译 opencv_ffmpeg.dll。cv::VideoCapture
和cv::VideoWriter
这两个类都无法处理音频。cv::VideoCapture
和cv::VideoWriter
这两个类都无法处理一个文件中存在超过一路视频流和超过一路音频流的情况。
OpenCV 的视频读写不够强大,无法处理音频,所以最终还是得直接调用 FFmpeg 进行更专业的编解码操作。目标就是写两个类 AudioVideoReader
和 AudioVideoWriter
对 FFmpeg 进行封装,解决上述问题。
第一个版本的设计解决了上述的第 1, 3 , 5 和 6 这 4 个问题。这也是最早的需求。我学习了 FFmpeg 源码包里面的 samples,大致阅读了 OpenCV cv::VideoCapture
和 cv::VideoWriter
里面调用 FFmpeg 的方法,完成了 avp::AudioVideoReader
,avp::AudioVideoWriter
和 avp::AudioVideoFrame
。这三个类或者接口体的定义放在了 AudioVideoProcessor.h 中。我的设计有这么几点:
- 采用了 pimpl idiom 接口与实现相分离的做法,如果把 EasyFFmpeg 中的 AudioVideoProcessor 文件夹中的源码编成库进行发布,客户端程序只需要包含 AudioVideoProcessor.h 即可使用,不需要包含 FFmpeg 的一大堆头文件。
namespace avp
{
class AudioVideoReader
{
public:
//...
private:
struct Impl;
std::shared_ptr<Impl> ptrImpl;
};
class AudioVideoWriter
{
public:
//...
private:
struct Impl;
std::shared_ptr<Impl> ptrImpl;
};
}
- 两个接口类的实现类
avp::AudioVideoReader::Impl
,avp::AudioVideoWriter::Impl
内部 FFmpeg 的数据结构是堆积摆放的,没有经过封装。 avp::AudioVideoFrame
仅仅是一个内存的 wrapper,我没有实现这个类的拷贝控制成员函数。- 做了类型定义
typedef std::pair<std::string, std::string> Option
,支持 FFmpegAVDictionary
形式的参数设置。
由于需求的进一步增多,我需要让自己的 AudioVideoReader
和 AudioVideoWriter
解决上述第 2 和第 6 两个问题。avp::AudioVideoFrame
的成员是无法表示 YUV420P,NV12 这种格式的帧的,所以我基本照搬了 FFmpeg AVFrame 的重要成员,完成了 avp::AudioVideoFrame2
,这个类有这么几个特点:
- 能够兼容用 FFmpeg AVFrame 表示的常规的音视频帧。
- 有构造函数,能按照指定的视频帧格式和音频帧格式构造帧。
- 具备内存管理功能,能进行拷贝控制。如果帧的数据内存由当前实例分配,通过引用计数管理内存。如果帧的数据内存是通过外部指针传入,禁用引用计数。
namespace avp
{
struct AudioVideoFrame2
{
// ...
// 通过外部内存构造音频帧,构造出的实例不管理内存
AudioVideoFrame2(unsigned char** data, int step, int sampleType, int numChannels, int channelLayout,
int numSamples, long long int timeStamp = -1LL, int frameIndex = -1);
// 通过外部内存构造视频帧,构造出的实例不管理内存
AudioVideoFrame2(unsigned char** data, int* steps, int pixelType, int width, int height,
long long int timeStamp = -1LL, int frameIndex = -1);
// 构造音频帧,分配内存,通过引用计数管理内存
AudioVideoFrame2(int sampleType, int numChannels, int channelLayout, int numSamples,
long long int timeStamp = -1LL, int frameIndex = -1);
// 构造视频帧,分配内存,通过引用计数管理内存
AudioVideoFrame2(int pixelType, int width, int height, long long int timeStamp = -1LL, int frameIndex = -1);
//...
std::shared_ptr<unsigned char> sdata; // 通过智能指针管理内存
unsigned char* data[8]; // 兼容 AVFrame::data
int steps[8]; // 兼容 AVFrame::linesize
// ...
};
}
当时还有个支持硬件编解码的需求,就是调用 Intel Quick Sync Video 和 NVIDIA NVENC 对 H.264 视频进行编解码。尽管 FFmpeg 支持这两个第三方库,但是兼容性并不理想。记得 2016 年初还在 Intel 的官方论坛上,看到有开发人员问为什么通过 FFmpeg 调用 Intel 的解码器,解出来的视频帧数量不对的问题。结果被 Intel 的支持怼回去说是 FFmpeg 的问题,用我们自家的 sample 解码没问题,╮(╯_╰)╭。所以我决定自己封装这两个硬件解码库。
如何同时兼容 FFmpeg 自己管理的编解码功能和需要我自己封装的编解码功能,成了这一版修改的重要问题。注意到音视频文件都是按照 stream 组织的,FFmpeg AVFormatContext
中也有 AVStream *
类型的 streams
这个成员。因此我决定这么处理:
定义新版的音视频读写类的接口类avp::AudioVideoReader2
和 avp::AudioVideoWriter2
,这两个类的实现类分别是 avp::AudioVideoReader2::Impl
和 avp::AudioVideoWriter2::Impl
。
对于解码, avp::AudioVideoReader2::Impl
内部采用 avp::StreamReader
进行组织:
avp::AudioStreamReader
继承avp::StreamReader
处理音频流。avp::VideoStreamReader
继承avp::StreamReader
处理视频流。
avp::AudioVideoReader2::Impl
中有成员 std::unique_ptr<avp::AudioStreamReader> audioStream
和 std::unqiue_ptr<avp::VideoStreamReader> videoStream
分别表示音频流和视频流。avp::AudioStreamReader
直接调用 FFmpeg 音频解码器进行解码。avp::VideoStreamReader
是一个基类,它有两个派生类:
avp::BuiltinCodecVideoStreamReader
直接调用 FFmpeg 自带的解码器进行解码的视频流。avp::QsvVideoStreamReader
调用 Intel Quick Sync Video 进行解码的视频流。
对于编码,也采用了类似的做法。 avp::AudioVideoWriter2::Impl
内部采用 avp::StreamWriter
进行组织:
avp::AudioStreamWriter
继承avp::StreamWriter
处理音频流。avp::VideoStreamWriter
继承avp::StreamWriter
处理视频流。
avp::AudioVideoWriter2::Impl
中有成员 std::unique_ptr<avp::AudioStreamWriter> audioStream
和 std::unqiue_ptr<avp::VideoStreamWriter> videoStream
分别表示音频流和视频流。avp::AudioStreamWriter
直接调用 FFmpeg 音频编码器进行编码。avp::VideoStreamWriter
是一个基类,它有三个派生类:
avp::BuiltinCodecVideoStreamWriter
直接调用 FFmpeg 自带的解码器进行编码的视频流。avp::QsvVideoStreamWriter
调用 Intel Quick Sync Video 进行编码的视频流。avp::NVENCVideoStreamWriter
调用 NVDIA NVENC 进行编码的视频流。
调用 Intel 和 NVIDIA 硬件编解码的代码 github 上没给出。
最后又需要解决前面所述第 7 个问题。前面的两个版本都无法处理这个情况。于是我写了 avp::AudioVideoReader3
,它的实现类 avp::AudioVideoReader3::Impl
中有一个成员 std::vector<std::unique_ptr<avp::StreamReader> >
保存所有音视频流解码类的基类指针。相应的,对于写音视频,我写了 avp::AudioVideoWriter3
,它的实现类 avp::AudioVideoWriter3::Impl
中有一个成员 std::vector<std::unique_ptr<avp::StreamWriter> >
保存所有音视频流编码类的基类指针。这样,音视频读写类在理论上就具备了处理超过一路音频和超过一路视频的能力。
本代码开发基本上是基于 FFmpeg 2.8.6,进入 3.0.0 系列后 FFmpeg 数据结构结构发生了很大变化 AVStream
中不再有 AVCodecContext *
成员,libavcodec 修改了编解码 API。
本代码仅在 Windows 平台上测试过。现在 FFmpeg 官网上提供的 Windows 下预编译好的二进制文件已经找不到 2.8.6 版本的了,不过我的 github 上还是给出了这个版本的头文件和 64 位的库文件。
从功能上说,这个库依然后很多不足之处。比如,我没有对 FFmpeg AVPacket
进行封装,这也意味着很多底层的工作并不能调用这个库实现。比如,我们不能用 EasyFFmpeg 做音视频编码包级别的复用和解复用。编解码方面,FFmpeg 是支持从一个 AVPacket
中解出多个 AVFrame
的,但是我这个封装不支持。我的封装也不支持同一路音频码流中变更 sample rate, sample format 和 channel layout 的情况。
上述工作是我在 2015 年 9 月到 2016 年 9 月间断断续续完成的。三个版本的变化反应的不仅是需求的变化,也是设计的变化。一开始问题比较简单,解决方法比较简单。随着问题的复杂化,设计也变得复杂。如果一开始就有比较丰富的需求,我可能一下子就做第三版的设计了。
工作这么几年,发现有好多人做过把 C 的库封装成 C++ 的库这样的事情。没想到自己也会去做这样的事情。要封装好一个库,这个库基本的原理是要了解的,而且还要有一些组织架构设计能力。如果没有这样的能力,就一边做,一边分析,一边学习。
目前来看,对 FFmpeg 封装最完备的应该是 QtAV。这个库很庞大,使用了 Qt,主要是针对播放功能做的。音视频编解码开发人员应该觉得这个封装用起来很爽。
感谢已经去世的雷霄骅,从他的博客和代码里面我学到了很多。