mp4文件目前已经成为了流媒体音视频行业的通用标准文件格式,它是基于mov格式基础上演变来的,特别适合多平台播放,录制一次,多个平台都可使用。但是,由于mp4格式相对比较复杂,直到mp4v2这个开源工程的出现,解决了这个问题。
通常,我们在使用mp4文件时,会遇到两个问题:如何从已有的mp4文件中抽取音视频数据帧;如何将音视频数据帧录制成mp4文件,并保持音视频同步。
上一篇文章已经使用mp4v2从mp4文件中抽取音视频数据帧(文章),本篇文章讲述如何将音视频数据帧(AAC+H264)保存成mp4文件,并保持音视频同步。
源码下载:
CSDN:https://download.csdn.net/download/haoyitech/10291438
源码说明:
开发工具:下载后,请用 VS2010 打开。
总体思路:从标准mp4文件,使用mp4v2提供的标准API,解析出音视频格式信息,视频需要获取关键的PPS和SPS,音频需要获取采样率、声道、扩展信息等等。将这些格式信息重组之后用来创建音视频轨道。
测试需要的mp4文件 => sample_save_mp4\bin\sample.mp4
mp4v2需要的头文件 => sample_save_mp4\libmp4v2
mp4v2静态调试库 => sample_save_mp4\libmp4v2\libmp4v2D.lib
mp4v2静态发行库 => sample_save_mp4\libmp4v2\libmp4v2.lib
浩一科技代码辅助库 => sample_save_mp4\common
注意:在vs2010工程配置当中,需要在预处理定义中加入 MP4V2_EXPORTS,否则,会在编译中出现链接失败的问题。
关键代码:(详见Csample_save_mp4Dlg)
bool Csample_save_mp4Dlg::BuildVideoTrack(string & inSPS, string & inPPS)
{
if( m_lpRecMP4 == NULL || inSPS.size() <= 0 || inPPS.size() <= 0 )
return false;
// 获取 width 和 height...
int nPicWidth = 0;
int nPicHeight = 0;
if( inSPS.size() > 0 ) {
CSPSReader _spsreader;
bs_t s = {0};
s.p = (uint8_t *)inSPS.c_str();
s.p_start = (uint8_t *)inSPS.c_str();
s.p_end = (uint8_t *)inSPS.c_str() + inSPS.size();
s.i_left = 8; // 这个是固定的,对齐长度...
_spsreader.Do_Read_SPS(&s, &nPicWidth, &nPicHeight);
}
// 正在创建视频轨道...
TRACE("=== 正在创建视频录制轨道,视频高:%lu,视频宽:%lu ===\n", nPicWidth, nPicHeight);
// 调用封装接口,直接创建视频轨道...
return m_lpRecMP4->CreateVideoTrack(m_strUTF8Rec.c_str(), VIDEO_TIME_SCALE, nPicWidth, nPicHeight, inSPS, inPPS);
}
bool LibMP4::CreateVideoTrack(const char* lpUTF8Name, int nVideoTimeScale, int width, int height, string & strSPS, string & strPPS)
{
if( strSPS.size() <= 0 || strPPS.size() <= 0 || lpUTF8Name == NULL )
return false;
// 如果句柄为空,则创建MP4对象...
if( m_hFileHandle == MP4_INVALID_FILE_HANDLE ) {
m_hFileHandle = MP4Create(lpUTF8Name);
}
// 再次判断文件句柄...
if( m_hFileHandle == MP4_INVALID_FILE_HANDLE )
return false;
// 设置TimeScale,时间刻度,通常为任意的固定数值...
BYTE * lpSPS = (BYTE*)strSPS.c_str();
uint32_t theTimeScale = nVideoTimeScale;
MP4SetTimeScale(m_hFileHandle, theTimeScale);
// 添加视频轨道, 不要用固定时间间隔,用每帧的时间间隔...
// 这里的是视频时间间隔,一定要用 MP4_INVALID_DURATION,计算两帧之间的时间差的方案...
// 这种方案可以满足各种信号来源的数据,用 fps 方式不精确...
m_videoID = MP4AddH264VideoTrack(m_hFileHandle,
theTimeScale,
MP4_INVALID_DURATION, //theTimeScale/fps,
width, height,
lpSPS[1],
lpSPS[2],
lpSPS[3],
3);
if( m_videoID == MP4_INVALID_TRACK_ID ) {
MP4Close(m_hFileHandle);
m_hFileHandle = MP4_INVALID_FILE_HANDLE;
return false;
}
// 设置视频level...
MP4SetVideoProfileLevel(m_hFileHandle, 0x7F);
// 设置SPS/PPS...
MP4AddH264SequenceParameterSet(m_hFileHandle, m_videoID, (BYTE*)strSPS.c_str(), strSPS.size());
MP4AddH264PictureParameterSet(m_hFileHandle, m_videoID, (BYTE*)strPPS.c_str(), strPPS.size());
m_nVideoTimeScale = nVideoTimeScale;
return true;
}
注意:视频时间间隔,一定要用 MP4_INVALID_DURATION,实际在写入单帧时间时需要计算两帧之间的时间差,只有这样写入mp4文件才会是同步的。
6、创建音频轨道:
bool Csample_save_mp4Dlg::BuildAudioTrack(int nAudioRate, string & inAES)
{
if( m_lpRecMP4 == NULL || nAudioRate <= 0 || inAES.size() <= 0 )
return false;
// 正在创建音频轨道...
TRACE("=== 正在创建音频录制轨道,采样率:%lu ===\n", nAudioRate);
// 调用封装接口,直接创建音频轨道...
return m_lpRecMP4->CreateAudioTrack(m_strUTF8Rec.c_str(), nAudioRate, inAES);
}
bool LibMP4::CreateAudioTrack(const char* lpUTF8Name, int nAudioSampleRate, string & strAES)
{
if( strAES.size() <= 0 || lpUTF8Name == NULL )
return false;
// 如果句柄为空,则创建MP4对象...
if( m_hFileHandle == MP4_INVALID_FILE_HANDLE ) {
m_hFileHandle = MP4Create(lpUTF8Name);
}
// 再次判断文件句柄...
if( m_hFileHandle == MP4_INVALID_FILE_HANDLE )
return false;
// 添加音频轨道,音频时间刻度固定为 1024,否则会出现问题...
// AAC 都是采用固定的刻度运转的...
m_audioID = MP4AddAudioTrack(m_hFileHandle, nAudioSampleRate, 1024, MP4_MPEG4_AUDIO_TYPE);
if( m_audioID == MP4_INVALID_TRACK_ID ) {
MP4Close(m_hFileHandle);
m_hFileHandle = MP4_INVALID_FILE_HANDLE;
return false;
}
// 设置音频 level 和 AES...
MP4SetAudioProfileLevel(m_hFileHandle, 0x02);//0xFF);
MP4SetTrackESConfiguration(m_hFileHandle, m_audioID, (BYTE*)strAES.c_str(), strAES.size());
return true;
}
注意:AAC音频都是统一的固定时间刻度1024。
7、数据帧抽取方式:
// 循环抽取音视频数据帧...
bool Csample_save_mp4Dlg::doMP4ReadFrame(MP4FileHandle inFile, MP4TrackId inVideoID, MP4TrackId inAudioID)
{
BOOL bAudioComplete = false;
BOOL bVideoComplete = false;
uint32_t dwOutVSendTime = 0;
uint32_t dwOutASendTime = 0;
uint32_t iVSampleInx = 1;
uint32_t iASampleInx = 1;
while( true ) {
// 读取一帧视频帧...
if( !bVideoComplete && !this->doMP4ReadOneFrame(inFile, inVideoID, iVSampleInx++, true, dwOutVSendTime) ) {
bVideoComplete = true;
continue;
}
// 读取一帧音频帧...
if( !bAudioComplete && !this->doMP4ReadOneFrame(inFile, inAudioID, iASampleInx++, false, dwOutASendTime) ) {
bAudioComplete = true;
continue;
}
// 如果音频和视频全部结束,退出循环...
if( bVideoComplete && bAudioComplete ) {
TRACE("=== MP4文件数据帧读取完毕 ===\n");
TRACE("=== 已读取视频帧:%lu ===\n", iVSampleInx);
TRACE("=== 已读取音频帧:%lu ===\n", iASampleInx);
break;
}
}
return true;
}
// 抽取一帧音频或视频数据帧...
bool Csample_save_mp4Dlg::doMP4ReadOneFrame(MP4FileHandle inFile, MP4TrackId tid, uint32_t sid, bool bIsVideo, uint32_t & outSendTime)
{
uint8_t * pSampleData = NULL; // 帧数据指针
uint32_t nSampleSize = 0; // 帧数据长度
MP4Timestamp nStartTime = 0; // 开始时间
MP4Duration nDuration = 0; // 持续时间
MP4Duration nOffset = 0; // 偏移时间
bool bIsKeyFrame = false; // 是否关键帧
uint32_t timescale = 0;
uint64_t msectime = 0;
// 读取轨道的采样率 和 当前帧的时间戳...
timescale = MP4GetTrackTimeScale( inFile, tid );
msectime = MP4GetSampleTime( inFile, tid, sid );
// 读取一帧数据帧,失败,直接返回...
if( false == MP4ReadSample(inFile, tid, sid, &pSampleData, &nSampleSize, &nStartTime, &nDuration, &nOffset, &bIsKeyFrame) )
return false;
// 计算当前读取数据帧的时间戳...
// 计算发送时间 => PTS => 刻度时间转换成毫秒...
msectime *= UINT64_C( 1000 );
msectime /= timescale;
// 计算开始时间 => DTS => 刻度时间转换成毫秒...
nStartTime *= UINT64_C( 1000 );
nStartTime /= timescale;
// 计算偏差时间 => CTTS => 刻度时间转换成毫秒...
nOffset *= UINT64_C( 1000 );
nOffset /= timescale;
// 返回发送时间(毫秒) => 已将刻度时间转换成了毫秒...
outSendTime = (uint32_t)msectime;
// 打印获取的音视频数据帧内容信息...
TRACE("[%s] duration = %I64d, offset = %I64d, KeyFrame = %d, SendTime = %lu, StartTime = %I64d, Size = %lu\n", bIsVideo ? "Video" : "Audio", nDuration, nOffset, bIsKeyFrame, outSendTime, nStartTime, nSampleSize);
// 直接对获取到音视频数据帧进行录像操作...
if( m_lpRecMP4 != NULL ) {
if( !m_lpRecMP4->WriteSample(bIsVideo, pSampleData, nSampleSize, outSendTime, nOffset, bIsKeyFrame) ) {
TRACE("=== 录制MP4数据帧失败! ===\n");
}
}
// 这里需要释放读取的缓冲区...
MP4Free(pSampleData);
pSampleData = NULL;
return true;
}
8、数据帧写入方式:
bool LibMP4::WriteSample(bool bIsVideo, BYTE * lpFrame, int nSize, uint32_t inTimeStamp, uint32_t inRenderOffset, bool bIsKeyFrame)
{
if( m_hFileHandle == MP4_INVALID_FILE_HANDLE || lpFrame == NULL || nSize <= 0 )
return false;
if( bIsVideo && m_videoID == MP4_INVALID_TRACK_ID )
return false;
if( !bIsVideo && m_audioID == MP4_INVALID_TRACK_ID )
return false;
// 从文件中获取已写入秒数...
m_dwWriteSec = this->GetDurationSecond();
// 第一帧数据必须是视频关键帧...
if( m_bFirstFrame ) {
// 如果是音频,将数据帧缓存起来...
// 注意:这里不能直接存音频,如果有视频,则会出现音视频不同步,因此,需要先缓存,如果确实没有视频再存盘。
if( !bIsVideo ) {
RTMPFrame theFrame;
theFrame.m_bKeyFrame = bIsKeyFrame;
theFrame.m_strData.assign((char*)lpFrame, nSize);
theFrame.m_nTimeStamp = inTimeStamp;
theFrame.m_nRenderOffset = inRenderOffset;
m_deqAudio.push_back(theFrame);
// 如果缓存了500帧之后,还没有视频,则认为只有音频,直接存盘...
if( m_deqAudio.size() >= 500 ) {
// 设置非第一帧标志...
m_bFirstFrame = false;
KH_DeqFrame::iterator itor;
// 开始存储缓存的音频数据帧,使用固定的帧时间间隔...
for(itor = m_deqAudio.begin(); itor != m_deqAudio.end(); ++itor) {
RTMPFrame & myFrame = (*itor);
if( itor == m_deqAudio.begin() ) {
m_dwFirstStamp = myFrame.m_nTimeStamp;
}
MP4WriteSample(m_hFileHandle, m_audioID, (BYTE*)myFrame.m_strData.c_str(), myFrame.m_strData.size(), MP4_INVALID_DURATION, 0, myFrame.m_bKeyFrame);
m_dwWriteSize += myFrame.m_strData.size();
//m_dwWriteRecMS = myFrame.m_nTimeStamp - m_dwFirstStamp;
}
// 存盘之后,释放音频缓存...
TRACE("[No Video] Audio-Deque = %d\n", m_deqAudio.size());
m_deqAudio.clear();
}
return true;
}
// 视频非关键帧,直接丢弃...
if( !bIsKeyFrame )
return true;
// 设置非第一帧标志...
m_bFirstFrame = false;
m_dwFirstStamp = inTimeStamp;
// 之前缓存的音频数据,直接丢弃,否则会出现音视频不同步...
TRACE("[Has Video] Audio-Deque = %d\n", m_deqAudio.size());
m_deqAudio.clear();
}
// 准备一些共同的数据...
MP4TrackId theTrackID = bIsVideo ? m_videoID : m_audioID;
bool bWriteFlag = false;
if( bIsVideo ) {
// 判断是否需要保存第一帧...
if( m_VLastFrame.m_strData.size() <= 0 ) {
m_VLastFrame.m_bKeyFrame = bIsKeyFrame;
m_VLastFrame.m_strData.assign((char*)lpFrame, nSize);
m_VLastFrame.m_nTimeStamp = inTimeStamp;
m_VLastFrame.m_nRenderOffset = inRenderOffset;
return true;
}
// 准备需要的数据内容...
int uFrameMS = inTimeStamp - m_VLastFrame.m_nTimeStamp;
MP4Duration uDuration = uFrameMS * m_nVideoTimeScale / 1000;
MP4Duration uOffset = m_VLastFrame.m_nRenderOffset * m_nVideoTimeScale / 1000;
if( uFrameMS <= 0 ) uDuration = 1;
//TRACE("Video-Duration = %I64d, offset = %I64d, size = %lu, keyFrame = %d\n", uDuration, uOffset, m_VLastFrame.m_strData.size(), m_VLastFrame.m_bKeyFrame);
// 调用写入帧的接口函数,视频需要计算帧间隔...
bWriteFlag = MP4WriteSample(m_hFileHandle, theTrackID, (BYTE*)m_VLastFrame.m_strData.c_str(), m_VLastFrame.m_strData.size(), uDuration, uOffset, m_VLastFrame.m_bKeyFrame);
// 计算写盘量和总时间...
m_dwWriteSize += m_VLastFrame.m_strData.size();
//m_dwWriteRecMS = inTimeStamp - m_dwFirstStamp;
// 保存这一帧的数据,下次使用...
m_VLastFrame.m_bKeyFrame = bIsKeyFrame;
m_VLastFrame.m_strData.assign((char*)lpFrame, nSize);
m_VLastFrame.m_nTimeStamp = inTimeStamp;
m_VLastFrame.m_nRenderOffset = inRenderOffset;
} else {
// 音频数据,直接调用接口写盘,音频不用计算帧间隔时间,采用固定的帧间隔...
bWriteFlag = MP4WriteSample(m_hFileHandle, theTrackID, lpFrame, nSize, MP4_INVALID_DURATION, 0, bIsKeyFrame);
// 计算写盘量和总时间...
m_dwWriteSize += nSize;
//m_dwWriteRecMS = ((inTimeStamp >= m_dwFirstStamp) ? (inTimeStamp - m_dwFirstStamp) : 0);
}
// 返回存盘结果...
return bWriteFlag;
}
注意:第一帧优先写入视频数据帧,不要直接存音频,如果有视频,则会出现音视频不同步,因此,需要先缓存,如果确实没有视频再存盘。
更多信息:
************************************************************************************************************************