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

gPhoto2 を WebAssembly に移植し、ウェブアプリから USB 経由で外部カメラを制御した方法をご覧ください。

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

また、ウェブ アプリケーションから USB 経由でデジタル一眼レフやミラーレス カメラを操作できる gPhoto2 で構築されたデモもお見せしました。この投稿では、gPhoto2 ポートの技術的な詳細を詳しく説明します。

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

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

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

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

「アプリ」を示す図「libtool」に依存する「libgphoto2 fork」に依存。「libtool」ブロックが「libgphoto2 port」に動的に依存する「libgphoto2 camlibs」です。「libgphoto2 port」で「libusb fork」に静的に依存します。

これらのライブラリで使用されているものを含め、ほとんどの構成ベースのビルドシステムでは、さまざまなフラグを使用して依存関係のパスをオーバーライドできます。そのため、まずそれを行いました。しかし、依存関係グラフが複雑になると、各ライブラリの依存関係のパス オーバーライドのリストが冗長になり、エラーが発生しやすくなります。また、ビルドシステムが、非標準パスに依存関係が存在することを想定していないバグもいくつか見つかりました。

代わりに、カスタム システム ルートとして別のフォルダ(「sysroot」と省略されることがよくあります)を作成し、関連するすべてのビルドシステムがそのフォルダを指すようにするのが簡単な方法です。そうすれば、各ライブラリはビルド時に指定された sysroot で依存関係を検索すると同時に、同じ sysroot に自身のインストールも行って、他の人がより簡単に見つけられるようにします。

Emscripten はすでに独自の sysroot を (path to emscripten cache)/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() の場合は、アプリの起動時にエミュレートされたファイル システムにサイド モジュールをプリロードすることもできます。これらのフラグや微調整を、多くの動的ライブラリを含む既存の 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 によって表され、市販のほぼすべてのカメラでサポートされている Picture Transfer Protocol を使用するだけで十分です。

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

「アプリ」を示す図「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 では、カメラ ライブラリでウィジェット ツリーの形式で独自の設定を定義できます。ウィジェット タイプの階層は、次の要素で構成されます。

  • ウィンドウ - トップレベルの設定コンテナ <ph type="x-smartling-placeholder">
      </ph>
    • セクション - 他のウィジェットの名前付きグループ
    • ボタン フィールド
    • テキスト フィールド
    • 数値フィールド
    • 日付フィールド
    • 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() 関数を呼び出し、結果として得られるメモリ内ファイルを、他のウェブ API に簡単に渡すことが可能な Blob に変換する 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 つずつ実行されるようにします。

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

モジュールのコンテキストをプライベート(エクスポートされていない)変数に保持することで、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 は、非常に複雑なアプリケーションでも有能なコンパイル ターゲットを提供します。1 つのプラットフォーム用に構築したライブラリやアプリケーションをウェブに移植すると、デスクトップ デバイスとモバイル デバイスのどちらでも、はるかに多くのユーザーが利用できるようになります。