feature image

2020年11月15日 | ブログ記事

ゲーム用リモートデスクトップアプリを作りました & WebM(Matroska), MediaRecorderについて【AdC2日目】

この記事は2020年アドベントカレンダー2日目の記事です。

宣伝

こんにちは、りょはです。
この前ゲーム用のリモートデスクトップアプリを作ったのでぜひ使ってみてください。
美少女ゲームやターン制のゲームが非常にやりやすい自信作です。
使い方とか諸々は以下を参照してください。

WebM(Matroska), MediaRecorderについて

本編の始まりです。

導入
MediaRecorderの使い方
WebM(Matroska)について
メタ情報の追加
codecsについて(おまけ)
最後に

導入

MediaRecorderはMediaStreamをキャプチャーして簡単に動画として保存できるWebAPIです。
MediaStreamはgetUserMediaとかで取得できるメディアコンテンツのストリームです。
ストリームは動画や音声をtrackとして含んでいて、これをWebRTCに流し込むことで相手に動画を送ることができます。

MediaRecorderのユースケースとしては、Web会議の録画やブラウザから投稿するtiktok的な何かとかがですかね。
上で宣伝したアプリでも使っていて、Nintendo switchみたいな直前のプレイを切り抜いて保存できる機能で使用しました。

この記事の本題は、MediaRecorderで作っている動画が常につけたされ続けているライブストリーム形式であることから生じる問題をffmpegとかを使わずに解決しようという内容です。

デモサイトを用意しているので適宜参考にしてください。
https://victorious-mud-0d7cb7b00.azurestaticapps.net/
コードはこっちです。
https://github.com/ryoha000/media-recorder-sample

MediaRecorderの使い方

なんかいろいろできそうで面白そうなMediaRecorderですが、使い方は簡単です。

// MediaStream を取得
const stream = await navigator.mediaDevices.getUserMedia({ audio: false, video: true })
// 録画した動画をためておく配列
const chunks: Blob[] = []
// MediaRecorder を作成
const recorder = new MediaRecorder(stream)
// 録画開始
recorder.start(1000)
// 上で指定したms間隔でイベントが発火
recorder.ondataavailable = e => {
  // 録画したデータを収納
  chunks.push(e.data)
}

みたいな感じで使えます。

集めたBlobはvideoタグのsrcに指定することで再生でき、webmとしてダウンロードできます
詳しくはMDNを見てください。。

https://developer.mozilla.org/ja/docs/Web/API/MediaRecorder

WebM(Matroska)について

WebMについて

先ほどWebMの話が出てきましたがWebMは動画フォーマットの一種です。
詳しい説明はWikipediaとかを見てほしいですが、ロイヤリティーフリーでオープン、なおかついろんなブラウザで再生できるのでなんかナウいですね。
codecは映像にVP8・VP9・AV1、音声にVorbis・Opusが使えます。
この記事で一番大事なのはWebMはメディアコンテナとしてMatroskaのサブセットを採用していることです。(MediaRecorderのcodecについてはおまけで触れます)

https://ja.wikipedia.org/wiki/WebM

Matroskaの概要

Matroskaはマルチメディアコンテナフォーマットでcodecとかはなんかめっちゃいろいろなやつに対応しています。
対応してるものが多すぎてMatroskaってわかってるだけでは中身を読まないとプレイヤー側が再生できるかどうかわかんないので、WebMはサブセット(部分集合)としてMatroskaを採用しているんだと思います。(妄想)

Matroskaのデータの保持方法はXMLに非常に似ています。
このフォーマットをEBMLといいます。
ビジュアライズすると以下みたいな感じですね。

<EBML>
  <EBMLVersion />
  <EBMLReadVersion />
  <EBMLMaxIDLength />
  <EBMLMaxSizeLength />
  <DocType />
  <DocTypeVersion />
  <DocTypeReadVersion />
</EBML>
<Segment>
  <Info>
    <TimecodeScale />
    <MuxingApp />
    <WritingApp />
  </Info>
  <Tracks>
    <TrackEntry>
      <TrackNumber />
      <TrackUID />
      <TrackType />
      <CodecID />
      <Video>
        <PixelWidth />
        <PixelHeight />
      </Video>
    </TrackEntry>
  </Tracks>
  <Cluster>
    <Timecode />
    <SimpleBlock />
    <SimpleBlock />
    ...

Matroskaの具体的な表現方法

