MP4のファイル構造を解説

#事の発端

ある用途でPCの画面を長時間キャプチャしていたのですが、保存先をFAT32のドライブにしてしまったために動画がファイルサイズ上限の4GBに達してしまいました。
メタ情報が壊れてしまったため、動画ファイルは一切再生できない3.99GBのゴミファイルと化しました。
再生しようとしてもプレイヤーを開いた瞬間にエラーが出てしまいます。

結論から言うと、尻切れトンボになった動画を復活させることはできなかったのですが、なぜ復活できないのかをMP4ファイルの構造とともに解説します。

#MP4ファイルの中身

##動画ファイルに含まれるデータの種類


ひとくちに動画ファイルといっても、動画ファイルの中には色んなデータが含まれています。
主なものだと以下のような情報でしょうか。

- 画像データ
- 音声データ
- 字幕データ

音声データの中にも主音声と副音声があったり、字幕も複数言語が含まれていたりしますね。
市販のDVDでメニューが表示できるものだとそれ用のTOC情報が含まれているでしょうし、最近流行りのVR動画とかだとまた何か別の情報が含まれているんですかね。よく知らないですけど。

形式の違う色んなデータを一つのファイルに含め、各情報へのランダムアクセスを可能にるために考案されたのがMP4ファイルというわけです。

##MP4のBOX構造

実際にはMP4ファイルはBOXと呼ばれるひとかたまりのデータの木構造の形式でデータを記録しています。
BOXは直接データを含むこともできますし、別のBOXを子要素として含むこともできます。
BOXのことをatomと呼ぶ場合があるのはQuick Time時代の名残です。

最初の4バイトが子要素も含めたBOX全体の長さ、次の4バイトはBOXの種類を示す4文字のアルファベット、それ以降はBOXの中身です。


###子要素を持たない場合(leaf box)
子要素を持たないBOXはleafと呼びます。
実際のデータを持てるのはleaf boxのみです。
![図1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215103/0bf8defb-7fc9-ddcf-583f-c28ef1f4f2fb.png)

###子要素を持つ場合
このように子要素を含むこともできます。
子要素を持つ場合、親要素はデータを持てません。
![図2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215103/3cf2fa65-d947-835e-b463-715f27862b38.png)

###BOXの長さが4294967296バイト超の場合
BOXの長さの情報が2^32バイト(=4294967296)を超える場合、最初の4バイトには`0x00000001`が格納され[^1]、その変わりに9バイト～16バイトに実際のBOXの長さが書かれます。
![図3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215103/7da6e1fe-a6d1-344e-e9a7-acebeda6bcb1.png)

##解析結果サンプル

まず、メタ情報が壊れていない普通のMP4ファイルの構造を調べてみました。

キャプチャソフト： GeForce Experience(Windows)
キャプチャ時間: 0.5秒ぐらい

インデントは親子関係を示しています。
一番左はBOXのインデックス、その右はBOXの種類、その右は内容のバイト数です。
バイト数が書かれていないのは内容を持たない親要素です。

```movie.mp4
index: name - bytes

01:    ftyp  -  24
02:    mdat  -  239668
03:    moov
04:       mvhd  -  108
05:       trak
06:          tkhd  -  92
07:          mdia
08:             mdhd  -  32
09:             hdlr  -  44
10:             minf
11:                vmhd  -  20
12:                dinf  -  36
13:                stbl
14:                   stsd  -  168
15:                   stts  -  32
16:                   stss  -  20
17:                   stsz  -  48
18:                   stsc  -  28
19:                   co64  -  24
20:       trak
21:          tkhd  -  92
22:          mdia
23:             mdhd  -  32
24:             hdlr  -  44
25:             minf
26:                smhd  -  16
27:                dinf  -  36
28:                stbl
29:                   stsd  -  91
30:                   stts  -  32
31:                   stsz  -  28
32:                   stsc  -  28
33:                   co64  -  24
34:          udta  -  30
35:       udta  -  295
```

まず、最初にファイルタイプを示す`ftyp`が来ます。これは必ず最初です。

その後に続く各コンテナの意味は後で解説しますが、重要なのは最初の方に`mdat`があり、その後に`moov`が続いているということです。
`mdat`が動画と音声の本体で、`moov`はメタ情報です。`moov`内の1つめの`trak`が動画のメタ情報、2つめの`trak`は音声のメタ情報です。
このキャプチャソフトは動画データ本体の後にメタ情報を書き込んでいるようです。

##主なBOXの種類

