将 USB 应用移植到 Web 中。第 2 部分:gPhoto2

了解如何将 gPhoto2 移植到 WebAssembly,以便通过 Web 应用通过 USB 控制外接摄像头。

Ingvar Stepanyan
Ingvar Stepanyan

上篇博文中,我介绍了如何将 libusb 库移植到使用 WebAssembly / Emscripten、Asyncify 和 WebUSB 在 Web 上运行。

我还展示了一个使用 gPhoto2 构建的演示版,该演示版可以通过 Web 应用通过 USB 控制数码单反相机和无反光镜相机。在本文中,我将深入探讨 gPhoto2 移植背后的技术细节。

将构建系统指向自定义分支

由于我以 WebAssembly 为目标平台,因此无法使用系统发行版提供的 libusb 和 libgphoto2。相反,我需要应用使用我自定义的 libgphoto2 分支,而该 libgphoto2 分支必须使用我自定义的 libusb 分支。

此外,libgphoto2 使用 libtool 加载动态插件,虽然我无需像其他两个库那样分叉 libtool,但仍然需要将其构建为 WebAssembly,并将 libgphoto2 指向该自定义 build 而不是系统软件包。

下面是一个大致的依赖项图表(虚线表示动态链接):

该图显示“应用”依赖于“libgphoto2 fork”,而“libgphoto2 fork”依赖于“libtool”。“libtool”块动态依赖于“libgphoto2 ports”和“libgphoto2 camlibs”。最后,“libgphoto2 端口”静态依赖于“libusb 分支”。

大多数基于配置的构建系统(包括这些库中使用的构建系统)都允许通过各种标志替换依赖项的路径,因此我首先尝试的就是这样做。不过,当依赖项图变得复杂时,每个库的依赖项的路径替换项列表会变得冗长且容易出错。我还发现了一些 bug,其中 build 系统实际上并未准备好让其依赖项位于非标准路径中。

相反,更简单的方法是创建一个单独的文件夹作为自定义系统根目录(通常缩写为“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
();

在 Web 上,这种方法存在一些问题:

  • 没有针对 WebAssembly 模块的动态链接的标准支持。Emscripten 具有自己的自定义实现,可以模拟 libtool 使用的 dlopen() API,但需要您使用不同的标志构建“主要”和“边信号”模块,对于 dlopen(),还需要在应用启动期间将边信号模块预加载到模拟文件系统。将这些标志和调整集成到包含大量动态库的现有 autoconf 构建系统中可能很困难。
  • 即使实现了 dlopen() 本身,也无法枚举网络上某个文件夹中的所有动态库,因为大多数 HTTP 服务器出于安全考虑不会公开目录列表。
  • 在命令行上链接动态库(而不是在运行时枚举)也可能会导致问题,例如重复符号问题,这是由 Emscripten 和其他平台上共享库的表示法差异所致。

您可以将构建系统调整为适应这些差异,并在构建过程中将动态插件列表硬编码到某个位置,但要想更轻松地解决所有这些问题,最简单的方法是从一开始就避免使用动态链接。

事实证明,libtool 会抽象出不同平台上的各种动态链接方法,甚至支持为其他平台编写自定义加载器。它支持的内置加载器之一称为 "Dlpreopening"

“Libtool 为 dlopening libtool 对象和 libtool 库文件提供了特殊支持,因此即使在没有任何 dlopen 和 dlsym 函数的平台上,也可以解析其符号。

Libtool 通过在编译时将对象链接到程序中,并创建表示程序符号表的数据结构,在静态平台上模拟 -dlopen。如需使用此功能,您必须在链接程序时使用 -dlopen 或 -dlpreopen 标志声明您希望应用 dlopen 的对象(请参阅链接模式)。”

此机制允许在 libtool 级别(而非 Emscripten)模拟动态加载,同时将所有内容静态链接到单个库。

这唯一无法解决的问题是动态库的枚举。这些内容的列表仍需要在某个位置硬编码。幸运的是,我为该应用所需的一组插件非常少:

  • 在端口方面,我只关心基于 libusb 的相机连接,而不关心 PTP/IP、串行访问或 USB 驱动器模式。
  • 在 camlib 端,有各种特定于供应商的插件可能会提供一些专用功能,但对于常规设置控制和拍摄,只需使用图片传输协议即可,该协议由 ptp2 camlib 表示,并且市场上几乎所有摄像头都支持该协议。

更新后的依赖项图如下所示,其中所有内容都已静态链接在一起:

该图显示“应用”依赖于“libgphoto2 fork”,而“libgphoto2 fork”依赖于“libtool”。“libtool”依赖于“ports: libusb1”和“camlibs: libptp2”。“ports: libusb1”依赖于“libusb fork”。

因此,我为 Emscripten build 硬编码了以下内容:

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 添加为所有可执行文件(示例、测试和我自己的演示版应用)的链接标志,并将这两个文件作为链接标志添加到 -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>
// …

如果我日后决定在同一应用中关联特定于相机的插件,这种命名方案还可以防止名称冲突。

实现所有这些更改后,我可以成功构建测试应用并加载插件。

生成设置界面

gPhoto2 允许相机库以 widget 树的形式定义自己的设置。widget 类型的层次结构包括:

  • 窗口 - 顶级配置容器
    • 版块 - 其他微件的命名组
    • 按钮字段
    • 文本字段
    • 数字字段
    • 日期字段
    • 切换开关
    • 单选按钮

您可以通过公开的 C API 查询每个 widget 的名称、类型、子项以及所有其他相关属性(对于值,还可以进行修改)。它们共同为自动生成可与 C 语言交互的任何语言的设置界面奠定了基础。

您可以通过 gPhoto2 或随时在相机上更改设置。此外,某些微件可以是只读的,甚至只读状态本身也取决于相机模式和其他设置。例如,快门速度M(手动模式)下是可写数字字段,但在 P(程序模式)下则变为信息只读字段。在 P 模式下,快门速度的值也会是动态的,并会根据摄像头所看场景的亮度不断变化。

总而言之,请务必在界面中始终显示来自已连接摄像头的最新信息,同时允许用户在同一界面中修改这些设置。这种双向数据流的处理方式更为复杂。

gPhoto2 没有机制来检索仅更改的设置,只能检索整个树或单个 widget。为了让界面保持最新状态,同时避免闪烁和丢失输入焦点或滚动位置,我需要一种方法来对调用之间的 widget 树进行差异化处理,并仅更新更改的界面属性。幸运的是,这个问题在 Web 上已经得到了解决,并且是 ReactPreact 等框架的核心功能。我为此项目选择了 Preact,因为它更轻量,并且可以满足我所需的一切。

现在,在 C++ 端,我需要通过之前关联的 C API 检索并递归遍历设置树,并将每个 widget 转换为 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 构建界面:

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 可以负责对结果进行差异化处理,并仅更新界面中已更改的部分的 DOM,而不会干扰页面焦点或编辑状态。仍存在一个问题,即双向数据流。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}));
 
}
}

