【FFMpeg視訊開發與應用基礎】五、呼叫FFMpeg SDK封裝音訊和視訊為視訊檔案
《FFMpeg視訊開發與應用基礎——使用FFMpeg工具與SDK》視訊教程已經在“CSDN學院”上線,視訊中包含了從0開始逐行程式碼實現FFMpeg視訊開發的過程,歡迎觀看!連結地址:FFMpeg視訊開發與應用基礎——使用FFMpeg工具與SDK
音訊和視訊的封裝過程為解封裝的逆過程,即將獨立的音訊資料和視訊資料按照容器檔案所規定的格式封裝為一個完整的視訊檔案的過程。對於大多數消費者來說,視訊封裝的容器是大家最為熟悉的,因為它直接體現在了我們使用的音視訊副檔名上,比較常見的有mp4、avi、mkv、flv等等。
在進行音訊和視訊封裝時,我們將實際操作一系列音訊或視訊流資料的生成和寫入。所謂流,指的是一系列相關聯的包的集合,這些包一般同屬於一組按照時間先後順序進行解碼/渲染等處理的資料。在一個比較典型的視訊檔案中,我們通常至少會包含一個視訊流和一個音訊流。
在FFMpeg中,表示音訊流或視訊流有一個專門的結構,即”AVStream”實現。該結構主要對音訊和視訊資料的處理進行管理和控制。另外,”AVFormatContext”結構也是必須的,因為它包含了控制輸入和輸出的資訊。
音訊和視訊資料封裝為視訊檔案的主要步驟為:
1. 相關資料結構的準備
首先,根據輸出檔案的格式獲取AVFormatContext結構,獲取AVFormatContext結構使用函式avformat_alloc_output_context2實現。該函式的宣告為:
int avformat_alloc_output_context2(AVFormatContext **ctx, AVOutputFormat *oformat, const char *format_name, const char *filename);
其中:
- ctx:輸出到AVFormatContext結構的指標,如果函式失敗則返回給該指標為NULL;
- oformat:指定輸出的AVOutputFormat型別,如果設為NULL則使用format_name和filename生成;
- format_name:輸出格式的名稱,如果設為NULL則使用filename預設格式;
- filename:目標檔名,如果不使用,可以設為NULL;
分配AVFormatContext成功後,我們需要新增希望封裝的資料流,一般是一路視訊流+一路音訊流(可能還有其他音訊流和字幕流等)。新增流首先需要查詢流所包含的媒體的編碼器,這需要傳入codec_id後使用avcodec_find_encoder函式實現,將查詢到的編碼器儲存在AVCodec指標中。
之後,呼叫avformat_new_stream函式向AVFormatContext結構中所代表的媒體檔案中新增資料流。該函式的宣告如下:
AVStream *avformat_new_stream(AVFormatContext *s, const AVCodec *c);
其中各個引數的含義:
- s:AVFormatContext結構,表示要封裝生成的視訊檔案;
- c:上一步根據codec_id產生的編碼器指標;
- 返回值:指向生成的stream物件的指標;如果失敗則返回NULL指標。
此時,一個新的AVStream便已經加入到輸出檔案中,下面就可以設定stream的id和codec等引數。AVStream::codec是一個AVCodecContext型別的指標變數成員,設定其中的值可以對編碼進行配置。整個新增stream的例子如:
/* Add an output stream. */
static void add_stream(OutputStream *ost, AVFormatContext *oc, AVCodec **codec, enum AVCodecID codec_id)
{
AVCodecContext *c;
int i;
/* find the encoder */
*codec = avcodec_find_encoder(codec_id);
if (!(*codec))
{
fprintf(stderr, "Could not find encoder for '%s'\n", avcodec_get_name(codec_id));
exit(1);
}
ost->st = avformat_new_stream(oc, *codec);
if (!ost->st)
{
fprintf(stderr, "Could not allocate stream\n");
exit(1);
}
ost->st->id = oc->nb_streams - 1;
c = ost->st->codec;
switch ((*codec)->type)
{
case AVMEDIA_TYPE_AUDIO:
c->sample_fmt = (*codec)->sample_fmts ? (*codec)->sample_fmts[0] : AV_SAMPLE_FMT_FLTP;
c->bit_rate = 64000;
c->sample_rate = 44100;
if ((*codec)->supported_samplerates)
{
c->sample_rate = (*codec)->supported_samplerates[0];
for (i = 0; (*codec)->supported_samplerates[i]; i++)
{
if ((*codec)->supported_samplerates[i] == 44100)
c->sample_rate = 44100;
}
}
c->channels = av_get_channel_layout_nb_channels(c->channel_layout);
c->channel_layout = AV_CH_LAYOUT_STEREO;
if ((*codec)->channel_layouts)
{
c->channel_layout = (*codec)->channel_layouts[0];
for (i = 0; (*codec)->channel_layouts[i]; i++)
{
if ((*codec)->channel_layouts[i] == AV_CH_LAYOUT_STEREO)
c->channel_layout = AV_CH_LAYOUT_STEREO;
}
}
c->channels = av_get_channel_layout_nb_channels(c->channel_layout);
{
AVRational r = { 1, c->sample_rate };
ost->st->time_base = r;
}
break;
case AVMEDIA_TYPE_VIDEO:
c->codec_id = codec_id;
c->bit_rate = 400000;
/* Resolution must be a multiple of two. */
c->width = 352;
c->height = 288;
/* timebase: This is the fundamental unit of time (in seconds) in terms
* of which frame timestamps are represented. For fixed-fps content,
* timebase should be 1/framerate and timestamp increments should be
* identical to 1. */
{
AVRational r = { 1, STREAM_FRAME_RATE };
ost->st->time_base = r;
}
c->time_base = ost->st->time_base;
c->gop_size = 12; /* emit one intra frame every twelve frames at most */
c->pix_fmt = AV_PIX_FMT_YUV420P;
if (c->codec_id == AV_CODEC_ID_MPEG2VIDEO)
{
/* just for testing, we also add B frames */
c->max_b_frames = 2;
}
if (c->codec_id == AV_CODEC_ID_MPEG1VIDEO)
{
/* Needed to avoid using macroblocks in which some coeffs overflow.
* This does not happen with normal video, it just happens here as
* the motion of the chroma plane does not match the luma plane. */
c->mb_decision = 2;
}
break;
default:
break;
}
/* Some formats want stream headers to be separate. */
if (oc->oformat->flags & AVFMT_GLOBALHEADER)
c->flags |= AV_CODEC_FLAG_GLOBAL_HEADER;
}
2. 開啟音視訊
開啟音視訊主要涉及到開啟編碼音視訊資料所需要的編碼器,以及分配相應的frame物件。其中開啟編碼器如之前一樣,呼叫avcodec_open函式,分配frame物件呼叫av_frame_alloc以及av_frame_get_buffer。分配frame物件的實現如下:
static AVFrame *alloc_picture(enum AVPixelFormat pix_fmt, int width, int height)
{
AVFrame *picture;
int ret;
picture = av_frame_alloc();
if (!picture)
{
return NULL;
}
picture->format = pix_fmt;
picture->width = width;
picture->height = height;
/* allocate the buffers for the frame data */
ret = av_frame_get_buffer(picture, 32);
if (ret < 0)
{
fprintf(stderr, "Could not allocate frame data.\n");
exit(1);
}
return picture;
}
而上層開啟音視訊部分的實現如:
void Open_video(AVFormatContext *oc, AVCodec *codec, OutputStream *ost, AVDictionary *opt_arg, IOParam &io)
{
int ret;
AVCodecContext *c = ost->st->codec;
AVDictionary *opt = NULL;
av_dict_copy(&opt, opt_arg, 0);
/* open the codec */
ret = avcodec_open2(c, codec, &opt);
av_dict_free(&opt);
if (ret < 0)
{
fprintf(stderr, "Could not open video codec: %d\n", ret);
exit(1);
}
/* allocate and init a re-usable frame */
ost->frame = alloc_picture(c->pix_fmt, c->width, c->height);
if (!ost->frame)
{
fprintf(stderr, "Could not allocate video frame\n");
exit(1);
}
/* If the output format is not YUV420P, then a temporary YUV420P
* picture is needed too. It is then converted to the required
* output format. */
ost->tmp_frame = NULL;
if (c->pix_fmt != AV_PIX_FMT_YUV420P)
{
ost->tmp_frame = alloc_picture(AV_PIX_FMT_YUV420P, c->width, c->height);
if (!ost->tmp_frame)
{
fprintf(stderr, "Could not allocate temporary picture\n");
exit(1);
}
}
//開啟輸入YUV檔案
fopen_s(&g_inputYUVFile, io.input_file_name, "rb+");
if (g_inputYUVFile == NULL)
{
fprintf(stderr, "Open input yuv file failed.\n");
exit(1);
}
}
3. 開啟輸出檔案並寫入檔案頭
如果判斷需要寫出檔案的話,則需要開啟輸出檔案。在這裡,我們可以不再定義輸出檔案指標,並使用fopen開啟,而是直接使用FFMpeg的API——avio_open來實現輸出檔案的開啟功能。該函式的宣告如下:
int avio_open(AVIOContext **s, const char *url, int flags);
該函式的輸入引數為:
- s:輸出引數,返回一個AVIOContext;如果開啟失敗則返回NULL;
- url:輸出的url或者檔案的完整路徑;
- flags:控制檔案開啟方式,如讀方式、寫方式和讀寫方式;
實際的程式碼實現方式如下:
/* open the output file, if needed */
if (!(fmt->flags & AVFMT_NOFILE))
{
ret = avio_open(&oc->pb, io.output_file_name, AVIO_FLAG_WRITE);
if (ret < 0)
{
fprintf(stderr, "Could not open '%s': %d\n", io.output_file_name, ret);
return 1;
}
}
寫入檔案頭操作是生成視訊檔案中極為重要的一步,而實現過程卻非常簡單,只需要通過函式avformat_write_header即可,該函式的宣告為:
int avformat_write_header(AVFormatContext *s, AVDictionary **options);
其輸入引數實際上重要的只有第一個,即標記輸出檔案的控制代碼物件指標;options用於儲存無法識別的設定項,可以傳入一個空指標。其返回值表示寫檔案頭成功與否,成功則返回0,失敗則返回負的錯誤碼。
實現方式如:
/* Write the stream header, if any. */
ret = avformat_write_header(oc, &opt);
if (ret < 0)
{
fprintf(stderr, "Error occurred when opening output file: %d\n",ret);
return 1;
}
4. 編碼和封裝迴圈
以視訊流為例。編解碼迴圈的過程實際上可以封裝在一個函式Write_video_frame中。該函式從邏輯上可以分為3個部分:獲取原始視訊訊號、視訊編碼、寫入輸出檔案。
(1) 讀取原始視訊資料
這一部分主要實現根據時長判斷是否需要繼續進行處理、讀取視訊到AVFrame和設定pts。其中時長判斷部分根據pts和AVCodecContext的time_base判斷。實現如下:
AVCodecContext *c = ost->st->codec;
/* check if we want to generate more frames */
{
AVRational r = { 1, 1 };
if (av_compare_ts(ost->next_pts, ost->st->codec->time_base, STREAM_DURATION, r) >= 0)
{
return NULL;
}
}
讀取視訊到AVFrame我們定義一個fill_yuv_image函式實現:
static void fill_yuv_image(AVFrame *pict, int frame_index, int width, int height)
{
int x, y, i, ret;
/* when we pass a frame to the encoder, it may keep a reference to it
* internally;
* make sure we do not overwrite it here
*/
ret = av_frame_make_writable(pict);
if (ret < 0)
{
exit(1);
}
i = frame_index;
/* Y */
for (y = 0; y < height; y++)
{
ret = fread_s(&pict->data[0][y * pict->linesize[0]], pict->linesize[0], 1, width, g_inputYUVFile);
if (ret != width)
{
printf("Error: Read Y data error.\n");
exit(1);
}
}
/* U */
for (y = 0; y < height / 2; y++)
{
ret = fread_s(&pict->data[1][y * pict->linesize[1]], pict->linesize[1], 1, width / 2, g_inputYUVFile);
if (ret != width / 2)
{
printf("Error: Read U data error.\n");
exit(1);
}
}
/* V */
for (y = 0; y < height / 2; y++)
{
ret = fread_s(&pict->data[2][y * pict->linesize[2]], pict->linesize[2], 1, width / 2, g_inputYUVFile);
if (ret != width / 2)
{
printf("Error: Read V data error.\n");
exit(1);
}
}
}
然後進行pts的設定,很簡單,就是上一個frame的pts遞增1:
ost->frame->pts = ost->next_pts++;
整個獲取視訊訊號的實現如:
static AVFrame *get_video_frame(OutputStream *ost)
{
AVCodecContext *c = ost->st->codec;
/* check if we want to generate more frames */
{
AVRational r = { 1, 1 };
if (av_compare_ts(ost->next_pts, ost->st->codec->time_base, STREAM_DURATION, r) >= 0)
{
return NULL;
}
}
fill_yuv_image(ost->frame, ost->next_pts, c->width, c->height);
ost->frame->pts = ost->next_pts++;
return ost->frame;
}
(2) 視訊編碼
視訊編碼的方式同之前幾次使用的方式相同,即呼叫avcodec_encode_video2,實現方法如:
/* encode the image */
ret = avcodec_encode_video2(c, &pkt, frame, &got_packet);
if (ret < 0)
{
fprintf(stderr, "Error encoding video frame: %d\n", ret);
exit(1);
}
(3) 寫出編碼後的資料到輸出視訊檔案
這部分的實現過程很簡單,方式如下:
/* rescale output packet timestamp values from codec to stream timebase */
av_packet_rescale_ts(pkt, *time_base, st->time_base);
pkt->stream_index = st->index;
/* Write the compressed frame to the media file. */
// log_packet(fmt_ctx, pkt);
return av_interleaved_write_frame(fmt_ctx, pkt);
av_packet_rescale_ts函式的作用為不同time_base度量之間的轉換,在這裡起到的作用是將AVCodecContext的time_base轉換為AVStream中的time_base。av_interleaved_write_frame函式的作用是寫出AVPacket到輸出檔案。該函式的宣告為:
int av_interleaved_write_frame(AVFormatContext *s, AVPacket *pkt);
該函式的宣告也很簡單,第一個引數是之前開啟並寫入檔案頭的檔案控制代碼,第二個引數是寫入檔案的packet。返回值為錯誤碼,成功返回0,失敗則返回一個負值。
Write_video_frame函式的整體實現如:
int Write_video_frame(AVFormatContext *oc, OutputStream *ost)
{
int ret;
AVCodecContext *c;
AVFrame *frame;
int got_packet = 0;
AVPacket pkt = { 0 };
c = ost->st->codec;
frame = get_video_frame(ost);
av_init_packet(&pkt);
/* encode the image */
ret = avcodec_encode_video2(c, &pkt, frame, &got_packet);
if (ret < 0)
{
fprintf(stderr, "Error encoding video frame: %d\n", ret);
exit(1);
}
if (got_packet)
{
ret = write_frame(oc, &c->time_base, ost->st, &pkt);
}
else
{
ret = 0;
}
if (ret < 0)
{
fprintf(stderr, "Error while writing video frame: %d\n", ret);
exit(1);
}
return (frame || got_packet) ? 0 : 1;
}
以上是寫入一幀視訊資料的方法,寫入音訊的方法於此大同小異。整個編碼封裝的迴圈上層實現如:
while (encode_video || encode_audio)
{
/* select the stream to encode */
if (encode_video && (!encode_audio || av_compare_ts(video_st.next_pts, video_st.st->codec->time_base, audio_st.next_pts, audio_st.st->codec->time_base) <= 0))
{
encode_video = !Write_video_frame(oc, &video_st);
if (encode_video)
{
printf("Write %d video frame.\n", videoFrameIdx++);
}
else
{
printf("Video ended, exit.\n");
}
}
else
{
encode_audio = !Write_audio_frame(oc, &audio_st);
if (encode_audio)
{
printf("Write %d audio frame.\n", audioFrameIdx++);
}
else
{
printf("Audio ended, exit.\n");
}
}
}
5. 寫入檔案尾,並進行收尾工作
寫入檔案尾的資料同寫檔案頭一樣簡單,只需要呼叫函式av_write_trailer即可實現:
int av_write_trailer(AVFormatContext *s);
該函式只有一個引數即視訊檔案的控制代碼,當返回值為0時表示函式執行成功。
整個流程的收尾工作包括關閉檔案中的資料流、關閉輸出檔案和釋放AVCodecContext物件。其中關閉資料流的實現方式如:
void Close_stream(AVFormatContext *oc, OutputStream *ost)
{
avcodec_close(ost->st->codec);
av_frame_free(&ost->frame);
av_frame_free(&ost->tmp_frame);
sws_freeContext(ost->sws_ctx);
swr_free(&ost->swr_ctx);
}
關閉輸出檔案和釋放AVCodecContext物件:
if (!(fmt->flags & AVFMT_NOFILE))
/* Close the output file. */
avio_closep(&oc->pb);
/* free the stream */
avformat_free_context(oc);
至此,整個處理流程便結束了。正確設定輸入的YUV檔案就可以獲取封裝好的音視訊檔案。