USB-Anwendungen ins Web portieren Teil 2: gPhoto2

Hier erfahren Sie, wie gFoto2 in WebAssembly übertragen wurde, um externe Kameras über eine Webanwendung über USB zu steuern.

Im vorherigen Beitrag habe ich gezeigt, wie die libusb-Bibliothek mit WebAssembly / Emscripten, Asyncify und WebUSB für das Web portiert wurde.

Außerdem habe ich eine Demo gezeigt, die mit gPhoto2 erstellt wurde. Damit lassen sich digitale Spiegelreflexkameras und spiegellose Kameras über eine Webanwendung über USB steuern. In diesem Beitrag gehe ich auf die technischen Details des gFoto2-Ports ein.

Build-Systeme auf benutzerdefinierte Forks verweisen

Da ich für WebAssembly gedacht war, konnte ich libusb und libgphoto2 aus den Systemverteilungen nicht verwenden. Stattdessen musste ich in meiner Anwendung meine benutzerdefinierte Abspaltung von libgphoto2 verwenden, während diese Abspaltung von libgphoto2 meine benutzerdefinierte Fork von libusb verwenden musste.

Außerdem verwendet libgphoto2 libtool zum Laden dynamischer Plug-ins, und obwohl ich libtool nicht wie die anderen beiden Bibliotheken verzweigen musste, musste ich es trotzdem in WebAssembly erstellen und libgphoto2 auf diesen benutzerdefinierten Build statt auf das Systempaket verweisen.

Hier ist ein ungefähres Abhängigkeitsdiagramm (dynamische Verknüpfungen durch gestrichelte Linien):

Ein Diagramm zeigt „die App“ abhängig von „libgphoto2 fork“, das von „libtool“ abhängt. Der „libtool“-Block hängt dynamisch von „libgphoto2 Ports“ und „libgphoto2 camlibs“ ab. Schließlich hängt "libgphoto2 Ports" statisch von der "libusb Fork" ab.

Die meisten konfigurationsbasierten Build-Systeme, einschließlich der in diesen Bibliotheken verwendeten, ermöglichen das Überschreiben von Pfaden für Abhängigkeiten über verschiedene Flags. Das habe ich als Erstes versucht. Wenn das Abhängigkeitsdiagramm jedoch komplex wird, wird die Liste der Pfadüberschreibungen für die Abhängigkeiten der einzelnen Bibliothek ausführlich und fehleranfällig. Ich habe auch einige Fehler gefunden, bei denen Build-Systeme nicht darauf vorbereitet waren, dass ihre Abhängigkeiten in nicht standardmäßigen Pfaden liefen.

Stattdessen ist es einfacher, einen separaten Ordner als benutzerdefinierten Systemstamm (oft als „sysroot“ abgekürzt) zu erstellen und alle beteiligten Build-Systeme darauf zu verweisen. Auf diese Weise sucht jede Bibliothek während des Build-Prozesses nach ihren Abhängigkeiten im angegebenen sysroot-Dienst und installiert sich selbst im selben Sysroot, damit andere sie leichter finden können.

Emscripten hat bereits einen eigenen Sysroot unter (path to emscripten cache)/sysroot, der für seine Systembibliotheken, Emscripten-Ports und Tools wie CMake und pkg-config verwendet wird. Ich habe mich auch dafür entschieden, denselben Sysroot für meine Abhängigkeiten wiederzuverwenden.

# 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) # …

Bei einer solchen Konfiguration musste ich nur make install in jeder Abhängigkeit ausführen. Daraufhin wurde es unter dem sysroot installiert und die Bibliotheken fanden sich dann automatisch gegenseitig.

Dynamisches Laden verwenden

Wie bereits erwähnt, verwendet libgphoto2 libtool, um E/A-Portadapter und Kamerabibliotheken zu listen und dynamisch zu laden. Der Code zum Laden von E/A-Bibliotheken sieht beispielsweise so aus:

