🖼️

MediaStreamへのPNGグリッチの適用

2024/12/13に公開

TL;DR

  • MediaStreamに対してPNGグリッチを適用しました
  • PNGグリッチは、Rustで実装しWasm化したものを利用しています
  • グリッチを行うコンテキストをリソースとして定義することで、グリッチのための処理をJavaScriptで柔軟に記述できるようになります

作成したもの

navigator.mediaDevices.getDisplayMedia()で取得したMediaStreamのビデオトラックに対してPNGグリッチを適用しています。

Zennの画面からグリッチアートを作成している様子
図1. ZennのトップページにPNGグリッチを適用している様子

ストリーム処理

MediaStreamをストリーム処理することで、全体を実現しています。PNGグリッチはMediaDeviesオブジェクトとHTMLVideElementの間のGlitchFilterとして実装されています(図2)。

MediaStreamのパイプライン
図2. ストリーム処理の様子

GlitchFilterMediaStreamTrackProcessorMediaStreamTrackGeneratorを利用して実装されています。これらはInsertable Stream For MediaStream APIという仕様に定義されています。

https://github.com/chikoski/mediastream-experiments/blob/main/png-glitch/src/ts/ui/ui-state.ts#L17-L25

MediaStreamTrackProcessorを利用して、入力のMediaStreamから動画像の流れるMediaStreamTrackを抽出し、各フレームの流れるReadableStreamを作成します。またMediaStreamTrackGeneratorは指定した種類のデータを書き込めるWritableStreamを持っています。GlitchFilterはこの2つのストリームの間に挟まるTransformStreamとして実現されています。

https://github.com/chikoski/mediastream-experiments/blob/main/png-glitch/src/ts/worker.ts#L9-L11

なお、GlitchFilterWebWorker上で実行されています。

GlitchFilterの処理

GlitchFilterは次の手順でVideoFrameにPNGグリッチを適用します:

  1. OffScreenCanvasを利用しVideFrameからPNG画像を作成します
  2. 作成したPNG画像のバイト列からPNGグリッチを適用するためのコンテキストを作成します
  3. 操作後のPNG画像のデータからビットマップデータを作成します
  4. 作成したビットマップデータと、元のVideoFrameのタイムスタンプから新しいVideoFrameを作成します
  5. 作成したVideoFrameTransformStreamDefaultControllerに渡して、ストリームに送出します

コードは次のようになっています。実際にはステップ1と2の間で、ランダムにVideoFrameの部分をコピーすることでTransposeも適用しています。

https://github.com/chikoski/mediastream-experiments/blob/45c4364b10b25ea6c5618d0bcb80d23c951ff083/png-glitch/src/ts/worker.ts#L48-L81

ステップ2で作成されているコンテキストは、chikoski:glitch-art/png-glitchablepngリソースとして定義されています。これをRustで実装し、Wasmコンポーネントとしてビルドしたものを利用しています。

https://github.com/chikoski/mediastream-experiments/blob/29ca9fa8979889311312c4b2597d79fcdd4d7b5a/png-glitch/src/wasm/png-glitchable-impl/src/lib.rs#L98-L114

ビットマップからPNGへの変換をどこで行うべきか

PNGグリッチはPNGファイルの書式を保ちつつデータを破壊することで、グリッチアートを作成する手法です。一方、VideoFrameから直接作成できるのははビットマップ画像です。つまりPNGグリッチをVideoFrameに対して適用するには、ビットマップ画像からPNG画像を作成する必要があります。これには以下の2通りの方針があるように思います:

  • ブラウザーの提供するAPIを利用してPNG画像の作成を行う
  • PNG画像への変換をWasm内で行う

最初は後者の手法を取っていました。これはメインスレッド内で処理を行っていた場合、OffScreenCanvasからのBlobオブジェクト作成と、BlobオブジェクトからArrayBufferオブジェクトの作成に概ね255ミリ秒(16フレーム弱)かかっていたためです。この時は体感できるほどの遅延がありました。

これをWebWorkerに移したところ、大きな遅延は無くなりました。よって現在は前者の手法をとっています。図3はキャプチャーのウィンドウ(上)とグリッチさせたもの(下)との比較を行なっています。体感できるほどの大きな遅延はなくなっています。

キャプチャー元とグリッチさせたものとの比較
図3. キャプチャーのウィンドウ(上)とグリッチさせたもの(下)との比較

なおOffScreenCanvasを利用したPNG画像の作成は、次のように行なっています:

https://github.com/chikoski/mediastream-experiments/blob/main/png-glitch/src/ts/worker.ts#L66-L67

コンテキストをWIT上のリソースとして定義することの利点

グリッチ対象のデータは、Wasm内部で管理されているオブジェクトとして表現されています。またグリッチのための操作によって値がどんどん変更されていきます。つまりWITのリソースとして表現することができます。

WIT上のリソースとして表現することで、グリッチのための操作はコンテキストへのメソッド呼び出しとして実現できます。これによってTypeScriptやJavaScritのコードを変更することで、作成されるグリッチアートを変更することができます。つまり、気軽に試行錯誤ができるようになりました。

実際Rust内部でグリッチアートの作成自体も実装したこともありました。このとき、作成されたグリッチアートが気に入らない場合、次のような修正作業が必要となりました:

  1. Rust側のコードの変更
  2. Wasmへのビルドとトランスパイル
  3. トランスパイルされたコードの配置と、TypeScriptのビルド
  4. 結果の確認

Wasmのビルドとトランスパイルが特に面倒でした。実際、ビルドしたファイルを取り違えたり、トランスパイルした結果を適切な位置に配置しなかったといったミスも頻発したため、処理を自動化するためのビルドスクリプトを作成したこともあります。このビルドスクリプトのメンテナンスも面倒でした。

グリッチ処理をWIT上のリソースとして定義し直すことで、Wasmはグリッチ処理のためのAPIを提供し、実際の処理はTypeScriptで定義するといった形に責任分解点を変更することができました。これによりビルドスクリプトの削除を含むビルド手順の簡略化を行うことができ、TypeScript側のコードを変更するだけでグリッチアートの作成方法の変更ができるようになりました。TypeScriptビルドツールの提供するホットリロード機能と組み合わせることによって、試行錯誤は次のように簡単にできるようになりました:

  1. TypeScriptのコードの変更
  2. ブラウザーでの確認

まとめと感想

この記事ではMediaStreamに対してPNGグリッチを適用しました:

  • Insertable Stream For MediaStream APIはビデオフレームの処理をWebWorkerで行うことを可能にします
  • OffScreenCanvasを利用することで、VideoFrameからPNGデータの作成を高速に行えます
  • WITのリソースを適切に使うことで、WasmはAPIの提供、TypeScriptはAPIの利用側といった形で責任分解点を設定できます

3つ目の点は試行錯誤を容易にする以外に、コードの見通しもよくする効果があったようにも思います。Wasmはライブラリーであり、アプリはあくまでTypeScriptで書かれているというような理解が可能になりました。Wasmの内容に踏み込む必要もなくなり、抽象度の高く理解しておけば良くなったようにも思います。

Discussion