22
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2019-11-15

概要

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

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

詳しい話や、ポエムなど必要ないという方は、こちら(https://github.com/kichiki/finsta360 )からコードをダウンロードして

$ ./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
と叩けば、壊れたファイル(`VID_20191023_202638_00_005.insv`)から復元されたファイル(`finsta360_00_005.insv`)が戻ってきます!

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

はじめに

最近、ZENKEI AI FORUMという公開イベントを金沢で月1でやっています。で、アーカイブもかねてONE Xでイベントの内容を録画しています。

これまでも同じカメラで 5K パノラマで撮影していて、バッテリーがもてば128GBのメモリーカードで、ぎりぎり2時間半のイベントが収まるという感じでした。しかし前回のイベントではカメラトラブルがあったようで(バッテリー切れ?)最後のファイルが書き出しに失敗したようです。ffplay で開こうとすると

$ 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

というように「moov アトムが見つからない」という警告が出て、再生できませんでした。

フェーズ1〜既存の復元ツールを試してみる

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

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

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

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

フェーズ2〜MP4コンテナについて勉強

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

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

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

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

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

MP4 ファイルを開いてパースするコード
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('')

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

  • ftyp
  • mdat
  • moov
  • free

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

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

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

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

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

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

フェーズ3〜AACについて勉強

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

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

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

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

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

フェーズ4〜AACの抜き出しからmoovの復元

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

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

`mdat` からサンプルテーブルを構成するコード
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')

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

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

サンプルテーブルから atom boxes のサイズを計算するコード
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))

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

リファレンスの `moov` を抜き出し保存するコード
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')

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

サンプルテーブルとリファレンスの `moov` から正しい `moov` を構成し保存するコード
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))

この時点では stszco64 を再構成しただけで疲れ切ってしまったので、 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 を書き出すコードです。

壊れた MP4 と復元された `moov` から復元された MP4 を構成し保存するコード
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))

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

フェーズ5〜カメラのメタデータとスティッチ

気分としては、この時点で問題解決できたと思ってて、「ちょっと大変だったけど楽勝だった」くらいに思ってました。当のビデオは360パノラマ動画なので、壊れたファイルがもう1つあります。それも同じように復元しました。で、こちらも問題なく ffplay で再生できました。(注:それなりに長く再生させて、注意深く見ていれば、この時にプレーヤが警告を出していたはずですが、この時は「できた、できた」と思ってました。)

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

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

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

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

サンプルテーブルから `moov` を復元するコードへの追加コード
# 上の、サンプルテーブルから 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))

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

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

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

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

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

フェーズ6〜H264の勉強

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

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

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

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

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

修正された `mdat` からサンプルテーブルを構成するコード
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)

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

フェーズ7〜サンプルテーブルから正しくdurationを計算

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

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

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

最終的なサンプルテーブルから `moov` を復元するコード
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))

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

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

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

フェーズ8〜完成!

このようにコードもいろいろと整理して、一度コード全体をまっさらな状態から計算し直していたら、見付けました!大きな(しかし下らない)間違いを。上で

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

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

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

全部まとめたトップレベルの関数
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)

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

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

まとめ

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

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

付記(改題について)

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

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

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

付録:実況ツイート

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

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

22
10
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
22
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?