将 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 指向该自定义构建,而不是系统软件包。

近似的依赖关系图(虚线表示动态链接):

显示“应用”的示意图依赖于“libgphoto2 fork”,而后者依赖于“libtool”。“libtool”块动态依赖于“libgphoto2 端口”和“libgphoto2 camlibs”。最后是“libgphoto2 端口”静态依赖于“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”函数和“侧”模块,尤其是对于 dlopen(),还会在应用启动过程中将辅助模块预加载到模拟文件系统中。将这些标志和调整集成到包含大量动态库的现有 autoconf 构建系统中可能比较困难。
  • 即使 dlopen() 本身已实现,也无法枚举 Web 上的特定文件夹中的所有动态库,因为出于安全考虑,大多数 HTTP 服务器都不会公开目录列表。
  • 在命令行上关联动态库(而不是在运行时进行枚举)也可能会导致一些问题,例如重复符号问题。此问题是由 Emscripten 中共享库与其他平台上的表示法不同而引起的。

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

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

“Libtool 为 dlopening libtool 对象和 libtool 库文件提供了特殊支持,因此即使在没有任何 dlopen 和 dlsym 函数的平台上,也可以解析其符号。
...
Libtool 在编译时将对象链接到程序,并创建表示程序符号表的数据结构,从而在静态平台上模拟 -dlopen。如需使用此功能,您必须在链接程序时使用 -dlopen 或 -dlpreopen 标记来声明您希望应用 dlopen 的对象(请参阅链接模式)。”

此机制允许在 libtool 级别(而不是 Emscripten)模拟动态加载,同时以静态方式将所有内容关联到一个库中。

唯一无法解决的问题是动态库的枚举。但仍需对这些对象的列表进行硬编码。幸运的是,应用所需的插件集就很小:

  • 在端口方面,我只关心基于 libusb 的相机连接,而不关心 PTP/IP、串行访问或 U 盘模式。
  • 在 camlibs 端,有各种供应商专用的插件,这些插件可能会提供一些专用功能,但对于常规设置控制和捕获,使用图片传输协议就足够了,该协议由 ptp2 camlib 表示,市场上几乎每个相机都支持。

更新后的依赖项图表如下所示(所有内容以静态方式关联在一起):

显示“应用”的示意图依赖于“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 类型的层次结构由以下部分组成:

  • 窗口 - 顶级配置容器 <ph type="x-smartling-placeholder">
      </ph>
    • 版块 - 其他微件的命名分组
    • 按钮字段
    • 文本字段
    • 数字字段
    • 日期字段
    • 切换开关
    • 单选按钮

您可以通过公开的 C API 查询每个 widget 的名称、类型、子项和所有其他相关属性(如果是值,还可以修改这些属性)。两者相辅相成,为自动生成设置界面(使用可与 C 语言交互的任何语言)奠定了基础。

您可随时通过 gPhoto2 或相机更改设置。此外,有些 widget 可以处于只读状态,甚至只读状态本身也取决于相机模式和其他设置。例如,在 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 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 甚至为最复杂的应用提供了强大的编译目标。通过它们,您可以将以前为单一平台构建的库或应用移植到网络上,从而使其可供大量在桌面设备和移动设备上使用的用户使用。