了解如何将 gPhoto2 移植到 WebAssembly,以通过 Web 应用程序经由 USB 控制外部相机。
在之前的文章中,我展示了如何将 libusb 库移植到 Web 端,使其能在 WebAssembly / Emscripten、Asyncify 和 WebUSB 上运行。
我还展示了一个演示,该演示使用 gPhoto2,可以通过 Web 应用程序经由 USB 控制数码单反相机和微单相机。在这篇文章中,我将更深入地探讨 gPhoto2 移植背后的技术细节。
将构建系统指向自定义分支
由于我的目标是 WebAssembly,因此我无法使用系统发行版提供的 libusb 和 libgphoto2。相反,我需要我的应用程序使用我的 libgphoto2 自定义分支,而 libgphoto2 的该分支必须使用我的 libusb 自定义分支。
此外,libgphoto2 使用 libtool 加载动态插件,即使我不需要像其他两个库那样 fork libtool,我仍然必须将其构建为 WebAssembly,并将 libgphoto2 指向该自定义构建,而不是系统包。
这是一个近似的依赖关系图(虚线表示动态链接)
大多数基于 configure 的构建系统(包括这些库中使用的构建系统)都允许通过各种标志覆盖依赖项的路径,因此这就是我首先尝试做的事情。但是,当依赖关系图变得复杂时,每个库的依赖项的路径覆盖列表变得冗长且容易出错。我还发现了一些错误,其中构建系统实际上并未准备好让其依赖项位于非标准路径中。
相反,更简单的方法是创建一个单独的文件夹作为自定义系统根目录(通常缩短为“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,但这需要您使用不同的标志构建“main”和“side”模块,并且,特别是对于dlopen()
,还需要在应用程序启动期间将 side 模块预加载到模拟文件系统中。将这些标志和调整集成到具有大量动态库的现有 autoconf 构建系统中可能很困难。 - 即使实现了
dlopen()
本身,也无法在 Web 上枚举某个文件夹中的所有动态库,因为大多数 HTTP 服务器出于安全原因不公开目录列表。 - 在命令行上链接动态库而不是在运行时枚举也可能导致问题,例如 重复符号问题,这是由 Emscripten 和其他平台上共享库的表示形式之间的差异引起的。
可以调整构建系统以适应这些差异,并在构建期间的某个位置硬编码动态插件列表,但是解决所有这些问题的更简单方法是首先避免动态链接。
事实证明,libtool 抽象了不同平台上的各种动态链接方法,甚至支持为其他平台编写自定义加载器。它支持的内置加载器之一称为“Dlpreopening”
“Libtool 为 dlopen libtool 对象和 libtool 库文件提供了特殊支持,以便即使在没有任何 dlopen 和 dlsym 功能的平台上也可以解析它们的符号。
…
Libtool 通过在编译时将对象链接到程序中,并创建表示程序符号表的数据结构,从而在静态平台上模拟 -dlopen。为了使用此功能,您必须在使用 -dlopen 或 -dlpreopen 标志链接程序时声明要 dlopen 的对象(请参阅 链接模式)。”
这种机制允许在 libtool 级别而不是 Emscripten 级别模拟动态加载,同时将所有内容静态链接到单个库中。
这无法解决的唯一问题是动态库的枚举。这些库的列表仍然需要硬编码在某个地方。幸运的是,我的应用程序所需的插件集非常少
- 在端口方面,我只关心基于 libusb 的相机连接,而不关心 PTP/IP、串行访问或 USB 驱动器模式。
- 在 camlibs 方面,有各种供应商特定的插件,它们可能提供一些专门的功能,但对于常规设置控制和捕获,使用 图片传输协议就足够了,该协议由 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 需要一种方法来确定哪个符号属于哪个库。为了实现这一点,它要求开发人员重命名所有暴露的符号,例如 {函数名称}
,为 {库名称}_LTX_{函数名称}
。最简单的方法是使用 #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 属性。幸运的是,这在 Web 上是一个已解决的问题,并且是 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 的更改位,而不会中断页面焦点或编辑状态。仍然存在的一个问题是双向数据流。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 中的任何位置找到它。
查看控制台实用程序中相应函数的源代码,我发现它实际上根本没有获取视频,而是不断检索相机的预览作为单个 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 和官方 Sony 软件的本机性能相匹配。
同步 USB 访问
当在另一个操作正在进行时请求 USB 数据传输时,通常会导致“设备忙”错误。由于预览和设置 UI 会定期更新,并且用户可能同时尝试捕获图像或修改设置,因此不同操作之间的此类冲突非常频繁。
为了避免这些冲突,我需要在应用程序中同步所有访问。为此,我构建了一个基于 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,使其可供桌面和移动设备上的更多用户使用。