瞭解如何將 gPhoto2 移植至 WebAssembly,透過網路應用程式透過 USB 控制外接相機。
在上一篇文章中,我說明瞭如何將 libusb 程式庫移植到網路上,並透過 WebAssembly / Emscripten、Asyncify 和 WebUSB 執行。
我還展示了使用 gPhoto2 建構的示範,可透過網路應用程式透過 USB 控制 DSLR 和無反光鏡相機。在這篇文章中,我將深入探討 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()
,在應用程式啟動期間將 side 模組預先載入模擬的檔案系統。將這些旗標和調整納入含有大量動態程式庫的現有 autoconf 建構系統,可能會相當困難。 - 即使已實作
dlopen()
,也無法列舉網路上特定資料夾中的所有動態程式庫,因為大多數 HTTP 伺服器基於安全性考量,不會公開目錄清單。 - 在指令列上連結動態程式庫,而不是在執行階段列舉,也可能導致問題,例如重複符號問題,這是因為 Emscripten 和其他平台的共用程式庫表示方式不同。
您可以將建構系統調整為符合這些差異,並在建構期間在某處硬式編碼動態外掛程式清單,但要解決所有這些問題,更簡單的方法是從一開始就避免動態連結。
事實上,libtool 會在不同平台上抽象化各種動態連結方法,甚至支援為其他人編寫自訂載入器。其中一個內建的載入器稱為「Dlpreopening」:
「Libtool 提供 dlopening libtool 物件和 libtool 程式庫檔案的特殊支援,因此即使在沒有 dlopen 和 dlsym 函式的平台上,也能解析這些符號。
…
Libtool 會在靜態平台上模擬 -dlopen,方法是在編譯時將物件連結至程式,並建立代表程式符號表的資料結構。如要使用這項功能,您必須在連結程式時使用 -dlopen 或 -dlpreopen 標記,宣告應用程式要 dlopen 的物件 (請參閱「連結模式」)。」
這個機制可在 libtool 層級模擬動態載入 (而非 Emscripten),同時將所有項目靜態連結至單一程式庫。
這項方法無法解決的問題只有動態程式庫的列舉。這些項目的清單仍需在某處硬式編碼。幸運的是,我需要的應用程式外掛程式組合很少:
- 在通訊埠方面,我只在乎以 libusb 為基礎的相機連線,而非 PTP/IP、序列存取或 USB 磁碟機模式。
- 在 camlib 方面,有許多供應商專屬外掛程式可提供一些專門功能,但如果只想控制一般設定和拍攝,只要使用 Picture Transfer Protocol 即可,因為 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,以便打破這項預期。
我解決這個問題的方法,是針對使用者目前正在編輯的任何輸入欄位,選擇不採用 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 提供可靠的編譯目標,即使是最複雜的應用程式也適用。您可以將先前為單一平台建構的程式庫或應用程式移植到網頁,讓更多使用者 (包括桌機和行動裝置使用者) 都能使用這些程式庫或應用程式。