どうもこんばんわ。
最近ずっと フルスロットルHeart っていう曲聞いてる、、掛け合いめっちゃよい
本題
コンテナフォーマットの一つ、WebM
を解析したり、組み立てたりするコードを書けるようになりましょう。(?)
これ https://github.com/takusan23/ZeroMirror の中の WebMへ書き込む処理 https://github.com/takusan23/ZeroMirror/tree/master/zerowebm を作ってる際に調査したやつ
環境
Kotlin
で書きます。
あと 2/10/16進数 の変換ができる電卓が必要です(Windows 10 の最初から入ってる電卓のプログラマーモードでOKです)
ざっくり WebM
WebM
ってのは音声と映像を一つのファイルに保存する技術の名前で、mp4
、mpeg2-ts
とかの仲間です(H.264
/H.265
/VP8
/VP9
等のコーデックの仲間ではないです。コーデックでエンコードしたデータを保存する技術です)
(Android
ではMediaMuxer
を使えばらくらく保存できるのですが、ストリーミングできるWebM
を作りたかったので)
動画ファイルの拡張子がmp4
以外だったときランキングで、三番目ぐらいに居座ってそう。(avi,mov の次くらい?、iPhoneが mov らしいのよね)
WebM
はMatroska
のサブセットになってます。ので仕様書なんかはMatroska
のを見るのが良いと思う。
コーデックはVP8
/VP9
/Opus
などが対応しています。保存方法にはEBML
を使ってます(Matroska
がそう)。
WebM を見るアプリ
MKVToolNix ってのがあります。これが神レベルで使いやすい。
DLしたら mkvtoolnix-gui.exe を起動して、infoツール
にしてwebm
をドラッグアンドドロップすれば見れます。
https://mkvtoolnix.download/
ざっくり EBML
よく xml
と言われてますが、xml
にはある終了タグや属性などはないのでどっちかというと yml (yaml)
が近いと思います。
終了タグが無いので、タグの後についてる長さを見て子要素、データを取り出していきます。
こんな感じで入ってる(かなり端折った
バイナリを見る
実際のバイナリを見ながら、もう少し解説を
ちなみに以下のバイナリは最適化してない(難しそうだったので、後述)ので本来であればもっと短くなります。
1A 45 DF A3 10 00 00 34 42 86 10 00 00 01 01 42
F7 10 00 00 01 01 42 F2 10 00 00 01 04 42 F3 10
00 00 01 08 42 82 10 00 00 04 77 65 62 6D 42 87
10 00 00 01 02 42 85 10 00 00 01 02 18 53 80 67
01 FF FF FF FF FF FF FF 15 49 A9 66 10 00 00 34
2A D7 B1 10 00 00 04 00 0F 42 40 4D 80 10 00 00
13 7A 65 72 6F 6D 69 72 72 6F 72 5F 7A 65 72 6F
77 65 62 6D 57 41 10 00 00 0A 7A 65 72 6F 6D 69
72 72 6F 72 16 54 AE 6B 10 00 00 87 AE 10 00 00
32 D7 10 00 00 01 01 73 C5 10 00 00 01 01 86 10
00 00 05 56 5F 56 50 39 83 10 00 00 01 01 E0 10
00 00 10 B0 10 00 00 03 00 05 00 BA 10 00 00 03
00 02 D0 AE 10 00 00 4B D7 10 00 00 01 02 73 C5
10 00 00 01 02 86 10 00 00 06 41 5F 4F 50 55 53
83 10 00 00 01 02 63 A2 10 00 00 13 4F 70 75 73
48 65 61 64 01 02 00 00 80 BB 00 00 00 00 00 E1
10 00 00 0F B5 10 00 00 04 47 3B 80 00 9F 10 00
00 01 02 1F 43 B6 75 01 FF FF FF FF FF FF FF E7
10 00 00 04 00 00 00 00
バイナリのレイアウト
こんなのがずっっっと続いてます。
| | |
---|
ID | Data size | Data |
なんのデータかを示します | Data のサイズです | データです。数値/ASCII/バイナリ など |
サイズは可変長 (後述)、Max 4バイト? | サイズは可変長 (後述)、Max 8バイト | Data size の値 |
たとえば...
57 41 10 00 00 0a 7a 65 72 6f 6d 69 72 72 6f 72
の場合は
バイナリ | 0x57 0x41 | 0x10 0x00 0x00 0x0A | 0x7A 0x65 0x72 0x6F 0x6D 0x69 0x72 0x72 0x6F 0x72 |
---|
なに? | ID | Data size | Data |
あたい | Writing App | 10 | zeromirror |
になります!(なんでこうなるのかをこれから書きます)
バイナリの読み方 ID編
IDの一覧はこれです:https://www.matroska.org/technical/elements.html
まずは ID から。ID
はその名の通りデータが何なのかを示すものです。
で、IDなのですが、これ賢くて、2進数にした後に左から何ビット目に1が立っているかでIDの長さが分かっちゃう用になってます!。
例えば上記の Writing App
の 0x57 0x41
を2進数にした場合
16進数 | 2進数 |
---|
0x57 0x41 | 0101 0111 0100 0001 |
こうなります(5
を2進にすると101
ですが、4桁に合わせるため先頭に0
を入れてます)
で、1
が左から2ビット目に立ってますよね?すると上記のIDは 2バイト分 になります!!!
2バイト取り出した0x57 0x41
をID一覧と見比べるとWriting App
であるとわかりますね!
これを VINT というらしいですよ?(データのサイズを含めつつ、ちゃんと目印としても使える)
他に例をもう一個、 Cluster
のIDを見てみると
16進数 | 2進数 |
---|
0x1F 0x43 0xB6 0x75 | 0001 1111 0100 0011 1011 0110 0111 0101 |
こうなりますね?(変換後は1 1111 0100 0011 1011 0110 0111 0101
になりますが、4桁揃えにするため 0001 1111 0100 0011 1011 0110 0111 0101
にしてます)
で、1
が左から4番目に立ってますので、IDは4バイト分と判断できるわけです。
4バイト取り出した 0x1F 0x43 0xB6 0x75
をID一覧から探すと Cluster
であるとわかりますね。
多分最大 4バイト までだと思います。
あ、VINTのわかりやすい表があったので貼っておきますね
https://github.com/ietf-wg-cellar/ebml-specification/blob/master/specification.markdown#vint-examples
バイナリの読み方 Data size 編
実際にData size
で合ってるのかはわからない...(Content size
説?
これは Data が何バイト分かを示すものです。で、こいつ自信も可変長です。
例え行きましょう。上記のWriting App
で
16進数 | 2進数 |
---|
0x10 0x00 0x00 0x0A | 0001 0000 0000 0000 0000 0000 0000 1010 |
ここでやらないといけないのは、Data size 自身の長さ
とData の長さ
を出すことです。
Data size
自身の長さは ID のときと同じように、左から1が何ビット目に立っているかで判断できます。
ただ、IDと違ってData size
は最大8バイトまであります。
今回は1
が左から4バイト目に立っているため、Data size
自身は4バイトあることがわかります。
で、2進数にした後に左から1
を抜いたあと10進数にした値が、Data
の長さになります。
0001 0000 0000 0000 0000 0000 0000 1010
-> 0000 0000 0000 0000 0000 0000 0000 1010
-> 2進数を10進数にした10
Data
は10バイトです!
これもわかりやすい表があったので貼っておきますね
https://www.matroska.org/technical/notes.html#ebml-lacing
バイナリの読み方 Data 編
Data size
で出した長さだけあります。
そのデータが 数値 / ASCII / バイナリ / 入れ子 など何のデータかはIDの一覧から見て下さい。多分IDだけだとわからないはず。。。
Element type でわかるはず
番外編 なんで賢いのか
なんで賢いのかというと、IDが知らない/対応していない場合にスキップして次のデータを読み取れるからなんですね。
だってIDの長さ知らないけど、IDの長さは 2進数にして1が何ビット目に立ってるか を計算していけば ID分からん未知のデータ として解析できるわけです。
もし IDに長さが含まれなかった 場合、パース前に予めIDと長さの対応表みたいなのを持っておく必要がある上、未知のIDが来た場合に解析ができなくなります。
WebMで必要な値
WebMに話を戻します。
多分以下の値が必要です。
んなもん分からんわって方はこっちのほうが正しいです:https://www.matroska.org/technical/diagram.html
これらはさっき話した、EBMLの仕組みに沿ってバイナリを入れていけばいいのですが、、、
EBMLだけ知っていればできるわけではなく、以下の要素は別に説明しないとと思うのでします。
- Codec private data
- SimpleBlock
ざっくり何が入ってるか
その前に何に何が入ってるかざっくり
- EBML
- おまじないみたいなの
- ファイルが WebM だよ みたいなの
- Segment
- Info
- 動画の長さとか書き込みアプリケーションが何かとかをいれる
- 動画の長さが入ってないとシークバーが使えない
- Tracks
- Track
- 音声なら、サンプリングレート、チャンネル数、コーデックの種類を入れます
- 映像なら、動画の高さや幅、コーデックの種類を入れます
- トラック番号もここで入れます
- Cue
- Cluster
- SimpleBlockを入れる
- 最初に時間を入れる、その次に SimpleBlock の時間を相対時間で入れる(2バイトで)
- 0xFF 0xFF を超える場合は Cluster を作り直す
- SimpleBlock
Codec private data
ここでは音声コーデックがOpus
のときの話。
これは音声トラックに必要なデータです。
Track
に定義されていない値を入れるのに使う、本当にプライベートなデータです。
おそらく Opus
を利用している場合は入れる必要があり、プライベートなデータなためEBML
の仕組みにも乗っかってません!!!
Opus の Codec private data
まずはこれ見て下さい。
https://wiki.xiph.org/OggOpus#ID_Header
https://www.rfc-editor.org/rfc/rfc7845#section-5
はい、完全にEBML
じゃないですね。デコーダーに追加情報を渡すために必要なようです。
0x4F 0x70 0x75 0x73 0x48 0x65 0x61 0x64 0x01 0x02 0x00 0x00 0x80 0xBB 0x00 0x00 0x00 0x00 0x00
中身
まず先頭から8バイト分は、OpusHead
をASCIIにしたものになります。
0x4F | 0x70 | 0x75 | 0x73 | 0x48 | 0x65 | 0x61 | 0x64 |
---|
O | p | u | s | H | e | a | d |
そして次の1バイトはバージョンですが、0x01でいいそうです。
その次の1バイトはチャンネル数です。モノラルなら0x01
、ステレオなら0x02
でしょう。
その次の2バイト分はわかりません。Pre-skip
って書いてあるけど知らん
その次の4バイト分はサンプリングレートです。なんとリトルエンディアンです
0x80 0xBB 0x00 0x00
リトルエンディアンなので電卓にそのまま突っ込んでも多分変な値になります。ちなみに正解は10進数で48000
になるべきです。
さらにJava (JVM で動く Kotlin も)
もビッグエンディアンなのでおかしくなると思います。
電卓で正しい値を出すためには(Windowsの電卓はビッグエンディアンっぽい?)、バイトを逆順にする必要があります。なので、
0x80 0xBB 0x00 0x00
を 0x00 0x00 0xBB 0x80
にした後に電卓に入れると正しい値になると思います。
最後の3バイトはわからん、使わなそうなので0x00
で埋めてます
音声のTrack
に入れるCodec private data
は以上です。
SampleBlock
これもちょっと特殊で、Data
の先頭4バイトに値を入れる必要があります。
最初に入れる内容
0x81 0x00 0x00 0x80
最初の1バイトはトラック番号です。TracksにTrackを追加する際に指定すると思います。それです(音声なのか映像なのか)
次の2バイトは時間です。 0xFF 0xFF までしか時間が追加出来ないです(Short.MAX_VALUE ?)(多分ミリ秒になるので、32秒ぐらいかな)
0xFF 0xFF を超える場合は、Cluster
を作り直すところからやる必要があります。(後述)
最後の1バイトはキーフレームかどうかです。キーフレームなら0x80
、そうでなければ0x00
だと思います。
おわりです。
WebMパーサーを書こう
はいここまで来たらかけますね、書きましょう
流れ
- EBMLを読み出す
- Segmentの入れ子になってる要素を読み出す
- Clusterを読み出す
Kotlinで書く
適当にプロジェクトを作って下さい。
列挙型
適当に
パーサーを書く
ID
とりあえず VInt を計算するやつ書きますか、あの1がとこに立ってるかのやつ
書きました。downTo
いいね、もうKotlinしかできない
Javaだと、Byteは符号付きになるので、AND 0xFF
をしないとだめです。多分
こんな感じでわかるはず
DataSize
Data
の長さを表すDataSize
です。
左から数えて最初の1を消した後の16進数がそうです。
ByteArray から Int はこちらを参考にしました、ありがとうございます。
https://gist.github.com/groovelab/38d381a943556299f205b47307bf60d7
多分左側へビットを動かしてIntにしてるんだと思います、
[ 0x10, 0x20, 0x30 ]
だったら...
| | | |
---|
左側へ2バイト移動 | 0x10 | 0x00 | 0x00 |
左側へ1バイト移動 | | 0x20 | 0x00 |
左側へ0バイト移動 | | | 0x30 |
XOR する | | | 0x102030 |
それと、 0x01 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF
の場合のことを考えないといけないんですよね。
これは長さが不明の場合に指定されています。JavaScript
のMediaRecorder API
で録画したデータがまさに長さ不定になります。
ただ、子要素には長さが入っているため、これを全部足せばいいと思いました。
Data
DataSize
分取り出すだけなので特筆することはないかと
組み合わせる
これらの拡張関数を呼び出すと一つの要素をパースできるようになります。
と、その前にパース結果を入れるデータクラスを作りましょう。
要素をパースする関数はこちら。Data size
が不定0x01 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF
の場合は動かないと思います。先頭から見ていってもいいけどさあ...
後はこれを再帰的に呼び出せばすべての要素が取り出せるはずです!
再帰的に呼び出す
入れ子になってるタグの場合は再度parseElement
を呼び出すようにしています。
そうじゃない場合は配列に入る。
最後にこれをmain関数
で呼び出すなりすればいいと思います。
もしかするとここまでのコードで Javaの機能 を使ってないので他のプラットフォームでも動くかもしれないです。
以下の例では Java の File API
を呼び出してるのでJVM
のみですが、他のプラットフォームでも Kotlin の ByteArray
が取得できれば使えるかもしれないです。
あ!ちなみに JS の MediaRecorder API だと 長さ不定 (0x01 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF) の WebM を出力するのでこのままでは使えません終わりです。
こんな感じになるはず。
EBMLVersion = ...
EBMLReadVersion = ...
EBMLMaxIDLength = ...
EBMLMaxSizeLength = ...
DocType = ...
DocTypeVersion = ...
DocTypeReadVersion = ...
Seek = ...
Seek = ...
Seek = ...
Void = ...
Duration = ...
TimestampScale = ...
MuxingApp = ...
WritingApp = ...
TrackNumber = ...
TrackUID = ...
FlagLacing = ...
Language = ...
CodecID = ...
TrackType = ...
Channels = ...
SamplingFrequency = ...
CodecPrivate = ...
TrackNumber = ...
TrackUID = ...
FlagLacing = ...
Language = ...
CodecID = ...
TrackType = ...
PixelWidth = ...
PixelHeight = ...
サイズが不明な Clsuter ...
JS
のMediaRecoder API
を使ったできた動画って、DataSize
が0x01 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF
になっていて、おそらく子要素を次のClusterが来るまでなめていくしか無いです。
というわけで こちらのコード
次のCluster
が見つかるまで子要素の長さを足していく関数です。
これを、parseChildElement
関数に組み込めば...、多分長さがわからないCluster
も解析できるようになるはずです。
長さ不明、パースしんどいな...
ソースコード
https://github.com/takusan23/ZeroWebM
ブラウザの挙動?
- Chrome は 要素の途中だろうとぶった切ってくる?
- なので
DataSize
分データがあるか怪しい(え???)
- Clusterのパースの際はお気をつけて
- コードあってるけどデータが良くない時があった;;
ArrayIndexOutOfBoundsException: Index 56492651 out of bounds for length 56492651
- これ自分が書いたコードが悪いようにみえるじゃん...
- Firefox はぱっと見要素の終わりに揃えていそう
そのほか
- Opusの場合、常にキーフレームかもしれないです
- Android の ExoPlayer では Opus の SimpleBlock は全部キーフレームにしないと再生できませんでした;;
おまけ 書き込み作る
どっちかというとこっち本題にしたかったけどもう疲れた 上に気分がPixel Watch に傾いてるのでもう無理
流れ
- ID要素をバイト配列にする
- DataSizeを計算する
- めんどそう
- 長さ不明はパーサーがかわいそうなのでちゃんとしようね
- ID要素のバイト配列、DataSizeのバイト配列、Dataの配列をくっつける
- これを全部繰り返す
パースよりやさしそう
一つの要素を表すデータクラス
今回は楽するために、DataSize
が常に4バイトになります;;。4バイトを超えたら対応できないので各自いい感じに...
この記事の冒頭の最適化してないの話はここにつながるわけですね。。
main関数とかで呼び出すようにすればいいと思います
例えばこれで EBMLヘッダー
を作れます、
ちゃんとパーサーに認識されてました
他の要素も作ろう
疲れたので全カットで。
注意点としては、Tracks > Track
で音声トラックを追加する場合、AudioTrack
のSampling frequency
がFloat
なので注意して下さい。
KotlinならInt.toBits()
を呼び出すだけかも?// TODO
ばっかで使えたもんじゃないな
まぁ動いているのでヨシ!
writing app
好きな文字列にできるのいいな(すごくどうでもいい)
ソースコード
再掲
https://github.com/takusan23/ZeroWebM
おわりに
Pixel Watch はよ来い!!!!
docomoでもセルラー通信できたら WearOS でもLTEバンド取得できるのか試してみたかったんだけどな、、、
Kotlinの便利機能ばっかり使ったのであんまり参考にならなそう。
参考にしました
たすかります!!!