WebCodecs の VideoDecoder を使用して H.264 の動画を再生してみた

f:id:aptpod_tech-writer:20211110183537j:plain

はじめに

こんにちは。Visual M2M Data Visualizer Team の白金です。

弊社の製品の intdashでは、H.264形式の動画データを収集/計測できます。計測した動画データは、Fragmented MP4 のフォーマットを使用したライブ動画をストリーミング再生したり、計測した動画を後から確認するためにHLSのフォーマットで再生する機能があります。

今回は、ライブ動画の再生機能を改善するための施策として 先日 Google Chrome の Version 94 でリリースされた WebCodecs の機能に含まれる VideoDecoder を使用して、H.264 のライブ動画をストリーミングで再生を試してみたのでご紹介します。

WebCodecs とは

WebCodecs は、ブラウザが内部で実装している H.264、V8 などのコーデックを用いて Video、Audio の動画ストリームをエンコード・デコードを実現するための低レベルAPIです。エンコード・デコードのみに特化したAPIとなるため、通信プロトコルに依存せず、アプリケーションが定義するタイミング、設定内容に応じて動画ストリームのエンコード・デコードが可能になります。

VideoDecoder を使用することで解決したい課題

VideoDecoder は通信プロトコルに依存しないため、弊社が提供している intdash Streaming Control Protocol との相性が良く、さらに下記2点の課題を解決することが可能になる見込みです。

課題 1: 欠損時の各動画フレームのタイムスタンプの判別

弊社から提供している Visual M2M Data Visualizer は、Fragmented MP4 フォーマットを使用した ライブ動画のストリーミング再生と、他のセンサーの値と、タイムスタンプを同時に可視化する機能を提供しています。

f:id:aptpod_tech-writer:20211109185049p:plain
正確なタイムスタンプが取得できない課題

Fragmented MP4 のタイムスタンプは、計測を開始した時刻の情報と Google Chrome で参照可能な Video DOM の currentTime プロパティで判別が可能ですが、動画を計測するための各カメラは移動体に設置し、モバイル回線を通じて H.264 の計測データを送信するケースがあるため、通信環境、伝送帯域に依存して送信する H.264 フレームが欠損する場合があります。当ケースが発生すると欠損したフレームを詰めた状態でFragmented MP4 に変換されるケースがあるため、ライブ動画のタイムスタンプの表示にずれが生じることがあります。*1

f:id:aptpod_tech-writer:20211109185107p:plain
正確なタイムスタンプを参照可能にする

VideoDecoder を使用すると、デコードした結果の画像とあわせてデコード時に指定したタイムスタンプを取得することができるため、上記課題を解決できるようになります。

課題 2: Google Chrome で動作する H.264 デコーダーの遅延

弊社から提供している Visual M2M Data Visualizer は、様々な H.264 エンコーダーで作成した動画を再生するケースがあります。

f:id:aptpod_tech-writer:20211109184403p:plain
ハードウェアアクセラレーションを使用した時のデコード遅延の課題

動画を計測する際に使用する H.264 エンコーダーと、Google Chrome が使用している ハードウェアアクセラレーションの H.264 デコーダーの組み合わせによっては、デコードまでの十分なフレームを確保してからデコードするケースがあるため、低遅延表示の再生に影響が発生する可能性があります。当ケースはソフトウェアデコードを使用することで改善することがありますが、 Google Chrome の詳細設定で、「ハードウェア アクセラレーションが使用可能な場合は使用する」を変更する必要があり、他のコンテンツページへの影響範囲が広くカジュアルに設定変更ができない、など運用面で課題がありました。

f:id:aptpod_tech-writer:20211109184441p:plain
VideoDecoder でソフトウェア・ハードウェアデコードの設定を切り替える

VideoDecoder を使用することで、作成したインスタンスごとにハードウェアアクセラレーションの使用の ON / OFFを設定することが可能になるため、上記運用の課題が解決できるようになります。

VideoDecoder の使い方

ここからは、VideoDecoder を使用したサンプルコードも含めてご紹介したいと思います。

VideoDecoder は、動画をデコードするためのAPIに特化しているため、シンプルな構成になっています。 各APIの仕様については、こちら に詳細情報が掲載されています。

実装の流れとしては、下記5つのステップになります。

  1. output, error の Callback を実装します。
  2. Callback を指定して VideoDecoder のインスタンスを作成します。
  3. デコードする動画の情報を設定します。
  4. 分割された動画のキーフレーム、サブフレームをデコードします。
  5. デコードが完了した画像から output の Callback に渡されて呼び出されます。