先ほどXMLのような形式で保持していると書きましたがstringみたいな形で持っているわけではありません。
{{タグ名}}{{後ろのデータ部分のサイズ(byte)}}{{データ}}みたいな形式でもっています。
データのところは子タグだったり整数、浮動小数点数、バイナリ等が入ったりします。

最初にサイズは1byteと決めてしまっていると255byteが上限になって、サイズがめっちゃでかいと入らなくなって困りますよね?
大きくしすぎても無駄が多くなるしどうしようってなります。
これを解消するためにサイズ部分はVINTという方法でエンコードしています。

方式としてはサイズ部分で最初にbitが立っているindex(1index)と同じだけのbyte数をサイズ部分として確保しているという仕様です。
その立ったbitより後ろの部分がbig endianでサイズの数字になっています。

10000001 => size部分は1byteでdata00000011byte
01001000 11000111 => size部分は2byteでdata001000110001112247byte

みたいな感じですね。

メタ情報の追加

Duration

デモサイトshow initial webmボタンを押してみて下さい。
すると録画されていた動画が流れるんですが見てみてください。
シークできないですよね?
今度はshow webm eblmを押してみてください。
何も手を入れていない状態のタグの構造が表示されます。
シークできる動画にはあるDurationタグがないのが原因なんですが、考えてみれば当然でこの録画はいつ終わるかわかりませんよね?
なのでDuration(動画時間)がないんですがシークするための解決方法は2つあります。

まず一つは裏技的な方法としてvideoタグについてるcurrentTimeプロパティをめっちゃ大きくすることです。
これの欠点はちょっとカッコ悪いことと長い動画だと時間がかかることですね。
currentTimeにめっちゃでかい値を指定するのはデモサイトのplay video with big current timeボタンを押してみてください。

もう一つの解決方法はシンプルです。Durationタグがないなら足したらいいんですね。
デモサイトのplay video with correct durationボタンを押してみてください。
Durationタグを挿入した後がこれです。

ではDurationタグを挿入するために総動画時間を求めてみましょう。
MediaRecorderでは動画のフレームをSimpleBlockタグ内で保存しています。
そのSimpleBlockタグは親にClusterタグを持っています、。
SimpleBlockの中にはその親Cluster内での相対時間があります。
じゃあ動画全体における絶対時間をClusterタグはどこに持っているかというと、子のTimestampタグ内に置いてあります。
それならこれらを足していけばいいですね。
この時間の単位はTimecodeScaleタグで決めてて、最大精度はナノ秒でデフォルトではミリ秒です。
抜粋コードです。

// dataにはタグの配列が入ってる
for (const tag of data) {
  // 使ってるparserが古いので名前が違う(タグのIDは同じだから問題はない)
  if (tag.name === 'Timecode') {
    baseTimecode = tag.value ?? 0
  }
  if (tag.name === 'SimpleBlock') {
    const block: SimpleBlock = ebmlBlock(tag.data)
    const t = baseTimecode + block.timecode
    // MediaRecorderではないけど仕様上はBlockの並びは時間にソートされてなくてもいい
    if (duration < t) {
      duration = t
    }
  }
}

あとはタグを入れたらいいですね。
公式ドキュメントを読むとDurationタグはInfoタグ内に置くものらしいです。
注意が必要なことは二つあります。
一つはInfoタグ内の最初の子要素は、先ほど時間の制度を決めてると言ったTimecodeScaleタグじゃないといけないんですね。
もう一つは親タグであるInfoタグのサイズ部分を変更しなきゃいけないことです。
おいおいちょっとまってくれ、Infoはさらに親タグを持っているじゃないかとなるんですが、MediaRecorderで構成したSegment. Clusterタグはサイズが0x01FFFFFFFFFFFFFFが指定されています。
これは不定長の予約されているサイズです。
いつ動画が終わるかわからないのでこういうことを指定しています。
あとでサイズは指定しますが今は後回しで。

横道にそれましたが早速TimecodeScaleタグの直後にDurationタグを入れちゃいましょう。
ドキュメントによるとDurationタグのIDは0x4489で、valueは浮動小数点型らしいです。
JavaScriptは実行環境でBig EndianかLittle Endianか変わります。
EBMLではすべてBig Endianなので気を付けましょう。
抜粋コードです。

