將 USB 應用程式轉移到網路。第 2 部分:gPhoto2

瞭解 gPhoto2 如何移植到 WebAssembly,以便從網頁應用程式,透過 USB 控制外接鏡頭。

Ingvar Stepanyan
Ingvar Stepanyan

前一篇文章中,我們展示了 libusb 程式庫如何移植至網路,並使用 WebAssembly、Emscripten、Asyncify 和 WebUSB

我也示範了使用 gPhoto2 建立的示範,該專案可透過 USB 從網路應用程式控制數位單眼相機和無鏡像鏡頭。本文將深入探討 gPhoto2 連接埠背後的技術細節。

將建構系統指向自訂分支

我指定的是 WebAssembly,因此無法使用系統發行套件提供的 libusb 和 libgphoto2。我必須讓應用程式使用自訂的 libgphoto2 分支,並將 libgphoto2 分支的分支需使用我自訂的 Libusb 分支。

此外,libgphoto2 使用 libtool 載入動態外掛程式,雖然我不需要像其他兩個程式庫一樣對 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,但你必須使用不同旗標建構「main」和「side」模組,特別是 dlopen(),這樣才能在應用程式啟動時將側邊模組預先載入至模擬檔案系統。將這些標記和修改內容整合到具有許多動態程式庫的現有 Autoconf 建構系統上,可能並不容易。
  • 即使實作 dlopen(),也無法列舉網路上特定資料夾中的所有動態程式庫,因為大多數 HTTP 伺服器不會基於安全考量而公開目錄清單。
  • 在指令列中連結動態程式庫,而非在執行階段中列舉,也可能造成問題,例如重複符號問題,是 Emscripten 和其他平台中共用程式庫的表示法所導致的差異。

您可以在建構過程中根據這些差異調整建構系統,並在建構過程中對動態外掛程式清單進行硬式編碼,但如果要解決所有這些問題的更簡單方法,就是避免一開始使用動態連結。

然而,libtool 會抽離不同平台上的各種動態連結方法,甚至支援為他人編寫自訂載入器。它支援的其中一個內建載入器稱為 「Dlpreopening」

「Libtool 提供 dlopening libtool 物件和 libtool 程式庫檔案特殊支援,因此即使平台沒有 dlopen 和 dlsym 函式,也能解析這些元素的符號。
...
Libtool 會在編譯期間將物件連結至程式,並建立代表程式符號資料表的資料結構,藉此在靜態平台上模擬 -dlopen。如要使用這項功能,您必須在連結程式時,使用 -dlopen 或 -dlpreopen 標記來宣告您希望應用程式開啟的物件 (請參閱連結模式)。」

這項機制允許在 libtool 層級 (而非 Emscripten) 模擬動態載入,同時將所有靜態內容連結至單一程式庫。

無法解決的問題的唯一問題是列舉動態程式庫。依然需要以硬式編碼的方式寫入這些清單。幸好,我在應用程式中需要使用的外掛程式組合並不多:

  • 我在連接埠側,只關注 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 允許相機程式庫以小工具樹狀結構的形式定義自己的設定。小工具類型的階層包含:

  • 視窗 - 頂層設定容器
    • 版面:其他小工具的命名群組
    • 按鈕欄位
    • 文字欄位
    • 數值欄位
    • 日期欄位
    • 切換
    • 圓形按鈕

您可以透過公開的 C API 查詢每個小工具的名稱、類型、子項和所有其他相關屬性 (在值的情況下也會修改)。綜上所述,這些原則可做為基礎,讓您使用任何可與 C 互動的語言自動產生設定 UI。

你隨時可以透過 gPhoto2 或相機本身變更設定。此外,部分小工具可能處於唯讀狀態,甚至只有唯讀狀態取決於相機模式和其他設定。舉例來說,快門速度M (手動模式) 中可寫入的數值欄位,但在 P (程式模式) 中變成資訊唯讀欄位。在 P 模式中,快門速度的值也會動態變化,並視相機所查看的環境亮度而定。

最重要的是,請務必在使用者介面中一律顯示已連結攝影機的最新資訊,同時讓使用者在同一個 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;
  }
  // …