f:id:aptpod_tech-writer:20211109074607p:plain
VideoDecoder の使い方のサンプル

下記は上図をもとに実装した VideoDecoder のサンプルコードです。

const canvasElement = document.getElementByID('canvas')

/**
 * VideoDecoder のインスタンスを作成します。
 */
const videoDecoder = new VideoDecoder({
  // 動画フレームのデコードが完了すると、output のコールバックが実行されます。
  output: (videoFrame) => {
    // デコードした videoFrame を Canvas に描画します。
    canvasElement.width = videoFrame.codedWidth
    canvasElement.height = videoFrame.codedHeight
    const context = canvas.getContext('2d')
    context.drawImage(videoFrame, 0, 0)

    // デコード実行時に指定したタイムスタンプも参照可能です。
    console.log(videoFrame.timestamp)

    // 必要な処理が完了したら、VideoFrame を破棄します。
    videoFrame.close()
  },
  // 動画フレームのデコードが失敗すると、error のコールバックが実行されます。
  error: (error) => {
    // エラー情報を出力します。
    console.error(error)
  }
})

/**
 * VideoDecoder でデコードする動画の情報を設定します。
 */
videoDecoder.configure({
  codec 'avc1.64001E',
  hardwareAcceleration: 'prefer-hardware'
})

/**
 * キーフレームをデコードします。
 */
