search
LoginSignup
8

More than 1 year has passed since last update.

posted at

updated at

MediaRecorder API で録画した動画の seek に時間がかかる問題

MediaRecorder API で録画した動画の seek に時間がかかる問題

問題

Chrome の MediaRecorderAPI で保存した動画は video.controlsseekable ではなく 、そのまま HTMLVideoElement に読み込ませるとシークできなくなる。
前回の記事 の通り、 video.duration に大きな数字を入れることで video.controls は一応 seekable になる。

const video = document.createElement("video");
let now = 0;
video.addEventListener("loadeddata", ()=>{
  video.currentTime = 24*60*60*1000;
  now = performance.now();
});
video.addEventListener("seeked", ()=>{
  console.info("seeked", performance.now() - now);
  document.body.appendChild(video);
});
video.src = "recorded_by_media_recorder_api.webm";
video.controls = true;

しかし、 1時間近く録画して生成した大きな webm 動画に関しては、 video.controls を利用した seek が非常に遅くなる問題が発生する

原因

  1. MediaRecorder API で 録画した webm の コンテナである EBML のヘッダ領域に SeekHead 要素が ない ので loadedmetadata 時点では duration 情報は一切ない。
  2. 上述のハック では HTMLVideoElementHTTP/1.1 Range Request をしながらファイルの先頭から順番に読み込んでいき、 EBML の Segment > Cluster > Timecode 要素と Segment > Cluster > SimpleBlock 要素を 逐次読み込んで時刻情報を得ていく ので、極めて効率が悪い。 f

対処法

1. その場しのぎ「あらかじめ動画全体を読み込んでおく」

Range Request で逐次解析するのが極端な遅さの原因なので、あらかじめ動画ファイルをメモリに乗せておけばよい。

const xhr = new XMLHttpRequest();
xhr.responseType = responseType;
xhr.open("GET", "foo.webm");
xhr.send();
xhr.onreadystatechange = ()=>{
  if(xhr.readyState !== 4){ return; }
  if(200 !== xhr.status){ return; }
  const blob = xhr.response;
  const url = URL.createObjectURL(blob);
  const video = document.createElement("video");
  video.src = url;
  video.onloadeddata = ()=>{
    video.currentTime = 24*60*60*1000;
    video.seeked = ()=>{
      document.body.appendChild(video);
    };
  };
};

これで Range Request が発生する場合よりは seek の応答性は早くなる。しかしまだ遅い。なぜなら seek が発生するたびにファイル先頭から逐次時刻を計算していることには変わらないからである。

2. 根本的な解決策「webm ファイルを動的に構成する」

時刻計算を省くためには webm ファイルのメタデータにシーク情報を与えておく必要がある。
MediaRecorderondataavailable で送られてくる BlobEventev.blob の中の EBML を逐次解析し、 Clustor 要素の位置を記録しておく。
その後、録画終了時に Clustor 要素への参照を記録した Segment > SeekHead 要素を追加するか、MediaSourceAPI を使用して動的に構成したヘッダを渡すなりすればよい。

webm ファイルの中身をみて SeekHead がないことを確認する

 
ここ によると ebml-viewermkvinfo を使えばよいとあるので、私は mkvinfo を利用した。 mkvtoolnix をインストール すればよい。

$ mkvinfo -g foo.webm

とすれば GUI で mkv の中身をみることができる。標準ではメタ情報しか表示しないのでメニューから全ての情報を取得を選択するようにする。
中を覗いてみると SeekHead がないことが確認できる。

EBML の詳細については

などが明るい。

JS から EBML を読み込む

themasch/node-ebmlmathiasvr/ebml-block を使用する。

MediaRecorderAPI が生成する webm の EBML 構造

<EBML>
  ...