lt_dlinit ();
lt_dladdsearchdir (iolibs);
result = lt_dlforeachfile (iolibs, foreach_func, list);
lt_dlexit ();

Bei diesem Ansatz gibt es im Web einige Probleme:

  • Die dynamische Verknüpfung von WebAssembly-Modulen wird nicht standardmäßig unterstützt. Emscripten hat eine benutzerdefinierte Implementierung, mit der die von libtool verwendete dlopen() API simuliert werden kann. Dabei müssen jedoch Haupt- und Nebenmodule mit unterschiedlichen Flags erstellt werden. Insbesondere für dlopen() müssen außerdem die Nebenmodule beim Start der Anwendung vorab in das emulierte Dateisystem geladen werden. Es kann schwierig sein, diese Flags und Optimierungen in ein vorhandenes Autoconf-Build-System mit vielen dynamischen Bibliotheken zu integrieren.
  • Selbst wenn dlopen() selbst implementiert ist, gibt es keine Möglichkeit, alle dynamischen Bibliotheken in einem bestimmten Ordner im Web aufzulisten, da die meisten HTTP-Server aus Sicherheitsgründen keine Verzeichniseinträge preisgeben.
  • Wenn Sie dynamische Bibliotheken über die Befehlszeile verknüpfen, anstatt sie in der Laufzeit zu katalogisieren, kann es zu Problemen wie dem Problem mit doppelten Symbolen kommen. Dies wird durch Unterschiede bei der Darstellung gemeinsam genutzter Bibliotheken in Emscripten und auf anderen Plattformen verursacht.

Es ist möglich, das Build-System an diese Unterschiede anzupassen und die Liste der dynamischen Plug-ins irgendwo während des Builds hartcodieren. Eine noch einfachere Möglichkeit, all diese Probleme zu lösen, besteht darin, dynamische Links von vornherein zu vermeiden.

Es hat sich herausgestellt, dass libtool verschiedene dynamische Verknüpfungsmethoden auf verschiedenen Plattformen abstrahiert und sogar das Schreiben benutzerdefinierter Loader für andere unterstützt. Einer der unterstützten integrierten Loader heißt "Dlpreopening":

„Libtool bietet spezielle Unterstützung für das dlopening libtool-Objekt- und libtool-Bibliotheksdateien, sodass ihre Symbole auch auf Plattformen ohne dlopen- und dlsym-Funktionen aufgelöst werden können.
...
Libtool emuliert „-dlopen“ auf statischen Plattformen, indem es bei der Kompilierung Objekte mit dem Programm verknüpft und Datenstrukturen erstellt, die die Symboltabelle des Programms darstellen. Um diese Funktion verwenden zu können, müssen Sie die Objekte, die Ihre Anwendung dlopen ausführen soll, beim Verknüpfen Ihres Programms mit dem Flag „-dlopen“ oder „-dlpreopen“ deklarieren (siehe Linkmodus).“

Dieser Mechanismus ermöglicht die Emulation des dynamischen Ladens auf libtool-Ebene anstelle von Emscripten, während alles statisch in einer einzigen Bibliothek verknüpft wird.

Das einzige Problem, das dadurch nicht gelöst wird, ist die Aufzählung dynamischer Bibliotheken. Die Liste dieser muss noch irgendwo hartcodiert werden. Zum Glück sind für die App nur wenige Plug-ins erforderlich:

  • Auf der Portsseite geht es nur um die libusb-basierte Kameraverbindung und nicht um PTP/IP-, serielle Zugriff- oder USB-Speichermodi.
  • Auf der Camlibs-Seite gibt es verschiedene anbieterspezifische Plug-ins, die einige spezielle Funktionen bieten. Für die allgemeine Steuerung und Erfassung von Einstellungen reicht es jedoch aus, das Picture Transfer Protocol zu verwenden, das durch die ptp2-camlib repräsentiert und von praktisch jeder Kamera auf dem Markt unterstützt wird.

