USB アプリケーションをウェブに移植する。パート 2: gPhoto2

ウェブアプリから USB 経由で外部カメラを制御するために、gPhoto2 を WebAssembly に移植しました。

前回の投稿では、WebAssembly / Emscripten、Asyncify、WebUSB を使用して、libusb ライブラリをウェブで実行するように移植する方法を説明しました。

また、ウェブ アプリケーションから USB 経由でデジタル一眼レフカメラやミラーレス カメラを制御できる gPhoto2 で構築したデモも紹介しました。この投稿では、gPhoto2 ポートの背後にある技術的な詳細について詳しく説明します。

ビルドシステムがカスタム フォークを指すようにする

WebAssembly をターゲットにしていたため、システム ディストリビューションで提供される libusb と libgphoto2 を使用できませんでした。代わりに、アプリケーションで libgphoto2 のカスタム フォークを使用する必要がありましたが、libgphoto2 のフォークでは libusb のカスタム フォークを使用する必要がありました。

さらに、libgphoto2 は動的プラグインの読み込みに libtool を使用します。他の 2 つのライブラリのように libtool をフォークする必要はありませんでしたが、WebAssembly でビルドし、システム パッケージではなくそのカスタムビルドに libgphoto2 をポイントする必要がありました。

おおよその依存関係図を以下に示します(破線はダイナミック リンクを示しています)。

図は、「libtool」に依存する「libgphoto2 fork」に依存する「アプリ」を示しています。「libtool」ブロックは「libgphoto2 ポート」と「libgphoto2 camlibs」に動的に依存します。最後に、「libgphoto2 ポート」は「libusb fork」に静的に依存します。

これらのライブラリで使用されているものを含め、ほとんどの構成ベースのビルドシステムでは、さまざまなフラグを使用して依存関係のパスをオーバーライドできるようになっています。それを最初に試してみました。ただし、依存関係グラフが複雑になると、各ライブラリの依存関係に対するパス オーバーライドのリストは冗長になり、エラーが発生しやすくなります。また、依存関係が標準以外のパスに存在するようにビルドシステムが実際には準備できていないバグもいくつか見つかりました。

それよりも簡単なアプローチは、カスタム システムルートとして別のフォルダ(「sysroot」と短縮されることが多い)を作成し、関連するすべてのビルドシステムをそのフォルダを指すようにすることです。これにより、各ライブラリは、ビルド時に指定された sysroot で依存関係を検索し、同じ sysroot に自身をインストールするため、他のライブラリが見つけやすくなります。

Emscripten はすでに (path to emscripten cache)/sysroot の下に独自の sysroot を持ち、これをシステム ライブラリEmscripten ポート、CMake や pkg-config などのツールに使用します。依存関係にも同じ sysroot を再利用することを選択しました。

# This is the default path, but you can override it
# to store the cache elsewhere if you want.
#
# For example, it might be useful for Docker builds
# if you want to preserve the deps between reruns.
EM_CACHE = $(EMSCRIPTEN)/cache

# Sysroot is always under the `sysroot` subfolder.
SYSROOT = $(EM_CACHE)/sysroot

# …

# For all dependencies I've used the same ./configure command with the
# earlier defined SYSROOT path as the --prefix.
deps/%/Makefile: deps/%/configure
        cd $(@D) && ./configure --prefix=$(SYSROOT) # …

このような構成では、各依存関係で make install を実行するだけで sysroot にインストールされ、ライブラリが自動的に互いを検出できました。

動的読み込みの取り扱い

前述のように、libgphoto2 は libtool を使用して、I/O ポート アダプターとカメラ ライブラリを列挙し、動的に読み込みます。たとえば、I/O ライブラリを読み込むコードは次のようになります。

lt_dlinit ();
lt_dladdsearchdir (iolibs);
result = lt_dlforeachfile (iolibs, foreach_func, list);
lt_dlexit ();

