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

gPhoto2 を WebAssembly に移植し、ウェブアプリから USB 経由で外部カメラを制御する方法について説明します。

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

また、gPhoto2 で作成したデモも紹介しました。このデモでは、ウェブ アプリケーションから USB 経由で DSLR カメラとミラーレス カメラを制御できます。この記事では、gPhoto2 ポートの技術的な詳細について説明します。

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

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

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

依存関係の概略図を次に示します(破線は動的リンクを示します)。

図は、「libtool」に依存する「libgphoto2 fork」に依存する「アプリ」を示しています。「libtool」ブロックが「libgphoto2 port」と「libgphoto2 camlibs」に動的に依存する最後に、「libgphoto2 ports」は「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 をシミュレートできるカスタム実装がありますが、異なるフラグを使用して「main」モジュールと「side」モジュールをビルドする必要があります。特に dlopen() の場合は、アプリケーションの起動時にside モジュールをエミュレートされたファイル システムにプリロードする必要があります。これらのフラグと調整を、多数の動的ライブラリを含む既存の autoconf ビルドシステムに統合するのは難しい場合があります。
  • dlopen() 自体が実装されている場合でも、ほとんどの HTTP サーバーはセキュリティ上の理由からディレクトリ リストを公開しないため、ウェブ上の特定のフォルダ内のすべての動的ライブラリを列挙する方法はありません。
  • ランタイムで列挙するのではなく、コマンドラインで動的ライブラリをリンクすると、Emscripten と他のプラットフォームの共有ライブラリの表現の違いによって引き起こされる、シンボルの重複の問題などの問題が発生する可能性もあります。

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

調べてみると、libtool はさまざまなプラットフォームのさまざまな動的リンク方法を抽象化しており、他のプラットフォーム用にカスタム ローダの作成もサポートしています。サポートされている組み込みローダの一つは「Dlpreopening」と呼ばれます。

「Libtool は、dlopen と dlsym 関数のないプラットフォームでもシンボルを解決できるように、libtool オブジェクトと libtool ライブラリ ファイルの dlopen を特別にサポートしています。

Libtool は、コンパイル時にオブジェクトをプログラムにリンクし、プログラムのシンボル テーブルを表すデータ構造を作成することで、静的プラットフォームで -dlopen をエミュレートします。この機能を使用するには、プログラムをリンクするときに -dlopen フラグまたは -dlpreopen フラグを使用して、アプリに dlopen させるオブジェクトを宣言する必要があります(リンクモードをご覧ください)。

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

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

  • ポート側では、libusb ベースのカメラ接続のみを扱い、PTP/IP、シリアル アクセス、USB ドライブ モードは扱いません。
  • camlibs 側には、特定の機能を提供するベンダー固有のさまざまなプラグインがありますが、一般的な設定の制御とキャプチャには、Picture Transfer Protocol を使用すれば十分です。これは 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 ();

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

最後に、すべてのシンボルが単一のライブラリ内で静的にリンクされているため、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 では、カメラ ライブラリが独自の設定をウィジェット ツリーの形式で定義できます。ウィジェット タイプの階層は、次の要素で構成されます。

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

各ウィジェットの名前、タイプ、子、その他の関連プロパティは、公開された 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 に変換して、他のウェブ API に簡単に渡せるようにする capturePreviewAsBlob() というメソッドを公開しました。

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 以上を安定して達成し、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 つずつ実行されるようにします。

オペレーション エラーは呼び出し元に返されますが、重大な(予期しない)エラーが発生すると、チェーン全体が拒否されたプロミスとしてマークされ、その後新しいオペレーションがスケジュールされなくなります。

モジュール コンテキストを非公開(エクスポートなし)の変数に保持することで、schedule() 呼び出しを経由せずにアプリの他の場所で誤って context にアクセスするリスクを最小限に抑えています。

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

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

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

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

まとめ

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

これらの投稿で紹介したように、WebAssembly、Asyncify、Fugu API は、非常に複雑なアプリケーションでも有能なコンパイル ターゲットを提供します。以前に単一のプラットフォーム用に作成したライブラリやアプリケーションをウェブに移植することで、デスクトップ デバイスとモバイル デバイスの両方で、はるかに多くのユーザーに利用してもらえるようになります。