USB-Anwendungen ins Web portieren Teil 2: gPhoto2

Hier erfahren Sie, wie gPhoto2 auf WebAssembly umgestellt wurde, um externe Kameras über USB über eine Webanwendung zu steuern.

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

Außerdem habe ich eine Demo mit gPhoto2 gezeigt, mit der DSLR- und spiegellose Kameras über USB von einer Webanwendung aus gesteuert werden können. In diesem Beitrag gehe ich näher auf die technischen Details des gPhoto2-Ports ein.

Build-Systeme auf benutzerdefinierte Forks verweisen

Da ich auf WebAssembly ausgerichtet war, konnte ich die von den Systemdistributionen bereitgestellten libusb und libgphoto2 nicht verwenden. Stattdessen musste meine Anwendung meine benutzerdefinierte Fork von libgphoto2 verwenden, während diese Fork von libgphoto2 meine benutzerdefinierte Fork von libusb verwenden musste.

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

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

Ein Diagramm zeigt, dass „die App“ von „libgphoto2 fork“ abhängt, das wiederum von „libtool“ abhängt. Der Block „libtool“ 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 zuerst versucht. Wenn die Abhängigkeitsgrafik jedoch komplex wird, wird die Liste der Pfadüberschreibungen für die Abhängigkeiten jeder Bibliothek lang und fehleranfällig. Außerdem habe ich einige Fehler gefunden, bei denen die Build-Systeme nicht darauf vorbereitet waren, dass ihre Abhängigkeiten sich in nicht standardmäßigen Pfaden befinden.

Stattdessen ist es einfacher, einen separaten Ordner als benutzerdefinierten Systemstamm (oft zu „sysroot“ verkürzt) zu erstellen und alle beteiligten Build-Systeme darauf zu verweisen. So sucht jede Bibliothek beim Build sowohl nach ihren Abhängigkeiten im angegebenen sysroot als auch installiert sich selbst im selben sysroot, damit andere sie leichter finden können.

Emscripten hat bereits ein eigenes sysroot unter (path to emscripten cache)/sysroot, das für seine Systembibliotheken, Emscripten-Ports und Tools wie CMake und pkg-config verwendet wird. Ich habe auch dasselbe sysroot für meine Abhängigkeiten wiederverwendet.

# 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 dieser Konfiguration musste ich nur make install in jeder Abhängigkeit ausführen, wodurch sie unter dem sysroot installiert wurde. Die Bibliotheken fanden sich dann automatisch.

Dynamisches Laden

Wie bereits erwähnt, verwendet libgphoto2 libtool, um I/O-Port-Adapter und Kamerabibliotheken zu zählen 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 jedoch einige Probleme:

  • Es gibt keine Standardunterstützung für die dynamische Verknüpfung von WebAssembly-Modulen. Emscripten hat eine benutzerdefinierte Implementierung, mit der die von libtool verwendete dlopen() API simuliert werden kann. Sie müssen jedoch „Haupt“- und „Neben“-Module mit unterschiedlichen Flags erstellen und speziell für dlopen() auch die Nebenmodule beim Starten der Anwendung in das emulierte Dateisystem vorladen. Es kann schwierig sein, diese Flags und Optimierungen in ein bestehendes autoconf-Buildsystem mit vielen dynamischen Bibliotheken einzubinden.
  • 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 Verzeichnislisten bereitstellen.
  • Das Verknüpfen dynamischer Bibliotheken über die Befehlszeile anstelle der Aufzählung zur Laufzeit kann ebenfalls zu Problemen führen, z. B. zum Problem mit doppelten Symbolen, das durch Unterschiede zwischen der Darstellung freigegebener Bibliotheken in Emscripten und auf anderen Plattformen verursacht wird.

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

Wie sich herausstellte, abstrahiert libtool verschiedene Methoden der dynamischen Verknüpfung auf verschiedenen Plattformen und unterstützt sogar das Schreiben benutzerdefinierter Lader für andere. Einer der unterstützten integrierten Lader heißt „Dlpreopening“:

„Libtool bietet eine spezielle Unterstützung für das dlopen von 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 Objekte zur Laufzeit in das Programm einbindet und Datenstrukturen erstellt, die die Symboltabelle des Programms darstellen. Wenn Sie diese Funktion verwenden möchten, müssen Sie die Objekte deklarieren, die Ihre Anwendung dlopen soll. Verwenden Sie dazu beim Verknüpfen Ihres Programms die Flags „-dlopen“ oder „-dlpreopen“ (siehe Link-Modus).“

Mit diesem Mechanismus kann das dynamische Laden auf libtool-Ebene statt auf Emscripten-Ebene emuliert werden, 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 Elemente muss noch irgendwo hartcodiert werden. Glücklicherweise waren für die App nur wenige Plug-ins erforderlich:

  • Bei den Ports geht es mir nur um die libusb-basierte Kameraverbindung und nicht um PTP/IP, seriellen Zugriff oder USB-Laufwerkmodi.
  • Für die Camlibs gibt es verschiedene anbieterspezifische Plug-ins, die einige spezielle Funktionen bieten. Für die allgemeine Einstellung und Aufnahme reicht jedoch das Picture Transfer Protocol, das durch die Camlib „ptp2“ dargestellt wird und von praktisch jeder Kamera auf dem Markt unterstützt wird.

