美文网首页
ffmpeg开发——深入理解MP4文件格式

ffmpeg开发——深入理解MP4文件格式

作者: 拉丁吴 | 来源:发表于2024-04-30 14:58 被阅读0次

前言

前面我们通过介绍音视频的基本概念,ffmpeg基本工作框架,以及使用一个小demo来实践我们的知识,可以说算是对ffmpeg有了一个基本的认识。

接下来想要在目前的基础之上更进一步的理解和学习ffmpeg,我们需要在前面的基础之上学习底层一些的知识。就比如我们今天准备分析的媒体文件格式。文件的格式会影响到ffmpeg媒体文件读取,解复用,解码,编码,复用的全过程,学习文件的格式有助于我们理解ffmpeg的这些处理流程。

分析工具

MP4情况介绍

MP4是一种基于MPEG-4 Part 12(2015)MPEG-4 Part 14标准的数字多媒体容器格式。以存储数字音频及数字视频为主,但也可以存储字幕和静止图像。 MPEG-4 Part 12定义了基础媒体文件格式,Part 14在Part 12的基础上定义了MP4的文件格式。

因此本文分析也注意以这这两种标准的文档为基础进行分析。不过由于mpeg-4借鉴了苹果的QuickTimeg格式(mov文件),两者的视频文件的结构高度相似,我们也可以参考苹果的QuickTime File Format

PS:我今天才知道MP4这种媒体格式标准文档是收费的,哈哈,找了半天没有找到MPEG-4 Part 14全文文档。

术语与概念

MP4中有许多概念或者术语需要我们提前进行理解。

Box(Atom)

盒子是组成MP4文件的基本结构,MP4中有大量不同类型的box。在Apple的QuickTime-File_Format文档中则称作Atom(原子),两者是差不多的概念。我们主要以Box进行表述。

box由Header和Data(Body)组成。

box之间可以组合或者嵌套,形成新的box。

主要的box排列和嵌套方式如下

image.png image.png

具体看MPEG-4 Part 12(2015)整个文档第29-30页.

我们关注的顶层的box主要有三个:ftyp,moov,mdat。

container box

可称作容器盒子,这种类型的box内部成员一般不是具体的信息,而是box。

Track

轨道,表示一类数据的集合。比如音频轨道,视频轨道等。

Sample

采样,对视频而言,sample是一帧画面;对音频而言,sample是一段时间内的音频数据。

Chunk

是轨道中一组采样数据组成的块。

mp4基本结构

mp4文件中的一个基本结构是box,box内部由box header和box data(或可以称作body)组成

image.png

Box Header

正常情况下header只有两个字段,size和type

名称 大小(byte) 含义
size 4 box的大小(从文件头开始算计)
type 4 box的类型(一般是一个固定值)

在这正常的两个字段之外,还可能出现拓展的字段largesize,version,flags:

image.png

在正常情况下,box占8字节,前4字节表示box大小,后4个字节表示box类型。假如box很大4个字节的size无法表示(这种情况不太常见),则size会被设置为1,然后往后拓展8个字节用largesize来表示box的大小。假如size=0,表示到了文件末尾。

version和flag这两个拓展空间则是FullBox这种拓展Box中会出现。

Box Data

box data是数据主体部分,这里并没有固定的结构,需要根据不同类型的box来分析。

Box种类

MP4中有大量不同类型的box,不同的类型box中存储了不同用处的信息。比如ftyp,moov,mvhd等等,后面会详细介绍。

box组织结构

在MP4中box是排列嵌套组合的树形结构,box中可以包含其他一个或者多个box。

image.png

我们添加一个视频文件在线解析之后,得到box的结构如下:

image.png

图形化展示box相互隶属关系:

image.png

MP4中的box

File Type Box

  • type:ftyp
  • Container Box: NULL (ftpy没有父容器)

ftyp一般出现在文件的开头,描述文件的版本和兼容的协议等信息。

aligned(8) class FileTypeBox extends Box(‘ftyp’) {
     unsigned int(32) major_brand;
     unsigned int(32) minor_version;
     unsigned int(32) compatible_brands[]; // to end of the box
}

结构