ウェブの場合、この方法にはいくつかの問題があります。

  • WebAssembly モジュールの動的リンクに対する標準サポートはありません。Emscripten には、libtool で使用される dlopen() API をシミュレートできるカスタム実装がありますが、dlopen() では、異なるフラグを使用して「main」モジュールと「side」モジュールをビルドする必要があります。また、アプリの起動時にエミュレートされたファイル システムにサイド モジュールをプリロードする必要もあります。これらのフラグや微調整を、多数の動的ライブラリを含む既存の autoconf ビルドシステムに統合することは困難です。
  • ほとんどの HTTP サーバーはセキュリティ上の理由からディレクトリのリストを公開しないため、dlopen() 自体が実装されている場合でも、ウェブ上の特定のフォルダにあるすべての動的ライブラリを列挙することはできません。
  • ランタイムで列挙するのではなく、コマンドラインで動的ライブラリをリンクすると、シンボルの重複の問題などの問題が発生する可能性があります。この問題は、Emscripten と他のプラットフォームでの共有ライブラリの表現の違いが原因で発生します。

ビルドシステムをこれらの違いに適応させ、ビルド中に動的プラグインのリストをハードコードすることもできますが、そのようなすべての問題を解決するより簡単な方法は、最初から動的リンクを避けることです。

libtool が、さまざまなプラットフォーム上にあるさまざまなダイナミック リンク方法を抽象化し、他のプラットフォーム用のカスタム ローダーの作成もサポートしています。サポートされる組み込みローダーの一つは、"Dlpreopening" と呼ばれます。

「Libtool は、libtool オブジェクトと libtool ライブラリ ファイルの dlopen に対する特別なサポートを提供します。これにより、dlopen 関数と dlsym 関数を持たないプラットフォームでもシンボルを解決できます。
...
Libtool は、コンパイル時にオブジェクトをプログラムにリンクし、プログラムのシンボル テーブルを表すデータ構造を作成することで、静的プラットフォームで -dlopen をエミュレートします。この機能を使用するには、プログラムをリンクするときに -dlopen フラグまたは -dlpreopen フラグを使用して、アプリで dlopen するオブジェクトを宣言する必要があります(リンクモードを参照)。

このメカニズムにより、すべてを 1 つのライブラリに静的にリンクしながら、Emscripten ではなく libtool レベルで動的読み込みをエミュレートできます。

この方法で解決できない唯一の問題は、動的ライブラリの列挙です。それらのリストは、どこかにハードコードする必要があります。幸いなことに、アプリに必要なプラグインは最小限です。

  • ポートについては、libusb ベースのカメラ接続のみを重視しており、PTP/IP、シリアル アクセス、USB ドライブモードについては関係ありません。
  • Camlib 側には、特殊な機能を提供するベンダー固有のさまざまなプラグインがありますが、一般的な設定の制御とキャプチャには、画像転送プロトコルを使用するだけで十分です。画像転送プロトコルは ptp2 camlib で表され、市販のほぼすべてのカメラでサポートされています。

更新された依存関係図は次のようになります。すべてが静的にリンクされています。

図は、「libtool」に依存する「libgphoto2 fork」に依存する「アプリ」を示しています。「libtool」が「ports: libusb1」と「camlibs: libptp2」に依存します。「ports: libusb1」は「libusb fork」に依存します。

Emscripten ビルド用にハードコードしたコードは次のようになります。

LTDL_SET_PRELOADED_SYMBOLS();
lt_dlinit ();
#ifdef __EMSCRIPTEN__
  result = foreach_func("libusb1", list);
#else
  lt_dladdsearchdir (iolibs);
  result = lt_dlforeachfile (iolibs, foreach_func, list);
#endif
lt_dlexit ();

and

LTDL_SET_PRELOADED_SYMBOLS();
lt_dlinit ();
#ifdef __EMSCRIPTEN__
  ret = foreach_func("libptp2", &foreach_data);