So sieht das aktualisierte Abhängigkeitsdiagramm aus, in dem alle Elemente statisch miteinander verknüpft sind:

Ein Diagramm zeigt, dass „die App“ von „libgphoto2 fork“ abhängt, das wiederum 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 ();

und

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-Buildsystem musste ich -dlpreopen mit beiden Dateien als Link-Flags für alle ausführbaren Dateien (Beispiele, Tests und meine eigene Demo-App) hinzufügen, so:

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

Nachdem alle Symbole jetzt statisch in einer einzigen Bibliothek verknüpft sind, muss libtool ermitteln können, welches Symbol zu welcher Bibliothek gehört. Dazu müssen Entwickler alle freigegebenen Symbole wie {function name} in {library name}_LTX_{function name} umbenennen. Am einfachsten geht das mit #define, um 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>
// …

Dieses Benennungsschema verhindert auch Namenskonflikte, falls ich in Zukunft kameraspezifische Plug-ins in derselben App verknüpfen möchte.

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

Benutzeroberfläche für Einstellungen generieren

Mit gPhoto2 können Kamerabibliotheken ihre eigenen Einstellungen in Form eines Widget-Baums definieren. Die Hierarchie der Widgettypen besteht aus:

  • Fenster – Konfigurationscontainer der obersten Ebene
    • Abschnitte: benannte Gruppen anderer Widgets
    • Schaltflächenfelder
    • Textfelder
    • Numerische Felder
    • Datumsfelder
    • Toggles
    • Optionsfelder

Der Name, der Typ, die untergeordneten Elemente und alle anderen relevanten Eigenschaften der einzelnen Widgets können über die externe C API abgefragt und im Falle von Werten auch geändert werden. Zusammen bilden sie die Grundlage für die automatische Generierung der Benutzeroberfläche für Einstellungen in jeder Sprache, die mit C interagieren kann.

Die Einstellungen können jederzeit über gPhoto2 oder direkt auf 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. Die Belichtungszeit ist beispielsweise im M-Modus (manueller Modus) ein beschreibbares numerisches Feld, im P-Modus (Programmmodus) jedoch ein nur zur Information dienendes Feld, das nicht geändert werden kann. Im P-Modus ist der Wert der Verschlusszeit ebenfalls dynamisch und ändert sich kontinuierlich je nach Helligkeit der Szene, auf die die Kamera gerichtet ist.

Insgesamt ist es wichtig, immer aktuelle Informationen von der verbundenen Kamera in der Benutzeroberfläche anzuzeigen und gleichzeitig dem Nutzer zu ermöglichen, diese Einstellungen über dieselbe Benutzeroberfläche zu bearbeiten. Dieser bidirektionale Datenfluss ist schwieriger zu verarbeiten.

gPhoto2 bietet keinen Mechanismus, um nur geänderte Einstellungen abzurufen, sondern nur den gesamten Baum oder einzelne Widgets. 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 vergleichen und nur die geänderten UI-Eigenschaften zu aktualisieren. Glücklicherweise ist dieses Problem im Web gelöst und bildet die Hauptfunktion von Frameworks wie React oder Preact. Ich habe für dieses Projekt Preact verwendet, da es viel schlanker ist und alles bietet, was ich benötige.

Auf C++-Seite musste ich nun den Einstellungsbaum über die oben verlinkte C-API abrufen und rekursiv durchgehen 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 kann 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 wiederholtes Ausführen dieser Funktion in einem unendlichen Ereignis-Loop konnte ich dafür sorgen, dass die Benutzeroberfläche der Einstellungen immer die neuesten Informationen enthält. Außerdem wurden Befehle an die Kamera gesendet, wenn eines der Felder vom Nutzer bearbeitet wurde.

Preact kann die Ergebnisse vergleichen und das DOM nur für die geänderten Teile der Benutzeroberfläche aktualisieren, ohne den Fokus der Seite oder die Bearbeitungsstatus zu stören. Ein Problem bleibt jedoch: der bidirektionale Datenfluss. Frameworks wie React und Preact wurden für einen unidirektionalen Datenfluss entwickelt, da dies die Analyse der Daten und den Vergleich zwischen Wiederholungen erheblich erleichtert. Ich gehe jedoch davon ab, indem ich einer externen Quelle – der Kamera – erlaube, die Benutzeroberfläche der Einstellungen jederzeit zu aktualisieren.

Ich habe dieses Problem umgangen, indem ich die Aktualisierung der Benutzeroberfläche für alle Eingabefelder deaktiviert habe, die gerade 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 Inhaber. Entweder bearbeitet der Nutzer das Feld gerade und wird nicht durch die aktualisierten Werte der Kamera gestört, oder die Kamera aktualisiert den Feldwert, während das Bild unscharf ist.

