瞭解如何將 gPhoto2 移植至 WebAssembly,透過網路應用程式透過 USB 控制外接相機。
在上一篇文章中,我說明了如何將 libusb 程式庫移植到網路上,並透過 WebAssembly/Emscripten、Asyncify 和 WebUSB 執行。
我還示範了使用 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」和「side」模組,尤其是dlopen()
時,也需要在應用程式啟動時將側邊模組預先載入到模擬的檔案系統。將這些標記或調整至包含許多動態程式庫的現有 Autoconf 建構系統中,可能並不容易。 - 即使已實作
dlopen()
,也無法列舉網路上特定資料夾中的所有動態程式庫,因為大多數 HTTP 伺服器基於安全性考量,不會公開目錄清單。 - 透過指令列連結動態程式庫 (而非在執行階段中列舉) 也可能會導致問題,例如由 Emscripten 和其他平台上的共用程式庫表示法不同而導致符號重複問題。
您可以將建構系統調整為符合這些差異,並在建構期間在某處硬式編碼動態外掛程式清單,但要解決所有這些問題,更簡單的方法是從一開始就避免動態連結。
結果證明,libtool 簡化了在不同平台上的各種動態連結方法,甚至支援為他人編寫自訂載入器。其中一個內建的載入器稱為「Dlpreopening」:
「Libtool 提供 dlopening libtool 物件和 libtool 程式庫檔案的特殊支援,因此即使在沒有任何 dlopen 和 dlsym 函式的平台上,也能解析這些符號。
…
Libtool 會在靜態平台上模擬 -dlopen,方法是在編譯時將物件連結至程式,並建立代表程式符號表的資料結構。如要使用這項功能,您必須在連結程式時使用 -dlopen 或 -dlpreopen 標記,宣告應用程式要 dlopen 的物件 (請參閱「連結模式」)。」
這個機制可在 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,查詢每個小工具的名稱、類型、子項和所有其他相關屬性,並在值的情況下進行修改。這兩種語言可共同提供基礎,讓您使用任何可與 C 互動的語言,自動產生設定 UI。
您隨時可以透過 gPhoto2 或相機本身變更設定。此外,部分小工具可能為唯讀,甚至連唯讀狀態本身也取決於相機模式和其他設定。舉例來說,快門速度在 M (手動模式) 中為可寫的數值欄位,但在 P (程式模式) 中則為資訊唯讀欄位。在 P 模式下,快門速度的值也會隨時間變化,並根據相機拍攝的場景亮度持續變動。
總而言之,請務必在 UI 中隨時顯示已連結攝影機的最新資訊,同時讓使用者透過相同的 UI 編輯這些設定。這種雙向資料流較複雜。
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 可處理結果差異,並只針對 UI 的變更位元更新 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 中找到這項功能。
查看控制台公用程式中對應函式的原始碼後,我發現它實際上並未取得任何影片,而是在無限迴圈中持續擷取相機的預覽畫面,並逐一寫入這些圖片,形成 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 以上 FPS,與 gPhoto2 和官方 Sony 軟體的原生效能相符。
同步處理 USB 存取權
當要求 USB 資料傳輸時,如果另一項作業正在進行中,通常會導致「裝置忙碌」錯誤。由於預覽畫面和設定 UI 會定期更新,且使用者可能會同時嘗試擷取圖片或修改設定,因此不同作業之間的這種衝突情況非常頻繁。
為避免發生這些問題,我需要同步處理應用程式中的所有存取作業。為此,我建立了以承諾為基礎的非同步佇列:
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 可為即使是最複雜的應用程式提供強大的編譯目標。您可以將先前為單一平台建構的程式庫或應用程式移植到網頁,讓更多使用者 (包括桌機和行動裝置使用者) 都能使用這些程式庫或應用程式。