#else
  lt_dladdsearchdir (dir);
  ret = lt_dlforeachfile (dir, foreach_func, &foreach_data);
#endif
lt_dlexit ();

autoconf ビルドシステムで、すべての実行可能ファイル(例、テスト、独自のデモアプリ)のリンクフラグとして、両方のファイルを含む -dlpreopen を追加する必要がありました。

if HAVE_EMSCRIPTEN
LDADD += -dlpreopen $(top_builddir)/libgphoto2_port/usb1.la \
         -dlpreopen $(top_builddir)/camlibs/ptp2.la
endif

最後に、すべてのシンボルが 1 つのライブラリに静的にリンクされているので、どのシンボルがどのライブラリに属しているかを判断する手段が libtool に必要です。これを実現するには、デベロッパーが {function name} などの公開されているすべてのシンボルの名前を {library name}_LTX_{function name} に変更する必要があります。最も簡単な方法は、#define を使用して実装ファイルの先頭でシンボル名を再定義することです。

// …
#include "config.h"

/* Define _LTX_ names - required to prevent clashes when using libtool preloading. */
#define gp_port_library_type libusb1_LTX_gp_port_library_type
#define gp_port_library_list libusb1_LTX_gp_port_library_list
#define gp_port_library_operations libusb1_LTX_gp_port_library_operations

#include <gphoto2/gphoto2-port-library.h>
// …

また、この命名スキームにより、今後同じアプリにカメラ固有のプラグインをリンクする場合に、名前の競合を防ぐことができます。

これらの変更をすべて実装したら、テスト アプリケーションをビルドし、プラグインを正常に読み込むことができます。

設定 UI の生成

gPhoto2 では、カメラ ライブラリで独自の設定をウィジェット ツリーの形式で定義できます。ウィジェット タイプの階層は、次のように構成されます。

  • ウィンドウ - 最上位の構成コンテナ
    • セクション - 他のウィジェットの名前付きグループ
    • ボタンのフィールド
    • テキスト フィールド
    • 数値フィールド
    • 日付フィールド
    • 切り替え
    • ラジオボタン

各ウィジェットの名前、型、子、その他すべての関連プロパティは、公開されている C API を使用してクエリできます(値の場合は変更もできます)。これらを組み合わせることで、C と対話できるあらゆる言語で設定 UI を自動生成するための基盤が提供されます。

設定は、gPhoto2 またはカメラ本体でいつでも変更できます。さらに、一部のウィジェットは読み取り専用にできます。読み取り専用の状態自体もカメラモードやその他の設定に依存します。たとえば、シャッター速度は、M(手動モード)では書き込み可能な数値フィールドですが、P(プログラム モード)では読み取り専用の情報フィールドになります。P モードでは、シャッター スピードの値も動的であり、カメラが見ているシーンの明るさに応じて連続的に変化します。

要するに、接続されたカメラからの最新情報を UI に常に表示し、同時にユーザーが同じ UI からそれらの設定を編集できるようにすることが重要です。このような双方向データフローは、処理が複雑になります。

gPhoto2 には、変更された設定のみを取得するメカニズムはなく、ツリー全体または個々のウィジェットのみを取得するメカニズムはありません。ちらつきや入力フォーカスやスクロール位置を失うことなく UI を最新の状態に保つには、呼び出し間でウィジェット ツリーを比較し、変更された UI プロパティのみを更新する方法が必要でした。幸いなことに、これはウェブ上で解決された問題であり、ReactPreact などのフレームワークのコア機能です。このプロジェクトでは、Preact を使用します。これは、はるかに軽量で、必要なことをすべて実行できるからです。

C++ 側では、先ほどリンクされた C API を介して設定ツリーを取得して再帰的に確認し、各ウィジェットを JavaScript オブジェクトに変換する必要がありました。