Live-Videofeed erstellen

Während der Pandemie haben viele Menschen auf Online-Meetings umgestellt. Dies führte unter anderem zu Knappheiten auf dem Webcam-Markt. Aufgrund der besseren Videoqualität im Vergleich zu den integrierten Kameras in Laptops und aufgrund der genannten Engpässe haben viele Besitzer von DSLR- und spiegellosen Kameras nach Möglichkeiten gesucht, ihre Fotokameras als Webcam zu verwenden. Einige Kamerahersteller haben sogar offizielle Dienstprogramme zu diesem Zweck mitgeliefert.

Wie die offiziellen Tools unterstützt gPhoto2 das Streaming von Video von der Kamera zu einer lokal gespeicherten Datei oder direkt zu einer virtuellen Webcam. Ich wollte diese Funktion nutzen, um in meiner Demo eine Liveansicht zu zeigen. Obwohl sie im Konsolendienstprogramm verfügbar ist, konnte ich sie in den APIs der libgphoto2-Bibliothek nicht finden.

Beim Betrachten des Quellcodes der entsprechenden Funktion im Konsolen-Dienstprogramm habe ich festgestellt, dass kein Video abgerufen wird, sondern stattdessen die Vorschau der Kamera in einer endlosen Schleife als einzelne JPEG-Bilder abgerufen und einzeln in einen M-JPEG-Stream geschrieben wird:

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

Ich war erstaunt, dass dieser Ansatz so effizient funktioniert, dass ein Eindruck von flüssigem Echtzeitvideo entsteht. Ich war noch skeptischer, ob ich die gleiche Leistung auch in der Webanwendung erzielen könnte, da alle zusätzlichen Abstraktionsebenen und Asyncify im Weg standen. Ich beschloss jedoch, es trotzdem zu versuchen.

Auf der C++-Seite habe ich eine Methode namens capturePreviewAsBlob() freigegeben, die dieselbe gp_camera_capture_preview()-Funktion aufruft und die resultierende In-Memory-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, die ähnlich wie die in gPhoto2 funktioniert. Sie ruft ständig Vorschaubilder als Blobs ab, decodiert sie im Hintergrund mit createImageBitmap und überträgt sie im nächsten Animationsframe auf den Canvas:

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

Mit diesen modernen APIs wird sichergestellt, dass die gesamte Dekodierung im Hintergrund erfolgt und der Canvas nur aktualisiert wird, wenn sowohl das Bild als auch der Browser vollständig für das Zeichnen vorbereitet sind. So konnte ich auf meinem Laptop eine konstante Bildrate von über 30 fps erzielen, was der nativen Leistung von gPhoto2 und der offiziellen Sony-Software entsprach.

USB-Zugriff synchronisieren

Wenn eine USB-Datenübertragung angefordert wird, während ein anderer Vorgang bereits läuft, führt dies in der Regel zu der Fehlermeldung „Gerät ist belegt“. Da sich die Vorschau und die Einstellungen regelmäßig aktualisieren und der Nutzer gleichzeitig ein Bild aufnehmen oder die Einstellungen ändern möchte, sind solche Konflikte zwischen verschiedenen Vorgängen sehr häufig.

Um sie zu vermeiden, musste ich alle Zugriffe innerhalb der Anwendung synchronisieren. Dazu habe ich eine promisebasierte 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-Versprechens verkette und das verkettete Ergebnis als neuen Wert von queue speichere, kann ich dafür sorgen, dass alle Vorgänge nacheinander und ohne Überschneidungen ausgeführt werden.

Alle Vorgangsfehler werden an den Aufrufer zurückgegeben. Bei kritischen (unerwarteten) Fehlern wird die gesamte Kette als abgelehntes Versprechen gekennzeichnet und es wird sichergestellt, dass danach kein neuer Vorgang geplant wird.

Indem ich den Modulkontext in einer privaten (nicht exportierten) Variablen belasse, minimiere ich das Risiko, dass versehentlich an anderer Stelle in der App auf context zugegriffen wird, ohne den schedule()-Aufruf zu verwenden.

Um alles zusammenzuführen, muss jeder Zugriff auf den Gerätekontext jetzt in einen schedule()-Aufruf wie diesen eingewickelt werden:

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

und

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

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

Fazit

Weitere Informationen zur Implementierung finden Sie in der Codebasis auf GitHub. Ich möchte auch Marcus Meissner für die Pflege von gPhoto2 und für die Überprüfung meiner Upstream-PRs danken.

Wie in diesen Beiträgen gezeigt, bieten WebAssembly-, Asyncify- und Fugu-APIs ein leistungsfähiges Kompilierungsziel für selbst die komplexesten Anwendungen. Sie können damit eine Bibliothek oder Anwendung, die zuvor für eine einzelne Plattform entwickelt wurde, ins Web portieren und so für eine wesentlich größere Anzahl von Nutzern auf Computern und Mobilgeräten verfügbar machen.