</EBML>
<Segment>
  <Info>
    <!--
    ここに duration がないため `ffmpeg -i` などでみると duration が n/a となる
    <Duration>0</Duration>
    -->
    <TimecodeScale>1000000</TimecodeScale>
    <!--
      1000000ns = 1ms
      以後の時刻の単位系を決める
    -->
    ...
  </Info>
  <!--
  ここに SeekHead が欲しい
  <SeekHead>
    <Seek>
      <SeekID>[1F][43][B6][75]</SeekID>// EBML ID
      <SeekPosition>11451489464</SeekPosition> // offset from <Segment />
    </Seek>
    ...
  </SeekHead>
  -->
  <Tracks>
    <TrackEntry>
      <TrackNumber>1</TrackNumber>
      <TrackType>1</TrackType><!-- 1=video, 2=audio -->
      ...
    </TrackEntry>
    ...
  </Tracks>
  <Cluster>
    <Timecode>0</Timecode><!-- このクラスタの開始時刻(TimecodeScale単位) -->
    <SimpleBlock>
      <!--SimpleBlock の中身は ebml-block を使えば読める-->
      <track>1</track>
      <timecode>0</timecode><!-- クラスタ先頭からの時刻(TimecodeScale単位) -->
      <keyframe/>
      ...
    </SimpleBlock>
    <SimpleBlock>
      <timecode>0.033</timecode>
      ...
    </SimpleBlock>
    ...
    <!--
    SimpleBlock があと 98 個続く
    -->
<!--
Cluster, Segment は不定長要素なので閉じタグはない
新たな Clustor は level 0 要素(EBMLとSegment) からやり直される
-->
<EBML>...</EBML>
<Segment>
  ...
  <Cluster>
    <Timecode>0</Timecode>
    <SimpleBlock>...</SimpleBlock>
    ...
...
<!--
以後繰り返し
-->

動的解析

node-ebml は node の stream 標準ライブラリに依存しているため、そのまま MediaRecorder に適用できないが、書き換えるのは容易だった。ソースは公開していないので各自挑戦してみてほしい。
ちなみに buffer の polyfill は feross/buffer が良かった。
あるいは oeuillot/node-matroska ならば browserify で動くかもしれない。

追記

  • node-ebml も node-matrosika も webm streaming のための不定長要素を読み込むことができないバグがあったので報告した
  • Matroska の libebml の該当コード
  • chromium の libwebm の該当コード

追記

上述の問題を解決した EBML パーサを書いた (https://github.com/legokichi/ts-ebml)

以下のようにして WebM のストリームを動的に解析することができる


import EBML, {Decoder, Encoder, Refiner, tools} from "./";

const Buffer: typeof global.Buffer = require("buffer/").Buffer;

const decoder = new Decoder();
const encoder = new Encoder();
const refiner = new Refiner();

async function recorder_main() {
    const stream = await navigator.mediaDevices.getUserMedia({video: true, audio: true});
  const rec = new MediaRecorder(stream, { mimeType: 'video/webm; codecs="vp8, opus"' });  
  const tasks: Promise<void>[] = []; 

  rec.ondataavailable = (ev: BlobEvent)=>{
    const chunk = ev.data;

    const task = readAsArrayBuffer(chunk) // Blob -> Promise<ArrayBuffer>
      .then((buf)=>{
        const chunks = decoder.decode(buf); // Blob -> EBMLElement[]
        refiner.read(chunks); // EBMLElement[] -> (EBMLCluster[], duration, ...)
      });

    tasks.push(task);
  };
  rec.start(100);

  await new Promise((resolve)=> setTimeout(resolve, 30 * 1000) );

  rec.stop();
  rec.ondataavailable = undefined;
  rec.stream.getTracks().map((track) => { track.stop(); });

  await tasks.reduce((o, prm) => o.then(() => prm), Promise.resolve(void 0)); // wait 30 sec

  // metadata: SeekHead と Duration が挿入された新しい metadata
  // clusterStartPos: 最初の Cluster 要素の位置
  const {metadata, clusterStartPos} = refiner.putRefinedMetaData();
  const webmBuf = await readAsArrayBuffer(WebM);
  const clustersBuf = webmBuf.slice(clusterStartPos);
  // metadata の置き換え
  const refined = new Blob([metadata, clustersBuf], {type: "video/webm"});

  const originalVid = await putVideo(WebM, "plain recorded webm");
  const refinedVid = await putVideo(refined, "refined webm");

  console.assert(! Number.isFinite(originalVid.duration)); // Infinity
  console.assert(  Number.isFinite(refinedVid.duration));  // Finite

  originalVid.currentTime = 1000 * 60 * 60 * 24 * 7;
  originalVid.onseeked = ()=>{
    originalVid.onseeked = <any>undefined;
    originalVid.currentTime = 0;
    console.assert(refinedVid.duration === originalVid.duration); // Duration も一致
  }
}

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
What you can do with signing up
8