目录
场景
在流媒体服务器中,有一个数据流向是传感器采集原始图像(yuv等数据),之后经过服务器编码出标准的h264或者h265的裸流,需求就是将这些裸流录制成视频存储。此处默认数据输入端函数为:
DataInput(FRAME_INFO *info, unsigned char *pData, int dwDataLen)
此函数会一直被调用,pData为h264或者h265的某一帧数据,包括一些SPS PPS I P B帧等(如果对h264和h265格式不是很理解的同学可以先去学一下,大致差不多,只要大概清楚如何找到SPS PPS I这些关键帧即可),dwDataLen为数据长度,info为这一帧数据的信息,里面包含了视频流的一些基本信息,包括宽、高、帧率等等。。在拥有这些数据的基础上我们将通过一个开始和结束指令进行对这个网络流进行存储。
ffmpeg封装接口调用
此处就不详细将具体的代码,把接口调用流程讲一下,包括中间有几个可能会遇到的坑或者我自己遇到的一些问题的分享。
接口流程
- avformat_alloc_output_context2
- avformat_new_stream
- av_dump_format
- avio_open
- avformat_write_header
- av_interleaved_write_frame
- av_write_trailer
以上六步骤是录像的六个关键步骤,1、申请输出封装文件的上下文AVFormatContext,填入你需要封装的文件名,接口内会自动识别你需要封装的文件后缀;2、创建视频流,每一个封装文件AVFormatContext内部都有AVStream要录像所以要new一个stream,此处只讲视频,所以只new一个视频即可,new完之后还要给他赋参数,如果是本地读取的文件转封装,那参数可直接从原有文件读取出来整体拷贝,但是我们现在是网络流,所以所有的视频流参数都要从头到尾根据帧信息手写;3、打印封装格式信息;4、打开输出流文件句柄;5、写入封装文件头;6、写入帧数据;7、结束后写入文件尾信息。
封装写入细节
1、何时开始记录数据
那就是要找到这个流的头,h264是SPS,h265是vps,是h264或者h265流我相信你有办法获取到,简单的判断方法就是h264找00 00 00 01 67 h265找00 00 00 01 40,找到之后记录标志位,将此刻开始的数据存起来,后面要用。
bool IsFirstFrame(unsigned char* p)
{
if (m_emVideoFormat == EVIDEO_ENCODE_H265)
{
return p[0] == 0 && p[1] == 0 && p[2] == 0 && p[3] == 1 && p[4] == 0x40;//VPS
}
else if (m_emVideoFormat == EVIDEO_ENCODE_H264)
{
return p[0] == 0 && p[1] == 0 && p[2] == 0 && p[3] == 1 && p[4] == 0x67;//SPS
}
return false;
}
2、何时开始初始化
前面收集数据之后什么时候开始初始化ffmpeg,执行下面的new呢avformat_alloc_output_context2,avformat_new_stream,就是在找到第一步的sps或者vps之后再次找到 I 帧时,就可以初始化了。
3、avformat_new_stream参数赋值
AVStream *outStream = avformat_new_stream(m_pOutFormatCtx, NULL);new出来之后,需要给outStream赋值,如下代码,最重要的两个地方:
1、h265的avi格式的存储时需要加上outStream->codecpar->codec_tag = MKTAG('H', '2', '6', '5');否则将不能正常录下avi;会报以下错误:
[rawvideo @ 000001e3acd89b40] Packet too small (38)
[rawvideo @ 000001e3acd89b40] Packet too small (11)
[rawvideo @ 000001e3acd89b40] Packet too small (44)
[rawvideo @ 000001e3acd89b40] Invalid buffer size, packet size 47523 < expected frame_size 24883200
2、outStream->codecpar->extradata一定要赋值,给的数据就是从h264SPS——> I 帧之前;h265VPS——> I 帧之前的数据,如果不给的话mp4和mkv的播放将不正常。mp4播放不出来,mkv写入头avformat_write_header返回invaliddata均是因为没写入extradata,,从firstframe(sps或者vps)到I帧之前的数据(不包括i)。
AVStream *outStream = avformat_new_stream(m_pOutFormatCtx, NULL);
if (m_emVideoFormat == EVIDEO_ENCODE_H264)
{
outStream->codecpar->codec_id = AV_CODEC_ID_H264;
}
else if (m_emVideoFormat == EVIDEO_ENCODE_H265)
{
outStream->codecpar->codec_id = AV_CODEC_ID_H265;
if (m_emRecordVideoFmt == RVFMT_AVI)
{
//仅仅avi H265时需要写入此参数
outStream->codecpar->codec_tag = MKTAG('H', '2', '6', '5');
}
} else
{
break;
}
outStream->codecpar->codec_type = AVMEDIA_TYPE_VIDEO;
outStream->codecpar->width = dwWidth;
outStream->codecpar->height = dwHeight;
outStream->id = m_pOutFormatCtx->nb_streams - 1;
outStream->codecpar->extradata = (u8*)malloc(m_qwSaveHeadSize);
memcpy(outStream->codecpar->extradata, m_chacheBuffer, m_qwSaveHeadSize);
outStream->codecpar->extradata_size = m_qwSaveHeadSize;
4、打开输出文件句柄并写入头
写入头有可能会报错,av_format_write_header 返回-22 dimensions not set 表示avstream参数宽高没设置,返回AVERROR_INVALIDDATA说明上面一步骤没写入extradata。具体的错误码可以在ffmpeg源码#include <libavutil/error.h>中查看。
av_dump_format(m_pOutFormatCtx, 0, m_szCurFile, 1);
int ret = -1;
if (!(outFmt->flags & AVFMT_NOFILE))
{
ret = avio_open(&m_pOutFormatCtx->pb, m_szCurFile, AVIO_FLAG_WRITE);
if (ret < 0)
{
TZTEK_CLASSLOG(LOG_MOD_RECORD, TZTEK_ERROR_LEV, "Error occurred when avio_open\n");
break;
}
}
ret = avformat_write_header(m_pOutFormatCtx, NULL);
5、写入帧
此处调用av_interleaved_write_frame写入帧需要搞明白两个地方
1、AVPacket内存使用机制,你可以定义一个指针new一个pkt,当然new之后就需要free;也可以使用局部变量再单独为pkt->data创建空间,此时只要av_packet_unref减少引用即可,里面的data和AVBufferRef 可以详细再去研究一下网上文档很多。此处随便给一个:
(69条消息) AVPacket详解_with_dream的博客-CSDN博客_avpacket
有一个需要注意的点就是帧数据拷贝的时候要 memcpy(pkt->data, pData, dwDataLen);深拷贝而不是直接给指针赋值,浅拷贝会导致多次free数据。
//取帧塞入进行封装
AVPacket pkt1, *pkt = &pkt1;//为pkt创建内存不包含data的内存
av_new_packet(pkt, dwDataLen);//为pkt->data创建内存
pkt->flags |= IsIFrame(pData) ? AV_PKT_FLAG_KEY : 0;
pkt->stream_index = 0;
memcpy(pkt->data, pData, dwDataLen);
pkt->size = dwDataLen;
//default time_base
pkt->pts = m_pOutFormatCtx->streams[0]->time_base.den/dwFps * frameNum;
pkt->pts += m_continuousOtherFrameNum;
pkt->dts = pkt->pts;
int ret = av_interleaved_write_frame(m_pOutFormatCtx, pkt);
if (ret < 0)
{
av_interleaved_write_frame\n");
return false;
}
av_packet_unref(pkt);//引用计数-1释放data内存
2、pts和dts以及timebase
此处也不多讲只是带过一些我自己的理解,首先此处我们没有b帧所以采用pts=dts原因不解释;其次timebase每一种封装格式都不同,所以如果是转封装的话可以从源封装格式中的pts直接通过函数转成现在的pts,但是网络流的话,在写AVStream的时候,这个timebase就自动给你写入了,代表每一种封装格式都有固定的timebase,所以
pts=m_pOutFormatCtx->streams[0]->time_base.den/fps * frameNum
pts是从0开始并且单调递增的,实际时间戳是 1/帧率 每帧,如帧率为25,那就是时间戳第一帧开始分别是:
0ms 40ms 80ms 120ms 。。。
那么pts记录的则是再乘以timebase的分母即为ffmpeg的时间戳,此时间戳写入pts即可,若time_base=1/600,则pts分别是:
0 24 48 72 .。。。
注意:若帧中有私有帧sei不需要渲染显示的,则需要跳过,不能计入pts,否则会导致录下来的视频长度不对。
6、关闭文件写入尾
在触发停止采集的指令之后需要调用av_write_trailer函数写入尾部信息,释放初始化的资源,保证录入的文件的完整性。注意前面的ffmpeg接口调用出错时不要调用这个函数,正常释放资源即可。
if (m_pOutFormatCtx != nullptr && m_ffmpegOccurredError == false)
{
av_write_trailer(m_pOutFormatCtx);
}
if (m_pOutFormatCtx && !(m_pOutFormatCtx->oformat->flags & AVFMT_NOFILE))
{
avio_close(m_pOutFormatCtx->pb);// for new : avio_close(m_pOc->pb);
}
if (m_pOutFormatCtx != nullptr)
{
avformat_free_context(m_pOutFormatCtx);
m_pOutFormatCtx = nullptr;
}