So sieht das aktualisierte Abhängigkeitsdiagramm aus, in dem alles statisch miteinander verknüpft ist:

Ein Diagramm zeigt „die App“ abhängig von „libgphoto2 fork“, das von „libtool“ abhängt. „libtool“ hängt von „ports: libusb1“ und „camlibs: libptp2“ ab. „ports: libusb1“ hängt von der „libusb fork“ ab.

Das habe ich für Emscripten-Builds hartcodiert:

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

sowie

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

Im Autoconf-Build-System musste ich jetzt -dlpreopen mit beiden Dateien als Link-Flags für alle ausführbaren Dateien (Beispiele, Tests und meine eigene Demo-App) hinzufügen:

if HAVE_EMSCRIPTEN
LDADD += -dlpreopen $(top_builddir)/libgphoto2_port/usb1.la \
         -dlpreopen $(top_builddir)/camlibs/ptp2.la
endif

Da nun alle Symbole in einer einzigen Bibliothek statisch verknüpft sind, benötigt libtool eine Möglichkeit, um zu ermitteln, welches Symbol zu welcher Bibliothek gehört. Dazu müssen Entwickler alle angezeigten Symbole wie {function name} in {library name}_LTX_{function name} umbenennen. Am einfachsten ist es, indem Sie #define verwenden, um die Symbolnamen oben in der Implementierungsdatei neu zu definieren:

// …
#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>
// …

Durch dieses Benennungsschema werden auch Namenskonflikte verhindert, falls ich zukünftig kameraspezifische Plug-ins in derselben App verknüpfe.

Nachdem alle diese Änderungen implementiert waren, konnte ich die Testanwendung erstellen und die Plug-ins erfolgreich laden.

Benutzeroberfläche für Einstellungen wird generiert

Mit gPhoto2 können Kamerabibliotheken ihre eigenen Einstellungen in einer Widget-Baumstruktur festlegen. Die Hierarchie der Widget-Typen setzt sich so zusammen:

  • Fenster – Konfigurationscontainer der obersten Ebene
    • Bereiche – benannte Gruppen anderer Widgets
    • Schaltflächenfelder
    • Textfelder
    • Numerische Felder
    • Datumsfelder
    • Ein/Aus-Schaltfläche
    • Optionsfelder

Name, Typ, untergeordnete Elemente und alle anderen relevanten Eigenschaften jedes Widgets können über die expposed C API abgefragt (und im Fall von Werten auch geändert) werden. Zusammen bilden sie die Grundlage für das automatische Generieren der Benutzeroberfläche für Einstellungen in jeder Sprache, die mit C interagieren kann.

Die Einstellungen können entweder über gFoto2 oder jederzeit direkt an der Kamera geändert werden. Außerdem können einige Widgets schreibgeschützt sein. Selbst der schreibgeschützte Status hängt vom Kameramodus und anderen Einstellungen ab. Beispielsweise ist die Auslösergeschwindigkeit ein beschreibbares numerisches Feld in M (manueller Modus) und wird in P (Programmmodus) zu einem schreibgeschützten Informationsfeld. Im P-Modus ist auch der Wert der Belichtungszeit dynamisch und ändert sich kontinuierlich je nach Helligkeit des von der Kamera aufgenommenen Bilds.

Insgesamt ist es wichtig, in der Benutzeroberfläche immer die aktuellen Informationen der verbundenen Kamera anzuzeigen und dem Nutzer gleichzeitig die Möglichkeit zu geben, diese Einstellungen auf derselben Benutzeroberfläche zu bearbeiten. Die Verarbeitung eines solchen bidirektionalen Datenflusses ist komplexer.