static std::pair<val, val> walk_config(CameraWidget *widget) {
  val result = val::object();

  val name(GPP_CALL(const char *, gp_widget_get_name(widget, _)));
  result.set("name", name);
  result.set("info", /* … */);
  result.set("label", /* … */);
  result.set("readonly", /* … */);

  auto type = GPP_CALL(CameraWidgetType, gp_widget_get_type(widget, _));

  switch (type) {
    case GP_WIDGET_RANGE: {
      result.set("type", "range");
      result.set("value", GPP_CALL(float, gp_widget_get_value(widget, _)));

      float min, max, step;
      gpp_try(gp_widget_get_range(widget, &min, &max, &step));
      result.set("min", min);
      result.set("max", max);
      result.set("step", step);

      break;
    }
    case GP_WIDGET_TEXT: {
      result.set("type", "text");
      result.set("value",
                  GPP_CALL(const char *, gp_widget_get_value(widget, _)));

      break;
    }
    // …

JavaScript 側では、configToJS を呼び出し、返された設定ツリーの JavaScript 表現を確認し、Preact 関数 h を使用して UI を構築できます。

let inputElem;
switch (config.type) {
  case 'range': {
    let { min, max, step } = config;
    inputElem = h(EditableInput, {
      type: 'number',
      min,
      max,
      step,
      …attrs
    });
    break;
  }
  case 'text':
    inputElem = h(EditableInput, attrs);
    break;
  case 'toggle': {
    inputElem = h('input', {
      type: 'checkbox',
      …attrs
    });
    break;
  }
  // …

この関数を無限のイベントループで繰り返し実行することで、設定 UI で常に最新情報を表示し、いずれかのフィールドが編集されるたびにカメラにコマンドを送信できます。

Preact は、ページのフォーカスや編集状態を妨げることなく、UI の変更された部分についてのみ、結果の差分と DOM の更新を処理します。残っている問題の一つは、双方向のデータフローです。React や Preact のようなフレームワークは、単方向データフローを中心として設計されています。データの推論や再実行の比較がはるかに簡単になるからです。しかし、私は、カメラという外部ソースが設定 UI をいつでも更新できるようにすることで、その期待を破ります。

この問題を回避するため、現在ユーザーが編集している入力フィールドの UI の更新を無効にしました。

/**
 * Wrapper around <input /> that doesn't update it while it's in focus to allow editing.
 */
class EditableInput extends Component {
  ref = createRef();

  shouldComponentUpdate() {
    return this.props.readonly || document.activeElement !== this.ref.current;
  }

  render(props) {
    return h('input', Object.assign(props, {ref: this.ref}));
  }
}

このようにして、特定のフィールドのオーナーは常に 1 人のみとなります。ユーザーが現在編集中であり、カメラから更新された値によって中断されないか、フォーカスが合っていないときにカメラがフィールドの値を更新しています。

ライブ「動画」フィードの作成

パンデミック中、多くの人がオンライン会議に移行しました。特に、ウェブカメラ市場の不足につながりました。ノートパソコンの内蔵カメラよりも画質を向上させるために、デジタル一眼レフやミラーレス カメラを所有する多くの人が、カメラをウェブカメラとして使用する方法を模索し始めました。一部のカメラベンダーは、この目的のために公式ユーティリティを出荷しています。

公式ツールと同様に、gPhoto2 はカメラからローカルに保存されたファイルへの動画のストリーミングや、仮想ウェブカメラへの直接のストリーミングもサポートしています。その機能を使用して、このデモでライブビューを提供したかったのです。しかし、これはコンソール ユーティリティでは利用できますが、libgphoto2 ライブラリ API のどこにも見つかりませんでした。

コンソール ユーティリティで対応する関数のソースコードを見ると、実際には動画をまったく取得せず、無限ループ内でカメラのプレビューを個々の JPEG 画像として取得し、1 つずつ書き込んで M-JPEG ストリームを形成していることがわかりました。

while (1) {
  const char *mime;
  r = gp_camera_capture_preview (p->camera, file, p->context);
  // …

このアプローチが効率的に機能し、スムーズなリアルタイム動画の効果に驚きました。ウェブ アプリケーションでも同じパフォーマンスを実現できることに、さらに懐疑的でした。追加の抽象化や Asyncify もその妨げになります。とにかく試してみることにしました。

C++ 側では、同じ gp_camera_capture_preview() 関数を呼び出し、結果のメモリ内ファイルを Blob に変換する capturePreviewAsBlob() というメソッドを公開しました。これにより、他のウェブ API に簡単に渡せるようになります。

val capturePreviewAsBlob() {
  return gpp_rethrow([=]() {
    auto &file = get_file();

    gpp_try(gp_camera_capture_preview(camera.get(), &file, context.get()));

    auto params = blob_chunks_and_opts(file);
    return Blob.new_(std::move(params.first), std::move(params.second));
  });
}

JavaScript 側には、gPhoto2 に似たループがあります。プレビュー画像を Blob として取得し続け、createImageBitmap でバックグラウンドでデコードして、次のアニメーション フレームでキャンバスに転送します。

while (this.canvasRef.current) {
  try {
    let blob = await this.props.getPreview();

    let img = await createImageBitmap(blob, { /* … */ });
    await new Promise(resolve => requestAnimationFrame(resolve));
    canvasCtx.transferFromImageBitmap(img);
  } catch (err) {
    // …
  }
}

こうした最新の API を使用することで、すべてのデコード処理がバックグラウンドで行われ、画像とブラウザの両方で描画の準備が整って初めてキャンバスが更新されます。これにより、ノートパソコンで一貫した 30 FPS 以上の FPS を達成できました。これは、gPhoto2 と Sony の公式ソフトウェアの両方のネイティブ パフォーマンスに匹敵するものです。

USB アクセスの同期

別の処理の進行中に USB データ転送がリクエストされると、通常は「デバイスがビジー状態」というエラーになります。プレビューと設定 UI は定期的に更新され、ユーザーは画像をキャプチャしたり設定を変更しようとしたりすることがあるため、異なる操作間でこのような競合が非常に頻繁に発生していました。

これを回避するには、アプリケーション内のすべてのアクセスを同期する必要がありました。そのために、Promise ベースの非同期キューを作成しました。

let context = await new Module.Context();

let queue = Promise.resolve();

function schedule(op) {
  let res = queue.then(() => op(context));
  queue = res.catch(rethrowIfCritical);
  return res;
}

各オペレーションを既存の queue Promise の then() コールバックで連結し、連結結果を queue の新しい値として格納することで、すべてのオペレーションが重複することなく 1 つずつ順番に実行されるようになります。

オペレーション エラーはすべて呼び出し元に返されますが、重大な(予期しない)エラーの場合はチェーン全体が拒否された Promise としてマークされ、その後に新しいオペレーションがスケジュールされないようにします。

モジュールのコンテキストをプライベート(エクスポートされていない)変数に保存することで、schedule() 呼び出しを経由せずに、アプリ内の別の場所で誤って context にアクセスするリスクを最小限に抑えることができます。

まとめると、デバイス コンテキストへの各アクセスは、次のように schedule() 呼び出しでラップする必要があります。

let config = await this.connection.schedule((context) => context.configToJS());

and

this.connection.schedule((context) => context.captureImageAsFile());

その後、すべてのオペレーションが競合なしで正常に実行されました。

おわりに

実装について詳しくは、GitHub のコードベースをご覧ください。また、gPhoto2 のメンテナンスと私のアップストリームの PR のレビューをしてくれた Marcus Meissner に感謝します。

これらの投稿で示したように、WebAssembly、Asyncify、Fugu API は、最も複雑なアプリケーションにもコンパイル ターゲットとして機能しています。単一のプラットフォーム向けに構築したライブラリやアプリケーションをウェブに移植して、デスクトップ デバイスとモバイル デバイスの両方で、はるかに多くのユーザーが利用できるようになります。