这样一来,任何给定字段始终只有一个所有者。用户目前正在编辑该字段,不会受到摄像头更新的值的干扰;或者,摄像头在离焦状态下更新字段值。

创建实时“视频”Feed

在疫情期间,许多人改为通过线上会议进行交流。这导致了摄像头市场出现缺货。为了获得比笔记本电脑内置摄像头更好的视频画质,并应对上述缺货问题,许多数码单反相机和无反相机用户开始寻找将其摄影相机用作摄像头的方法。一些相机供应商甚至发布了专门用于此目的的官方实用程序。

与官方工具一样,gPhoto2 也支持将视频从摄像头流式传输到本地存储的文件,或直接流式传输到虚拟摄像头。我想使用该功能在演示中提供实时视图。不过,虽然该函数在控制台实用程序中可用,但我在 libgphoto2 库 API 的任何位置都找不到它。

查看控制台实用程序中相应函数的源代码后,我发现它实际上根本没有获取视频,而是不断以单独的 JPEG 图片的形式检索相机的预览,并以无限循环的方式逐个写出这些图片,以形成 M-JPEG 流:

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

我很惊讶,这种方法的效率足以让用户获得流畅的实时视频体验。我对在 Web 应用中也能达到相同的性能更加怀疑,因为有所有额外的抽象和 Asyncify 阻碍。不过,我还是决定试一试。

在 C++ 端,我公开了一个名为 capturePreviewAsBlob() 的方法,该方法会调用相同的 gp_camera_capture_preview() 函数,并将生成的内存中文件转换为 Blob,以便更轻松地传递给其他 Web 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 和官方索尼软件的原生性能相当。

同步 USB 访问权限

如果在另一个操作正在进行时请求 USB 数据传输,通常会导致“设备繁忙”错误。由于预览界面和设置界面会定期更新,并且用户可能会同时尝试拍摄图片或修改设置,因此不同操作之间的此类冲突非常频繁。

为了避免这些问题,我需要同步应用内的所有访问。为此,我构建了一个基于 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 的新值,我可以确保所有操作按顺序逐个执行,且不会重叠。

任何操作错误都会返回给调用方,而严重(意外)错误会将整个链标记为已被拒绝的 promise,并确保之后不会安排任何新操作。

通过将模块上下文保留在私有(非导出的)变量中,我可以最大限度地降低在应用的其他位置意外访问 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 可为最复杂的应用提供强大的编译目标。借助这些技术,您可以将之前为单个平台构建的库或应用移植到 Web 上,从而面向桌面设备和移动设备上的更多用户提供这些库或应用。