gFoto2 verfügt nicht über einen Mechanismus, mit dem nur geänderte Einstellungen, sondern nur die gesamte Struktur oder einzelne Widgets abgerufen werden können. Um die Benutzeroberfläche auf dem neuesten Stand zu halten, ohne zu flackern und den Eingabefokus oder die Scrollposition zu verlieren, brauchte ich eine Möglichkeit, die Widget-Bäume zwischen den Aufrufen zu unterscheiden und nur die geänderten UI-Eigenschaften zu aktualisieren. Glücklicherweise ist dies ein gelöstes Problem im Web, das die Hauptfunktion von Frameworks wie React oder Preact ist. Für dieses Projekt habe ich mich für Preact entschieden, da es viel einfacher ist und alles erfüllt, was ich brauche.

Auf der C++-Seite musste ich nun den Einstellungsbaum über die zuvor verknüpfte C API abrufen, rekursiv durchlaufen und jedes Widget in ein JavaScript-Objekt konvertieren:

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

Auf der JavaScript-Seite könnte ich jetzt configToJS aufrufen, die zurückgegebene JavaScript-Darstellung des Einstellungsbaums durchgehen und die Benutzeroberfläche über die Preact-Funktion h erstellen:

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

Durch das wiederholte Ausführen dieser Funktion in einer unendlichen Ereignisschleife konnte ich die Einstellungsoberfläche so einstellen, dass immer die neuesten Informationen angezeigt werden und immer dann Befehle an die Kamera gesendet werden, wenn ein Feld vom Nutzer bearbeitet wird.

Mithilfe von Preact können die Ergebnisse differenziert und das DOM nur für die geänderten Teile der Benutzeroberfläche aktualisiert werden, ohne den Seitenfokus oder die Bearbeitungsstatus zu beeinträchtigen. Ein Problem, das weiterhin besteht, ist der bidirektionale Datenfluss. Frameworks wie React und Preact wurden für den unidirektionalen Datenfluss entwickelt, da es viel einfacher ist, die Daten nachzuverfolgen und sie zwischen Wiederholungen zu vergleichen. Ich bin damit aber nicht mehr so erwartungsgemäß, dass ich einer externen Quelle – der Kamera – erlaube, die Benutzeroberfläche für die Einstellungen jederzeit zu aktualisieren.

Ich habe dieses Problem umgangen, indem ich die Aktualisierungen der Benutzeroberfläche für alle Eingabefelder deaktiviert habe, die derzeit vom Nutzer bearbeitet werden:

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

So gibt es für jedes Feld immer nur einen Eigentümer. Entweder bearbeitet der Nutzer es gerade, ohne dass die Aktualisierung der Kamerawerte unterbrochen wird, oder die Kamera aktualisiert den Feldwert, während er nicht im Fokus ist.

Einen Live-Videofeed erstellen

Während der Pandemie sind viele Menschen zu Onlinemeetings gewechselt. Dies führte unter anderem zu Mängeln auf dem Webcam-Markt. Um eine bessere Videoqualität im Vergleich zu den eingebauten Kameras in Laptops zu erzielen, und als Reaktion auf den Mangel an Spiegelreflexkameras begannen viele Besitzer von DSLR- und spiegellosen Kameras, nach Möglichkeiten zu suchen, ihre Fotokameras als Webcams zu nutzen. Einige Kameras haben zu diesem Zweck sogar offizielle Versorgungsprogramme ausgeliefert.

Wie die offiziellen Tools unterstützt auch gFoto2 das Streamen von Videos von der Kamera in eine lokal gespeicherte Datei oder direkt auf eine virtuelle Webcam. Ich wollte diese Funktion nutzen, um einen Livestream in meiner Demo zu ermöglichen. Die Funktion ist zwar im Konsolen-Dienstprogramm verfügbar, aber ich konnte sie nirgendwo in den APIs der Bibliothek „libgphoto2“ finden.

Im Quellcode der entsprechenden Funktion im Konsolen-Dienstprogramm habe ich festgestellt, dass das Programm überhaupt kein Video erhält, sondern stattdessen die Kameravorschau in einer Endlosschleife als einzelne JPEG-Bilder abruft und sie einzeln ausschreibt, um einen M-JPEG-Stream zu erzeugen:

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