videoDecoder.decode(new EncodedVideoChunk({
  type: 'key',
  timestamp: 1234,
  data: new Uint8Array([......]).buffer,
)}

/**
 * サブフレームをデコードします。
 */
videoDecoder.decode(new EncodedVideoChunk({
  type: 'delta',
  timestamp: 4567,
  data: new Uint8Array([......]).buffer,
)}

/**
 * VideoDecoder のインスタンスを解放します。
 */
videoDecoder.close()

H.264 の動画をデコードする

VideoDecoder でデコードする情報は、 configure メソッドで指定する codec、または任意で指定する description で決まります。

H.264 の動画フォーマットを指定する場合は、 Annex BAVCDecoderConfigurationRecord の2種類があります。 どちらも、 codec の指定方法に違いはありませんが、description の指定方法によって EncodedVideoChunk で指定する data のフォーマットが異なります。

では、それぞれ違いを見ていきましょう。

Annex B

Annex B はバイトストリーム形式です。開始コードプレフィックス 0x00、0x00、0x00、0x01 から始まる NAL Unit で構成されています。 詳細は、T-REC-H.264 に添付されている仕様書に記載されていますので、ここでは詳細は割愛します。

videoDecoder.configure({
  // 事前に判定済みのcodec、またはSPSフレームから判定します。
  codec 'avc1.64001E',
  hardwareAcceleration: 'prefer-hardware'
})

videoDecoder.decode(new EncodedVideoChunk({
  type: 'key',
  timestamp: 1234,
  // 開始コードプレフィックスから始まる各SPS、PPS、IDR の NAL Unit の先頭に追加したデータを指定します。
  data: new Uint8Array([
     // SPS
     0x00, 0x00, 0x00, 0x01, 0x67, 0x64, 0x00, 0x1e, ...,
     // PPS
     0x00, 0x00,  0x00, 0x01, 0x68, ...,
     // IDR
     0x00, 0x00, 0x00, 0x01, 0x65, ...,
  ]).buffer,
)}

videoDecoder.decode(new EncodedVideoChunk({
  type: 'delta',
  timestamp: 4567,
  // 開始コードプレフィックスから始まる non-IDR の NAL Unit を指定します。
  data: new Uint8Array([
     // non-IDR
     0x00, 0x00, 0x00, 0x01, 0x41, ...,
  ]).buffer,
)}

AVCDecoderConfigurationRecord

VideoDecoderConfig に掲載されているように、configure メソッドで description を指定すると、 SPS、 PPS の NAL Unit を使用した AVCDecoderConfigurationRecord で定義されているフォーマットでデコードするようになります。

// SPS、PPS NAL Unit から、 AVCDecoderConfigurationRecord に変換します。
// 当サンプルコードでは変換する処理は割愛します。
// 詳細は、上記添付の仕様書を参照してください。
const sps = new Uint8Array([0x00, 0x00, 0x00, 0x01, 0x67, 0x64, 0x00, 0x1E, ...])
const pps = new Uint8Array([0x00, 0x00, 0x00, 0x01, 0x68, ....])
const extradata = toAVCDecoderConfigurationRecord(sps, pps)

videoDecoder.configure({
  codec 'avc1.64001E',
  description: extradata,
  hardwareAcceleration: 'prefer-hardware'
})

videoDecoder.decode(new EncodedVideoChunk({
  type: 'key',
  timestamp: 1234,
  data: new Uint8Array([
     // AVCDecoderConfigurationRecord の `lengthSizeMinusOne` に応じたByteLengthと IDRの NAL Unit を指定します。
     [Length Bytes], 0x65, ...,
  ]).buffer,
)}

videoDecoder.decode(new EncodedVideoChunk({
  type: 'delta',
  timestamp: 1234,
  data: new Uint8Array([
     // AVCDecoderConfigurationRecord の `lengthSizeMinusOne` に応じたByteLengthと non-IDR の NAL Unit を指定します。
     [Length Bytes], 0x41, ...,
  ]).buffer,
)}

H.264 の動画を再生してみる

MP4形式の動画ファイルを gstreamer 、Server を経由して Google Chrome で表示した VideoDecoder で再生するためのデモです。

下図構成で実装しています。

f:id:aptpod_tech-writer:20211108202751p:plain
Web Codecs デモ構成図

Server を起動する

Server のコードは下記の通りです。必要な処理のみ実装しています。 npm i -S ws で依存パッケージをインストールして実行してください。

H.264 の開始コードとNAL Unit 判定については、最低限のケアのみサポートしています。*2

/* @ts-check */

const net = require('net')
const EventEmitter = require('stream')
const ws = require('ws')

/**
 * 定義値
 */
const WEB_SOCKET_SERVER_PORT = 18000
const TCP_SERVER_PORT = 3010
const H264_START_CODE = new Uint8Array([0x00, 0x00, 0x01])

/**
 * EventEmitter と使用するEvent Nameを定義します。
 */
const EVENT_NAME = {
  CHUNKED_H264_PARSE: 'CHUNKED_H.264_PARSE',
  H264_FRAME_SEND: 'H.264_FRAME_SEND',
}

const eventEmitter = new EventEmitter()

/**
 * ブラウザと連携する WebSocket Server を作成します。
 */
const wsServer = new ws.Server({
  port: WEB_SOCKET_SERVER_PORT,
})
wsServer.on('connection', (conn) => {
  console.log('connect WebSocket Client')
  
  const handler = (data) => {
    conn.send(data)
  }

  eventEmitter.on(EVENT_NAME.H264_FRAME_SEND, handler)

  conn.on('close', () => {
    console.log('close WebSocket Client')
    eventEmitter.off(EVENT_NAME.H264_FRAME_SEND, handler)
  })
})
wsServer.on('listening', () => {
  console.log(`listening WebSocket Server on port ${WEB_SOCKET_SERVER_PORT}`)
})

/**
 * 開始コードプリフィックスから始まる NAL Unit の単位に分割し、
 * H264_FRAME_SEND Event に連携します。
 */
let restChunkedH264Frame = new ArrayBuffer(0)
eventEmitter.on(EVENT_NAME.CHUNKED_H264_PARSE, (data) => {
  let restBuffer = concatArrayBuffer(restChunkedH264Frame, data)

  while (true) {
    const restU8a = new Uint8Array(restBuffer)

    const startIndex = arrayIndexOfMulti(restU8a, H264_START_CODE, 0)
    if (startIndex < 0) {
      break
    }

    const endIndex = arrayIndexOfMulti(restU8a, H264_START_CODE, startIndex + 1)
    if (endIndex < 0) {
      break
    }

    const naluWithStartCode = restBuffer.slice(startIndex, endIndex)
    eventEmitter.emit(EVENT_NAME.H264_FRAME_SEND, naluWithStartCode)

    restBuffer = restBuffer.slice(endIndex)
  }

  restChunkedH264Frame = restBuffer
})

/**
 * gstreamer の tcpclientsink から H.264 のデータを受信する
 * TCP Server を作成します。
 */
net.createServer((conn) => {
  console.log('connect TCP Client')
  conn.on('data', (data) => {
    eventEmitter.emit(EVENT_NAME.CHUNKED_H264_PARSE, data)
  });
  conn.on('close', () => {
    restChunkedH264Frame = new ArrayBuffer(0)
    console.log('client closed connection');
  });
}).listen(TCP_SERVER_PORT, () => {
  console.log(`listening TCP Server on port ${TCP_SERVER_PORT}`)
})

/**
 * ArrayBuffer を結合します。
 * @param {ArrayBuffer} buffer1
 * @param {ArrayBuffer} buffer2
 * @returns {ArrayBuffer}
 */
const concatArrayBuffer = (buffer1, buffer2) => {
  const dstU8a = new Uint8Array(buffer1.byteLength + buffer2.byteLength)
  dstU8a.set(new Uint8Array(buffer1), 0)
  dstU8a.set(new Uint8Array(buffer2), buffer1.byteLength)
  return dstU8a.buffer
}

/**
 * searchElements で与えられた内容と同じ内容を持つ
 * 最初の配列要素の添字を返します。
 * 存在しない場合は -1 を返します。
 * @param {Uint8Array} array
 * @param {Uint8Array} searchElements
 * @param {number} fromIndex
 * @returns {number}
 */
const arrayIndexOfMulti = (array, searchElements, fromIndex) => {
  const index = array.indexOf(searchElements[0], fromIndex);

  if (searchElements.length === 1 || index === -1) {
    return index
  }

  let i = index
  for (
    let j = 0;
    j < searchElements.length && i < array.length;
    i++, j++
  ) {
    if (array[i] !== searchElements[j]) {
      return arrayIndexOfMulti(array, searchElements, index + 1)
    }
  }

  return i === index + searchElements.length ? index : -1
}

動画を再生する画面を表示する

次に、Google Chrome で 動画を再生するデモを準備します。 下記 Javascript を含むHTMLのコードを localhost でホスティングしたサーバーで表示します。

WebSocket を 前述で起動した Server に接続し、開始コードと NAL Unit を message で取得し、WebCodecs の VideoDecoder を使用してデコードします。

また、下記サンプルコードは最低限のケア*3のみサポートしているため、エラー発生時の処理が十分ではありません。

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf8" />
    <title>Web Codecs H.264 Play Demo</title>
  </head>

  <body>
    <h1 id="timestamp">Display Timestamp</h1>
    <canvas id='canvas'></canvas>

    <script>
      /**
       * 参照する HTMLElement を取得します。
       */
      const timestampElement = document.getElementById('timestamp')
      const canvasElement = document.getElementById('canvas')

      /**
       * H.264 NAL 関連の定義
       */
      const NAL_UNIT_TYPE = {
        IDR: 5,
        NON_IDR: 1,
        SPS: 7,
        PPS: 8,
      }
      let cachedSPSBuffer = new ArrayBuffer()
      let cachedPPSBuffer = new ArrayBuffer()

      /**
       * 複数の ArrayBuffer を結合します。
       * @param {ArrayBuffer[]} arrayBuffers
       * @returns {ArrayBuffer}
       */
      const concatArrayBuffers = (arrayBuffers) => {
        const sumByteLength = arrayBuffers.reduce((acc, cur) => {
          return acc + cur.byteLength
        }, 0)

        const concatenatedUint8Array = new Uint8Array(sumByteLength)
        let offset = 0

        for (const arrayBuffer of arrayBuffers) {
          concatenatedUint8Array.set(new Uint8Array(arrayBuffer), offset);
          offset += arrayBuffer.byteLength
        }

        return concatenatedUint8Array.buffer
      }

      /**
       * タイムスタンプの文字列を整形します。
       * @param {number} unixTimeMillisecond
       * @returns {string}
       */
      const formatTimestampText = (unixTimeMillisecond) => {
        const d= new Date(unixTimeMillisecond)
        const year = d.getFullYear()
        const month = (d.getMonth() + 1).toString().padStart(2, '0')
        const date = d.getDate().toString().padStart(2, '0')
        const hour = d.getHours().toString().padStart(2, '0')
        const minute = (d.getMinutes()).toString().padStart(2, '0')
        const second = d.getSeconds().toString().padStart(2, '0')
        const millisecond = d.getMilliseconds().toString().padStart(3, '0')
        return `${year}/${month}/${date} ${hour}:${minute}:${second}.${millisecond}`
      }

      /**
       * VideoDecoder のインスタンスを作成します。
       */
      let prevDisplayedTimestamp = 0
      const videoDecoder = new VideoDecoder({
        /**
         * @param {VideoFrame} videoFrame
         * @returns {void}
         */
        output: (videoFrame) => {
          // Callback は順不同に実行される可能性があるため、
          // 直前のタイムスタンプと逆転している場合は描画をスキップします。
          if (videoFrame.timestamp > prevDisplayedTimestamp) {
            // デコードしたVideoFrame を Canvas に描画します。
            canvasElement.width = videoFrame.codedWidth
            canvasElement.height = videoFrame.codedHeight
            const context = canvas.getContext('2d')
            context.drawImage(videoFrame, 0, 0)

            // デコード実行時に指定したタイムスタンプを表示します。
            timestampElement.textContent = formatTimestampText(videoFrame.timestamp)
          }

          // 表示したタイムスタンプを保持します。
          prevDisplayedTimestamp = videoFrame.timestamp

          // 必要な処理が完了したら、VideoFrame を破棄します。
          videoFrame.close()          
        },
        /**
         * @param {Error} error
         * @returns {void}
         */
        error: (error) => {
          console.error('VideoDecoder Error', error)
        }
      })

      /**
       * WebSocket のインスタンスを作成、必要な情報を設定します。
       */
      const url = 'ws://localhost:18000'
      const ws = new WebSocket(url)
      ws.binaryType = 'arraybuffer'

      ws.onmessage = (message) => {
        // 開始コードプリフィックス "0x00、0x00、0x01" から始まる NAL Unit の単位で取得します。
        const messageData = message.data

        // 当デモは、WebSocket のメッセージ受信した時刻をタイムスタンプとして扱います。
        const timestamp = Date.now()

        const u8a = new Uint8Array(message.data)
        const nalUnitType = u8a[3] & 0x1f

        switch (nalUnitType) {
          case NAL_UNIT_TYPE.IDR:
            const spsU8a = new Uint8Array(cachedSPSBuffer)
            const hexProfile = spsU8a[4].toString(16).padStart(2, '0')
            const hexCompatibility = spsU8a[5].toString(16).padStart(2, '0')
            const hexLevel = spsU8a[6].toString(16).padStart(2, '0')
            const codec = `avc1.${hexProfile}${hexCompatibility}${hexLevel}`

            const hardwareAcceleration = 'prefer-hardware'

            videoDecoder.configure({
              codec,
              hardwareAcceleration,
            })

            videoDecoder.decode(new EncodedVideoChunk({
              type: 'key',
              timestamp,
              data: concatArrayBuffers([
                cachedSPSBuffer,
                cachedPPSBuffer,
                u8a.buffer,
              ])
            }))
            break

          case NAL_UNIT_TYPE.NON_IDR:
            videoDecoder.decode(new EncodedVideoChunk({
              type: 'delta',
              timestamp,
              data: u8a.buffer
            }))
            break

          case NAL_UNIT_TYPE.SPS:
            cachedSPSBuffer = u8a.buffer
            break

          case NAL_UNIT_TYPE.PPS:
            cachedPPSBuffer = u8a.buffer
            break

          default:
            break
        }
      }
    </script>
  </body>
</html>

動画を送信する

最後に、gstreamer を使用して、MP4 のデータを H.264 の Annex B のフォーマットで擬似的に 動画をストリーミングします。動画データは、tcp プロトコルで、事前に起動した Server に送信します。

${MP4_SRC_PATH} にはMP4ファイルのパスを指定します。MP4ファイルは ffmpeg を使用して libx264 のエンコーダーで作成した動画で確認しました。

gst-launch-1.0 -q\
  filesrc location="${MP4_SRC_PATH}" \
  ! qtdemux \
  ! h264parse config-interval=1 update-timecode=true \
  ! video/x-h264,stream-format=byte-stream,alignment=au \
  ! tcpclientsink port=3010

WebCodecs の VideoDecoder を使用して Google Chrome でライブ動画のストリーミング再生することができました!*4

youtu.be

おわりに

WebCodecs の VideoDecoder を使用して、ライブ動画ストリーミングを再生することを確認できました。

今回ご紹介できなかった、Media Source Extension API を使用した Fragmented MP4 の動画再生の比較、他の H.264 エンコーダーで作成した ライブ動画ストリーミングの確認結果については、次回以降のテックブログでご紹介したいと思います。

上記も含めて弊社製品、またはフロンドエンドに興味を持って頂けた方は是非、こちらの弊社採用ページもご覧ください。

製品に関するお問い合わせはこちらへ!

www.aptpod.co.jp

*1:ライブ動画計測時に回収できなかった動画データは後で回収することで、HLS のフォーマットで再生する動画はずれなく再生が行われます。

*2:tcp から受信したデータから、0x00、0x00、0x01 が見つかるまで、一つの開始コードプリフィックスから始まる NAL Unit を抽出するように実装しています。また、IDR、 non-IDR の1フレームが複数のスライスで構成されている H.264 のフレームの再生には対応していません。

*3:受信する message に含まれる開始コードは、0x00, 0x00, 0x01, ... のみサポートしています。また、受信する message が一定時間経過した後にVideoDecoder をが自動で closeする処理のリカバリー処理は実装していません。

*4:添付の動画の Web ページ画面左上のタイムスタンプは、H.264 の NAL Unit を受信した時点のタイムスタンプとしてサンプルコードを実装しました。そのため、MP4ファイルの動画をストリーミング再生している iPhone に表示している時刻と一致していません。