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

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

Ingvar Stepanyan
Ingvar Stepanyan

上一篇博文中,我介绍了如何移植 libusb 库,以便使用 WebAssembly / Emscripten、Asyncify 和 WebUSB 在网络上运行。

我还演示了使用 gPhoto2 构建的演示,该演示可以通过 Web 应用通过 USB 控制数码单反和无反相机。在这篇博文中,我将深入介绍 gPhoto2 端口背后的技术细节。

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

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

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

以下是大致的依赖关系图(虚线表示动态链接):

一张示意图,显示了依赖于“libgphoto2 fork”的“the app”,而“libgphoto2 fork”依赖于“libtool”。“libtool”代码块动态依赖于“libgphoto2 port”和“libgphoto2 camlibs”。最后,“libgphoto2 port”以静态方式依赖于“libusb fork”。

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

更好的方法是创建一个单独的文件夹作为自定义系统根目录(通常简写为“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、串行访问或 U 盘模式。
  • 在 camlib 端,有各种特定于供应商的插件,它们可能会提供一些专门的功能,但对于常规设置而言,控制和捕获就足以使用传输协议了,该协议由 ptp2 camlib 表示,并受市场上几乎所有相机的支持。

以下是更新后的依赖关系图,其中所有内容以静态方式链接在一起:

一张示意图,显示了依赖于“libgphoto2 fork”的“the app”,而“libgphoto2 fork”依赖于“libtool”。“libtool”依赖于“ports: libusb1”和“camlibs: libptp2”。“ports: libusb1”依赖于“libusb 分支”。

以下是我针对 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 和这两个文件添加为所有可执行文件(示例、测试和我自己的演示版应用)的链接标记,如下所示:

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 的名称、类型、子级和所有其他相关属性可通过公开的 C API 进行查询(如果值有值,还可进行修改)。二者共同为以可与 C 语言进行交互的任何语言自动生成设置界面奠定了基础。

您可以通过 gPhoto2 更改设置,也可以随时通过相机更改设置。此外,有些 widget 可能是只读的,甚至只读状态本身取决于相机模式和其他设置。例如,“快门速度”在 M(手动模式)中是可写的数字字段,但在 P(节目模式)中会变为只读字段。在 P 模式下,快门速度的值也是动态的,并且会根据相机所观察场景的亮度不断变化。

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

gPhoto2 没有仅检索已更改设置的机制,只能检索整个树或单个微件。为了在不闪烁以及丢失输入焦点或滚动位置的情况下使界面保持最新状态,我需要一种方法来区分调用之间的 widget 树,并仅更新更改后的界面属性。幸运的是,这在网络上已得到解决,也是 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() 函数,并将生成的内存中文件转换为可以更轻松地传递给其他 Web API 的 Blob

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 和索尼官方软件的原生性能不相上下。

同步 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,并确保此后不会安排任何新操作。

通过将模块上下文保持在私有(非导出)变量中,我可以最大限度地降低在不调用 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 即使是最复杂的应用,也能提供强大的编译目标。利用这些工具,您可以将以前为单一平台构建的库或应用移植到网络上,使大量用户同时在桌面设备和移动设备上使用它们。