名称 大小(byte) 含义
size 4 box的大小
type 4 box的类型
Minor version 4 版本号
Major brand 4 品牌名称(见https://www.ftyps.com
Minor version 4 版本号
Compatible brand 不定 兼容协议

我们解析一个短视频看它的ftyp情况:

image.png

由于Box Header属于Box中的公共结构,后面的Box介绍会忽略掉Header部分。

Media Data Box

  • type: mdat
  • Container: NULL

存储音频视频的媒体数据。

aligned(8) class MediaDataBox extends Box(‘mdat’) {
    bit(8) data[];
}

测试视频展示的效果如下

image.png

Movie Box

  • type: moov
  • Container Box: NULL (moov没有父容器)

Movie Box是一个container box。内部包含一个Movie Header Box,若干个Track Box,Track Box中一般有一个音频轨道box,一个视频轨道box。

存储有MP4文件的元数据。

该box一般紧跟着开头的File Type Box或者跟在mdat后。

iso标准文档中提到一般在靠近开头,或者靠近结尾,但不是强制要求的。

Movie Box在MP4 文件中的结构大概是这样的

image.png

它内部往往

Movie Header Box

  • type: mvhd
  • Container: Movie Box (‘moov’)

定义并存储MP4文件的整体信息。

// FullBox在header处多拓展出来version,flags两个字段
aligned(8) class MovieHeaderBox extends FullBox(‘mvhd’, version, 0) {
    if (version==1) {
        unsigned int(64) creation_time; // 创建时间
        unsigned int(64) modification_time; // 修改时间
        unsigned int(32) timescale; // 时间刻度,一秒钟有多少个时间刻度
        unsigned int(64) duration; // 视频时长
    } else { // version==0
        unsigned int(32) creation_time;
        unsigned int(32) modification_time;
        unsigned int(32) timescale;
        unsigned int(32) duration;
    }
    // 播放速率  高16位表示整数部分,低16位表示小数部分
    template int(32) rate = 0x00010000; // typically 1.0
    // 播放音量 高8位表示整数部分,低8位表示小数部分   1.0 (0x0100)
    template int(16) volume = 0x0100; // typically, full volume
    const bit(16) reserved = 0;
    const unsigned int(32)[2] reserved = 0;
    // 视频转换矩阵
    template int(32)[9] matrix =
    { 0x00010000,0,0,0,0x00010000,0,0,0,0x40000000 };
    // Unity matrix
    bit(32)[6] pre_defined = 0;
    // 表示下一个轨道的轨道ID 要比当前轨道ID大 (不可以是0)
    unsigned int(32) next_track_ID;
}

  • timescale

音视频文件中的时间单位并不是我们日常使用的秒/毫秒/微秒,而是自定义的,这个定义通过timescale来实现。它是时间刻度的意思,表示一秒钟内经过多少个时间单位。它的倒数就是该轨道所采用的时间的一个基本单位。

mvhd 存储了媒体文件的公共整体信息,timescale一般为1000,在编辑媒体文件涉及时间的整体信息时会用到,比如duration。

不同轨道的数据(音频视频)会有各自的timescale,不会用mvhd box中的timescale。

测试视频显示情况如下:

image.png

Track Box

  • type: trak
  • Container: Movie Box (‘moov’)

track box是一个containerbox,里面会包含很多别的box,有2个很关键 Track Header Box Media Box

Track Header Box

  • type: tkhd
  • Container: Track Box (‘trak’)

我们先来看看Track Header Box的信息

aligned(8) class TrackHeaderBox
extends FullBox(‘tkhd’, version, flags){
    if (version==1) {
        unsigned int(64) creation_time;
        unsigned int(64) modification_time;
        unsigned int(32) track_ID; // 轨道ID
        const unsigned int(32) reserved = 0;
        unsigned int(64) duration;
    } else { // version==0
        unsigned int(32) creation_time;
        unsigned int(32) modification_time;
        unsigned int(32) track_ID;
        const unsigned int(32) reserved = 0;
        unsigned int(32) duration;
    }
    const unsigned int(32)[2] reserved = 0;
    template int(16) layer = 0;
    template int(16) alternate_group = 0;
    template int(16) volume = {if track_is_audio 0x0100 else 0};
    const unsigned int(16) reserved = 0;
    template int(32)[9] matrix=
    { 0x00010000,0,0,0,0x00010000,0,0,0,0x40000000 };
    // unity matrix
    
    unsigned int(32) width;
    unsigned int(32) height;
}
  • creation_time:创建时间
  • modification_time:修改时间;
  • track_ID:轨道ID,当前track的唯一标识,不能为0,不能重复;
  • duration:当前track的完整时长(需要除以timescale得到具体秒数);
  • layer:视频轨道的叠加顺序,数字越小越靠近观看者,比如1比2靠上,0比1靠上;
  • alternate_group:track的分组ID
  • volume:audio track的音量,介于0.0~1.0之间;
  • matrix:视频的变换矩阵;
  • width、height:视频的宽高;
image.png

Media Box

  • type: mdia
  • Container: Track Box (‘trak’)

Container Box。

Media Header Box

  • type: mdhd
  • Container: Track Box (‘mdia’)

Media Header Box中声明了非媒体数据的总体信息,但是和轨道的类型有关。比如音频轨道,则Media Header Box保存的就是音频媒体的总体信息(创建时间,修改时间,时长,时间刻度等)。

aligned(8) class MediaHeaderBox extends FullBox(‘mdhd’, version, 0) {
    if (version==1) {
        unsigned int(64) creation_time; // 该轨道数据的创建时间
        unsigned int(64) modification_time;// 该轨道数据的修改时间
        unsigned int(32) timescale; // 该轨道数据的时间刻度(一秒钟经过的时间单位数)
        unsigned int(64) duration; // 该轨道数据的时长
    } else { // version==0
        unsigned int(32) creation_time;
        unsigned int(32) modification_time;
        unsigned int(32) timescale;
        unsigned int(32) duration;
    }
    bit(1) pad = 0; 
    unsigned int(5)[3] language; // ISO-639-2/T language code
    unsigned int(16) pre_defined = 0;
}

这里的timescale表示该轨道数据的timescale(音频和视频的timescale不同)。

image.png

比如我们的测试视频的这个视频轨道,timescale=30_000,表示1秒钟内经过了30_000个时间单位,那么此时该轨道的时间基本单位就是1/30_000 秒。该视频轨道中的时间计算基本都以这个时间单位作为基准。

Handler Reference Box

  • type: hdlr
  • Container: Media Box (‘mdia’) or Meta Box (‘meta’)

在Media Box中,则用于标识该轨道的媒体类型;在Meta Box中,则用于声明Meta Box的结构或者格式。

aligned(8) class HandlerBox extends FullBox(‘hdlr’, version = 0, 0) {
    unsigned int(32) pre_defined = 0;
    /*
    * handler_type取值范围是如下三个
    * vide(0x76 69 64 65),video track;
    * soun(0x73 6f 75 6e),audio track;
    * hint(0x68 69 6e 74),hint track;
    */
    unsigned int(32) handler_type; 
    const unsigned int(32)[3] reserved = 0;
    string name; // UTF-8 字符 轨道名称
}

测试视频数据显示如下

image.png

Media Information Box

  • type: minf
  • Container: Media Box (‘mdia’)

一个Container Box,包含了轨道媒体数据的特征信息。

数据索引类的box

Sample Table Box

  • type: stbl
  • Container: Media Information Box (‘minf’)

一个Container Box,内部包含大量的box,用来存储所有时间和数据索引。使用此处的表,可以及时定位样本,确定它们的类型(例如是否为 I 帧),并确定它们的大小、容器以及在该容器中的偏移量。

image.png

stblbox中的child box包括:

  • stsd:(Container Box)媒体数据的描述信息
  • stco:thunk在文件中的偏移;
  • stsc:每个thunk中包含几个sample;
  • stsz:每个sample的size(单位是字节);
  • stts:每个sample的时长;
  • stss:哪些sample是关键帧;
  • ctts:帧解码到渲染的时间差值,通常用在B帧的场景;

接下来我们就一一介绍这些child box。

Sample Description Box

  • type: stsd
  • Container: Sample Table Box (‘stbl’)

提供了有关所使用的编码类型的详细信息,以及该编码所需的任何初始化信息。


aligned(8) abstract class SampleEntry (unsigned int(32) format) extends Box(format){
    const unsigned int(8)[6] reserved = 0;
    unsigned int(16) data_reference_index;
}


class BitRateBox extends Box(‘btrt’){
    unsigned int(32) bufferSizeDB;
    unsigned int(32) maxBitrate;
    unsigned int(32) avgBitrate;
}

aligned(8) class SampleDescriptionBox (unsigned int(32) handler_type)
extends FullBox('stsd', version, 0){
    int i ;
    unsigned int(32) entry_count;
    for (i = 1 ; i <= entry_count ; i++){
        SampleEntry(); // an instance of a class derived from SampleEntry
    }
}

(Decoding)Time to Sample Box

  • type: stts
  • Container: Sample Table Box (‘stbl’)

记录每一个sample被解码的时间(解码时间戳)。

aligned(8) class TimeToSampleBox extends FullBox(’stts’, version = 0, 0) {
    unsigned int(32) entry_count; // 条目数
    int i;
    for (i=0; i < entry_count; i++) {
        unsigned int(32) sample_count; // 数据采样片的个数
        unsigned int(32) sample_delta; //该采样片所代表的时长
    }
}

我们以自己的测试视频为例,一共有451个采样片的数据(这里无法得知每个sample的大小),每个采样的时间是1001个时间单位,于是我们计算得知这个轨道的数据总时长是:

total_time = 451x1001 = 451451 //此时的单位不是秒,也不是毫秒,而是媒体文件time_scale的倒数

total_time_sec = 451451/30_000 = 15.05s // 采样数据总时长
image.png

而我们计算采样数据的每一帧解码时间的公式为(时间轴为0):

DT(n+1) = DT(n) + STTS(n) // 一般以0开头

DT(1) = DT(0) +STTS(0) = 0 // 从0开始,表示第一个sample从0开始解码
DT(2) = DT(1) +STTS(1) = 0+1001 = 1001 // 第二个sample从1001个时间单位开始解码
sample index 1 2 3
sample delta 1001 1001 1001
decode time 0 1001 2002

Sync Sample Box

  • type: stss
  • Container: Sample Table Box (‘stbl’)

对于视频帧而言,I帧非常重要,是视频解码的关键,seek操作时都是找到对应时间点附近的I帧,然后开始读取数据解码。stss就是存储I帧的的索引信息的。(关于帧类型见视频的一些基本概念——帧类型

aligned(8) class SyncSampleBox extends FullBox(‘stss’, version = 0, 0) {
    unsigned int(32) entry_count; // I帧的个数
    int i;
    for (i=0; i < entry_count; i++) {
        unsigned int(32) sample_number; // I帧的在sample中序列号
    }
}

我们看看测试视频的I帧索引情况:


image.png

信息显示,一共有两个I帧,第一个在第一帧(一般视频的第一帧就是I帧),第二个在251个采样片位置

Composition Time to Sample Box

  • type: ctts
  • Container: Sample Table Box (‘stbl’)

该 Box 记录的是每个 sample 的 Composition time 和 Decode time 之间的偏移。Composition time 可以理解为显示时间。即显示时间与解码时间之间的偏移。

aligned(8) class CompositionOffsetBox extends FullBox(‘ctts’, version, 0) {
    unsigned int(32) entry_count; // 条目数
    int i;
    if (version==0) {
        for (i=0; i < entry_count; i++) {
            unsigned int(32) sample_count; // 采样片的数据
            unsigned int(32) sample_offset; // 采样的偏移,也即CT与DT之间的偏移时间
        }
    }else if (version == 1) {
        for (i=0; i < entry_count; i++) {
            unsigned int(32) sample_count;
            signed int(32) sample_offset;
        }
    }
}

测试视频的ctts box显示如下

image.png

利用解码时间DT和这个ctts表里的偏移量,我们可以计算出显示时间CT。

CT的求解公式如下:

CT(n) = DT(n) + CTTS(n) 

以测试视频为例

sample index(Frame Index) F1 F2 F3 F4 F5 F6 F7 F8 F9
sample offset 2002 5005 2002 0 1001 5005 2002 0 1001
decode time 0 1001 2002 3003 4004 5005 6006 7007 8008
CT 2002 6006 4004 3003 5005 10010 8008 7007 9009
显示顺序 F1 F4 F3 F5 F2 F8 F7 F9 F6

从显示顺序来看,解码第1帧和显示第1帧一致,但是解码第2帧则在第5帧显示,解码第3帧在第三帧显示,解码第4帧在第2帧显示,解码第5帧在第4帧显示。

这说明解码第一帧是I帧,解码第二帧是P帧,其余的三帧都是B帧。因为I帧的解码不依赖其他帧,P帧的解码依赖前一帧,B帧的解码依赖前后两帧(关于视频的一些基本概念——帧类型

我们通ffprobe命令来打印该测试视频的帧类型:

ffprobe -show_frames -select_streams v -of xml sample.mp4 >videoframes.xml

// 数据修剪过后如下:

<?xml version="1.0" encoding="UTF-8"?>
<ffprobe>
    <frames>
        // 解码帧数从0开始计数
        <frame media_type="video"   pict_type="I" coded_picture_number="0"  />
        <frame media_type="video"   pict_type="B" coded_picture_number="3"  />
        <frame media_type="video"     pict_type="B" coded_picture_number="2"  />
        <frame media_type="video"   pict_type="B" coded_picture_number="4"  />
        <frame media_type="video"   pict_type="P" coded_picture_number="1"  />
        <frame media_type="video"    pict_type="B" coded_picture_number="7"  />
        <frame media_type="video"   pict_type="B" coded_picture_number="6"  />
        <frame media_type="video"     pict_type="B" coded_picture_number="8"  />
        <frame media_type="video"    pict_type="P" coded_picture_number="5"  />
    ...
    </frames>
</ffprobe>

ffprobe显示出来的解码帧的顺序和我们计算的是一致的,而且帧类型与我们预想的一致。

Sample To Chunk Box

  • type: stsc
  • Container: Sample Table Box (‘stbl’)

保存每个chunk包含多少个sample

aligned(8) class SampleToChunkBox extends FullBox(‘stsc’, version = 0, 0) {
    unsigned int(32) entry_count; // 条目数
    for (i=1; i <= entry_count; i++) {
         // 共享相同samples_per_chunk的chunk合并为一组,first_chunk就是组内第一个chunk的序号
        unsigned int(32) first_chunk;
        unsigned int(32) samples_per_chunk; // 每个chunk有多少个sample
        unsigned int(32) sample_description_index; //指向stsd的 entry_count的数量
    }
}
image.png

测试视频的每个chunk都只有一个sample。这个表格太简单了,我们换一个测试视频来看下stsc表的复杂情形:

image.png

该数据表应该做如下解读:

  • first_chunk = 1,这组chunk的第一个chunk的序号为1,该组chunk中,每个chunk都含有14个sample。
  • first_chunk = 66,这组chunk的第一个的序号为66,该组chunk中,每个chunk都含有13个sample。(由本组的first_chunk可以推知上一组的chunk为65个)
  • first_chunk = 69,这组chunk的第一个的序号为69,该组chunk中,每个chunk都含有14个sample。(由第组的first_chunk可以推知上一组的chunk为3个)

Sample Size Boxes

  • type: stsz
  • Container: Sample Table Box (‘stbl’)

记录每个sample的size大小

aligned(8) class SampleSizeBox extends FullBox(‘stsz’, version = 0, 0) {
    unsigned int(32) sample_size; //每个sample的size大小
    unsigned int(32) sample_count; // sample的数量
    if (sample_size==0) {
        for (i=1; i <= sample_count; i++) {·························
            unsigned int(32) entry_size; // 样本数的大小
        }
    }
}
image.png

我们可以看到第一个sample是相对其他sample最大的,因为I帧保存相对完整的图片信息,B帧P帧都只保存部分图片信息,所以数据大小是不一样的。

一般数据量最大的是I帧,B帧数据量最小,P帧居中。

Sample Offset Box

  • type: stco/co64
  • Container: Sample Table Box (‘stbl’)

记录每个chunk在文件中的偏移量,stco有两种形式,正常是stco,假如视频轨道数据过大的时候,则使用新的box co64,它与stco的主要区别是chunk_offset定义改为64位int,从而可以容纳更大的数据所带来的偏移。

aligned(8) class ChunkOffsetBox extends FullBox(‘stco’, version = 0, 0) {
    unsigned int(32) entry_count; //条数数量
    for (i=1; i <= entry_count; i++) {
        unsigned int(32) chunk_offset; // 偏移量
    }
}

aligned(8) class ChunkLargeOffsetBox extends FullBox(‘co64’, version = 0, 0) {
    unsigned int(32) entry_count;
    for (i=1; i <= entry_count; i++) {
        unsigned int(64) chunk_offset;// 偏移量(64位)
    }
}
image.png

几个实践

以上就是MP4文件的数据基本结构以及主要的box的情况,下面来看看如何利用MP4的box表结构来解决实际的问题。

1.如何获取视频的宽高

需要找到文件中video类型的track,然后读取track内的tkhd表: tkhd->width tkhd->height。

2.如何seek time

下面我们假设要把测试视频sample.mp4拖动到第1秒,那么究竟应该怎么从哪里读取数据才能正确的显示第1秒的帧呢(以视频为例)?

  • 首先进行时间转换

MP4中并不使用现实时间,需要转换为MP4中的时间单位,根据mvhd表,我们知道timescale=30_000,视频轨道的时间基本单位则为1/30_000,每个刻度那么1秒在MP4中的时间为

    time = 1x30_000 = 30_000 个时间单位
  • 找到30_000个时间单位之前第一个的I帧

为什么是I帧呢?因为视频的解码只有I帧不依赖外部帧独立解码,B帧P帧都需要依赖外部帧来解码,因此只能先找到I帧才能开启解码。

我们首先需要确定30_000个时间单位之前第一个帧。在stts表中,有每个sample的解码时间表,每个sample占用1001个时间单位,那么时间最近的解码帧应该是解码帧第29帧(第29029个时间单位)。

然后我们查找stss表发现只有两个I帧,分别在第一个和第251个,于是我们只能从第一帧开始解码

  • 根据sample找到对应的chunk

stsc表中我们知道每个chunk都只包含1个sample,那么第一个sample对应的就是第一个chunk

  • 找到chunk在文件中的偏移

根据stco表,我们找到第一个chunk在文件中的偏移 offset= 17731

于是我们从17731位置处读取文件开始解码。

总结一下

  • 从mvhd中读取timescale
  • 从stts表中找到最近的帧
  • 从stss表中找到该帧最近的I帧
  • 从stsc表读取chunk与sample的关系
  • 从stco表中读到该chunk在文件的偏移

是会有点晕

3.如何提升MP4第一帧的展示(文件结构层面)

我们在前文讲解moov时提到它可以在ftyp之后,也可以在最后mdat之后。但一般而言,moov在末尾处(mdat之后)是比较合适的,首先保存完音视频数据之后才能得到数据的相关情况,紧接着存储moov很合理;其次,如果moov在mdat之前,那么假如我们需要修改moov->udta box中的信息时,mdat中的数据整体都需要移动,则索引数据也需要同时变动,这是很低效的。因此一般我们生成的视频默认moov box是在后面的。


image.png

但是在流媒体播放MP4 文件时,假如moov box在文件末尾处,那么就需要花更长的时间才能读取到数据索引的相关信息,从而拿到第一帧数据的偏移位置的时机就更晚。

因此提升第一帧的方式就是把moov box的位置前提。我们可以使用ffmpeg达成这样的效果


ffmpeg -i input.mp4 -movflags faststart output.mp4

我们使用的测试视频是抖音下载的一个视频,显然抖音已经做了相关优化,把moov box移到ftyp之后。

image.png

总结

我们基本上对MP4中比较重要常见的box进行了介绍,说实话还是很难记得住,很繁杂且没有规律,因此这个还是更适合作为一种资料文档,随时查阅即可。

但是如果能够比较熟悉媒体文件的结构,对于我们理解ffmpeg读取文件,解复用过程以及解码编码都会有很多帮助。

如果想要加深印象,建议用python或js来写个小demo,根据我们学习的box结构来读取一个MP4的信息。

相关文章

网友评论

      本文标题:ffmpeg开发——深入理解MP4文件格式

      本文链接:https://www.haomeiwen.com/subject/fzsxfjtx.html