MP4文件简介
一、MP4文件格式:
mp4是由很多box组成的,每个box包含header和data,其中data可以是数据,也可以是别的box。
其中主要的box有:ftypbox、moovbox、mdatbox等。
>>mvhd中记录了创建时间、修改时间、时间度量标尺、可播放时长等信息。
>>一般mp4中都包含两个track,一个audio track,一个video track。track里面包含了mp4中各种信息表,如: stts/ctts/stss/等。
二、 stts/ctts/stsc/stsz等表格的解析
1. stts: Time-To-Sample Atoms
Time-To-Sample Atoms,存储了媒体sample的时长信息,提供了时间和相关sample之间的映射关系。该atom包含了一个表,关于time和sample号之间的索引关系。表的每个entry给出了具有相同时间间隔的连续的sample的个数和这些sample的时间间隔值。将这些时间间隔相加在一起,就可以得到一个完整的time与sample之间的映射。将所有的时间间隔相加在一起,就可以得到该track的时间总长。
例如上表表示,有4个sample的duration为3,2个sample的duration为1, 3个sample的duration为2……注意,这里的duration并不是真实时间,是由时间戳和timescale计算得到的值。
具体stts的写入过程如下:
void MPEG4Writer::Track::addOneSttsTableEntry(
size_t sampleCount, int32_t duration) {
if (duration == 0) {
ALOGW("0-duration samples found: %zu", sampleCount);
}
mSttsTableEntries->add(htonl(sampleCount));
mSttsTableEntries->add(htonl(duration));
}
void MPEG4Writer::Track::writeSttsBox() {
mOwner->beginBox("stts");
mOwner->writeInt32(0); // version=0, flags=0
if (mMinCttsOffsetTicks == mMaxCttsOffsetTicks) {
// For non-vdeio tracks or video tracks without ctts table,
// adjust duration of first sample for tracks to account for
// first sample not starting at the media start time.
// TODO: consider signaling this using some offset
// as this is not quite correct.
uint32_t duration;
CHECK(mSttsTableEntries->get(duration, 1));
duration = htonl(duration); // Back to host byte order
mSttsTableEntries->set(htonl(duration + getStartTimeOffsetScaledTime()), 1);
}
mSttsTableEntries->write(mOwner);
mOwner->endBox(); // stts
}
所以,当mMinCttsOffsetTicks == mMaxCttsOffsetTicks (无B帧)的情况下,第一帧的duration需要加上starttimeoffsetScaledTime。其中starttimeoffsetScaledTime是当前track的startime减去所有track中最小的startime。
2. ctts: Composition Offset Atom
ctts:Composition Offset Atom。每一个视频sample都有一个解码时间和一个显示时间。在有B帧的情况下解码时间和显示时间不一致,这时候就需要通过ctts去做调整。
如上表,其中DT为解码时间,CT为显示时间,composition offset为ctts表中保存的每帧的偏移量。ctts[n] = CT[n] - DT[n]。
ctts中数据的保存方法如下:
void MPEG4Writer::Track::addOneCttsTableEntry(
size_t sampleCount, int32_t duration) {
if (!mIsVideo) {
return;
}
mCttsTableEntries->add(htonl(sampleCount));
mCttsTableEntries->add(htonl(duration));
}
void MPEG4Writer::Track::writeCttsBox() {
// There is no B frame at all
if (mMinCttsOffsetTicks == mMaxCttsOffsetTicks) {
return;
}
// Do not write ctts box when there is no need to have it.
if (mCttsTableEntries->count() == 0) {
return;
}
ALOGV("ctts box has %d entries with range [%" PRId64 ", %" PRId64 "]",
mCttsTableEntries->count(), mMinCttsOffsetTicks, mMaxCttsOffsetTicks);
mOwner->beginBox("ctts");
mOwner->writeInt32(0); // version=0, flags=0
int64_t deltaTimeUs = kMaxCttsOffsetTimeUs -
mOwner->getStartTimeOffsetTimeUs(mStartTimestampUs);
int64_t delta = (deltaTimeUs * mTimeScale + 500000LL) / 1000000LL;
mCttsTableEntries->adjustEntries([delta](size_t /* ix */, uint32_t (&value)[2]) {
// entries are pairs; adjust only ctts
uint32_t duration = htonl(value[1]); // back to host byte order
// Prevent overflow and underflow
if (delta > duration) {
duration = 0;
} else if (delta < 0 && UINT32_MAX + delta < duration) {
duration = UINT32_MAX;
} else {
duration -= delta;
}
value[1] = htonl(duration);
});
mCttsTableEntries->write(mOwner);
mOwner->endBox(); // ctts
}
3. stsc: Sample-To-Chunk Atom。
为了优化数据访问,通常把sample封装到chunk中,一个chunk可能会包含一个或者多个sample。每个chunk会有不同的size,每个chunk中的sample也会有不同的size。stsc table提供了从sample到chunk的一个映射,每个table entry可能包含一个或者多个chunk。Table entry包含的内容包括第一个chunk号、每个chunk包含的sample的个数以及sample的描述ID:
如上表表示,第1、2个chunk包含的sample个数都是3个,sample的ID都是23,第3、4个chunk包含的sample个数都是1个,sample的ID都是23,第5个到最后一个chunk包含的sample个数都是1个,sample的ID都是24.
stsc中数据的保存方法如下:
void MPEG4Writer::Track::addOneStscTableEntry(
size_t chunkId, size_t sampleId) {
mStscTableEntries->add(htonl(chunkId));
mStscTableEntries->add(htonl(sampleId));
mStscTableEntries->add(htonl(1));
}
void MPEG4Writer::Track::writeStscBox() {
mOwner->beginBox("stsc");
mOwner->writeInt32(0); // version=0, flags=0
mStscTableEntries->write(mOwner);
mOwner->endBox(); // stsc
}
4.stsz: Sample Size Atom
stsz表中存储了每个sample的size。
如上表表示,1-5个sample的size都是"size"。
stsc中数据的保存方法如下:
在MPEG4Writer::Track::threadEntry()中会调用mStszTableEntries->add(htonl(sampleSize));把每个sample的size写入mStszTableEntries。
void MPEG4Writer::Track::writeStszBox() {
mOwner->beginBox("stsz");
mOwner->writeInt32(0); // version=0, flags=0
mOwner->writeInt32(0);
mStszTableEntries->write(mOwner);
mOwner->endBox(); // stsz
}
5.stco/co64: Chunk Offset Atom
stco/co64中存储了每个chunk在文件中的位置。stco表示use32BitFileOffset,co64表示use64BitFileOffset。
如上表表示,1-5个chunk在文件中的位置"offset"。
stco中数据的保存方法如下:
在MPEG4Writer::Track::threadEntry()中会调用addChunkOffset
MPEG4Writer::Track::addChunkOffset中调用mStcoTableEntries->add(htonl(value))把每个chunk的offset写入mStcoTableEntries。
void MPEG4Writer::Track::writeStcoBox(bool use32BitOffset) {
mOwner->beginBox(use32BitOffset? "stco": "co64");
mOwner->writeInt32(0); // version=0, flags=0
if (use32BitOffset) {
mStcoTableEntries->write(mOwner);
} else {
mCo64TableEntries->write(mOwner);
}
mOwner->endBox(); // stco or co64
}
ps: 每个chunk的大小受音视频交错时间的影响,音视频交错时间越长,chunk的size越大。音视频交错时间在MPEG4Writer.cpp中用mInterleaveDurationUs表示,初始化为1s,如果APP调用的是mediarecorder也可通过(key == "interleave-duration-us")去设置。
6. stss: Sync Sample Atom
标记了每个关键帧的sample。如果该表不存在,就表示媒体流每个sample都是关键帧。
如上表表示第1个关键帧是第1帧,第2个关键帧是第31帧,第3个关键帧是第61帧,第4个关键帧是第91帧……
stss中数据的保存方法如下:
void MPEG4Writer::Track::addOneStssTableEntry(size_t sampleId) {
mStssTableEntries->add(htonl(sampleId));
}
void MPEG4Writer::Track::writeStssBox() {
mOwner->beginBox("stss");
mOwner->writeInt32(0); // version=0, flags=0
mStssTableEntries->write(mOwner);
mOwner->endBox(); // stss
}
三、 具体应用
step1: 具体播放时,比如用户需要seek到某一位置,播放器首先可以根据进度条得到用户seek到的时间seektime。
step2: 利用stts表,根据seektime找到其对应的seeksample
step3: 利用stss表,查看seeksample是否为关键帧,如果是则继续,如果不是则按照播放器规定的方法(查找最邻近关键帧、查找前面一个关键帧、查找后面一个关键帧)更新seeksample。
step4: 利用stsc表,根据通过seeksample找到对应的seekchunk和其在chunk中是第几个sample
step5: 利用stsz表,计算seeksample在其所在chunk中的地址偏移量sampleindex
step6: 利用stco表,查找seekchunk在文件中的位置chunkindex
step7: chunkindex + sampleindex 就是用户seek到的帧在文件中的位置。
step8: 取出数据去解码。
step9: 如果文件存在B帧需要根据ctts去调整pts,否则pts=dts。
step10: rennder
ffmpeg解析mp4文件
命令:
ffprobe -unit -show_frames -select_streams V -i input.mp4 -print_format xml > output.xml
部分解析结果:
部分参数解析:
pkt_dts:
pkt_dts[1] = 0;
pkt_dts[n+1] = pkt_dts[n] + stts[n];
pkt_dts_time:
pkt_dts_time[n] = pkt_dts[n] / timescale;
pkt_pts:
pkt_pts[n] = pkt_dts[n]; 无B帧的情况
pkt_pts[n] = pkt_dts[n] + ctts[n]; 有B帧的情况
pkt_pts_time:
pkt_pts_time[n] = pkt_pts[n] / timescale;
实际问题分析
起因:
视频录制帧率不达标
720P@30fps |
720P@60fps |
1080P@30fps |
1080P@60fps |
4k@30fps |
4k@60fps |
|
EIS on |
24.5fps |
55.3fps |
25.3fps |
54.6fps |
25.8fps |
52.7fps |
EIS off |
29.9fps |
58.6fps |
29.9fps |
59.3fps |
29.2fps |
58.4fps |
原因分析:
加入视频防抖功能后,视频数据需要经过好几个pipline的处理才送给encoder,所以视频数据的StartTime较大,一般为200ms。音频由于数据量少且处理简单,所以音频数据的StartTime较小,一般为30ms。
在MPEG4Writer中会用stts表格记录每帧的duration,第一帧的duration为第二帧timestamp减去第一帧timestamp再加上mStartTimestampUs。其中mStartTimestampUs是当前track的StartTime减去所有track中最小的StartTime。因此,stts中记录的视频第一帧的duration一般为230ms。
ffmpeg解析视频的frame rate,是根据stts中视频帧的个数之和除以stts中视频帧的duration之和。由于视频第一帧的duration为230ms,所以计算的frame rate就会变小。
解决办法:
基于以上分析我们研究了两个解决方案:
a.在MediaRecorder中增加startoffset
这个方案就是在录像开始的时候扔掉startoffset时长的数据,保证音视频第一帧有效数据的时间戳相差不大。
b. 让MPEG4Writer中stts第一帧的duration为实际duration(不加mStartTimestampUs),并在mpeg4中增加kWaitDuration。
这个方案首先设置第一帧的duration,从而改变ffmpeg解析出的视频总时长。这个改动可以提高计算的帧率,但是音视频的时长差会变大,视频播放结束后,音频还会继续
播放。所以需要再增加kWaitDuration,在视频录制结束时,让video继续录制kWaitDuration时间后再结束。
a方案的优点,音视频开始时间戳对齐更准。缺点,录像开始需要丢掉部分数据。
b方案的优点,开始时候不用丢掉很多数据。缺点,audio和video的时间戳无法对齐。
针对以上方案我们进行了对比测试,两个方案均可fix此问题。方案a用户体验更好,所以最终选择方案a。
结果:
帧率达标。
720P@30fps |
720P@60fps |
1080P@30fps |
1080P@60fps |
4k@30fps |
4k@60fps |
|
EIS on |
30.04fps |
60.01fps |
30.03fps |
60.07fps |
29.97fps |
59.57fps |
EIS off |
30.06fps |
60.01fps |
30.03fps |
60.08fps |
30.03fps |
60.01fps |