壊れた MP4 ファイルを復元する python スクリプト、ONE X編

# 概要

Insta360 [ONE X](https://www.insta360.com/product/insta360-onex) など、いわゆるアクションカメラで撮影した動画ファイルは大抵３０分で分割されますが、最後の１つが「`moov` が見つからない」と言われて再生できない時があります。ファイルサイズはそれなりにあるので、何かデータは保存されているのに！と歯痒い思いで、泣く泣くゴミ箱に捨てた方も多いと思います。今日の話は、この再生できない動画ファイルを復元する、という話です。（できます！）

やったことは、生データが記録されている `mdat` の情報だけから H264 と AAC を分類することで `moov` に必要なサンプルテーブルを再構成した、という話です。 [ONE X](https://www.insta360.com/product/insta360-onex) での話ですが、 H264/AAC コーデックの MP4 ファイル（つまりほとんどの動画ファイル）に使える話だと思います。

詳しい話や、ポエムなど必要ないという方は、こちら（https://github.com/kichiki/finsta360 ）からコードをダウンロードして
<pre>$ ./finsta360.py\
 -s ../Data/MP4/VID_20191023_202638_00_005.insv\
 -r ../Data/MP4/VID_20191023_195632_00_004.insv\
 -o finsta360_00_005.insv
</pre>と叩けば、壊れたファイル（`VID_20191023_202638_00_005.insv`）から復元されたファイル（`finsta360_00_005.insv`）が戻ってきます！

ということで、詳しい話やポエムが読みたい方は、お進みください。

# はじめに
最近、[ZENKEI AI FORUM](https://www.zenkei.com/ai-seminar/)という公開イベントを金沢で月１でやっています。で、アーカイブもかねて[ONE X](https://www.insta360.com/product/insta360-onex)でイベントの内容を録画しています。
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">お待たせしておりました先月のZENKEI AI FORUM 2019のビデオ１本目、ぼくのイントロです。<a href="https://t.co/tYAXSy1AGb">https://t.co/tYAXSy1AGb</a><br>遅れたのは、ぼくのメインマシンiMacをCatalinaにアップグレードしたらあれこれ動かなくなって（以下省略）<br><br>ビデオはこの後 <a href="https://twitter.com/nguyenz13?ref_src=twsrc%5Etfw">@nguyenz13</a> さん、長東さん、古川さんと続く予定です！</p>&mdash; ichiki kengo (@ichiki_k) <a href="https://twitter.com/ichiki_k/status/1192260575138467840?ref_src=twsrc%5Etfw">November 7, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

これまでも同じカメラで 5K パノラマで撮影していて、バッテリーがもてば128GBのメモリーカードで、ぎりぎり２時間半のイベントが収まるという感じでした。しかし前回のイベントではカメラトラブルがあったようで（バッテリー切れ？）最後のファイルが書き出しに失敗したようです。`ffplay` で開こうとすると
<pre>$ ffplay VID_20191023_202638_00_005.insv 
ffplay version 4.2.1 Copyright (c) 2003-2019 the FFmpeg developers
  built with clang version 9.0.0 (tags/RELEASE_900/final)

...中略...

[mov,mp4,m4a,3gp,3g2,mj2 @ 0x7ff62380b400] moov atom not found
VID_20191023_202638_00_005.insv: Invalid data found when processing input
</pre>
というように「`moov` アトムが見つからない」という警告が出て、再生できませんでした。


# フェーズ１〜既存の復元ツールを試してみる
最初は、誰でもするように、まず「moov atom not found」でググってみました。するとMP4ファイルが壊れることは世間でもそれなりに頻繁に起こっているようで、壊れたMP4ファイルを復元してくれるソフトウェアも（オープンソースのものも含め）いくつか存在していました。

* https://github.com/ponchio/untrunc
* https://github.com/NiklasRosenstein/movrepair

前者は、ググったページの多くが言及されていて、 C++ で書かれたツールで、内部で `ffmpeg` とかを叩いている構成の大きなプロジェクトでした。これを自分でビルドするのも面倒だなと思いましたが、面白いことにこのプロジェクトは Dockerfile も提供していて、自分の環境を汚すことなく試すことができるようになっていました。

このプログラムは、壊れたファイルの他に、壊れていない（正常な）MP4ファイルも与えて、欠損した `moov` などの情報を復元しようとするもののようです。しかし、手元の壊れたファイルを試してみたところ、何の結果ファイルも出力してくれませんでした。（なにか使い方を間違っていたのかな？）

後者は python で書かれたもので、比較的シンプルな構成のプロジェクトでした。（考えてみれば、状況はバイナリファイルの一部が壊れていて、この壊れた部分をうまいこと修復すればいいだけなので、デコードとか再エンコードとかが絡まなければ `ffmpeg` などを持ち出すまでもない話ではあります。）こちらも、先のツールと同様に、正常なファイルをリファレンスとして、壊れたファイルを復元するものです。しかし、こちらのツールでも、うまくいきませんでした。

# フェーズ２〜MP4コンテナについて勉強
既存のツールではうまくいかなかったけど、それなりのサイズを持った、つまり不完全だけどデータが保存されているファイルが目の前にある訳で、MP4コンテナの仕様に従ってバイナリを解読していけば、何が足りてないか分かるだろうし、足りてないものが分かればそれを補ってやればいいだろう、という見立てのもと、まずは MP4 の仕様など、情報を探しました。

MP4 は QuickTime の拡張で、その仕様はアップルのサイト

* [QuickTime File Format Specification](https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html)

に細かく書いてありました。日本語では、以下のサイト 

* https://github.com/uupaa/H264.js/wiki/MP4

の情報がとても詳しくまとまっていました。

これらの情報を横目で眺めながら（真剣にきっちりと読もうとは、最初から思ってませんでした）必要なところをつまみ食いしながら、自分でパーサーを書くことにします。そもそもバイナリを触るだけでいいだろう（そのレベルしか立ち入れないだろう）と思ってたし、最近は python ばかり触ってるので python で書くことにしました。

<details><summary>MP4 ファイルを開いてパースするコード</summary><div>

```python
import struct
from datetime import datetime, timedelta

def parse_mvhd(buf):
    # Movie Header Atoms

    version            = buf[0]
    creation_time      = struct.unpack('>I', buf[4:8])[0]
    modification_time  = struct.unpack('>I', buf[8:12])[0]
    time_scale         = struct.unpack('>I', buf[12:16])[0]
    duration           = struct.unpack('>I', buf[16:20])[0]
    preferred_rate     = struct.unpack('>I', buf[20:24])[0]
    preferred_volume   = struct.unpack('>H', buf[24:26])[0]
    # next 10 bytes are reserved
    matrix_structure   = struct.unpack('>IIIIIIIII', buf[36:72])
    preview_time       = struct.unpack('>I', buf[72:76])[0]
    preview_duration   = struct.unpack('>I', buf[76:80])[0]
    poster_time        = struct.unpack('>I', buf[80:84])[0]
    selection_time     = struct.unpack('>I', buf[84:88])[0]
    selection_duration = struct.unpack('>I', buf[88:92])[0]
    current_time       = struct.unpack('>I', buf[92:96])[0]
    next_track_id      = struct.unpack('>I', buf[96:100])[0]

    print(f'version            : {version}')
    print(f'creation time      : {datetime(1904,1,1) + timedelta(seconds=creation_time)}')
    print(f'modification_time  : {datetime(1904,1,1) + timedelta(seconds=modification_time)}')
    print(f'time scale         : {time_scale}')
    print(f'duration           : {duration} / {duration/time_scale} sec / {duration/time_scale/60} min')
    print(f'preferred_rate     : {preferred_rate}')
    print(f'preferred_volume   : {preferred_volume}')
    print(f'matrix_structure   : {matrix_structure}')
    print(f'preview_time       : {preview_time}')
    print(f'preview_duration   : {preview_duration}')
    print(f'poster_time        : {poster_time}')
    print(f'selection_time     : {selection_time}')
    print(f'selection_duration : {selection_duration}')
    print(f'current_time       : {current_time}')
    print(f'next_track_id      : {next_track_id}')

def parse_tkhd(buf):
    #Track Header Atoms

    version   = buf[0]
    flags     = buf[1:4]
    creation_time      = struct.unpack('>I', buf[4:8])[0]
    modification_time  = struct.unpack('>I', buf[8:12])[0]
    track_id           = struct.unpack('>I', buf[12:16])[0]
    # next 4 bytes are reserved
    duration           = struct.unpack('>I', buf[20:24])[0]
    # next 8 bytes is reserved
    layer              = struct.unpack('>H', buf[32:34])[0]
    alternate_group    = struct.unpack('>H', buf[34:36])[0]
    volume             = struct.unpack('>H', buf[36:38])[0]
    # next 2 bytes are reserved
    matrix_structure   = struct.unpack('>IIIIIIIII', buf[40:76])
    track_width        = struct.unpack('>I', buf[76:80])[0]
    track_height       = struct.unpack('>I', buf[80:84])[0]

    print(f'version           : {version}')
    print(f'flags             : {flags}')
    print(f'creation time     : {datetime(1904,1,1) + timedelta(seconds=creation_time)}')
    print(f'modification_time : {datetime(1904,1,1) + timedelta(seconds=modification_time)}')
    print(f'track_id          : {track_id}')
    print(f'duration          : {duration}')
    print(f'layer             : {layer}')
    print(f'alternate_group   : {alternate_group}')
    print(f'volume            : {volume}')
    print(f'matrix_structure  : {matrix_structure}')
    print(f'track_width       : {track_width}')
    print(f'track_height      : {track_height}')

def parse_mdhd(buf):
    #Media Header Atoms

    version   = buf[0]
    flags     = buf[1:4]
    creation_time      = struct.unpack('>I', buf[4:8])[0]
    modification_time  = struct.unpack('>I', buf[8:12])[0]
    time_scale         = struct.unpack('>I', buf[12:16])[0]
    duration           = struct.unpack('>I', buf[16:20])[0]
    language           = struct.unpack('>H', buf[20:22])[0]
    quality            = struct.unpack('>H', buf[22:24])[0]

    print(f'version           : {version}')
    print(f'flags             : {flags}')
    print(f'creation time     : {datetime(1904,1,1) + timedelta(seconds=creation_time)}')
    print(f'modification_time : {datetime(1904,1,1) + timedelta(seconds=modification_time)}')
    print(f'time scale        : {time_scale}')
    print(f'duration          : {duration} / {duration/time_scale} sec / {duration/time_scale/60} min')
    print(f'language          : {language}')
    print(f'quality           : {quality}')


def parse_stsd(buf):
    #Sample Description Atoms

    print('DATA:')
    print_binaries(buf)
    print(f'size of buf: {len(buf)}')

    version   = buf[0]
    flags     = buf[1:4]
    n_entries = struct.unpack('>I', buf[4:8])[0]

    print(f'version           : {version}')
    print(f'flags             : {flags}')
    print(f'number of entries : {n_entries}')

    sample_description_table = []
    for i in range(n_entries):
        i0 = 8 + i*4
        i1 = i0 + 4
        if len(buf) < i1: break
        sample_description_size = struct.unpack('>I', buf[i0:i0+4])[0]
        data_format = str(buf[i0+4:i0+8], 'utf-8')
        data_reference_index = struct.unpack('>H', buf[i0+14:i0+16])[0]
        sample_description_table.append(
            (sample_description_size, data_format, data_reference_index))
        print('%d: size: 0x%X, format: %s, ref_index: 0x%X' % (
            i, sample_description_size, data_format, data_reference_index))

def parse_stsz(buf):
    #Sample Size Atoms

    version   = buf[0]
    flags     = buf[1:4]
    sample_size = struct.unpack('>I', buf[4:8])[0]
    n_entries = struct.unpack('>I', buf[8:12])[0]

    print(f'version           : {version}')
    print(f'flags             : {flags}')
    print(f'sample_size       : {sample_size}')
    print(f'number of entries : {n_entries}')

    sizes = []
    for i in range(n_entries):
        i0 = 12 + i*4
        i1 = i0 + 4
        if len(buf) < i1: break
        size = struct.unpack('>I', buf[i0:i1])[0]
        sizes.append(size)
        print(f'  {i}: {size}')

def parse_stsc(buf):
    #Sample-to-Chunk Atoms

    version   = buf[0]
    flags     = buf[1:4]
    n_entries = struct.unpack('>I', buf[4:8])[0]

    print(f'version           : {version}')
    print(f'flags             : {flags}')
    print(f'number of entries : {n_entries}')

    stoc = []
    for i in range(n_entries):
        i0 = 8 + i*12
        i1 = i0 + 12
        if len(buf) < i1: break
        first_chunk       = struct.unpack('>I', buf[i0:i0+4])[0]
        samples_per_chunk = struct.unpack('>I', buf[i0+4:i0+8])[0]
        sample_desc_id    = struct.unpack('>I', buf[i0+8:i0+12])[0]
        stoc.append((first_chunk, samples_per_chunk, sample_desc_id))
        print(f'  {i}: {(first_chunk, samples_per_chunk, sample_desc_id)}')

def parse_stco(buf):
    #Chunk Offset Atoms

    version   = buf[0]
    flags     = buf[1:4]
    n_entries = struct.unpack('>I', buf[4:8])[0]

    print(f'version           : {version}')
    print(f'flags             : {flags}')
    print(f'number of entries : {n_entries}')

    chunk_offset_table = []
    for i in range(n_entries):
        i0 = 8 + i*4
        i1 = i0 + 4
        if len(buf) < i1: break
        offset = struct.unpack('>I', buf[i0:i1])[0]
        chunk_offset_table.append(offset)
        print(f'  {i}: {offset}')

def parse_co64(buf):
    #64-bit chunk offset atoms

    version   = buf[0]
    flags     = buf[1:4]
    n_entries = struct.unpack('>I', buf[4:8])[0]

    print(f'version           : {version}')
    print(f'flags             : {flags}')
    print(f'number of entries : {n_entries}')

    chunk_offset_table = []
    for i in range(n_entries):
        i0 = 8 + i*8
        i1 = i0 + 8
        if len(buf) < i1: break
        offset = struct.unpack('>Q', buf[i0:i1])[0]
        chunk_offset_table.append(offset)
        print(f'  {i}: {offset}')

def parse_stts(buf):
    #Time-to-Sample Atoms

    version   = buf[0]
    flags     = buf[1:4]
    n_entries = struct.unpack('>I', buf[4:8])[0]

    print(f'version           : {version}')
    print(f'flags             : {flags}')
    print(f'number of entries : {n_entries}')

    time_to_sample_table = []
    for i in range(n_entries):
        i0 = 8 + i*8
        i1 = i0 + 8
        if len(buf) < i1: break
        sample_count    = struct.unpack('>I', buf[i0:i0+4])[0]
        sample_duration = struct.unpack('>I', buf[i0+4:i0+8])[0]
        time_to_sample_table.append((sample_count, sample_duration))
        print(f'  {i}: {(sample_count, sample_duration)}')

def parse_stss(buf):
    #Sync Sample Atoms

    version   = buf[0]
    flags     = buf[1:4]
    n_entries = struct.unpack('>I', buf[4:8])[0]

    print(f'version           : {version}')
    print(f'flags             : {flags}')
    print(f'number of entries : {n_entries}')

    sync_sample_table = []
    for i in range(n_entries):
        i0 = 8 + i*4
        i1 = i0 + 4
        if len(buf) < i1: break
        sample = struct.unpack('>I', buf[i0:i1])[0]
        sync_sample_table.append(sample)
        print(f'  {i}: {sample}')

def parse_uuid(buf):
    print_binaries(buf[:16])
    print('%s' % str(buf[16:], 'utf-8'))

def print_binaries(buf, cur=None):
    if cur is None: cur = 0
    for i in range(0, len(buf), 8):
        print('%010X : ' % (i+cur), end='')
        j = min(i+8, len(buf))
        buf_ = buf[i:j]
        print(' '.join(['%02X'%(b) for b in buf_]), end='')
        print(' : ', end='')
        print(''.join(['%c'%(b) for b in buf_]))

def print_atom_headers(f, verbose=False, pre_label=''):
    atom_start = f.tell()
    buf = f.read(8)

    n = struct.unpack('>I', buf[:4])[0]
    atom_type = str(buf[4:], 'utf-8')

    if n == 1:
        # decode 64-bit size
        buf = f.read(8)
        n = struct.unpack('>Q', buf)[0]
    #elif n == 0:
    #    raise ValueError('not implemented yet')

    #print(f'{atom_type} (size: {n})')
    if not pre_label is None:
        print('%s%s (size: 0x%X)' % (pre_label, atom_type, n))
    else:
        print('%s (size: 0x%X)' % (atom_type, n))
    data_start = f.tell()
    if verbose: print_binaries(buf, atom_start)


    if not atom_type in ('moov', 'trak', 'mdia', 'minf', 'edts', 'dinf', 'stbl'):
        if n > 8:
            if atom_type == 'uuid':
                n_ = n
            else:
                n_ = min(n, 128)

            buf = f.read(n_-8)
            if atom_type == 'mvhd':
                parse_mvhd(buf)
            elif atom_type == 'tkhd':
                parse_tkhd(buf)
            elif atom_type == 'mdhd':
                parse_mdhd(buf)
            elif atom_type == 'stsd':
                parse_stsd(buf)
            elif atom_type == 'stsz':
                parse_stsz(buf)
            elif atom_type == 'stsc':
                parse_stsc(buf)
            elif atom_type == 'stco':
                parse_stco(buf)
            elif atom_type == 'co64':
                parse_co64(buf)
            elif atom_type == 'stts':
                parse_stts(buf)
            elif atom_type == 'stss':
                parse_stss(buf)
            elif atom_type == 'uuid':
                parse_uuid(buf)
            else:
                print('DATA:')
                print_binaries(buf, cur=data_start)
    else:
        # sub Atoms
        sub_end = atom_start + n
        sub_cur = data_start
        while True:
            f.seek(sub_cur)
            if f.tell() != sub_cur: raise ValueError(f'seek failed? {f.tell()} != {sub_cur}')
            sub_n, sub_type = print_atom_headers(f, verbose=False, pre_label=pre_label+atom_type+ ' / ')
            if sub_n == 0: break
            sub_cur += sub_n
            if sub_cur >= sub_end: break
            print('')

    return n, atom_type


def print_atoms(filename, verbose=False):
     with open(filename, 'rb') as f:
        f.seek(0, 2)
        file_size = f.tell()
        print('file size : 0x%010X' % (file_size))
        print('')

        cur = 0
        while True:
            f.seek(cur)
            if f.tell() != cur: raise ValueError(f'seek failed? {f.tell()} != {cur}')
            n, _ = print_atom_headers(f, verbose=verbose)
            print('size : 0x%X' % (n))
            if n == 0: break
            cur += n
            if cur >= file_size: break
            print('')
```
</div></details>

調べてみると、トップレベルの atom box の構成は、 ONE-X の場合、正常なファイルでは

* `ftyp`
* `mdat`
* `moov`
* `free`

となっているものが、壊れたファイルの場合、 `mdat` までしか存在してませんでした。あと、この残っていた `mdat` box のサイズが０となっていました。想像するに、サイズとメタデータ（`moov` box）は録画終了時（ファイル保存時）に確定させて書き込むのだろうと思います。今回はこれらの情報を書き込む前に電源が切れたか何かで、ヘッダーにサイズの値が０のままの（書きかけの） `mdat` box だけがある不完全な動画ファイルが残されたようです。

この時点ではまだ、トップレベルの atom box のぼやっとした概念しか分かっていませんでした。（つまり `mdat` にデータ、 `moov` にメタデータ、というだけの理解です。）そして、このレベルでできることとして、完全なファイルにある（同じカメラで撮影された）`moov` box をそのまま壊れたファイルに後付けすれば、もしかしたらうまくいくかも、と思って試してみました。しかし、当然のように、うまくいきませんでした。

いろいろググっていると、全く同じようなことを試みている人がいらっしゃいました。

* Qiita: [MP4のファイル構造を解説](https://qiita.com/satken2/items/d14b4113fe3fb5f5597b)

実はこの時点まで、ぼくは漠然と、`mdat` には動画のデータだけ並んでて、音声はメタデータのどこかに置かれてる、と勝手に思い込んでいました。しかし、この方の記事を読んで、サンプルテーブルというものが本質的に大事なものなんだ、と分かりました。

つまり `mdat` には、動画ストリームとオーディオストリームが細切れのチャンクとして配置されていて、サンプルテーブルと呼ばれる各チャンクのオフセットとサイズのリストが `moov` box の中でも重要な情報である、ということです。このサンプルテーブルがないことには、`mdat` の中にある情報はただのバイナリの羅列に過ぎない、と。

この記事を書いた方も、この「needle in a haystack」な状況を前に動画ファイルの復元を断念されたようです。ぼくも、もしいつもようにバックアップ用に音声録音してたら、壊れた動画の復元など諦めて、音声に静止画付けてお茶を濁してたと思います。しかしこの日に限って ZOOM H1n を持ってくのを忘れたためバックアップがなく、どうしてもこの壊れたファイルの中にあるデータを取り出す必要がありました。

# フェーズ３〜AACについて勉強
少しずつ、自分が置かれている状況が分かってきました。（まっとうな人間、普通のエンジニアなら、最初からこの地点に立って、ここからスタートすると思いますが、ぼくは、うまく行くかどうか分からない問題でもまず首を突っ込んで、行き当たりばったりに進めて行く性格のようです。それでいいような気もしますが。）

それで閃きました（というか、気付きました）。つまり極論すれば、たとえ `moov` （というか、サンプルテーブル）を失っても、`mdat` に格納されているバイナリー列をにらんで（心眼で見るとか、ディープラーニングで分類モデルを構築するとかして）どこがオーディオで、どこが動画かさえ分かってしまえば、サンプルテーブルを再構成できるし、それで上がりだ、ということです。

オーディオは AAC、動画は H264 でエンコードされていることは分かってます。（この時点では、ぼくは動画は H265 だと思い込んでました。ま、大差ないけれど。）

ということで、そもそもバイト列としてどんなものなのか何も知らなかったので、まずは簡単そうな AAC について調べてみました。

* https://wiki.multimedia.cx/index.php/ADTS
* Qiita: [AAC/ADTSヘッダパーサ](https://qiita.com/yohhoy/items/95beacf367dc393b53a7)

するとどうも、AAC チャンクにはもれなく ADTS ヘッダというものが付いているようです。これは先頭に識別子があって、かつ、チャンクの長さ (frame length) も含まれています。つまり `mdat` のデータ領域で ADTS ヘッダを探したら、 AAC チャンクが取り出せるだけでなく、残りは自動的に H264 チャンクということになります。つまり `mdat` だけあればサンプルテーブルが構成できてしまうのです。


# フェーズ４〜AACの抜き出しから`moov`の復元
まずは、この考えが正しいのか確認するため、`mdat` の先頭から ADTS ヘッダを探して、順次 AAC チャンクを抜き出して１つの AAC ストリームを構成してみました。できたファイルをプレーヤで開くと、見事オーディオが再生されました。約８分！壊れたファイルから、まずは音声だけですが復元することができました。

ということで、AAC チャンクの場所からサンプルテーブル、つまり動画と音声の各チャンクの先頭のオフセットアドレスと、サイズを構成することができました。以下のコードは、壊れたファイル `VID_20191023_202638_00_005.insv` の `mdat` からサンプルテーブル `(mov_table, aac_table)` を構成するものです。

<details><summary>`mdat` からサンプルテーブルを構成するコード</summary><div>

```python
def recover_sample_tables_from_mdat(filename, verbose=False):
    mov_table = []
    aac_table = []

    with open(filename, 'rb') as f_in:

        f_in.seek(0, 2)
        eof = f_in.tell()

        # here we new 'mdat' starts at 0x48
        # (to generalize the script, we'd better search 'mdat' box explicitly)
        f_in.seek(48)
        last = 48
        last_frame_length = 0
        n = 0
        while True:
            cur = f_in.tell()
            if cur >= eof: break

            if f_in.read(1)[0] != 0xFF: continue
            if f_in.read(1)[0] != 0xF1: continue
            if f_in.read(1)[0] != 0x4C: continue
            buf = f_in.read(3)
            if (buf[0] & 0b11111100) != 0x80: continue

            # from https://wiki.multimedia.cx/index.php/ADTS
            # AAAAAAAA AAAABCCD EEFFFFGH HHIJKLMM MMMMMMMM MMMOOOOO OOOOOOPP (QQQQQQQQ QQQQQQQQ)
            # 0th-byte 1st      2nd      3rd      4th      5th      6th      (7th      8th     )
            # 0xFF     0xF1     0x4C     0X80 -- typical case for Insta360 ONE-X
            # M 13 frame length, this value must include 7 or 9 bytes of header length
            #   FrameLength = (ProtectionAbsent == 1 ? 7 : 9) + size(AACFrame)
            frame_length = ((buf[0] & 0b11) << 11) | (buf[1] << 3) | (buf[2] >> 5)

            if (cur - last) > last_frame_length:
                if verbose: print(f'{n}: [mov] {last+last_frame_length}, {cur-last-last_frame_length}')
                mov_table.append((last+last_frame_length, cur-last-last_frame_length))

            if verbose: print(f'{n}: [aac] {cur}, {frame_length}')
            aac_table.append((cur, frame_length))


            f_in.seek(cur+frame_length)

            last = cur
            last_frame_length = frame_length
            n += 1

    return mov_table, aac_table

mov_table, aac_table = recover_sample_tables_from_mdat(
    '../Data/MP4/VID_20191023_202638_00_005.insv')
```
</div></details>

得られたサンプルテーブル `(mov_table, aac_table)` を元に、 `moov` box の中の動画および音声 `trak` の中の（ `mdia` の中の `minf` の中の `stbl` にある） `stsz` （サイズ）と `co64` （オフセット）を構成するのも、比較的簡単でした。

ちょっと面倒だったのは、入れ子になっている atom box のサイズを計算する部分です。ほとんどの `moov` の情報は（手を抜いて）壊れてないファイルから取り出した情報をそのままコピーしてますが、今 `mdat` から作り直した `stsz` と `co64` はサイズが変わります。これらの box を含む上の階層の atom boxes に対して、正しくサイズを計算する必要があります。ここでは「一般的な MP4 ファイルの復元」は念頭になくて、あくまでぼくの手元にある ONE X の出力した MP4 ファイルが復元できればよいと思っているので、`moov` の並びは決め打ちにしましたが、それでも、きちんとコードにまとめるのはちょっと面倒でした。

<details><summary>サンプルテーブルから atom boxes のサイズを計算するコード</summary><div>

```python
sample_size_tables = []
sample_size_tables.append([s for o, s in mov_table])
sample_size_tables.append([s for o, s in aac_table])

chunk_offset_tables = []
chunk_offset_tables.append([o for o, s in mov_table])
chunk_offset_tables.append([o for o, s in aac_table])

mov_stsz_size = len(sample_size_tables[0])* 4 + 20
aac_stsz_size = len(sample_size_tables[1])* 4 + 20

mov_co64_size = len(chunk_offset_tables[0])* 8 + 16
aac_co64_size = len(chunk_offset_tables[1])* 8 + 16

mov_stss_size = ((len(sample_size_tables[0])-1)//32 + 1)* 4 + 16


mov_stbl_size = 8 + 0x141 + 0x18 + 0x1C + mov_stsz_size + mov_co64_size + mov_stss_size
aac_stbl_size = 8 + 0x82  + 0x18 + 0x1C + aac_stsz_size + aac_co64_size

mov_minf_size = 8 + 0x14 + 0x24 + mov_stbl_size
aac_minf_size = 8 + 0x10 + 0x24 + aac_stbl_size

mov_mdia_size = 8 + 0x20 + 0x2E + mov_minf_size
aac_mdia_size = 8 + 0x20 + 0x2E + aac_minf_size

mov_trak_size = 8 + 0x5C + 0x24 + mov_mdia_size + 0x618
aac_trak_size = 8 + 0x5C + 0x24 + aac_mdia_size

moov_size = 8 + 0x6C + 0x73 + mov_trak_size + aac_trak_size

print('moov_size: 0x%X' % (moov_size))
print('trak_size: 0x%X, 0x%X' % (mov_trak_size, aac_trak_size))
print('mdia_size: 0x%X, 0x%X' % (mov_mdia_size, aac_mdia_size))
print('minf_size: 0x%X, 0x%X' % (mov_minf_size, aac_minf_size))
print('stbl_size: 0x%X, 0x%X' % (mov_stbl_size, aac_stbl_size))

print('stsz_size: 0x%X, 0x%X' % (mov_stsz_size, aac_stsz_size))
print('co64_size: 0x%X, 0x%X' % (mov_co64_size, aac_co64_size))
print('stss_size: 0x%X' % (mov_stss_size))
```
</div></details>

以下のコードは、リファレンスとして使う、壊れてないファイル`VID_20191023_195632_00_004.insv` から `moov` box を抜き出してファイル `ref_00_004.moov` に保存するコードです。

<details><summary>リファレンスの `moov` を抜き出し保存するコード</summary><div>

```python
from tqdm import tqdm
import gc

def read_atom_head(f):
    cur = f.tell()
    buf = f.read(8)

    n = struct.unpack('>I', buf[:4])[0]
    atom_type = str(buf[4:], 'utf-8')

    buf2 = None
    if n == 1:
        # decode 64-bit size
        buf2 = f.read(8)
        n = struct.unpack('>Q', buf2)[0]

    del buf
    del buf2
    gc.collect()

    return n, atom_type

def extract_moov(src_filename, dst_filename, n_chunk=65536):
    with open(src_filename, 'rb') as f_src,\
        open(dst_filename, 'wb') as f_dst:

        f_src.seek(0, 2)
        src_end = f_src.tell()

        # look for 'moov'
        src_cur = 0
        while True:
            f_src.seek(src_cur)
            if f_src.tell() != src_cur: raise ValueError(f'seek failed? {f_src.tell()} != {src_cur}')

            n, atom_type = read_atom_head(f_src)
            if atom_type == 'moov': break
            src_cur += n

        # 'moov' is found
        moov_start = src_cur

        # copy moov
        f_src.seek(moov_start)
        if f_src.tell() != moov_start: raise ValueError(f'seek failed? {f_src.tell()} != {moov_start}')

        for src_cur in tqdm(range(moov_start, src_end, n_chunk)):
            f_dst.write(f_src.read(n_chunk))
        if src_end - src_cur > 0:
            f_dst.write(f_src.read(src_end - src_cur))

extract_moov(
    '../Data/MP4/VID_20191023_195632_00_004.insv',
    'ref_00_004.moov')
```
</div></details>

次に示すのは、先に構成したサンプルテーブル `(mov_table, aac_table)` と、リファレンスとなる `moov` ファイル `ref_00_004.moov` から、正しい `moov` を復元し、ファイル `target_00_005.moov` を保存するコードです。

<details><summary>サンプルテーブルとリファレンスの `moov` から正しい `moov` を構成し保存するコード</summary><div>

```python
def get_sample_size_table(f):
    # 'stsz'
    #Sample Size Atoms

    buf = f.read(12)
    version   = buf[0]
    flags     = buf[1:4]
    sample_size = struct.unpack('>I', buf[4:8])[0]
    n_entries = struct.unpack('>I', buf[8:12])[0]

    print(f'version           : {version}')
    print(f'flags             : {flags}')
    print(f'sample_size       : {sample_size}')
    print(f'number of entries : {n_entries}')

    sample_size_table = []
    for i in range(n_entries):
        buf = f.read(4)
        size = struct.unpack('>I', buf)[0]
        sample_size_table.append(size)

    return sample_size_table

def get_chunk_offset_table(f):
    # 'co64'
    #64-bit chunk offset atoms

    buf = f.read(8)
    version   = buf[0]
    flags     = buf[1:4]
    n_entries = struct.unpack('>I', buf[4:8])[0]

    print(f'version           : {version}')
    print(f'flags             : {flags}')
    print(f'number of entries : {n_entries}')

    chunk_offset_table = []
    for i in range(n_entries):
        buf = f.read(8)
        offset = struct.unpack('>Q', buf)[0]
        chunk_offset_table.append(offset)

    return chunk_offset_table

def get_sync_sample_table(f):
    # 'stss'
    #Sync Sample Atoms

    buf = f.read(8)
    version   = buf[0]
    flags     = buf[1:4]
    n_entries = struct.unpack('>I', buf[4:8])[0]

    print(f'version           : {version}')
    print(f'flags             : {flags}')
    print(f'number of entries : {n_entries}')

    sync_sample_table = []
    for i in range(n_entries):
        buf = f.read(4)
        sample = struct.unpack('>I', buf)[0]
        sync_sample_table.append(sample)

    return sync_sample_table

moov_filename = 'ref_00_004.moov.moov'
dst_filename  = 'target_00_005.moov'

with open(moov_filename, 'rb') as f_moov,\
    open(dst_filename, 'wb') as f_dst:

    f_moov.seek(0, 2)
    file_size = f_moov.tell()

    cur = 0
    f_moov.seek(cur)
    if f_moov.tell() != cur: raise ValueError(f'seek failed? {f_moov.tell()} != {cur}')

    # moov
    n, atom_type = read_atom_head(f_moov)
    if atom_type != 'moov': raise ValueError('moov not found')
    f_dst.write(struct.pack('>I', moov_size))
    f_dst.write(b'moov')

    # mvhd
    n, atom_type = read_atom_head(f_moov)
    if atom_type != 'mvhd': raise ValueError('mvhd not found')
    f_dst.write(struct.pack('>I', n))
    f_dst.write(b'mvhd')
    f_dst.write(f_moov.read(n-8))

    # udta
    n, atom_type = read_atom_head(f_moov)
    if atom_type != 'udta': raise ValueError('udta not found')
    f_dst.write(struct.pack('>I', n))
    f_dst.write(b'udta')
    f_dst.write(f_moov.read(n-8))

    # movie track
    # trak
    n, atom_type = read_atom_head(f_moov)
    if atom_type != 'trak': raise ValueError('trak not found')
    f_dst.write(struct.pack('>I', mov_trak_size))
    f_dst.write(b'trak')

    # tkhd
    n, atom_type = read_atom_head(f_moov)
    if atom_type != 'tkhd': raise ValueError('tkhd not found')
    f_dst.write(struct.pack('>I', n))
    f_dst.write(b'tkhd')
    f_dst.write(f_moov.read(n-8))

    # edts
    n, atom_type = read_atom_head(f_moov)
    if atom_type != 'edts': raise ValueError('edts not found')
    f_dst.write(struct.pack('>I', n))
    f_dst.write(b'edts')
    f_dst.write(f_moov.read(n-8))

    # mdia
    n, atom_type = read_atom_head(f_moov)
    if atom_type != 'mdia': raise ValueError('mdia not found')
    f_dst.write(struct.pack('>I', mov_mdia_size))
    f_dst.write(b'mdia')

    # mdhd
    n, atom_type = read_atom_head(f_moov)
    if atom_type != 'mdhd': raise ValueError('mdhd not found')
    f_dst.write(struct.pack('>I', n))
    f_dst.write(b'mdhd')
    f_dst.write(f_moov.read(n-8))

    # hdlr
    n, atom_type = read_atom_head(f_moov)
    if atom_type != 'hdlr': raise ValueError('hdlr not found')
    f_dst.write(struct.pack('>I', n))
    f_dst.write(b'hdlr')
    f_dst.write(f_moov.read(n-8))

    # minf
    n, atom_type = read_atom_head(f_moov)
    if atom_type != 'minf': raise ValueError('minf not found')
    f_dst.write(struct.pack('>I', mov_minf_size))
    f_dst.write(b'minf')

    # vmhd
    n, atom_type = read_atom_head(f_moov)
    if atom_type != 'vmhd': raise ValueError('vmhd not found')
    f_dst.write(struct.pack('>I', n))
    f_dst.write(b'vmhd')
    f_dst.write(f_moov.read(n-8))

    # dinf
    n, atom_type = read_atom_head(f_moov)
    if atom_type != 'dinf': raise ValueError('dinf not found')
    f_dst.write(struct.pack('>I', n))
    f_dst.write(b'dinf')
    f_dst.write(f_moov.read(n-8))

    # stbl
    n, atom_type = read_atom_head(f_moov)
    if atom_type != 'stbl': raise ValueError('stbl not found')
    f_dst.write(struct.pack('>I', mov_stbl_size))
    f_dst.write(b'stbl')

    # stsd
    n, atom_type = read_atom_head(f_moov)
    if atom_type != 'stsd': raise ValueError('stsd not found')
    f_dst.write(struct.pack('>I', n))
    f_dst.write(b'stsd')
    f_dst.write(f_moov.read(n-8))

    # stts
    n, atom_type = read_atom_head(f_moov)
    if atom_type != 'stts': raise ValueError('stts not found')
    f_dst.write(struct.pack('>I', n))
    f_dst.write(b'stts')
    f_dst.write(f_moov.read(n-8))

    # stsc
    n, atom_type = read_atom_head(f_moov)
    if atom_type != 'stsc': raise ValueError('stsc not found')
    f_dst.write(struct.pack('>I', n))
    f_dst.write(b'stsc')
    f_dst.write(f_moov.read(n-8))

    # stsz
    n, atom_type = read_atom_head(f_moov)
    if atom_type != 'stsz': raise ValueError('stsz not found')
    f_dst.write(struct.pack('>I', mov_stsz_size))
    f_dst.write(b'stsz')
    buf = f_moov.read(n-8)
    f_dst.write(buf[:4]) # version + flags
    f_dst.write(struct.pack('>I', 0)) # sample_size
    f_dst.write(struct.pack('>I', len(sample_size_tables[0]))) # n_entries
    for sz in sample_size_tables[0]:
        f_dst.write(struct.pack('>I', sz))

    # co64
    n, atom_type = read_atom_head(f_moov)
    if atom_type != 'co64': raise ValueError('co64 not found')
    f_dst.write(struct.pack('>I', mov_co64_size))
    f_dst.write(b'co64')
    f_moov.read(n-8)
    f_dst.write(buf[:4]) # version + flags
    f_dst.write(struct.pack('>I', len(chunk_offset_tables[0]))) # n_entries
    for co in chunk_offset_tables[0]:
        f_dst.write(struct.pack('>Q', co))

    # stss
    n, atom_type = read_atom_head(f_moov)
    if atom_type != 'stss': raise ValueError('stss not found')
    f_dst.write(struct.pack('>I', mov_stss_size))
    f_dst.write(b'stss')
    f_moov.read(n-8)
    f_dst.write(buf[:4]) # version + flags
    # mov_stss_size = ((len(sample_size_tables[0])-1)//32 + 1)* 4 + 16
    mov_stss_entries = (len(sample_size_tables[0])-1)//32 + 1
    f_dst.write(struct.pack('>I', mov_stss_entries)) # n_entries
    ss = 1
    for i_ss in range(mov_stss_entries):
        f_dst.write(struct.pack('>I', ss))
        ss += 32

    # uuid
    n, atom_type = read_atom_head(f_moov)
    if atom_type != 'uuid': raise ValueError('uuid not found')
    f_dst.write(struct.pack('>I', n))
    f_dst.write(b'uuid')
    f_dst.write(f_moov.read(n-8))

    # audio track
    # trak
    n, atom_type = read_atom_head(f_moov)
    if atom_type != 'trak': raise ValueError('trak not found')
    f_dst.write(struct.pack('>I', aac_trak_size))
    f_dst.write(b'trak')

    # tkhd
    n, atom_type = read_atom_head(f_moov)
    if atom_type != 'tkhd': raise ValueError('tkhd not found')
    f_dst.write(struct.pack('>I', n))
    f_dst.write(b'tkhd')
    f_dst.write(f_moov.read(n-8))

    # edts
    n, atom_type = read_atom_head(f_moov)
    if atom_type != 'edts': raise ValueError('edts not found')
    f_dst.write(struct.pack('>I', n))
    f_dst.write(b'edts')
    f_dst.write(f_moov.read(n-8))

    # mdia
    n, atom_type = read_atom_head(f_moov)
    if atom_type != 'mdia': raise ValueError('mdia not found')
    f_dst.write(struct.pack('>I', aac_mdia_size))
    f_dst.write(b'mdia')

    # mdhd
    n, atom_type = read_atom_head(f_moov)
    if atom_type != 'mdhd': raise ValueError('mdhd not found')
    f_dst.write(struct.pack('>I', n))
    f_dst.write(b'mdhd')
    f_dst.write(f_moov.read(n-8))

    # hdlr
    n, atom_type = read_atom_head(f_moov)
    if atom_type != 'hdlr': raise ValueError('hdlr not found')
    f_dst.write(struct.pack('>I', n))
    f_dst.write(b'hdlr')
    f_dst.write(f_moov.read(n-8))

    # minf
    n, atom_type = read_atom_head(f_moov)
    if atom_type != 'minf': raise ValueError('minf not found')
    f_dst.write(struct.pack('>I', aac_minf_size))
    f_dst.write(b'minf')

    # smhd
    n, atom_type = read_atom_head(f_moov)
    if atom_type != 'smhd': raise ValueError('smhd not found')
    f_dst.write(struct.pack('>I', n))
    f_dst.write(b'smhd')
    f_dst.write(f_moov.read(n-8))

    # dinf
    n, atom_type = read_atom_head(f_moov)
    if atom_type != 'dinf': raise ValueError('dinf not found')
    f_dst.write(struct.pack('>I', n))
    f_dst.write(b'dinf')
    f_dst.write(f_moov.read(n-8))

    # stbl
    n, atom_type = read_atom_head(f_moov)
    if atom_type != 'stbl': raise ValueError('stbl not found')
    f_dst.write(struct.pack('>I', aac_stbl_size))
    f_dst.write(b'stbl')

    # stsd
    n, atom_type = read_atom_head(f_moov)
    if atom_type != 'stsd': raise ValueError('stsd not found')
    f_dst.write(struct.pack('>I', n))
    f_dst.write(b'stsd')
    f_dst.write(f_moov.read(n-8))

    # stts
    n, atom_type = read_atom_head(f_moov)
    if atom_type != 'stts': raise ValueError('stts not found')
    f_dst.write(struct.pack('>I', n))
    f_dst.write(b'stts')
    f_dst.write(f_moov.read(n-8))

    # stsc
    n, atom_type = read_atom_head(f_moov)
    if atom_type != 'stsc': raise ValueError('stsc not found')
    f_dst.write(struct.pack('>I', n))
    f_dst.write(b'stsc')
    f_dst.write(f_moov.read(n-8))

    # stsz
    n, atom_type = read_atom_head(f_moov)
    if atom_type != 'stsz': raise ValueError('stsz not found')
    f_dst.write(struct.pack('>I', aac_stsz_size))
    f_dst.write(b'stsz')
    f_moov.read(n-8)
    f_dst.write(buf[:4]) # version + flags
    f_dst.write(struct.pack('>I', 0)) # sample_size
    f_dst.write(struct.pack('>I', len(sample_size_tables[1]))) # n_entries
    for sz in sample_size_tables[1]:
        f_dst.write(struct.pack('>I', sz))

    # co64
    n, atom_type = read_atom_head(f_moov)
    if atom_type != 'co64': raise ValueError('co64 not found')
    f_dst.write(struct.pack('>I', aac_co64_size))
    f_dst.write(b'co64')
    f_moov.read(n-8)
    f_dst.write(buf[:4]) # version + flags
    f_dst.write(struct.pack('>I', len(chunk_offset_tables[1]))) # n_entries
    for co in chunk_offset_tables[1]:
        f_dst.write(struct.pack('>Q', co))
```
</div></details>

この時点では `stsz` と `co64` を再構成しただけで疲れ切ってしまったので、 duration は壊れてない `moov` のコピペで済ませました。（注：後で、この点はきちんと対応しました。）

このようにして ADTS ヘッダーの情報を元に `mdat` から復元した `moov` を、壊れた MP4 ファイルの後ろにくっつけ、めでたく `moov` box を含んだ MP4 ファイルを再構成することが出来ました。

以下は、壊れたファイル `VID_20191023_202638_00_005.m4v` と復元された `moov` ファイル `target_00_005.moov` から、復元した MP4 ファイル `test_00_005.insv` を書き出すコードです。

<details><summary>壊れた　MP4 と復元された `moov` から復元された MP4 を構成し保存するコード</summary><div>

```python
from tqdm import tqdm

src_filename = '../Data/MP4/VID_20191023_202638_00_005.insv'
moov_filename = 'target_00_005.moov'
dst_filename = 'test_00_005.insv'

n_chunk = 2048

with open(src_filename, 'rb') as f_src,\
    open(moov_filename, 'rb') as f_moov,\
    open(dst_filename, 'wb') as f_dst:

    f_src.seek(0, 2)
    file_size = f_src.tell()

    cur = 0
    f_src.seek(cur)
    if f_src.tell() != cur: raise ValueError(f'seek failed? {f_src.tell()} != {cur}')

    # ftyp
    n, atom_type = read_atom_head(f_src)
    if atom_type != 'ftyp': raise ValueError('ftyp not found')

    f_src.seek(cur)
    if f_src.tell() != cur: raise ValueError(f'seek failed? {f_src.tell()} != {cur}')
    buf = f_src.read(n)
    f_dst.write(buf)
    print_binaries(buf)
    cur += n

    # mdat
    n, atom_type = read_atom_head(f_src)
    if atom_type != 'mdat': raise ValueError('mdat not found')

    if n != 0: raise ValueError('size would be zero...')

    # fixed mdat header
    print_binaries(struct.pack('>Icccc', 1, b'm', b'd', b'a', b't'))
    print_binaries(struct.pack('>Q', file_size-0x20))
    f_dst.write(struct.pack('>Icccc', 1, b'm', b'd', b'a', b't'))
    f_dst.write(struct.pack('>Q', file_size-0x20))

    cur += 16
    f_src.seek(cur)
    if f_src.tell() != cur: raise ValueError(f'seek failed? {f_src.tell()} != {cur}')

    for cur in tqdm(range(cur, file_size, n_chunk)):
        f_dst.write(f_src.read(n_chunk))
    if file_size - cur > 0:
        f_dst.write(f_src.read(file_size - cur))
    print('')

    # search moov
    f_moov.seek(0, 2)
    moov_size = f_moov.tell()
    print(f'moov_size: {moov_size}')

    moov_cur = 0
    f_moov.seek(moov_cur)
    if f_moov.tell() != moov_cur: raise ValueError(f'seek failed? {f_moov.tell()} != {moov_cur}')
    n, atom_type = read_atom_head(f_moov)
    if atom_type != 'moov': raise ValueError(f'something is wrong...')

    # copy moov
    f_moov.seek(moov_cur)
    if f_moov.tell() != moov_cur: raise ValueError(f'seek failed? {f_moov.tell()} != {moov_cur}')
    for moov_cur in tqdm(range(moov_cur, moov_size, n_chunk)):
        f_dst.write(f_moov.read(n_chunk))
    if moov_size - moov_cur > 0:
        f_dst.write(f_moov.read(moov_size - moov_cur))
```
</div></details>

このファイル、実際に `ffplay` に食わせると、見事に再生してくれました！


# フェーズ５〜カメラのメタデータとスティッチ
気分としては、この時点で問題解決できたと思ってて、「ちょっと大変だったけど楽勝だった」くらいに思ってました。当のビデオは３６０パノラマ動画なので、壊れたファイルがもう１つあります。それも同じように復元しました。で、こちらも問題なく `ffplay` で再生できました。（注：それなりに長く再生させて、注意深く見ていれば、この時にプレーヤが警告を出していたはずですが、この時は「できた、できた」と思ってました。）

ここで新たな問題が発生しました。復元した２つのファイル（魚眼レンズの映像になっている）を１本のパノラマ動画（アスペクト比が２：１の equirectangular フォーマットの映像）に変換するために、専用の Insta360 Studio 2019 というスティッチソフトで開いてみると、本来パノラマ形式でプレビューされるはずが、魚眼レンズ形式のままです。壊れていないファイルを開くとと、もちろん、パノラマ形式で開かれます。また画像情報タブを開くと「ONE X」などのカメラ情報も見れます。どうやら、カメラに関するメタ情報（の一部）が落ちてしまっているようです。

普通に考えると、それらの情報は `udta` か `uuid` に入ってそうですが、それらは復元したファイルにも既に含まれています。つまり、そこにある情報だけでは不十分ということです。

ここでさらに詳しく壊れていないファイルの atom box の構造をみてみると、`moov` box の後に `free` box があり、また、２つのレンズのうち１つのファイル（00 とラベルされている方のファイル）にだけ `free` box の後ろに謎の領域が存在することが分かりました。

ということで、サンプルテーブルから `moov` を復元する際に、リファレンスとなる `moov` ファイルの（抜き出し時に EOF までコピーしてあった）問題の後ろの領域も含めてコピーするようにしてみました。

<details><summary>サンプルテーブルから `moov` を復元するコードへの追加コード</summary><div>

```python
# 上の、サンプルテーブルから moov を復元するコードの最後の部分に、以下を追加
moov_filename = 'ref_00_004.moov'
dst_filename  = 'target_00_005.moov'

with open(moov_filename, 'rb') as f_moov,\
    open(dst_filename, 'wb') as f_dst:

    # 途中は省略

    # just copy the rest of reference moov file
    n_chunk = 2048
    moov_cur = f_moov.tell()
    f_moov.seek(0, 2)
    moov_size = f_moov.tell()
    f_moov.seek(moov_cur)
    if f_moov.tell() != moov_cur: raise ValueError(f'seek failed? {f_moov.tell()} != {moov_cur}')
    for moov_cur in tqdm(range(moov_cur, moov_size, n_chunk)):
        f_dst.write(f_moov.read(n_chunk))
    if moov_size - moov_cur > 0:
        f_dst.write(f_moov.read(moov_size - moov_cur))
```
</div></details>

この結果、スティッチソフトに正しくパノラマだと認識されるようになりました。（これ以上詳しく追っていませんが、おそらく `free` box の後ろにある領域に追加の情報が入っていたのかな、と思います。カメラには、この他にも gyro の情報も、どこかに格納されているはずです。）

これで全て解決したか、と言うと、そうではありませんでした。スティッチソフトでプレビューは出来るのですが、実際に変換しようとすると、最初の１フレームくらいで変換が終了してしまいます。まだ何か、ぼくの理解していない（難しそうだから、とスキップしてた）部分が、足を引っ張っているようです。

この時点で、かなり手詰まり感が強く、いろいろググっていたら、 reddit に、同じカメラ ONE X で、同じ症状で、みんな悩んでいることが分かりました。

* [Insta360 One X Corrupt INSV File. How do I fix this?](https://www.reddit.com/r/Insta360/comments/b35nap/insta360_one_x_corrupt_insv_file_how_do_i_fix_this/)

しかし、ここはカメラユーザーのコミュニティのようで、技術的に新しい知見は得られませんでした。

その後も、スティッチソフトをあれこれ触ったり、付け足す領域を変えてみたりと、成功しない試行錯誤を繰り返していましたが、ある時ふと、復元した動画を `ffplay` で再生していた時に、スティッチが失敗する時間あたりにプレーヤーが警告を出ていることに気付きました。どうやら `mdat` の解析で抜き出したストリームが、一部、正しくなかったようです。

# フェーズ６〜H264の勉強
これまでは `mdat` の中を AAC チャンクの目印である ADTS ヘッダを探すことでサンプルテーブルを構成していました。その時のことを思い出すと、当初 ADTS ヘッダを探す条件を先頭３バイトにすると失敗するので、４バイトを取って、最後の２ビットは（サイズを表す領域なので）マスクして、マッチングしていました。どうも `mdat` の解析を ADTS ヘッダだけに頼るのは、実はそれほど確実ではなかったようです。

こういう認識から、面倒くさいなと思って敬遠していた H264 の方の格納方法について、やっぱりきちんと押さえておこうと思い直して、調べてみました。すると H264 のストリームの区切りは一般に NAL Unit とかいう（分かりにくい？）ものがあるようですが、MP4 コンテナに格納する際は単純に最初の４バイトに frame length が入っているようです。

以上の考察から、 `mdat` のデータからサンプルテーブルを構成する方法を、

* 先頭からまず ADTS ヘッダを探し、見つかればそのポイントと、ヘッダに書かれたサイズをオーディオチャンクとして記録し、その終わりに飛ぶ
* ADTS ヘッダが見つからなければそのポイントと、そこから４バイトに入っている値をサイズを動画チャンクとして記録し、その終わりに飛ぶ

というように修正しました。

<details><summary>修正された `mdat` からサンプルテーブルを構成するコード</summary><div>

```python
import struct

def recover_sample_tables_from_mdat_fast(filename, verbose=False):
    mov_table = []
    aac_table = []

    with open(filename, 'rb') as f_in:

        # look for 'mdat'
        src_cur = 0
        while True:
            f_in.seek(src_cur)
            if f_in.tell() != src_cur: raise ValueError(f'seek failed? {f_in.tell()} != {src_cur}')

            n, atom_type = read_atom_head(f_in)
            if atom_type == 'mdat': break
            src_cur += n

        # 'mdat' is found
        mdat_start = src_cur
        if n == 0:
            # mdat from impcomplete mp4 file
            f_in.seek(0, 2)
            mdat_end = f_in.tell()
            # seek the data_start position
            # 8 bytes for the header PLUS 8 bytes for the reserved space of the size
            f_in.seek(src_cur + 16)
        else:
            mdat_end   = src_cur + n

        n = 0
        while True:
            cur = f_in.tell()
            if cur >= mdat_end: break

            buf = f_in.read(4)

            if buf[0] != 0xFF or buf[1] != 0xF1 or buf[2] != 0x4C or (buf[3] & 0b11111100) != 0x80:
                # h264 chunk
                frame_length = struct.unpack('>I', buf)[0] + 4
                if cur+frame_length >= mdat_end: break

                if verbose: print(f'{n}: [mov] {cur}, {frame_length}')
                mov_table.append((cur, frame_length))
                f_in.seek(cur+frame_length)
            else:
                buf_2 = f_in.read(2)

                # from https://wiki.multimedia.cx/index.php/ADTS
                # AAAAAAAA AAAABCCD EEFFFFGH HHIJKLMM MMMMMMMM MMMOOOOO OOOOOOPP (QQQQQQQQ QQQQQQQQ)
                # 0th-byte 1st      2nd      3rd      4th      5th      6th      (7th      8th     )
                # 0xFF     0xF1     0x4C     0X80 -- typical case for Insta360 ONE-X
                # M 13 frame length, this value must include 7 or 9 bytes of header length
                #   FrameLength = (ProtectionAbsent == 1 ? 7 : 9) + size(AACFrame)
                frame_length = ((buf[3] & 0b11) << 11) | (buf_2[0] << 3) | (buf_2[1] >> 5)
                if cur+frame_length >= mdat_end: break

                if verbose: print(f'{n}: [aac] {cur}, {frame_length}')
                aac_table.append((cur, frame_length))
                f_in.seek(cur+frame_length)

            n += 1

    return mov_table, aac_table

mov_table, aac_table = recover_sample_tables_from_mdat_fast(
    '../Data/MP4/VID_20191023_202638_00_005.m4v',
    verbose=False)
```
</div></details>

以前のコードだと ADTS ヘッダを（相対的に長い）動画チャンクの部分も連続的にサーチしていたため、とても処理が遅かったのですが、新しい方法だと（動画チャンクもサイズが分かるので、スキップできるため）処理が格段に速くなりました。また確認のために、壊れていないファイルの `mdat` box に対してこのアルゴリズムを使って構成したサンプルテーブルを、 `moov` に格納されている本物のサンプルテーブルと比較したところ、完全に一致することも確認できました。（最初のアルゴリズムを書いた時に、きちんと、こういうチェックをしておくべきでした。）

# フェーズ７〜サンプルテーブルから正しくdurationを計算
今度こそ問題が全て解決した、と思って、壊れたファイル２つを復元し、 Insta360 Studio 2019 に食わせてみました。すると今度はプレビューもされず「ファイルが壊れている」とポップアップが出るようになりました。（振り返ると、この時のエラーは、直前の試行錯誤の過程で、暗黙のうちにテンポラリーファイルに出力していた中間状態の形式を変えていて、その変更を一部忘れていたというケアレスミスのせいだったのですが、この時は当然分かりませんでした。結果、コード自体は duration を正しく計算するようになり、よりよいものになったので、良かったのですが。）

いろいろと見返しているうちに、これまできちんと見ていなかった `stbl` box の中の `stts` box はサンプルテーブルの数を格納する場所であることに、今更ですが気付きました。（これまでは、リファレンスの `moov` の値をコピペしていました。）この気付きの後、改めて全体の atom box のサンプル数に関する値を見直していたら、 duration がサンプル数からきちんと計算できることが分かりました（というか、やっと理解できました）。

以下は、サンプルテーブルから duration も含め正しく再構成された `moov` を生成する最終的なコードです。

<details><summary>最終的なサンプルテーブルから `moov` を復元するコード</summary><div>

```python
def recover_moov_from_sample_tables(
    ref_filename, dst_filename,
    mov_table, acc_table,
    full_copy=True, n_chunk=65536,
    ):

    # constants
    mov_sample_duration = 1001
    aac_sample_duration = 1024

    mvhd_timescale = 48000
    mov_timescale = 30000
    aac_timescale = 48000

    n_mov_table = len(mov_table)
    n_aac_table = len(aac_table)

    mov_mdhd_duration = n_mov_table * mov_sample_duration
    aac_mdhd_duration = n_aac_table * aac_sample_duration
    mov_tkhd_duration = int(mov_mdhd_duration * mvhd_timescale / mov_timescale)


    sample_size_tables = []
    sample_size_tables.append([s for o, s in mov_table])
    sample_size_tables.append([s for o, s in aac_table])

    chunk_offset_tables = []
    chunk_offset_tables.append([o for o, s in mov_table])
    chunk_offset_tables.append([o for o, s in aac_table])


    # moov structure is assumed to be in the fixed format (for now)
    mov_stsz_size = len(sample_size_tables[0])* 4 + 20
    aac_stsz_size = len(sample_size_tables[1])* 4 + 20

    mov_co64_size = len(chunk_offset_tables[0])* 8 + 16
    aac_co64_size = len(chunk_offset_tables[1])* 8 + 16

    mov_stss_size = ((len(sample_size_tables[0])-1)//32 + 1)* 4 + 16


    mov_stbl_size = 8 + 0x141 + 0x18 + 0x1C + mov_stsz_size + mov_co64_size + mov_stss_size
    aac_stbl_size = 8 + 0x82  + 0x18 + 0x1C + aac_stsz_size + aac_co64_size

    mov_minf_size = 8 + 0x14 + 0x24 + mov_stbl_size
    aac_minf_size = 8 + 0x10 + 0x24 + aac_stbl_size

    mov_mdia_size = 8 + 0x20 + 0x2E + mov_minf_size
    aac_mdia_size = 8 + 0x20 + 0x2E + aac_minf_size

    mov_trak_size = 8 + 0x5C + 0x24 + mov_mdia_size + 0x618
    aac_trak_size = 8 + 0x5C + 0x24 + aac_mdia_size

    moov_size = 8 + 0x6C + 0x73 + mov_trak_size + aac_trak_size


    with open(ref_filename, 'rb') as f_moov,\
        open(dst_filename, 'wb') as f_dst:

        f_moov.seek(0, 2)
        file_size = f_moov.tell()

        cur = 0
        f_moov.seek(cur)
        if f_moov.tell() != cur: raise ValueError(f'seek failed? {f_moov.tell()} != {cur}')

        # moov
        copy_atom_box('moov', moov_size, f_moov, f_dst, only_header=True)

        #copy_atom_box('mvhd', None, f_moov, f_dst, only_header=False)
        # mvhd : duration = mov_tkhd_duration
        n = copy_atom_box('mvhd', None, f_moov, f_dst, only_header=True)
        buf = f_moov.read(n-8)
        # the following is unchanged
        f_dst.write(buf[:16])
        #duration           = struct.unpack('>I', buf[16:20])[0]
        f_dst.write(struct.pack('>I', mov_tkhd_duration))
        # the rest is unchanged
        f_dst.write(buf[20:])
        #...
        #next_track_id      = struct.unpack('>I', buf[96:100])[0]
        if n != (100+8): raise ValueError(f'ERROR: mov tkhd box size is not 108 but {n}')

        copy_atom_box('udta', None, f_moov, f_dst, only_header=False)

        # movie track
        # trak
        copy_atom_box('trak', mov_trak_size, f_moov, f_dst, only_header=True)

        #copy_atom_box('tkhd', None, f_moov, f_dst, only_header=False)
        # tkhd : duration = mov_tkhd_duration
        n = copy_atom_box('tkhd', None, f_moov, f_dst, only_header=True)
        buf = f_moov.read(n-8)
        # the following is unchanged
        f_dst.write(buf[:20])
        #duration           = struct.unpack('>I', buf[20:24])[0]
        f_dst.write(struct.pack('>I', mov_tkhd_duration))
        # the rest is unchanged
        f_dst.write(buf[24:])
        #...
        #track_height       = struct.unpack('>I', buf[80:84])[0]
        if n != (84+8): raise ValueError(f'ERROR: mov tkhd box size is not 92 but {n}')

        copy_atom_box('edts', None, f_moov, f_dst, only_header=False)

        # mdia
        copy_atom_box('mdia', mov_mdia_size, f_moov, f_dst, only_header=True)

        #copy_atom_box('mdhd', None, f_moov, f_dst, only_header=False)
        # mdhd : duration = mov_mdhd_duration
        n = copy_atom_box('mdhd', None, f_moov, f_dst, only_header=True)
        buf = f_moov.read(n-8)
        # the following is unchanged
        f_dst.write(buf[:16])
        #duration           = struct.unpack('>I', buf[16:20])[0]
        f_dst.write(struct.pack('>I', mov_mdhd_duration))
        # the rest is unchanged
        f_dst.write(buf[20:])
        #...
        #quality            = struct.unpack('>H', buf[22:24])[0]
        if n != (24+8): raise ValueError(f'ERROR: mov mdhd box size is not 32 but {n}')

        copy_atom_box('hdlr', None, f_moov, f_dst, only_header=False)

        # minf
        copy_atom_box('minf', mov_minf_size, f_moov, f_dst, only_header=True)
        copy_atom_box('vmhd', None, f_moov, f_dst, only_header=False)
        copy_atom_box('dinf', None, f_moov, f_dst, only_header=False)

        # stbl
        copy_atom_box('stbl', mov_stbl_size, f_moov, f_dst, only_header=True)
        copy_atom_box('stsd', None, f_moov, f_dst, only_header=False)

        #copy_atom_box('stts', None, f_moov, f_dst, only_header=False)
        # stts : sample_count = n_mov_table
        n = copy_atom_box('stts', None, f_moov, f_dst, only_header=True)
        buf = f_moov.read(n-8)
        f_dst.write(buf[:4]) # version + flags
        f_dst.write(struct.pack('>I', 1)) # n_entries
        f_dst.write(struct.pack('>I', n_mov_table)) # sample_count
        f_dst.write(struct.pack('>I', mov_sample_duration)) # sample_duration

        copy_atom_box('stsc', None, f_moov, f_dst, only_header=False)

        # stsz
        n = copy_atom_box('stsz', mov_stsz_size, f_moov, f_dst, only_header=True)
        buf = f_moov.read(n-8)
        f_dst.write(buf[:4]) # version + flags
        f_dst.write(struct.pack('>I', 0)) # sample_size
        f_dst.write(struct.pack('>I', len(sample_size_tables[0]))) # n_entries
        for sz in sample_size_tables[0]:
            f_dst.write(struct.pack('>I', sz))

        # co64
        n = copy_atom_box('co64', mov_co64_size, f_moov, f_dst, only_header=True)
        buf = f_moov.read(n-8)
        f_dst.write(buf[:4]) # version + flags
        f_dst.write(struct.pack('>I', len(chunk_offset_tables[0]))) # n_entries
        for co in chunk_offset_tables[0]:
            f_dst.write(struct.pack('>Q', co))

        # stss
        n = copy_atom_box('stss', mov_stss_size, f_moov, f_dst, only_header=True)
        buf = f_moov.read(n-8)
        f_dst.write(buf[:4]) # version + flags
        mov_stss_entries = (len(sample_size_tables[0])-1)//32 + 1
        f_dst.write(struct.pack('>I', mov_stss_entries)) # n_entries
        ss = 1
        for i_ss in range(mov_stss_entries):
            f_dst.write(struct.pack('>I', ss))
            ss += 32

        # uuid
        copy_atom_box('uuid', None, f_moov, f_dst, only_header=False)


        # audio track
        # trak
        copy_atom_box('trak', aac_trak_size, f_moov, f_dst, only_header=True)

        #copy_atom_box('tkhd', None, f_moov, f_dst, only_header=False)
        # tkhd : duration = aac_mdhd_duration
        n = copy_atom_box('tkhd', None, f_moov, f_dst, only_header=True)
        buf = f_moov.read(n-8)
        # the following is unchanged
        f_dst.write(buf[:20])
        #duration           = struct.unpack('>I', buf[20:24])[0]
        f_dst.write(struct.pack('>I', aac_mdhd_duration))
        # the rest is unchanged
        f_dst.write(buf[24:])
        #...
        #track_height       = struct.unpack('>I', buf[80:84])[0]
        if n != (84+8): raise ValueError(f'ERROR: audio tkhd box size is not 92 but {n}')

        copy_atom_box('edts', None, f_moov, f_dst, only_header=False)

        # mdia
        copy_atom_box('mdia', aac_mdia_size, f_moov, f_dst, only_header=True)

        #copy_atom_box('mdhd', None, f_moov, f_dst, only_header=False)
        # mdhd : duration = aac_mdhd_duration
        n = copy_atom_box('mdhd', None, f_moov, f_dst, only_header=True)
        buf = f_moov.read(n-8)
        # the following is unchanged
        f_dst.write(buf[:16])
        #duration           = struct.unpack('>I', buf[16:20])[0]
        f_dst.write(struct.pack('>I', aac_mdhd_duration))
        # the rest is unchanged
        f_dst.write(buf[20:])
        #...
        #quality            = struct.unpack('>H', buf[22:24])[0]
        if n != (24+8): raise ValueError(f'ERROR: audio mdhd box size is not 32 but {n}')

        copy_atom_box('hdlr', None, f_moov, f_dst, only_header=False)

        # minf
        copy_atom_box('minf', aac_minf_size, f_moov, f_dst, only_header=True)
        copy_atom_box('smhd', None, f_moov, f_dst, only_header=False)
        copy_atom_box('dinf', None, f_moov, f_dst, only_header=False)

        # stbl
        copy_atom_box('stbl', aac_stbl_size, f_moov, f_dst, only_header=True)
        copy_atom_box('stsd', None, f_moov, f_dst, only_header=False)

        #copy_atom_box('stts', None, f_moov, f_dst, only_header=False)
        # stts : sample_count = n_aac_table
        n = copy_atom_box('stts', None, f_moov, f_dst, only_header=True)
        buf = f_moov.read(n-8)
        f_dst.write(buf[:4]) # version + flags
        f_dst.write(struct.pack('>I', 1)) # n_entries
        f_dst.write(struct.pack('>I', n_aac_table)) # sample_count
        f_dst.write(struct.pack('>I', aac_sample_duration)) # sample_duration

        copy_atom_box('stsc', None, f_moov, f_dst, only_header=False)

        # stsz
        n = copy_atom_box('stsz', aac_stsz_size, f_moov, f_dst, only_header=True)
        buf = f_moov.read(n-8)
        f_dst.write(buf[:4]) # version + flags
        f_dst.write(struct.pack('>I', 0)) # sample_size
        f_dst.write(struct.pack('>I', len(sample_size_tables[1]))) # n_entries
        for sz in sample_size_tables[1]:
            f_dst.write(struct.pack('>I', sz))

        # co64
        n = copy_atom_box('co64', aac_co64_size, f_moov, f_dst, only_header=True)
        buf = f_moov.read(n-8)
        f_dst.write(buf[:4]) # version + flags
        f_dst.write(struct.pack('>I', len(chunk_offset_tables[1]))) # n_entries
        for co in chunk_offset_tables[1]:
            f_dst.write(struct.pack('>Q', co))

        if not full_copy: return

        # just copy the rest of reference moov file
        moov_cur = f_moov.tell()
        f_moov.seek(0, 2)
        moov_size = f_moov.tell()
        f_moov.seek(moov_cur)
        if f_moov.tell() != moov_cur: raise ValueError(f'seek failed? {f_moov.tell()} != {moov_cur}')
        for moov_cur in tqdm(range(moov_cur, moov_size, n_chunk)):
            f_dst.write(f_moov.read(n_chunk))
        if moov_size - moov_cur > 0:
            f_dst.write(f_moov.read(moov_size - moov_cur))
```
</div></details>

このように色々と調べるうちに、１つの MP4 ファイルには３種類の duration が存在することが分かりました。つまり、

* `mvhd` の duration : 動画のサンプル数に対し timescale 48000 で計算した duration
* 動画の `mdhd` の duration : 動画のサンプル数に対して timescale 30000 で計算した duration
* オーディオの `mdhd` の duration : オーディオのサンプル数に対して timescale 48000 で計算した duration

です。atom boxes の中にはこの他に、動画トラックとオーディオトラックのそれぞれの `tkhd` にも duration が指定されていますが、動画の方は `mvhd` の値、オーディオの方は `mdhd` の値が入っていました。

# フェーズ８〜完成！
このようにコードもいろいろと整理して、一度コード全体をまっさらな状態から計算し直していたら、見付けました！大きな（しかし下らない）間違いを。上で

> 今度はプレビューもされず「ファイルが壊れている」とポップアップが出るようになりました

と言っていた原因は、恐らく、この間違いです。つまり、中間ファイルとして `mdat` から構成したサンプルテーブルを、ファイルに書き出していたのですが、ある時点で、`(aac_table, mov_table)` という tuple から `(mov_table, aac_table)` という風に変えました。多分 MP4 の atom box の構成が mov が先で aac が後だったことに気付いて揃えたように思います。その結果、テストで使っていた中間ファイルが、レンズ０の方は新しいもの、レンズ１の方は古いもの、という状態になっていました。実際にレンズ１の復元された動画を `ffplay` で再生しようとしたら再生できなくて、気付きました。そういえば duration の計算を実装した後、数字を見ると、レンズ０が８分だったのに対してレンズ１の方が１３分と長くなったのを見て、「ほぉ、５分余分にデータが掘り出せたぞ」と呑気に喜んでたんですが、単に動画と音声のサンプルを取り違えていただけでした。（愚かさというのは、後で見るとよく分かりますね。逆に、その瞬間には分かりにくいものですが。）

以上の試行錯誤を全部まとめたトップレベルの関数が以下のようになります。

<details><summary>全部まとめたトップレベルの関数</summary><div>

```python
def finsta360(
    src_filename,
    ref_filename=None,
    dst_filename=None,
    keep_temp=False,
    verbose=False):

    if ref_filename is None:
        # check mode
        print_atoms(src_filename)
        return

    # temporary files
    ref_moov_filename = 'finsta360_ref.moov'
    new_moov_filename = 'finsta360_new.moov'

    # 1) extract reference moov
    print('')
    print('########################################')
    print(f'# 1) extracting reference moov from\n\t{ref_filename}')
    extract_moov(ref_filename, ref_moov_filename)
    if verbose:
        print_atoms(ref_moov_filename)

    # 2) regenerate sample tables from mdat
    print('')
    print('########################################')
    print(f'# 2) regenerate sample tables from mdat in\n\t{src_filename}')
    mov_table, aac_table = recover_sample_tables_from_mdat_fast(
        src_filename,
        verbose=False)
    if verbose:
        print(f'number of samples (movie) : {len(mov_table)}')
        print(f'number of samples (audio) : {len(aac_table)}')
        # constants
        mov_sample_duration = 1001
        aac_sample_duration = 1024

        mvhd_timescale = 48000
        mov_timescale = 30000
        aac_timescale = 48000

        n_mov_table = len(mov_table)
        n_aac_table = len(aac_table)

        mov_mdhd_duration = n_mov_table * mov_sample_duration
        aac_mdhd_duration = n_aac_table * aac_sample_duration
        mov_tkhd_duration = int(mov_mdhd_duration * mvhd_timescale / mov_timescale)

        # mvhd
        mvhd_duration_sec = mov_tkhd_duration / mvhd_timescale
        print(f'mvhd duration  : {mvhd_duration_sec} sec / {mvhd_duration_sec/60} min')
        # movie mdhd
        mov_duration_sec = mov_mdhd_duration / mov_timescale
        print(f'movie duration : {mov_duration_sec} sec / {mov_duration_sec/60} min')
        # audio mdhd
        aac_duration_sec = aac_mdhd_duration / aac_timescale
        print(f'audio duration : {aac_duration_sec} sec / {aac_duration_sec/60} min')

    # 3) rebuilding moov from the sample tables
    print('')
    print('########################################')
    print(f'# 3) rebuilding moov from the sample tables')
    recover_moov_from_sample_tables(
        ref_moov_filename,
        new_moov_filename,
        mov_table, aac_table,
        full_copy=True,
    )
    if verbose:
        print_atoms(new_moov_filename)

    if dst_filename is None:
        # test mode
        if not keep_temp:
            os.remove(ref_moov_filename)
            os.remove(new_moov_filename)
        return

    # 4) merging the rebuilt moov into the source
    print('')
    print('########################################')
    print(f'# 4) merging the rebuilt moov into\n\t{src_filename}\nas\n\t{dst_filename}')
    merge_moov(
        src_filename,
        new_moov_filename,
        dst_filename,
    )


    if not keep_temp:
        os.remove(ref_moov_filename)
        os.remove(new_moov_filename)

```
</div></details>

紆余曲折ありましたが、最終的にこのようにまとめて、壊れたファイルと壊れてないファイルを渡すと、サクッと復元してくれて、 Insta360 Studio 2019 もサクッとスティッチしてくれました。これまでの苦労がまるで嘘のようです。

実際に使う時は https://github.com/kichiki/finsta360 から clone してご使用ください。


# まとめ
という努力の元に編集されたイベントのビデオがこちらになります。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">１０月の ZENKEI AI FORUM のビデオ５本目、最後の１本は録画トラブルで編集など大変だった古川さんの「ゼロからはじめるAI」です。<a href="https://t.co/MG25U4rVZT">https://t.co/MG25U4rVZT</a><br>今月は１１月２７日開催です。今回も盛り沢山の内容を予定していますので、楽しみにしていてください！</p>&mdash; ichiki kengo (@ichiki_k) <a href="https://twitter.com/ichiki_k/status/1195376279152840704?ref_src=twsrc%5Etfw">November 15, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

壊れてないビデオ（004）の後に、今回復元できた８分の動画（005）をつないで、さらにその後、カメラトラブルに気付いてバックアップの THETA V で撮影再開したものを（トビがありますが）つないで、トータル２４分少々まで復元しました。疲れた…


# 付記（改題について）

会う人会う人に「せっかく頑張って書いたんだけど、全然注目されない」と愚痴っていたところ、ある人から「タイトルが悪い」とのアドバイスをもらいました。元のタイトルは

> 動画カメラがファイルの書き出しに失敗したとき、ONE X編

でしたが、「これだと、何がうれしいのか分からない」とのこと。確かにそうだと思ったので、前向きに変えてみました。


# 付録：実況ツイート
上記ストーリーは基本的に開発当時の実況的なツイートをベースにしてます。

最初は本文に挿入する形にしていましたが、読みにくいので、付録としてここにまとめておきます。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">某イベント <a href="https://t.co/A8hysssjcu">https://t.co/A8hysssjcu</a> の模様をInsta360 ONE-Xで撮影したもの、電源が切れたのかな？最後のファイルが尻切れでmoovがない、でもmdatにデータは詰まってる状態。salvageしたいと思うのが人情で、ここ数日粘ってる。<br>ググったらおすすめらしい <a href="https://t.co/lrZDFNe95N">https://t.co/lrZDFNe95N</a> とか試したがダメ</p>&mdash; ichiki kengo (@ichiki_k) <a href="https://twitter.com/ichiki_k/status/1192393792805978112?ref_src=twsrc%5Etfw">November 7, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr"><a href="https://t.co/mM17uxecxe">https://t.co/mM17uxecxe</a> とか <a href="https://t.co/sBQssCUU95">https://t.co/sBQssCUU95</a> とか見ながら（最近 python なのでそれで）パーサー書いて、sample table を復元する必要があると悟る。見れば同じ境遇の人が居た。<a href="https://t.co/qRywfHJmfj">https://t.co/qRywfHJmfj</a><br>この方は断念したみたいだが...</p>&mdash; ichiki kengo (@ichiki_k) <a href="https://twitter.com/ichiki_k/status/1192394588297629696?ref_src=twsrc%5Etfw">November 7, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">...でも結局は mdatのバイナリから、適宜 aac と h265 を腑分けできればいいんだよな。そんなツール、誰か書いてないのかな？<br>あ、あれだ、 deep learning で分類するか（ウソ）<br>諦めるか、何かもうちょっと粘るか、うーん...</p>&mdash; ichiki kengo (@ichiki_k) <a href="https://twitter.com/ichiki_k/status/1192394982167941121?ref_src=twsrc%5Etfw">November 7, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">あれ、もしかして aac の chunk にはもれなく ADTS ヘッダーついてるのか？そしたら分離できるか！？<a href="https://t.co/fB1az1XsoI">https://t.co/fB1az1XsoI</a></p>&mdash; ichiki kengo (@ichiki_k) <a href="https://twitter.com/ichiki_k/status/1192407086442803200?ref_src=twsrc%5Etfw">November 7, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">ADTS ヘッダーに frame length があるので、あぁ、腑分けできる。<a href="https://t.co/DGmtzKR28H">https://t.co/DGmtzKR28H</a><br><br>明日、試そうっと</p>&mdash; ichiki kengo (@ichiki_k) <a href="https://twitter.com/ichiki_k/status/1192411054967422976?ref_src=twsrc%5Etfw">November 7, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">ふぅ、できた。<br>aac の抜き出しは簡単で、そこから sample table の復元も簡単だったけど、この情報を moov にまとめるのが面倒だった。とりあえず同じカメラで撮った mp4 の moov を reference に復元された moov を入れて、プレーヤーで見れることまで確認。duration とかは reference のまま</p>&mdash; ichiki kengo (@ichiki_k) <a href="https://twitter.com/ichiki_k/status/1193028967143395328?ref_src=twsrc%5Etfw">November 9, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">ということで（ONE-Xの動画なので）セットのもう１つの壊れたMP4も同様に復元しよう（で、その後スティッチして、音量調整して、前後の動画とconcatして、YouTubeへアップだな）<br>汎用性がどれほどある情報か分からないけど、自分も忘れるので、この際 Qiita にでもまとめようと思ってる。</p>&mdash; ichiki kengo (@ichiki_k) <a href="https://twitter.com/ichiki_k/status/1193034512357216257?ref_src=twsrc%5Etfw">November 9, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">うむ、できた１組２本の動画を Insta360 Studio 2019 に食わせたが、スティッチしてくれない。どこかに正しい meta data とか入れとかないとダメなのかな？ uuid とか？</p>&mdash; ichiki kengo (@ichiki_k) <a href="https://twitter.com/ichiki_k/status/1193055383327109120?ref_src=twsrc%5Etfw">November 9, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">壊れてないファイルの moov の後ろにくっついてる free box と z box をコピーしてみたら、Insta360 Studio 2019 がカメラ情報はうまくゲットできたようで preview はできたが、stitch は１フレームしかしてくれない。メタデータの方とも何らかの整合を取らないとダメか...</p>&mdash; ichiki kengo (@ichiki_k) <a href="https://twitter.com/ichiki_k/status/1193397737623941120?ref_src=twsrc%5Etfw">November 10, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">ググってみると、やっぱり、みんな困ってるね<a href="https://t.co/Jnirt3u4yi">https://t.co/Jnirt3u4yi</a><br>そうか、あの辺に gyro の情報とかが入ってるんだな（それみて stitch するわけで、まぁ、うまくいかんわな...）</p>&mdash; ichiki kengo (@ichiki_k) <a href="https://twitter.com/ichiki_k/status/1193397973209608192?ref_src=twsrc%5Etfw">November 10, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">その後、カメラのメタデータとかはファイルの最後（free box の後ろ？）にあるらしいことが分かった。それを付けるとプレビューは出来た！が、変換は最初一瞬、いろいろやって２分くらいでしかし止まる。みるとプレーヤも警告を出してたので、復元が完璧でなかった模様。</p>&mdash; ichiki kengo (@ichiki_k) <a href="https://twitter.com/ichiki_k/status/1194609122915700738?ref_src=twsrc%5Etfw">November 13, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">で mdat から sample table を復元するのに、 h264 の方もマーカーないかと思って仕様を調べたら、mp4 に格納するときは頭の４バイトがサイズになってるらしい。壊れてない mp4 で確認したところ、これで復元すると sample table が完全に一致！</p>&mdash; ichiki kengo (@ichiki_k) <a href="https://twitter.com/ichiki_k/status/1194609957401845761?ref_src=twsrc%5Etfw">November 13, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">これで復元した２眼の mp4 を Insta360 Studio 2019 に食わすと、今度はプレビューすらしないで「壊れてる」と…<br>で今日改めて stbl box の中身を見てたら、あぁ stts に正しい sample count 入れなきゃダメじゃないか（ref の moov の値のまま置いていた）<br>明日、これを試そう</p>&mdash; ichiki kengo (@ichiki_k) <a href="https://twitter.com/ichiki_k/status/1194610574044217344?ref_src=twsrc%5Etfw">November 13, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">あぁ、しかもこれらの情報からきちんと duration 計算できるではないですか。ふむ、これできちんと整合する moov が書けるね。（最初からそうするべきだったけど……これを泥縄式と呼ぶ）</p>&mdash; ichiki kengo (@ichiki_k) <a href="https://twitter.com/ichiki_k/status/1194611936769392641?ref_src=twsrc%5Etfw">November 13, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">できた（今スティッチ中）<br>直近のスティッチャーが「ファイル壊れてる」と言ってたのは些末な問題だった（途中でコードの仕様をオレが変えた関係で中間ファイルの絵と音のテーブルが入れ替わってた…）<br>おかげで正しいduration計算できるようになったし結果オーライ<br>まずはイベントビデオを仕上げよう</p>&mdash; ichiki kengo (@ichiki_k) <a href="https://twitter.com/ichiki_k/status/1194828843619512321?ref_src=twsrc%5Etfw">November 14, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">という流れで、一発で壊れた ONE-X の動画ファイルを復元してくれるスクリプトを github にアップしました！<a href="https://t.co/bJwBD0axlr">https://t.co/bJwBD0axlr</a><br>python だけあれば動くはずです（tqdm だけ使ってます）汎用ではなくONE-Xのみに対応すればいいノリで書いてますので、悪しからず。<br>Qiita にポエムを書くかなぁ…</p>&mdash; ichiki kengo (@ichiki_k) <a href="https://twitter.com/ichiki_k/status/1195255403514187776?ref_src=twsrc%5Etfw">November 15, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>