全部解説しているときりがないですが、主なBOXの種類を解説します。
全量は[QuickTime File Format Specification - Movie Atoms](https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html#//apple_ref/doc/uid/TP40000939-CH204-SW1)にあるので、もっと知りたい人は読んでみてください。

###ftyp

File typeのことで、その名の通りどのような形式のファイルなのかやバージョンの情報が書かれています。
かなり古いページですが、[Complete List of all known MP4 / QuickTime 'ftyp' designations](https://www.ftyps.com)に形式のリストがあります。公式情報かはわかりません。

###mdat

肝心の動画や音声データ本体。
中身の構造はコーデックによって変わります。

###moov

動画のメタデータを格納しているBOX達の親コンテナです。
この下に`mvhd`や`trak`が格納されます。

###stbl

Sample Tableです。
MP4はメディアデータをサンプルという形式で記録します。（画質を表す時にサンプリングレートとか言いますよね）
また、複数のサンプルを集めたものをチャンクと呼びます。

>![Figure 2-32  Samples in a media](https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/art/qt_l_037.gif)
>QuickTime File Format Specification - Sample Atomsから引用

それぞれのチャンクやサンプルがどれだけの長さを持つか[^2]を示しているのがSample Tableです。
実際のデータはこのBOXの子要素に複数に分けられて記録されています。

実際に中身を読み解きたい場合は[QuickTime File Format Specification - Sample Atoms](https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html#//apple_ref/doc/uid/TP40000939-CH204-33307)を参照してください。

#ファイルの末端が破損した場合

今回再生できなくなった動画のコンテナ構成を見てみると、以下のようになっていました。

```test.mp4
index: name - bytes

01:    ftyp  -  24
02:    mdat  -  5206156371
```

mdatがファイルのほとんどを占めていて、メタ情報が全くありません。

各コンテナの順は特に決められていないので`mdat`の前にメタ情報がある場合もありますが、今回の形式の場合はメタ情報がファイル末尾に来る形式だったようで、録画中に上限の4GBに達したためにメタ情報の書き込みに失敗したと思われます。
おそらく、動画キャプチャの場合はユーザーが録画停止ボタンを押すまで動画の長さが確定しないので、メタ情報を末尾に書いた方が効率がいいのでしょう。

逆に、ストリーミング配信なんかする場合はメタ情報を最初に書いておくことで、ファイルを全て読み込まずとも動画の長さなどの情報が得られますね。
Youtubeを見るときを思い出してもらうとわかると思いますが、動画を最後まで読み込まなくても再生を始めることができます。

#修復を試みる

今回再生できなくなった動画の修復を試みました。
乱暴なやり方ですが、バイナリエディタを使って以下のように他の動画のメタ情報をくっつけることで修復を試みました。

 - `mdat`の末尾を数MB削る
 - 新しい`mdat`の長さをコンテナの先頭に書く
 - ファイル末尾に正常に録画できた動画のメタ情報をくっつける

再生したところ、こうなりました。
![movie.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215103/4a54d091-4e6d-37f6-639b-2a3f9a44097a.png)

くっつけた動画の長さが表示されて再生時間も順調に進んでいます。
最初は再生時間すら表示されなかったので少し改善しましたが、相変わらず画面は真っ黒のままです。

先ほど説明したように、`stbl`配下の情報を元に`mdat`のチャンクやサンプルを取得するので、`stbl`がなければ長い`mdat`はどこで区切って読めばいいのか全くわかりません。
このように他の動画の`stbl`を無理やりくっつけた場合、`stbl`に書かれている情報と実際の`mdat`のチャンクが一致しなかったため、デコードに失敗したと思われます。

なので、`stbl`が失われた動画ファイルは復活させることができないわけです。

#(参考)コンテナ構造を解析するスクリプト

上記サンプルでは、以下のような簡易スクリプトでファイル構造を調べています。
Windowsだと[MP4 Reader](http://www.softpedia.com/get/Multimedia/Video/Video-Players/MP4-Player-Free.shtml)というソフトがあるらしいことを後から発見しました。[車輪の再発明](https://dic.nicovideo.jp/a/車輪の再発明)というやつですね。
まあでもせっかく書いたので、一応載せておきます。

```python:mp4_descripter.py
# 使用方法
# $ python mp4_descripter.py [引数1] [引数2]
#
# 引数1: ファイル名
# 引数2: 内容を表示したいコンテナのインデックス

import sys
import binascii

leaf_list = ['fiel', 'mdat', 'rdrf', 'rmcd', 'rmcs', 'rmdr', 'rmqu', 'rmvc', 'wfex', 'cmvd', 'co64', 'dcom', 'elst', 'gmhd', 'hdlr', 'mdhd', 'smhd', 'stco', 'stsc', 'stsd', 'stss', 'stsz', 'stts', 'tkhd', 'vmhd']
parent_list = ['cmov', 'ctts', 'edts', 'esds', 'free', 'ftyp', 'iods', 'junk', 'mdia', 'minf', 'moov', 'mvhd', 'pict', 'pnot', 'rmda', 'rmra', 'skip', 'stbl', 'trak', 'uuid', 'wide']

def read_container(file, indent, index, end_offset, content_index):
    # read container size
    data = file.read(4)
    size = int.from_bytes(data, byteorder='big')
    if size == 0: return 0

    # read container type
    data = file.read(4)
    type = data.decode("utf-8") 
    is_leaf = (type in leaf_list)

    end = file.tell() + size - 8

    # when container size exceeds 2147483647
    if size == 1:
        file_pos = file.tell()
        data = file.read(8)
        size = int.from_bytes(data, byteorder='big')
        file.seek(file_pos, 0)

    if not is_leaf:
        file_pos = file.tell()
        file.seek(4, 1)
        next_type = ''
        try:
            next_type = file.read(4).decode("utf-8") 
        except Exception:
            next_type = 'unknown box name'
        file.seek(file_pos, 0)
        # unknown box is assumed as leaf
        if next_type not in leaf_list and next_type not in parent_list:
            is_leaf = True

    # make index string
    if index < 10:
        out_text = '0' + str(index) + ': '
    else:
        out_text = str(index) + ': '

    for i in range(indent): out_text += '   '

    if is_leaf:
        out_text += type
        out_text += '  -  '
        out_text += str(size)
        print(out_text)

        if index == int(content_index):
            # print binary data
            print('')
            print('offset:' + str(hex(file.tell() - 8)))
            data = file.read(size - 8)
            strarray = str(binascii.hexlify(data))[2:-1]
            outstr = ''
            for i in range(int(len(strarray) / 2)):
                if i % 8 == 0:
                    outstr = outstr + ' '
                if i % 16 == 0:
                    print(outstr)
                    outstr = ''
                temp = strarray[i*2:i*2+2]
                outstr = outstr + temp + ' '
            print(outstr)
            print('')
        else:
            # move to next container
            file.seek(size - 8, 1)
        return index + 1
    else:
        out_text += type
        print(out_text)
        if index == int(content_index):
            print('')
            print('offset:' + str(hex(file.tell() - 8)))
            print('no data')
            print('')
        index += 1
        while True:
            # move to children
            index = read_container(file, indent + 1, index, end, content_index)
            if file.tell() == end: break
        return index

if __name__ == '__main__':
    file = open(sys.argv[1],'r+b')
    
    try:
        content_index = sys.argv[2]
    except Exception:
        content_index = 0
        
    index = 1
    while True:
        index = read_container(file, 1, index, -1, content_index)
        
        # read_container returns zero when EOF
        if index == 0: break
            
    file.close()
			# move to next container
			file.seek(size - 8, 1)
		return index + 1
	else:
		out_text += type
		print(out_text)
		if index == int(sys.argv[2]):
			print('')
			print('offset:' + str(hex(file.tell() - 8)))
			print('no data')
			print('')
		index += 1
		while True:
			# move to children
			index = read_container(file, indent + 1, index, end)
			if file.tell() == end: break
		return index

if __name__ == '__main__':
	file = open(sys.argv[1],'r+b')
	index = 1
	index = read_container(file, 1, index, -1)
	index = read_container(file, 1, index, -1)
	file.close()

```

#参考URL
[QuickTime File Format Specification - Movie Atoms](https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html#//apple_ref/doc/uid/TP40000939-CH204-SW1)
[ごちうさMADで学びたいmp4のデータ構造](http://soy-curd.hatenablog.com/entry/2015/12/17/000011)
[Complete List of all known MP4 ](https://www.ftyps.com)
[MP4コンテナの中身を調べる](http://matsu623a.blogspot.com/2013/12/mp4.html)

[^1]: コンテナは長さと種別の情報で最低でも8バイトはあるはずなので、1バイトのコンテナはあり得ません。
[^2]: 動画の圧縮率というのは動画の中身によって変わります。圧縮率の傾向はコーデックによって変わりますが、一般的には動きの少ない動画の方が圧縮率は高くなります。