前言
書(shū)接上回,我們比較詳細(xì)的介紹了ffmpeg開(kāi)發(fā)過(guò)程中會(huì)接觸到的主要結(jié)構(gòu)體狗唉,當(dāng)然,其實(shí)還有AVFilter模塊涡真,但是對(duì)于初學(xué)者而言分俯,忽略掉過(guò)濾器部分也無(wú)傷大雅,并不影響對(duì)于ffmpeg開(kāi)發(fā)流程的主體的學(xué)習(xí)哆料,而且AVFilter也不算是特別常用缸剪,在音視頻開(kāi)發(fā)中也有其他方式可以實(shí)現(xiàn)AVFilter的效果,因此暫時(shí)可以先忽略东亦。
本文我們用一段相對(duì)完整杏节,但是不算復(fù)雜的ffmpeg程序來(lái)實(shí)現(xiàn)我們上文提到的那些知識(shí)。
環(huán)境準(zhǔn)備
在進(jìn)行ffmpeg開(kāi)發(fā)之前典阵,一般建議大家自行獲得ffmpeg源碼奋渔,手動(dòng)編譯獲得相應(yīng)的動(dòng)態(tài)庫(kù)(dll/so),然后再正式進(jìn)行c/c++開(kāi)發(fā)工作。
ffmpeg可以在windows,linux系統(tǒng)上開(kāi)發(fā)壮啊,一般推薦linux上來(lái)開(kāi)發(fā)(本人用的linux環(huán)境嫉鲸,但也有windows環(huán)境),因?yàn)閣indows其實(shí)也是模擬了一些linux的環(huán)境的歹啼。
windows環(huán)境安裝與編譯
windows環(huán)境下主要參考這篇文章ffmpeg庫(kù)編譯安裝及入門(mén)指南玄渗。
注意以下幾點(diǎn):
- 博文中作者的建議安裝選項(xiàng)大家都盡可能安裝上。
- ffmpeg源碼盡可能下載最新版本狸眼。
- 編譯ffmpeg庫(kù)的build-ffmpeg.sh腳本替換如下
#!/bin/sh
basepath=$(cd `dirname $0`;pwd)
echo ${basepath}
cd ${basepath}/ffmpeg-5.1.2-src
pwd
export PKG_CONFIG_PATH=${PKG_CONFIG_PATH}:/d/repos/ffmpeg/x264_install/lib/pkgconfig
echo ${PKG_CONFIG_PATH}
./configure --prefix=${basepath}/ffmpeg_5.2.1_install \
--enable-debug --disable-asm --disable-stripping --disable-optimizations \
--enable-gpl --enable-libx264 --disable-static --enable-shared \
--extra-cflags=-l${basepath}/x264_install/include --extra-ldflags=-L${basepath}/x264_install/lib
make -j8
make install
主要是添加一些可debug配置藤树,為后面調(diào)試做準(zhǔn)備
linux環(huán)境安裝與編譯
在linux中就不需要安裝MSYS2了,而缺的編譯工具什么的按照提示使用linux的軟件包管理管理工具(比如apt等)安裝即可份企。
然后下載最新源碼也榄,libx264源碼巡莹,編譯過(guò)程仍然可以使用或者 ffmpeg庫(kù)編譯安裝及入門(mén)指南中提供的編譯腳本司志。
注意build-ffmpeg.sh腳本同樣需要替換一下腳本:
#!/bin/sh
basepath=$(cd `dirname $0`;pwd)
echo ${basepath}
cd ${basepath}/ffmpeg-5.1.2-src
pwd
export PKG_CONFIG_PATH=${PKG_CONFIG_PATH}:/d/repos/ffmpeg/x264_install/lib/pkgconfig
echo ${PKG_CONFIG_PATH}
./configure \
--enable-debug --disable-asm --disable-stripping --disable-optimizations \
--enable-gpl --enable-libx264 --disable-static --enable-shared \
--extra-cflags=-l${basepath}/x264_install/include --extra-ldflags=-L${basepath}/x264_install/lib
make -j8
make install
去掉了--prefix=xxx配置甜紫,把ffmpeg生成物推送到系統(tǒng)默認(rèn)的環(huán)境變量的路徑中,免得還需要自行配置骂远,后面就可以直接調(diào)用和使用依賴庫(kù)了囚霸。如果想自定義產(chǎn)物生成目錄也可直接參考windows的腳本。
代碼編輯器
代碼編輯器可以使用 Visual Studio Code,Clion,或者其他趁手的都行激才。
生成產(chǎn)物與開(kāi)發(fā)使用
編譯成功之后拓型,不僅有ffmpeg依賴庫(kù)(lib文件夾)和頭文件(include文件夾),還有ffmpeg,ffprobe,ffplayer這樣的可執(zhí)行程序瘸恼,可以直接在命令行中進(jìn)行調(diào)用劣挫。
在后面的開(kāi)發(fā)過(guò)程中,我們至少會(huì)用到頭文件和依賴庫(kù)东帅。
對(duì)于windows環(huán)境而言压固,為了簡(jiǎn)單起見(jiàn),每新建一個(gè)工程靠闭,可以把ffmpeg生成的頭文件都添加進(jìn)來(lái)帐我,然后按需調(diào)(雖然有些不環(huán)保)。
windows環(huán)境在編譯時(shí)需要指定鏈接庫(kù)愧膀,還是可以參考 ffmpeg庫(kù)編譯安裝及入門(mén)指南拦键;linux如果編譯產(chǎn)物在系統(tǒng)默認(rèn)目錄中的話則不需要。
ffmpeg開(kāi)發(fā)
環(huán)境安裝完畢之后檩淋,正式進(jìn)入正題芬为。
我們要開(kāi)發(fā)的程序的功能是,讀取一個(gè)視頻文件狼钮,解碼音頻和視頻部分碳柱,并且把解碼后的視頻中的一幀或者幾幀圖保存成ppm格式。
這里主要包含到ffmpeg的解封裝熬芜,解碼莲镣,色彩空間轉(zhuǎn)換的過(guò)程,以及對(duì)解碼數(shù)據(jù)的認(rèn)識(shí)涎拉。
至于ppm瑞侮,它是一個(gè)未壓縮的RGB圖片的格式(jpg就是壓縮后的圖片格式),文件在操作系統(tǒng)中可以正常打開(kāi)查看鼓拧,這不是本文的重點(diǎn)半火。
函數(shù)入口
接下來(lái)我們直接看代碼
#include <cstdio>
#include "common.h"
#include "iostream"
// 因?yàn)閒fmpeg中的庫(kù)都是C編寫(xiě)的,使用cpp開(kāi)發(fā)季俩,引用C庫(kù)需要extern "C"配置钮糖,適配C/cpp函數(shù)名編譯的不同規(guī)則
extern "C"{
#include "libavformat/avformat.h"
#include "libavformat/avio.h"
#include "libavcodec/avcodec.h"
#include "libswscale/swscale.h"
#include "libavutil//imgutils.h"
}
using namespace std;
AVFormatContext *av_fmt_ctx_input = nullptr;
int video_stm_index = -1;
int audio_stm_index = -1;
int ret = 0;
// 提前定義好結(jié)構(gòu)體,便于解碼音頻和視頻時(shí)的變量的統(tǒng)一管理
typedef struct StreamContext{
//解碼音頻的解碼器上下文
AVCodecContext *audioAVCodecCtx = nullptr;
//解碼視頻的解碼器上下文
AVCodecContext *videoAVCodecCtx= nullptr;
//表示視頻的數(shù)據(jù)流
AVStream *videoStream= nullptr;
//表示音頻的數(shù)據(jù)流
AVStream *audioStream= nullptr;
//色彩空間轉(zhuǎn)換后的AVFrame
AVFrame *rgbFrame = nullptr;
};
// 根據(jù)定義好的結(jié)構(gòu)體聲明一個(gè)變量
struct StreamContext streamContext;
// 色彩空間轉(zhuǎn)換模塊的上下文
SwsContext *swsContext = nullptr;
/********其他函數(shù)***********/
//.....
// 后文補(bǔ)充
//......
/********其他函數(shù)***********/
// 入口函數(shù)
int main(int argc,char *args[]) {
// 同目錄下存放任意一個(gè)MP4文件,便于直接讀取
const char *input_file = "bunny.mp4";
// avformat_open_input店归,解封裝阎抒,并讀取文件頭信息,創(chuàng)建av_fmt_ctx_input結(jié)構(gòu)體對(duì)象
if ((ret = avformat_open_input(&av_fmt_ctx_input,input_file, nullptr, nullptr))<0){
print_log("avformat_open_input", ret); // 錯(cuò)誤處理消痛,print_log是自定義的一個(gè)函數(shù)且叁,用于打印一些錯(cuò)誤信息
return -1;
}
// 主要針對(duì)某些沒(méi)有文件頭的視頻文件情況,會(huì)嘗試從文件主體中去讀取一些文件的信息
ret = avformat_find_stream_info(av_fmt_ctx_input, nullptr);
if(ret<0){
print_log("avformat_find_stream_info", ret);
return ret;
}
// 打印一下av_fmt_ctx_input目前持有的信息秩伞,(如果不想要也可以去掉)
av_dump_format(av_fmt_ctx_input,-1,input_file,0);
// 1逞带,分別對(duì)視頻和音頻的解碼進(jìn)行初始化的準(zhǔn)備
// 就是獲取對(duì)應(yīng)的流,以及初始化對(duì)應(yīng)的解碼器
if (initVideo() < 0 || initAudio() < 0){
return -1;
}
// 初始化這個(gè)用來(lái)轉(zhuǎn)換的AVFrame,
// 需要手動(dòng)設(shè)置frame->data,frame->linesize這兩個(gè)空間 在前一篇文章中說(shuō)到過(guò)
ret = initRGBFrame();
if (ret<0){
print_log("initRGBFrame",-1);
return ret;
}
// 創(chuàng)建AVPakcet結(jié)構(gòu)體的對(duì)象纱新,前一篇文章說(shuō)過(guò)它是存放編碼數(shù)據(jù)的結(jié)構(gòu)體
AVPacket *av_packet = av_packet_alloc();
// av_read_frame 讀取視頻文件的中的數(shù)據(jù)流 到av_packet中展氓,
// 此時(shí)av_packet中就存放了一塊編碼過(guò)的數(shù)據(jù)
while (av_read_frame(av_fmt_ctx_input,av_packet)>=0){
// av_packet->stream_index表示這個(gè)packet數(shù)據(jù)來(lái)自AVFormatContext中的streams數(shù)組的哪個(gè)下標(biāo)
// 通過(guò)判斷來(lái)區(qū)分packet里面裝的是音頻數(shù)據(jù)還是視頻數(shù)據(jù),需要分開(kāi)解碼
if (av_packet->stream_index == video_stm_index){ // video_stm_index就是我們找到的視頻流所在的數(shù)組下標(biāo)
ret = decodeData(av_packet,streamContext.videoAVCodecCtx,1);
}else if (av_packet->stream_index == audio_stm_index){
// decode audio
}
if (ret<0){
break;
}
}
// 集中釋放AVCodecContext脸爱,AVPacket,AVFormatContext等資源
avcodec_free_context(&(streamContext.audioAVCodecCtx));
avcodec_free_context(&(streamContext.videoAVCodecCtx));
av_packet_free(&av_packet);
avformat_close_input(&av_fmt_ctx_input);
return 0;
}
上面是程序的變量和入口函數(shù)带饱,也就是整個(gè)程序的主框架了。
從上面的注釋可以比較通暢的了解程序的執(zhí)行過(guò)程阅羹。從中也能找到前一篇文章中提到的許多代碼片段勺疼,這里其實(shí)算是做了一個(gè)整合。
音視頻配置初始化
接下來(lái)我們看看initVideo和initAudio,其實(shí)兩者基本是一致的捏鱼,理論上可以合并成一個(gè)函數(shù)执庐。
int initVideo(){
//av_find_best_stream 用于從av_fmt_ctx_input中找到類(lèi)型為AVMEDIA_TYPE_VIDEO的流的數(shù)組下標(biāo)
// 當(dāng)然由于我們此時(shí)已經(jīng)直到AVFormatContext->nb_streams 流數(shù)組的長(zhǎng)度,所以可以手動(dòng)遍歷导梆。
// av_find_best_stream函數(shù)就是手動(dòng)遍歷查找的轨淌。
video_stm_index = av_find_best_stream(av_fmt_ctx_input,AVMEDIA_TYPE_VIDEO,-1,-1, nullptr,0);
if (video_stm_index == -1 ){ // 如果-1,表示沒(méi)有找到我們想要的數(shù)組下標(biāo),返回錯(cuò)誤
print_log("video_index_error",video_stm_index);
return -1;
}
cout<< "video stream index: "<<video_stm_index<<endl; //打印信息
//拿到了視頻流
streamContext.videoStream = av_fmt_ctx_input->streams[video_stm_index];
// 接著開(kāi)始準(zhǔn)備進(jìn)行解碼器的初始化
// 上一篇文章我們說(shuō)過(guò)看尼,視頻流中有解碼該流的數(shù)據(jù)的解碼器id
// 此時(shí)我們通過(guò)解碼器id递鹉,找到對(duì)應(yīng)的解碼器的詳細(xì)信息(AVCodec),或者也可以直接把它理解為解碼器
// avcodec_find_decoder是找對(duì)應(yīng)的解碼器藏斩,avcodec_find_encoder是找對(duì)應(yīng)的編碼器躏结,別弄錯(cuò)了
auto codec = avcodec_find_decoder(streamContext.videoStream->codecpar->codec_id);
// 然后通過(guò)這個(gè)codec,創(chuàng)建該解碼器的上下文狰域,
// 但是此時(shí)上下文里還沒(méi)有視頻流的有效信息
auto av_codec_ctx = avcodec_alloc_context3(codec);
// 于是我們把視頻流的有效信息賦值到解碼器上下文中
ret = avcodec_parameters_to_context(av_codec_ctx,streamContext.videoStream->codecpar);
if (ret<0){
print_log("video avcodec_parameters_to_context",ret);
return ret;
}
// 對(duì)解碼器進(jìn)行初始化媳拴,準(zhǔn)備開(kāi)始解碼
ret = avcodec_open2(av_codec_ctx,codec, nullptr);
if (ret<0){
print_log("video avcodec_open2",ret);
return ret;
}
streamContext.videoAVCodecCtx = av_codec_ctx;
return 0;
}
int initAudio(){
audio_stm_index = av_find_best_stream(av_fmt_ctx_input,AVMEDIA_TYPE_AUDIO,-1,-1, nullptr,0);
if (audio_stm_index == -1){
print_log("audio_index_error",audio_stm_index);
return -1;
}
cout<< "audio stream index "<<audio_stm_index<<endl;
streamContext.audioStream = av_fmt_ctx_input->streams[audio_stm_index];
auto codec = avcodec_find_decoder(streamContext.audioStream->codecpar->codec_id);
auto av_codec_ctx = avcodec_alloc_context3(codec);
ret = avcodec_parameters_to_context(av_codec_ctx,streamContext.audioStream->codecpar);
if (ret<0){
print_log("audio avcodec_parameters_to_context",ret);
return ret;
}
ret = avcodec_open2(av_codec_ctx,codec, nullptr);
if (ret<0){
print_log("audio avcodec_open2",ret);
return ret;
}
streamContext.audioAVCodecCtx = av_codec_ctx;
return 0;
}
根據(jù)上面的代碼和注釋?zhuān)材馨l(fā)現(xiàn),關(guān)于AVStream,AVCodec,AVCodecContext的使用基本都符合前一篇文章中對(duì)于對(duì)應(yīng)結(jié)構(gòu)體的基本使用說(shuō)明兆览。當(dāng)然這個(gè)過(guò)程中是有許多詳細(xì)的參數(shù)是可以設(shè)置的屈溉,也可以把他們變得復(fù)雜一點(diǎn),但是目前這不是重點(diǎn)抬探。
手動(dòng)配置AVFrame->data
接下來(lái)我們看看initRGBFrame的邏輯子巾。
int initRGBFrame(){
//先創(chuàng)建一個(gè)AVFrame結(jié)構(gòu)體
streamContext.rgbFrame = av_frame_alloc();
auto width = streamContext.videoAVCodecCtx->width;
auto height = streamContext.videoAVCodecCtx->height;
// 通過(guò)像素格式,圖片寬高,來(lái)計(jì)算當(dāng)前所需的緩沖空間大小线梗,最后一個(gè)字段是對(duì)齊字?jǐn)?shù)
auto bufferSize = av_image_get_buffer_size(AV_PIX_FMT_RGB24,width,height,1);
uint8_t * buffer = (uint8_t *)av_malloc(bufferSize);
// AV_PIX_FMT_RGB24 packed RGB 8:8:8, 24bpp, BGRBGR...
// 在data[8]數(shù)組中保存在data[0]中
//根據(jù)緩沖大小匿醒,像素格式,寬高來(lái)填充 rgbFrame->data和rgbFrame->linesize
av_image_fill_arrays(streamContext.rgbFrame->data,streamContext.rgbFrame->linesize,buffer,
AV_PIX_FMT_RGB24,width,height,1);
// 創(chuàng)建視頻幀轉(zhuǎn)換的上下文缠导,libswscale可以提供顏色轉(zhuǎn)換,圖片尺寸放縮等能力
swsContext = sws_getContext(width,height,streamContext.videoAVCodecCtx->pix_fmt,width,height,
AV_PIX_FMT_RGB24,0, nullptr, nullptr, nullptr);
if (swsContext == nullptr){
return -1;
}
return 0;
}
手動(dòng)創(chuàng)建并填充AVFrame的過(guò)程溉痢,需要首先創(chuàng)建AVFrame的結(jié)構(gòu)體僻造,然后申請(qǐng)?zhí)畛?rgbFrame->data和rgbFrame->linesize這兩個(gè)字段,前一篇文章中說(shuō)到過(guò)孩饼,不是編解碼過(guò)程中使用AVFrame需要我們手動(dòng)申請(qǐng)這塊內(nèi)存髓削。具體可以看FFmpeg開(kāi)發(fā)——基礎(chǔ)篇————AVFrame。
現(xiàn)在我們準(zhǔn)備先把視頻解碼成YUV幀镀娶,然后把YUV幀通過(guò)libswscale轉(zhuǎn)換成RGB幀立膛。解碼過(guò)程中使用AVFrame是不需要我們手動(dòng)申請(qǐng)或填充data等字段的,但是scale轉(zhuǎn)換過(guò)程自然就需要了梯码。
解碼與轉(zhuǎn)換
做完了上述的準(zhǔn)備之后宝泵,可以正式開(kāi)始進(jìn)行解碼操作了:從數(shù)據(jù)流中讀取數(shù)據(jù)到AVPacket中,然后把AVPakcet中的數(shù)據(jù)發(fā)送給解碼器轩娶,接著從解碼器中讀取數(shù)據(jù)到AVFrame中儿奶,就獲得了一個(gè)解碼后的幀。
int decodeData(AVPacket *av_packet,AVCodecContext *av_codec_ctx,int is_video) {
// 發(fā)送數(shù)據(jù)到解碼器
ret = avcodec_send_packet(av_codec_ctx,av_packet);
if (ret<0){
print_log("video avcodec_send_packet",ret);
return ret;
}
// 創(chuàng)建一個(gè)AVFrame用來(lái)承接解碼后的數(shù)據(jù)(此時(shí)不用在手動(dòng)填充data等字段了)
AVFrame *av_frame = av_frame_alloc();
while (true){
// 從解碼器中讀取解碼后的數(shù)據(jù)到AVFrame中
ret = avcodec_receive_frame(av_codec_ctx,av_frame);
if (ret == AVERROR_EOF){ // 到文件結(jié)束
ret = 0;
break;
} else if (ret == AVERROR(EAGAIN)){ // avpacket的數(shù)據(jù)不夠形成一幀數(shù)據(jù)鳄抒,需要繼續(xù)往解碼器發(fā)送avpacket
ret = 0;
break;
}else if(ret<0){ // 其他錯(cuò)誤
print_log("video decode error",ret);
break;
}else{
// ret>=0 表示正常,此時(shí)會(huì)得到的av_frame基本上都是YUV420P的色彩格式闯捎,
if(is_video>0){ // 處理視頻數(shù)據(jù)
// sws_scale函數(shù)可以對(duì)AVFrame進(jìn)行轉(zhuǎn)換(顏色空間轉(zhuǎn)換,圖片寬高放縮等)
// (YUV420P) to (packed RGB 8:8:8)
ret = sws_scale(swsContext, ( uint8_t const* const*)av_frame->data, av_frame->linesize, 0, av_frame->height,
streamContext.rgbFrame->data, streamContext.rgbFrame->linesize);
if (ret<0){
print_log("sws_scale_frame",ret);
break;
}
// 此時(shí)rgbFrame內(nèi)就保存了RGB格式的數(shù)據(jù)许溅,接下來(lái)我們只要把數(shù)據(jù)寫(xiě)入到文件即可
saveRGBImage(0);
ret = -1;
break; // 只解碼一幀就退出
}else{
}
}
}
av_frame_free(&av_frame);
return ret;
}
這里主要涉及到兩個(gè)點(diǎn)瓤鼻,解碼過(guò)程和轉(zhuǎn)換過(guò)程。
解碼過(guò)程的API調(diào)用比較簡(jiǎn)單贤重,也可以看AVFrame之編解碼使用方式茬祷。
轉(zhuǎn)換過(guò)程本質(zhì)上是YUV2RGB的算法以及數(shù)據(jù)存儲(chǔ)方式,關(guān)于前者其實(shí)在移動(dòng)開(kāi)發(fā)中關(guān)于視頻的一些基本概念——YUV與RGB的轉(zhuǎn)換介紹了相關(guān)轉(zhuǎn)換原理并蝗;而數(shù)據(jù)存儲(chǔ)方式則在文章FFmpeg開(kāi)發(fā)——基礎(chǔ)篇(一)之 AVFrame的data與linesize中有介紹到Planar和packed兩種存儲(chǔ)放在在AVFrame->data中的表現(xiàn)形式牲迫。了解不同的存儲(chǔ)方式在ffmpeg中的表現(xiàn)形式我們才能正確的保存數(shù)據(jù)。
保存文件
然后我們最后看看數(shù)據(jù)保存過(guò)程
void saveRGBImage(int index){
char fileName[32];
sprintf(fileName,"frame_%d.ppm",index); // 定義一下文件名frame_0.ppm
FILE *file = fopen(fileName,"wb"); // 打開(kāi)文件
if (file == nullptr){
return;
}
int width = streamContext.videoAVCodecCtx->width;
int height = streamContext.videoAVCodecCtx->height;
int line_size = streamContext.rgbFrame->linesize[0];
// 寫(xiě)入ppm文件的文件頭借卧,P6
fprintf(file, "P6\n%d %d\n255\n", width, height);
for (int i = 0; i < height; ++i) {
//相當(dāng)于一行一行的寫(xiě)入數(shù)據(jù)盹憎,(也可以計(jì)算數(shù)據(jù)總數(shù),一次性寫(xiě)入)
// line_size是一行的長(zhǎng)度铐刘,從第0行開(kāi)始陪每,每次寫(xiě)入一行長(zhǎng)度的數(shù)據(jù)
fwrite(streamContext.rgbFrame->data[0]+i*line_size,1,line_size,file);
}
fclose(file);
}
ppm格式的詳細(xì)信息見(jiàn)PPM文件格式詳解
log打印的函數(shù)
char* print_log(const char *tag,int ret){
const int max_buf = 1024;
char buf_log[2048] = "";
// av_strerror函數(shù)能夠根據(jù)當(dāng)前錯(cuò)誤碼給我們返回一些錯(cuò)誤信息
// 雖然非常粗糙,但是聊勝于無(wú)。
av_strerror(ret,buf_log,max_buf);
cout<< tag << " error: %d %s" << ret << buf_log << endl;
return "";
}
總結(jié)
把上述代碼合并之后檩禾,就是這個(gè)程序的完整代碼挂签。
我們可以從一個(gè)視頻文件中讀取數(shù)據(jù),解碼盼产,然后獲取其中第一幀YUV幀饵婆,轉(zhuǎn)換為RGB幀,最后把RGB幀保存為一張未壓縮的圖片文件戏售。
雖然我們對(duì)音頻的解碼做了初始化準(zhǔn)備配置侨核,本來(lái)想做些其他功能,后來(lái)感覺(jué)有點(diǎn)多余灌灾,demo中處理視頻就行了搓译,它和video的解碼過(guò)程是一致的。