Static linking with dlpreopen

The port depends on three components that normally come from the system: libgphoto2, its underlying libusb transport, and libtool's dynamic module loader. Cross-compiling to WebAssembly means none of those system packages can be used, so each had to be built from source with Emscripten.

Rather than pass an unwieldy list of dependency paths to each configure script, the build reuses Emscripten's own sysroot at (path to emscripten cache)/sysroot. Each library is installed into that directory with make install, and the next library in the chain discovers it automatically through the standard pkg-config and header search paths.

The more interesting problem is how libgphoto2 loads its plugins. At runtime it uses libtool's lt_dlopen to enumerate and load I/O adapters and camera libraries, which works on a normal OS with shared objects. WebAssembly has no native dynamic linking, and Emscripten's dlopen emulation requires preloading side modules into a virtual filesystem, which conflicts with autoconf builds and the lack of directory listings over HTTP.

The pragmatic solution is to avoid dynamic linking entirely. libtool supports a mechanism called "dlpreopening" that links all modules statically into the final binary and emulates the dlopen API at the libtool level. The only remaining issue is that plugin discovery has to be explicit — the app must hardcode which modules exist. For a camera control demo, that list is short:

  • On the I/O side, the only required transport is the WebUSB-backed libusb port.
  • On the camera side, the generic ptp2 PTP camlib covers essentially all modern cameras.

The build adds -dlpreopen flags pointing at both static modules, then links everything into one binary. libtool also expects each dynamic module's symbols renamed to the form {library name}_LTX_{function name}, typically done via #define at the top of each source file. The naming convention additionally avoids clashes if vendor-specific camlibs are added later.

Bidirectional settings UI with Preact

gPhoto2 exposes camera configuration as a tree of widgets: a top-level window containing sections, buttons, text fields, numeric fields, toggles, and radio buttons. Each widget's type, name, and current value are readable through the C API, and writable widgets can be modified back. One complication is that the tree and its read/write state change with camera mode, and values such as shutter speed can update as exposure changes on a live scene.

The web app turns that tree into a usable form by converting each widget into a JavaScript object on the C++ side, then passing that object into a Preact render loop. Iterating the tree on every frame keeps the UI in sync with the camera, but a naively rendered form would thrash the DOM.

Preact handles the diffing and applies only the changed attributes. Waiving ownership of a widget while it has the user's focus prevents the loop from clobbering in-progress edits with stale data from the camera. The rule is simple: if an input is focused, the camera's update for that field is skipped; otherwise the field value is refreshed from the latest tree traversal. With that in place, only the active editor or the camera owns each field at any instant.

Live preview via M-JPEG pull

gPhoto2's console app supports streaming the camera output to a file or virtual webcam, but the library API has no video function. The trick, it turns out, is that the console utility doesn't capture video at all. Instead, it runs an endless loop calling gp_camera_capture_preview() to fetch single JPEG previews and writes each one sequentially to form an M-JPEG stream.

Surprisingly, this approach yields a smooth realtime video impression natively. The question was whether the same performance could survive the extra abstraction layers in a web application built with Asyncify. It could.

On the C++ side, an exposed capturePreviewAsBlob() method calls the same gp_camera_capture_preview() function and converts the resulting in-memory file to a Blob for easier handling by web APIs:

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));
  });
}

The JavaScript side runs a loop that mirrors gPhoto2's approach: repeatedly fetch preview images as Blobs, decode them off the main thread with createImageBitmap, and transfer them to a canvas on the next animation frame:

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) {
    // …
  }
}

Because decoding happens in the background and canvas updates occur only when both the image and the browser are ready to draw, the demo sustained a consistent 30+ FPS on a laptop — on par with native gPhoto2 and the vendor's own desktop software.

Serializing USB operations

Concurrent USB transfers to the same device almost always fail with a "device is busy" error. With the live preview polling continuously while users adjust settings or trigger captures, such collisions were frequent. The fix was to funnel every device access through a promise-based async queue that executes operations strictly one at a time:

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;
}

Each operation is chained onto the end of the previous one via a then() callback, so calls are serialized in order with no overlap. Ordinary errors propagate back to the caller, but critical failures reject the entire chain and stop further operations from being scheduled. Storing the queue's module context in a private, non-exported variable also prevents accidental direct access to the context elsewhere in the application.

Once every interaction with the device was wrapped in a schedule() call:

let config = await this.connection.schedule((context) => context.configToJS());

and

this.connection.schedule((context) => context.captureImageAsFile());

all operations ran without conflicts.

A capable compilation target

The complete implementation is available in the web-gphoto2 repository on GitHub. Special thanks go to Marcus Meissner for maintaining gPhoto2 and reviewing the upstream pull requests that made this port possible.

This project demonstrates that WebAssembly, Asyncify, and the Fugu APIs are a serious target for porting even the most hardware-adjacent native libraries to the web. Applications built for a single platform can reach a far wider audience across desktop and mobile browsers with relatively little platform-specific rework.