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

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

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

最後に、すべてのシンボルが 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 では、カメラ ライブラリが独自の設定をウィジェット ツリーの形式で定義できます。ウィジェット タイプの階層は次のとおりです。

  • 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 プロミスの 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 は、最も複雑なアプリケーションでも優れたコンパイル ターゲットを提供します。以前に単一のプラットフォーム用に作成したライブラリやアプリケーションをウェブに移植することで、デスクトップ デバイスとモバイル デバイスの両方で、はるかに多くのユーザーに利用してもらえるようになります。