瞭解如何將 gPhoto2 移植到 WebAssembly,以便使用網頁應用程式透過 USB 控制外部相機。
在上一篇文章中,我展示瞭如何透過 WebAssembly / Emscripten、Asyncify 和 WebUSB 將 libusb 程式庫移植到網路上運作。
我還示範了使用 gPhoto2 建構的示範,這個 API 可以透過 USB 從網頁應用程式控制數位單眼相機和無鏡像相機。在這篇文章中,我將進一步說明 gPhoto2 連接埠背後的技術細節。
將建構系統指向自訂分支
由於我指定 WebAssembly,因此無法使用系統發行管道提供的 libusb 和 libgphoto2。因此,我希望應用程式使用自訂的 libgphoto2 分支,而 libgphoto2 的叉子必須使用我訂製的 Libusb,
此外,libgphoto2 會使用 libtool 載入動態外掛程式,雖然我不需要像其他兩個程式庫一樣建立 libtool,但我仍必須將其建構到 WebAssembly,並將 libgphoto2 指向該自訂版本,而非系統套件。
以下是大致依附元件圖表 (虛線表示動態連結):
大部分以設定的建構系統 (包括這些程式庫中使用的建構系統) 允許透過不同標記覆寫依附元件的路徑,因此我會先嘗試這麼做。不過,當依附元件圖變得複雜時,每個程式庫依附元件的路徑覆寫清單就會變得詳細且容易出錯。我還發現一些錯誤,讓建構系統實際上並未準備好讓依附元件出現在非標準路徑中。
相反地,最簡單的方法是以自訂系統根目錄建立單獨的資料夾 (通常縮短為「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」和「側」含有不同旗標的模組,尤其是dlopen()
的模組,也能在應用程式啟動時,將側邊模組預先載入到模擬的檔案系統。將這些標記或調整至包含許多動態程式庫的現有 Autoconf 建構系統中,可能並不容易。 - 即使已實作
dlopen()
本身,也無法列舉網路上特定資料夾中的所有動態程式庫,因為大多數 HTTP 伺服器不會基於安全考量公開目錄清單。 - 透過指令列連結動態程式庫 (而非在執行階段中列舉) 也可能會導致問題,例如由 Emscripten 和其他平台上的共用程式庫表示法不同而導致符號重複問題。
不過,您可以在建構期間根據這些差異調整建構系統,並將動態外掛程式清單硬式編碼,但是要解決所有這類問題更簡單的方法,就是避免一開始就使用動態連結。
結果證明,libtool 簡化了在不同平台上的各種動態連結方法,甚至支援為他人編寫自訂載入器。這類系統支援的內建載入程式稱為「Dlpreopening」:
「Libtool 為分散式 libtool 物件和 libtool 程式庫檔案提供特殊支援,因此即使平台沒有 dlopen 和 dlsym 功能,仍可解析其符號。
...
Libtool 在編譯期間將物件連結至程式,並建立代表程式符號表的資料結構,以便在靜態平台上模擬 -dlopen。如要使用這項功能,您必須在連結程式時,使用 -dlopen 或 -dlpreopen 標記宣告要應用程式收合的物件 (請參閱連結模式)。
此機制可讓您在 libtool 層級 (而非 Emscripten) 模擬動態載入,同時將所有內容以靜態方式連結至單一程式庫。
唯一的解決方法是動態程式庫列舉。這些清單仍必須以硬式編碼方式寫入。幸好,我幾乎是為應用程式所需的外掛程式組合:
- 在連接埠方面,我只關心 libusb 式攝影機連線,與 PTP/IP、序列存取或 USB 隨身碟模式無關。
- 錄音工具端有許多供應商專用的外掛程式,可能提供特定功能,但對於一般設定控制與擷取作業足以使用影像傳輸通訊協定,這項工具由 ptp2 camlib 表示,且市面上的每台攝影機都會支援。
更新後的依附元件圖表看起來會像這樣,而且所有項目都以靜態方式連結:
這就是我針對 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 查詢每個小工具的名稱、類型、子項和所有其他相關屬性 (若是值則也可修改)。綜合以上是,它們是自動產生設定 UI 的基礎,且支援任何能夠與 C 互動的語言。
您可以透過 gPhoto2 或相機本身的設定進行變更。另外,部分小工具可能為唯讀狀態,即使是唯讀狀態,也取決於相機模式和其他設定。舉例來說,快門速度是 M (手動模式) 中可寫入的數值欄位,但會成為 P (程式模式) 中的唯讀欄位。在 P 模式中,快門速度的值也會根據相機目前查看的場景亮度持續變動。
總而言之,請務必在使用者介面中一律顯示已連結相機的最新資訊,同時讓使用者在同一個使用者介面中編輯這些設定。這種雙向資料流較複雜。
gPhoto2 沒有能夠只擷取整個樹狀結構或個別小工具的設定變更的機制。為了讓 UI 保持在最新狀態,避免閃爍、遺失輸入焦點或捲動位置,我需要在叫用之間差異比較小工具樹狀結構,然後只更新已變更的 UI 屬性。幸好這個問題已經解決在網路上的問題,而且是 React 或 Preact 等架構的核心功能。因為這個專案更輕巧,但我能執行所有需要的功能,所以我使用 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 可以變更結果,並且只針對使用者介面已變更的位元更新 DOM,而不會影響頁面焦點或編輯狀態。還有一個問題是雙向資料流。React 和 Preact 等架構是專為單向資料流而設計,因為這樣能更容易理解資料並在重新執行之間進行比較,但我允許外部來源 (相機) 隨時更新設定 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}));
}
}
如此一來,任何特定欄位都只能有一位擁有者。使用者目前正在編輯位置,不會受到相機更新值的影響,就是在使用者失焦時,相機正在更新欄位值。
建立直播「影片」動態饋給
在疫情期間,有許多人轉向線上會議。除此之外,也導致網路攝影機市場的不足。為了確保影片畫質優於筆電的內建相機,並因應短缺問題,許多數位單眼相機和無鏡像鏡頭的相機擁有者開始設法將攝影相機當做網路攝影機使用。許多相機廠商甚至為了這個目的而出貨官方公用程式。
如同官方工具,gPhoto2 支援從相機串流影片至本機儲存的檔案,或直接傳輸至虛擬網路攝影機。我想利用這項功能,在示範中提供即時監控體驗。不過,雖然控制台公用程式中有這個功能,但我在 libgphoto2 程式庫 API 中找不到任何該 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
的新值後,我就能確保所有作業都依序執行,而且不會重疊。
任何作業錯誤都會傳回呼叫端,而重大 (非預期) 錯誤會將整個鏈結標示為遭拒,並確保之後不會安排新的作業。
只要將模組結構定義保留在私人 (未匯出) 變數中,就能盡量降低在應用程式內其他位置存取 context
的風險,而不必透過 schedule()
呼叫。
為了連結內容,現在每個對裝置情境的存取權都必須納入 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 可為即使是最複雜的應用程式提供強大的編譯目標。使用這些工具,您便能將程式庫或先前專為單一平台打造的應用程式移植到網路,與數百萬名桌上型電腦和行動裝置上的廣大使用者共用。