// getEBMLTagByFloatValue: (tag: number[], duration: number, isLittleEndian: boolean) => EBMLTag
// EBMLTag は `length` プロパティ(そのタグ全体のbyte数) と getNumberArray メソッド(そのタグのnumber[]を返す) を持ちます
const durationTag = getEBMLTagByFloatValue([0x44, 0x89], duration, checkLittleEndian())

const spliceData: SpliceEBMLData[] = []
// まずInfoタグのサイズを変えるデータを入れます
spliceData.push({
  // infoにはinfoタグが入っています
  start: info.sizeStart,
  deleteCount: info.sizeEnd - info.sizeEnd,
  // getReplaceSize は子要素に何かを足したときのsize(Uint8Array)を返してくれる関数です。
  item: getReplaceSize(prevArr, durationTag.length, info)
})
// Durationタグは上で言った通りtimeCodeScaleタグの後ろに入れます。
spliceData.push({ start: timeCodeScale.dataEnd, deleteCount: 0, item: duration.getNumberArray() })
// spliceEBML(prevArr: Uint8Array, spliceData: SpliceEBMLData[]) => Promise<Uint8Array>
// 挿入するための前までの配列といじるためのデータを渡します。
// 雰囲気はString.prototype.spliceのメソッドじゃなくなって配列になった版です
return await spliceEBML(prevArr, spliceData)

これで無事Durationタグを挿入できました!
でもシークは遅いしこまこまりんです。

不定長のサイズをちゃんと終わらせる

videoタグに影響があるのかはわかりませんが気持ち悪いので各サイズにちゃんと数字を入れましょう。
サイズが不定なのはClusterタグとSegmentタグだけです。
Segmentタグのサイズは不定長でサイズ部分が8byteだったのが短くなるのでその辺を注意してください。
抜粋コードです。

let diff = 0
const clusterDatas = data.filter(v => v.name === 'Cluster').map((v, i, arr) => {
  let correctSize: Uint8Array
  if (0 <= i && i < arr.length - 1) {
    // getSize(length: number) => Uint8Array
    // VINTエンコードでのUint8Arrayを返してくれる関数です
    correctSize = getSize(arr[i + 1].tagStart - v.dataStart)
  } else {
    // 最後の要素は次の要素がないのですべてのタグの終了場所までをサイズにします
    correctSize = getSize(data[data.length - 1].dataEnd - v.dataStart)
  }
  // IndefiniteSizeLength = 8 // 不定長サイズのbyte数
  diff += correctSize.length - IndefiniteSizeLength
  return { start: v.sizeStart, deleteCount: IndefiniteSizeLength , item: correctSize }
})

const segment = data.find(v => v.name === 'Segment')
// 上で言ったSegmentのサイズに気を付けようというあれです
const segmentSize = getSize(data[data.length - 1].dataEnd - segment.dataStart + diff)
diff += segmentSize.length - IndefiniteSizeLength
const segmentData = { start: segment.sizeStart, deleteCount: IndefiniteSizeLength , item: segmentSize }
return [segmentData, ...clusterDatas]

あとはこれをspliceEBML()に入れるだけでDurationと同じなのでコードは割愛します。
これでちゃんと閉じタグが作られます。

そのほかのメタタグとかを入れちゃう

シークの遅さは長いファイルじゃないと実感がわかないと思うので3時間の動画を用意しました。
デモページのplay 3h only durationボタンを押すとDurationタグだけ挿入した動画が再生されます。
シークが死ぬほど遅いですよね。
今度はplay 3h with many meta dataボタンを押してみてください。
まぁ耐えれるくらいだと思います。

なんでシークが遅いかというと指定した秒数がどこか判断しなきゃいけないからですね。
これを解決するのがSeekタグとCuePointタグです。

簡単に言うと両方ともあるタグがどこにあるかっていう情報を持っています。
INDEXみたいなものですね。
日本人感覚からすると違和感があるんですが、SeekタグはSegmentを直接親に持つ2階層目のタグの場所を持たせるもので、CuePointタグはある再生時間はどこにあるかを持たせるものです。
最初はSeekタグに時間を持たせるんだと思って勘違いしてました。

疑似的なタグとしてはこんな感じです。