Ich war beeindruckt, dass dieser Ansatz effizient genug funktioniert, um einen Eindruck von einer reibungslosen Videowiedergabe in Echtzeit zu bekommen. Ich war noch skeptischer, was die gleiche Leistung auch in der Webanwendung angeht, mit all den zusätzlichen Abstraktionen und den Asyncify-Problemen im Weg. Ich habe mich aber trotzdem dazu entschieden, es trotzdem zu versuchen.

Auf der C++-Seite habe ich eine Methode namens capturePreviewAsBlob() bereitgestellt, die dieselbe gp_camera_capture_preview()-Funktion aufruft und die resultierende speicherinterne Datei in eine Blob konvertiert, die einfacher an andere Web-APIs übergeben werden kann:

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

Auf der JavaScript-Seite habe ich eine Schleife, ähnlich der in gFotos2, die Vorschaubilder weiter als Blob abruft, sie im Hintergrund mit createImageBitmap decodiert und übertragt sie auf den Canvas im nächsten Animationsframe:

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

Die Verwendung dieser modernen APIs stellt sicher, dass die gesamte Decodierung im Hintergrund ausgeführt wird und der Canvas erst aktualisiert wird, wenn sowohl das Bild als auch der Browser vollständig zum Zeichnen vorbereitet sind. Dadurch wurde auf meinem Laptop eine konstant hohe Framerate von 30 fps erreicht, die der nativen Leistung von gFoto2 und der offiziellen Sony-Software entspricht.

USB-Zugriff synchronisieren

Wenn eine USB-Datenübertragung angefordert wird, während bereits ein anderer Vorgang ausgeführt wird, wird in der Regel der Fehler „Gerät ist ausgelastet“ ausgegeben. Da die Vorschau und die Benutzeroberfläche für Einstellungen regelmäßig aktualisiert werden und der Nutzer möglicherweise gleichzeitig versucht, ein Bild aufzunehmen oder Einstellungen zu ändern, traten solche Konflikte zwischen verschiedenen Vorgängen sehr häufig auf.

Um sie zu vermeiden, musste ich alle Zugriffe innerhalb der Anwendung synchronisieren. Dafür habe ich eine Promise-basierte asynchrone Warteschlange erstellt:

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

Indem ich jeden Vorgang in einem then()-Callback des vorhandenen queue-Promise verketten und das verkettete Ergebnis als neuen Wert von queue speichere, kann ich dafür sorgen, dass alle Vorgänge nacheinander und in der richtigen Reihenfolge und ohne Überschneidungen ausgeführt werden.

Vorgangsfehler werden an den Aufrufer zurückgegeben, während kritische (unerwartete) Fehler die gesamte Kette als abgelehntes Promise kennzeichnen und dafür sorgen, dass danach kein neuer Vorgang geplant wird.

Indem ich den Modulkontext in einer privaten (nicht exportierten) Variablen behalte, minimiere ich das Risiko, versehentlich an einer anderen Stelle in der App auf context zuzugreifen, ohne den schedule()-Aufruf auszuführen.

Jetzt muss jeder Zugriff auf den Gerätekontext in einem schedule()-Aufruf wie im folgenden Beispiel zusammengefasst werden:

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

sowie

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

Danach wurden alle Vorgänge ohne Konflikte ausgeführt.

Fazit

In der Codebase auf GitHub finden Sie weitere Informationen zur Implementierung. Außerdem möchte ich Marcus Meissner für die Wartung von gFoto2 und für seine Rezensionen zu meinen vorgelagerten PRs danken.

Wie diese Beiträge zeigen, bieten WebAssembly-, Asyncify- und Fugu-APIs ein leistungsstarkes Kompilierungsziel für selbst die komplexesten Anwendungen. Damit können Sie eine Bibliothek oder eine Anwendung, die zuvor für eine einzelne Plattform erstellt wurde, ins Web übertragen, sodass sie einer deutlich größeren Anzahl von Nutzern auf Desktop- und Mobilgeräten gleichermaßen zur Verfügung steht.