透過在無限的事件迴圈中重複執行這個函式,我就能使設定使用者介面一律顯示最新資訊,並在使用者編輯其中一個欄位時傳送指令給攝影機。

Preact 可以根據 UI 的變更部分更新結果,而不會中斷頁面焦點或編輯狀態。這個問題還有一個問題,就是雙向資料流。React 和 Preact 等架構是以單向資料流為設計基礎,目的是讓資料更容易理解資料,並在重新執行之間進行比較,但我會讓外部來源 (相機) 隨時更新設定使用者介面,藉此破壞了這個期待。

為解決這個問題,我已針對使用者正在編輯的任何輸入欄位停用使用者介面更新:

/**
 * 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}));
  }
}

如此一來,任何特定欄位都只能有一位擁有者。使用者可能正在編輯,不會受到相機更新的值幹擾,或是在失焦時更新欄位值。

建立即時的「影片」動態饋給

在疫情期間,許多人改採線上會議。此外,這個現象也導致網路攝影機市場出現短缺。為了讓影片畫質優於筆電內建相機,許多數位單眼相機和無鏡像的相機擁有者開始設法將攝影相機當做網路攝影機使用。有幾個相機廠商甚至為了達到這個目的而出貨的官方公用程式。

如同官方工具,gPhoto2 支援將相機中的影片串流至本機儲存的檔案,或直接將影片串流至虛擬網路攝影機。我想使用這項功能在示範中提供即時監控,不過,雖然這項工具可在主控台公用程式中取得,但 libgphoto2 程式庫 API 中的所有項目都找不到它。

查看主控台公用程式中對應函式的原始碼後,我發現實際上並沒有獲得影片,而是在無限迴圈中持續擷取相機的預覽畫面,改成以個別 JPEG 圖片格式保留相機的預覽畫面,然後逐一編寫以形成 M-JPEG 串流:

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

我得知這種操作方式非常有效,絕對能拍出流暢的即時影片。在網路應用程式中也能達到相同的性能,我還感到更懷疑,除了額外提供抽象化機制外,還處理了 Asyncify 作業。不過,我還是決定嘗試。

在 C++ 端,我公開了名為 capturePreviewAsBlob() 的方法,該方法會叫用相同的 gp_camera_capture_preview() 函式,並將產生的記憶體內檔案轉換為 Blob,以便更輕鬆地傳遞至其他網路 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 以上,與 gPhoto2 和官方 Sony 軟體的原生效能一致。

同步處理 USB 存取

如果在執行其他作業時要求 USB 資料傳輸,通常會導致「裝置忙碌中」錯誤。由於預覽和設定使用者介面會定期更新,而且使用者可能同時嘗試擷取圖片或修改設定,因此不同作業之間的衝突次數非常頻繁。

為了避開這些威脅,我需要同步應用程式中的所有存取操作。為此,我已建立基於承諾的非同步佇列:

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 的新值,就能確保所有作業都會逐一執行,不會出現重疊的情況。

任何作業錯誤都會傳回呼叫端,而重大 (非預期) 錯誤會將整個鏈結標示為遭拒的承諾,且確保之後不會安排任何新作業。

將模組結構定義保留在不公開 (非匯出) 變數中,我便能在不透過 schedule() 呼叫的情況下,意外地從應用程式中的其他位置存取 context,進而將風險降至最低。

如要連結兩者,現在每個裝置結構定義的存取權都必須納入 schedule() 呼叫中,如下所示:

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

以及

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

在那之後,所有作業都已成功執行,而不會產生衝突。

結論

如要進一步瞭解實作洞察,歡迎瀏覽 GitHub 上的程式碼集。另外也感謝 Marcus Meissner 維護 gPhoto2,以及對上游 PR 的評論。

如這些文章所示,WebAssembly、Asyncify 和 Fugu API 即使是最複雜的應用程式,都有強大的編譯目標。透過這種版面配置,您可以取用先前為單一平台建立的程式庫或應用程式,並移植到網路上,藉此提供給大量使用電腦和行動裝置的使用者。