<Segment>
  <--- Seekの親タグ --->
  <SeekHead>
    <Seek>
      <--- 対象のタグのIDを持つ。今回はInfoタグのID --->
      <SeekID>0x1549A966</SeekID>
      <--- 対象のタグの開始位置 --->
      <SeekPosition>0x47</SeekPosition>
    </Seek>
    <Seek>
      ...
    </Seek>
    <--- Seekタグはいくつでもいい --->
    ...
  </SeekHead>
  <Info>
    <--- ここにさっき入れたDurationとかがある --->
    ...
  </Info>
  ...
  <--- CuePointの親タグ --->
  <Cues>
    <CuePoint>
      <--- 何秒のところっていう指定 --->
      <CueTime>1000</CueTime>
      <CueTrackPosition>
        <--- Tracksタグで宣言してるTrackの数字、大体1 --->
        <CueTrack>1</CueTrack>
        <--- 指定時間を含むClusterタグの開始位置 --->
        <CueClusterPosition>0x1234</CueClusterPosition>
        <--- 指定時間ど真ん中のBlockCluster内で前から何番目か --->
        <CueBlockNumber>20</CueBlockNumber>
      </CueTrackPosition>
      <--- CueTrackPositionは何個でもいいらしいけど一つしか見たことない --->
    </CuePoint>
    <--- CuePointタグはいくつでもいい --->
    ...
  </Cues>
  <Cluster>
    <SimpleBlock />
    <--- SimpleBlockタグはいくつでもいい --->
    ...
  </Cluster>
  <--- Clusterタグはいくつでもいい --->
  ...
</Segment>

これを目指して作っていきましょう!

まずはCuesタグを挿入します。
SeekタグではCuesの場所も指定しないといけないからですね。
めんどくさいのはCuesが先に来るので長さも考慮してCueClusterPositionも指定してあげないといけないことですね。
抜粋コードです。

// getCuesDataはCuePointを構成するための情報を返す関数
const cuePointDatas = getCuesData(data)
const isLittleEndian = checkLittleEndian()

const cuePoints: EBMLTag[] = []
for (const cuePointData of cuePointDatas) {
  const cueTrack = getEBMLTagByUintValue([0xF7], cuePointData.cueTrack, isLittleEndian)
  // 上で言った自分自身の大きさも考慮しないといけないためcueSizeを足しています
  // cueSizeは関数の引数として渡されて、変わるまで再帰的にCuesを構築し続けます
  const cueClusterPosition = getEBMLTagByUintValue([0xF1], cuePointData.cueClusterPosition + cueSize, isLittleEndian)
  const cueBlockNumber = getEBMLTagByUintValue([0x53, 0x78], cuePointData.cueBlockNumber, isLittleEndian)

  const cueTrackPositions = getEBMLTagByEBMLTags([0xB7], [cueTrack, cueClusterPosition, cueBlockNumber])
  const cueTime = getEBMLTagByUintValue([0xB3], cuePointData.cueTime, isLittleEndian)

  const cuePoint = getEBMLTagByEBMLTags([0xBB], [cueTime, cueTrackPositions])
  cuePoints.push(cuePoint)
}

const cues = getEBMLTagByEBMLTags([0x1C, 0x53, 0xBB, 0x6B], cuePoints)
return cues

これをClusterタグの直上に入れます。
その辺はDurationと同じなのでコードは割愛します。
先ほどSegmentのサイズを指定したのでSegmentのサイズの更新も忘れずに

次はSeekHeadタグを入れます。
まぁCuesとほとんど同じなのでコードは割愛します。
気を付ける点は

です。
気になる人はGitHubでも見て下さい。

これでシークが爆速になりました!うれしい!!

codecsについて(おまけ)

上の方でcodecについて触れたと思いますがMediaRecorderで扱えるcodecsについて少し書きます。
そもそもcodecというのは動画や音声の圧縮方法ですね。
それぞれ一長一短らしいです。
結構ボリューミーですがMDNの記事が面白かったのでお勧めです。
https://developer.mozilla.org/ja/docs/Web/Media/Formats/Video_codecs

素で動画をフレームを持つのは容量がやばすぎなので当然MediaRecorderでもなんらかのcodecで動画を保存しています。
codecsはnew MediaRecorder()するときの第二引数に渡すことで指定できます。
const recorder = new MediaRecorder(stream, { mimeType: 'video/webm;codecs=h264' })みたいな感じですね。

まぁ当然どんなcodecでもいいわけじゃなくてブラウザが対応しているcodecだけです。
これは罠なんですがMDNのサンプルにある'video/mp4'はおそらくどんなブラウザでも動きません
https://developer.mozilla.org/ja/docs/Web/API/MediaRecorder/mimeType
どんなcodecsが許されているかはMediaRecorder.isTypeSupported(codecs)で確認できます。
https://developer.mozilla.org/ja/docs/Web/API/MediaRecorder/isTypeSupported

