概要
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
ということで、詳しい話やポエムが読みたい方は、お進みください。
はじめに
最近、ZENKEI AI FORUMという公開イベントを金沢で月1でやっています。で、アーカイブもかねてONE Xでイベントの内容を録画しています。
お待たせしておりました先月のZENKEI AI FORUM 2019のビデオ1本目、ぼくのイントロです。https://t.co/tYAXSy1AGb
— ichiki kengo (@ichiki_k) November 7, 2019
遅れたのは、ぼくのメインマシンiMacをCatalinaにアップグレードしたらあれこれ動かなくなって(以下省略)
ビデオはこの後 @nguyenz13 さん、長東さん、古川さんと続く予定です!
これまでも同じカメラで 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 をそのまま壊れたファイルに後付けすれば、もしかしたらうまくいくかも、と思って試してみました。しかし、当然のように、うまくいきませんでした。
いろいろググっていると、全く同じようなことを試みている人がいらっしゃいました。
- Qiita: MP4のファイル構造を解説
実はこの時点まで、ぼくは漠然と、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.insv
の mdat
からサンプルテーブル (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
から作り直した stsz
と co64
はサイズが変わります。これらの 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))
この時点では 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
を書き出すコードです。
壊れた 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」などのカメラ情報も見れます。どうやら、カメラに関するメタ情報(の一部)が落ちてしまっているようです。
普通に考えると、それらの情報は udta
か uuid
に入ってそうですが、それらは復元したファイルにも既に含まれています。つまり、そこにある情報だけでは不十分ということです。
ここでさらに詳しく壊れていないファイルの 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 してご使用ください。
まとめ
という努力の元に編集されたイベントのビデオがこちらになります。
10月の ZENKEI AI FORUM のビデオ5本目、最後の1本は録画トラブルで編集など大変だった古川さんの「ゼロからはじめるAI」です。https://t.co/MG25U4rVZT
— ichiki kengo (@ichiki_k) November 15, 2019
今月は11月27日開催です。今回も盛り沢山の内容を予定していますので、楽しみにしていてください!
壊れてないビデオ(004)の後に、今回復元できた8分の動画(005)をつないで、さらにその後、カメラトラブルに気付いてバックアップの THETA V で撮影再開したものを(トビがありますが)つないで、トータル24分少々まで復元しました。疲れた…
付記(改題について)
会う人会う人に「せっかく頑張って書いたんだけど、全然注目されない」と愚痴っていたところ、ある人から「タイトルが悪い」とのアドバイスをもらいました。元のタイトルは
動画カメラがファイルの書き出しに失敗したとき、ONE X編
でしたが、「これだと、何がうれしいのか分からない」とのこと。確かにそうだと思ったので、前向きに変えてみました。
付録:実況ツイート
上記ストーリーは基本的に開発当時の実況的なツイートをベースにしてます。
最初は本文に挿入する形にしていましたが、読みにくいので、付録としてここにまとめておきます。
某イベント https://t.co/A8hysssjcu の模様をInsta360 ONE-Xで撮影したもの、電源が切れたのかな?最後のファイルが尻切れでmoovがない、でもmdatにデータは詰まってる状態。salvageしたいと思うのが人情で、ここ数日粘ってる。
— ichiki kengo (@ichiki_k) November 7, 2019
ググったらおすすめらしい https://t.co/lrZDFNe95N とか試したがダメ
https://t.co/mM17uxecxe とか https://t.co/sBQssCUU95 とか見ながら(最近 python なのでそれで)パーサー書いて、sample table を復元する必要があると悟る。見れば同じ境遇の人が居た。https://t.co/qRywfHJmfj
— ichiki kengo (@ichiki_k) November 7, 2019
この方は断念したみたいだが...
...でも結局は mdatのバイナリから、適宜 aac と h265 を腑分けできればいいんだよな。そんなツール、誰か書いてないのかな?
— ichiki kengo (@ichiki_k) November 7, 2019
あ、あれだ、 deep learning で分類するか(ウソ)
諦めるか、何かもうちょっと粘るか、うーん...
あれ、もしかして aac の chunk にはもれなく ADTS ヘッダーついてるのか?そしたら分離できるか!?https://t.co/fB1az1XsoI
— ichiki kengo (@ichiki_k) November 7, 2019
ADTS ヘッダーに frame length があるので、あぁ、腑分けできる。https://t.co/DGmtzKR28H
— ichiki kengo (@ichiki_k) November 7, 2019
明日、試そうっと
ふぅ、できた。
— ichiki kengo (@ichiki_k) November 9, 2019
aac の抜き出しは簡単で、そこから sample table の復元も簡単だったけど、この情報を moov にまとめるのが面倒だった。とりあえず同じカメラで撮った mp4 の moov を reference に復元された moov を入れて、プレーヤーで見れることまで確認。duration とかは reference のまま
ということで(ONE-Xの動画なので)セットのもう1つの壊れたMP4も同様に復元しよう(で、その後スティッチして、音量調整して、前後の動画とconcatして、YouTubeへアップだな)
— ichiki kengo (@ichiki_k) November 9, 2019
汎用性がどれほどある情報か分からないけど、自分も忘れるので、この際 Qiita にでもまとめようと思ってる。
うむ、できた1組2本の動画を Insta360 Studio 2019 に食わせたが、スティッチしてくれない。どこかに正しい meta data とか入れとかないとダメなのかな? uuid とか?
— ichiki kengo (@ichiki_k) November 9, 2019
壊れてないファイルの moov の後ろにくっついてる free box と z box をコピーしてみたら、Insta360 Studio 2019 がカメラ情報はうまくゲットできたようで preview はできたが、stitch は1フレームしかしてくれない。メタデータの方とも何らかの整合を取らないとダメか...
— ichiki kengo (@ichiki_k) November 10, 2019
ググってみると、やっぱり、みんな困ってるねhttps://t.co/Jnirt3u4yi
— ichiki kengo (@ichiki_k) November 10, 2019
そうか、あの辺に gyro の情報とかが入ってるんだな(それみて stitch するわけで、まぁ、うまくいかんわな...)
その後、カメラのメタデータとかはファイルの最後(free box の後ろ?)にあるらしいことが分かった。それを付けるとプレビューは出来た!が、変換は最初一瞬、いろいろやって2分くらいでしかし止まる。みるとプレーヤも警告を出してたので、復元が完璧でなかった模様。
— ichiki kengo (@ichiki_k) November 13, 2019
で mdat から sample table を復元するのに、 h264 の方もマーカーないかと思って仕様を調べたら、mp4 に格納するときは頭の4バイトがサイズになってるらしい。壊れてない mp4 で確認したところ、これで復元すると sample table が完全に一致!
— ichiki kengo (@ichiki_k) November 13, 2019
これで復元した2眼の mp4 を Insta360 Studio 2019 に食わすと、今度はプレビューすらしないで「壊れてる」と…
— ichiki kengo (@ichiki_k) November 13, 2019
で今日改めて stbl box の中身を見てたら、あぁ stts に正しい sample count 入れなきゃダメじゃないか(ref の moov の値のまま置いていた)
明日、これを試そう
あぁ、しかもこれらの情報からきちんと duration 計算できるではないですか。ふむ、これできちんと整合する moov が書けるね。(最初からそうするべきだったけど……これを泥縄式と呼ぶ)
— ichiki kengo (@ichiki_k) November 13, 2019
できた(今スティッチ中)
— ichiki kengo (@ichiki_k) November 14, 2019
直近のスティッチャーが「ファイル壊れてる」と言ってたのは些末な問題だった(途中でコードの仕様をオレが変えた関係で中間ファイルの絵と音のテーブルが入れ替わってた…)
おかげで正しいduration計算できるようになったし結果オーライ
まずはイベントビデオを仕上げよう
という流れで、一発で壊れた ONE-X の動画ファイルを復元してくれるスクリプトを github にアップしました!https://t.co/bJwBD0axlr
— ichiki kengo (@ichiki_k) November 15, 2019
python だけあれば動くはずです(tqdm だけ使ってます)汎用ではなくONE-Xのみに対応すればいいノリで書いてますので、悪しからず。
Qiita にポエムを書くかなぁ…