-> MediaRecorder.isTypeSupported('video/webm;codecs=h264')
<- true

みたいに。

ChromiumでPCブラウザだと色々対応してますがモバイルの対応は本当にないので辛いですね。

MediaRecorderでとった動画をMP4で保存する。

僕は結構ここで悩んだので他の困っている人向けに書きますがあんまり本編と関係ありません。

上でvideo/mp4には対応していない(2020/11/15)と書きましたがじゃあどうやってMP4にすればいいんだって話ですね。
最初は自分でMP4向けのバイナリを書こうと思ったんですがWebMBlockMP4mdatをどんな感じで関連付ければいいのかわからなかったです。
有識者がいたら教えてほしいです。

結局僕はffmpeg通すようにしました。
今はどのブラウザでも動くWebAssembly実装で激熱のffmpeg.wasmですね。
使い方は公式ドキュメントを見たらわかると思います。
https://github.com/ffmpegwasm/ffmpeg.wasm

MP4でも対応しているcodecsをMediaRecorderで指定するようにしたら、動画部分を再エンコードする必要がなくてメタ情報だけを変えればいいようになるのでうれしいですよね。
これらの積集合をWikipediaでみてみるとVideoはH.264、AudioはOpusっぽいですね。
そしてMediaRecorder.isTypeSupportedでみてみるとChromeやらEdgeは対応してそうです。
なのでconst recorder = new MediaRecorder(stream, { mimeType: 'video/webm;codecs=h264,opus' })で指定してあげてこれでもらったBlobをffmpegに通すとmp4になります!うれしい!!!!!

これで終われば幸せなんですが何故か音声が壊れるので音声だけffmpegのオプションで再エンコードします。
僕の環境だけかもしれません。
音声だけだと早いです。

もう一つ問題があってffmpeg.wasm(v0.8)はモバイルでは動きません。
0.6以前だと動くのでモバイルで使わせたいなら0.6にしてください。
使い方は0.8とあんまり変わりませんが同期、非同期が違ったりするのでモバイル対応したい人は以下のファイルのffmpeg部分を参考にしてください。
https://github.com/ryoha000/portablerg-client/blob/master/src/lib/utils.ts

最後に

それなりに長い記事を読んでくれてありがとうございます。
初めは1時間の動画のメタタグの付与が5分以上かかって冷や汗かいていましたが、まともに書いたら3時間の動画が10秒くらいになったので実用に耐えうるかなって思います。
最初に宣伝したゲーム用リモートデスクトップアプリ、使ってくれたらうれしいです。
明日のAdCは @liquid1224 です。何書いてくれるかは知りませんがDTMの濃い記事を書いてくれると思います。楽しみ~。

さよなら。

参考にしたサイト

https://www.matroska.org/index.html
https://qiita.com/legokichi/items/14a8d39dbaa90c2a5ccb
https://qiita.com/ryiwamoto/items/0ff451da6ab76b4f4064#cluster

めちゃくちゃありがとう

@takashi_trap
@temma
...
...
...
and you

ryoha icon
この記事を書いた人
ryoha

美少女ゲームをするためだけに生まれてきた真の戦士です。夢は「グガガガガガ...コレガ....アイ..」ってなることです。

この記事をシェア

このエントリーをはてなブックマークに追加
共有

関連する記事

ERC20トークンを用いた宝探しゲーム(真)の提案【アドベントカレンダー2018 10日目】 feature image
2018年11月3日
ERC20トークンを用いた宝探しゲーム(真)の提案【アドベントカレンダー2018 10日目】
Azon icon Azon
2021年5月19日
CPCTF2021を実現させたスコアサーバー
xxpoxx icon xxpoxx
2023年4月27日
Vulkanのデバイスドライバを自作してみた
kegra icon kegra
2024年4月14日
Spotifyのクライアントを自作しよう
d_etteiu8383 icon d_etteiu8383
2023年8月21日
名取さなになりたくてOBSと連携する配信画面を作った
d_etteiu8383 icon d_etteiu8383
2023年3月30日
みやぎハッカソン2023に参加しました(ずんだ食べ食べ委員会)
mehm8128 icon mehm8128
記事一覧 タグ一覧 Google アナリティクスについて 特定商取引法に基づく表記