USB-Anwendungen ins Web portieren Teil 2: gPhoto2

Hier erfahren Sie, wie gPhoto2 zu WebAssembly übertragen wurde, um externe Kameras über USB aus einer Web-App zu steuern.

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

Ich habe auch eine Demo mit gPhoto2 gezeigt, mit der DSLR- und spiegellose Kameras über USB über eine Webanwendung gesteuert werden können. In diesem Beitrag gehe ich tiefer in die technischen Details des gPhoto2-Ports ein.

Build-Systeme auf benutzerdefinierte Forks verweisen

Da ich meine App auf WebAssembly ausgerichtet hatte, konnte ich die von den Systemverteilungen bereitgestellten libusb- und libgphoto2-Daten nicht verwenden. Stattdessen musste meine Anwendung meine benutzerdefinierte Fork von libgphoto2 und die Fork von libgphoto2 meine benutzerdefinierte Fork von libusb verwenden.

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

Hier ist ein Diagramm zu einer Abhängigkeit (gestrichelte Linien kennzeichnen dynamische Verlinkungen):

Ein Diagramm zeigt „die App“ abhängig von "libgphoto2 Fork",
das wiederum von "libtool" abhängt. „libtool“ Block hängt dynamisch von libgphoto2-Ports ab und "libgphoto2 camlibs". Schließlich: „libgphoto2 ports“ hängt 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 mithilfe verschiedener Flags. Das habe ich also zuerst versucht. Wenn das Abhängigkeitsdiagramm jedoch komplex wird, wird die Liste der Pfadüberschreibungen für die Abhängigkeiten jeder Bibliothek ausführlich und fehleranfällig. Ich habe auch einige Fehler gefunden, bei denen Build-Systeme nicht so vorbereitet waren, dass ihre Abhängigkeiten in nicht standardmäßigen Pfaden ausgeführt werden konnten.

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

Emscripten verfügt bereits über einen eigenen 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 mich dafür entschieden, denselben Sysroot auch 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, wodurch sie unter „sysroot“ installiert wurde. Dann fanden sich die Bibliotheken automatisch gegenseitig.

Umgang mit dynamischem Laden

Wie bereits erwähnt, verwendet libgphoto2 libtool zum Auflisten und dynamischen Laden von E/A-Port-Adaptern und Kamerabibliotheken. 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 ();

Im Web gibt es bei diesem Ansatz einige Probleme:

  • Es gibt keine Standardunterstützung für die dynamische Verknüpfung von WebAssembly-Modulen. Emscripten verfügt über eine benutzerdefinierte Implementierung, mit der die von libtool verwendete dlopen() API simuliert werden kann. Sie müssen dafür jedoch „main“ erstellen. und „Seite“ Module mit unterschiedlichen Flags, und insbesondere für dlopen(), auch um beim Start der Anwendung die Nebenmodule in das emulierte Dateisystem vorab zu laden. Es kann schwierig sein, diese Flags und Änderungen in ein vorhandenes Autoconf-Build-System mit vielen dynamischen Bibliotheken zu integrieren.
  • Selbst wenn die 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 veröffentlichen.
  • Das Verknüpfen dynamischer Bibliotheken in der Befehlszeile anstelle der Aufzählung in der Laufzeit kann auch zu Problemen wie dem Problem mit doppelten Symbolen führen. Diese entstehen durch Unterschiede zwischen der Darstellung gemeinsam genutzter Bibliotheken in Emscripten und auf anderen Plattformen.

Es ist möglich, das Build-System an diese Unterschiede anzupassen und die Liste der dynamischen Plug-ins irgendwo während des Builds hartzucodieren. Aber noch einfacher können Sie all diese Probleme lösen, indem Sie zunächst keine dynamischen Links erstellen.

Wie sich herausstellt, entfernt libtool verschiedene dynamische Verknüpfungsmethoden auf verschiedenen Plattformen und unterstützt sogar das Schreiben benutzerdefinierter Loader für andere Plattformen. Einer der integrierten Loader wird "Dlpreopening" genannt:

„Libtool unterstützt das dlopening-Objekt libtool und die 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 zum Kompilieren mit dem Programm verknüpft und Datenstrukturen erstellt, die die Symboltabelle des Programms darstellen. Damit Sie diese Funktion nutzen können, müssen Sie beim Verknüpfen Ihres Programms mit dem Flag „-dlopen“ oder „-dlpreopen“ die Objekte deklarieren, die von der Anwendung dlopen geöffnet werden sollen (siehe Linkmodus).

Mit diesem Mechanismus kann das dynamische Laden auf libtool-Ebene anstelle von Emscripten emuliert und gleichzeitig alles statisch mit einer einzigen Bibliothek verknüpft werden.

Das einzige dadurch nicht gelöste Problem ist die Aufzählung dynamischer Bibliotheken. Die Liste dieser Elemente muss irgendwo noch hartcodiert werden. Zum Glück benötigen wir nur ein Minimum an Plug-ins für die App:

  • Was die Ports betrifft, liegt mir nur die libusb-basierte Kameraverbindung vor, nicht die PTP/IP-Verbindung, der serielle Zugriff oder die USB-Laufwerksmodi.
  • Bei Camlibs gibt es verschiedene anbieterspezifische Plug-ins, die möglicherweise einige spezielle Funktionen bieten. Für die Steuerung und Erfassung von allgemeinen Einstellungen reicht es jedoch aus, das Picture Transfer Protocol zu verwenden. Es wird von ptp2 Camlib dargestellt und von praktisch jeder Kamera auf dem Markt unterstützt.

So sieht das aktualisierte Abhängigkeitsdiagramm mit allem aus, das statisch miteinander verknüpft ist:

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

Das habe ich also 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-Build-System musste ich nun -dlpreopen mit diesen 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 statisch in einer einzigen Bibliothek verknüpft sind, muss libtool feststellen können, welches Symbol zu welcher Bibliothek gehört. Dazu müssen Entwickler alle preisgegebenen Symbole wie {function name} in {library name}_LTX_{function name} umbenennen. Am einfachsten geht das, wenn Sie #define verwenden, um die Symbolnamen am Anfang 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 Namensschema verhindert auch Namenskonflikte für den Fall, dass ich später kameraspezifische Plug-ins in derselben App verknüpfen möchte.

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

Benutzeroberfläche für Einstellungen generieren

Mit gPhoto2 können Kamerabibliotheken eigene Einstellungen in Form einer Widget-Struktur festlegen. Die Hierarchie der Widget-Typen umfasst Folgendes:

  • Fenster – Konfigurationscontainer der obersten Ebene <ph type="x-smartling-placeholder">
      </ph>
    • Bereiche: benannte Gruppen anderer Widgets
    • Schaltflächenfelder
    • Textfelder
    • Numerische Felder
    • Datumsfelder
    • Ein/Aus-Schaltfläche
    • Optionsfelder

Über die offengelegte C API können der Name, der Typ, die untergeordneten Elemente und alle anderen relevanten Eigenschaften jedes Widgets abgefragt (und bei Werten auch geändert werden) werden. Gemeinsam bilden sie die Grundlage für die automatische Generierung einer Benutzeroberfläche für Einstellungen in jeder Sprache, die mit C interagieren kann.

Die Einstellungen können jederzeit entweder über gPhoto2 oder in der Kamera selbst geändert werden. Darüber hinaus sind einige Widgets schreibgeschützt, und sogar der schreibgeschützte Status selbst hängt vom Kameramodus und anderen Einstellungen ab. Beispielsweise ist der Auslösergeschwindigkeit in M (manueller Modus) ein beschreibbares numerisches Feld, wird aber in P (Programmmodus) zu einem schreibgeschützten Feld, das nur zu Informationszwecken dient. Im P-Modus ist auch der Wert der Belichtungszeit dynamisch und ändert sich abhängig von der Helligkeit des von der Kamera erfassten Bereichs kontinuierlich.

Insgesamt ist es wichtig, dass die Informationen der verbundenen Kamera immer auf dem neuesten Stand sind und gleichzeitig dem Nutzer die Möglichkeit gegeben wird, diese Einstellungen über dieselbe Benutzeroberfläche zu bearbeiten. Die Verarbeitung eines solchen bidirektionalen Datenflusses ist komplexer.

gPhoto2 verfügt nicht über einen Mechanismus, um nur geänderte Einstellungen abzurufen, sondern nur die gesamte Baumstruktur oder einzelne Widgets. Um die Benutzeroberfläche auf dem neuesten Stand zu halten, ohne zu flackern und den Eingabefokus oder die Scrollposition zu verlieren, benötigte ich eine Möglichkeit, die Widgetstrukturen zwischen den Aufrufen zu unterscheiden und nur die geänderten Benutzeroberflächeneigenschaften zu aktualisieren. Glücklicherweise ist dies im Web ein gelöstes Problem und die Hauptfunktion von Frameworks wie React oder Preact. Für dieses Projekt habe ich mich für Preact entschieden, da es viel schlanker ist und alles kann, was ich brauche.

Auf der C++-Seite musste ich nun den Einstellungsbaum über die zuvor verknüpfte C-API abrufen und 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;
  }
  // …

Indem ich diese Funktion wiederholt in einer Endlosschleife ausführe, konnte ich in der Benutzeroberfläche für Einstellungen immer die neuesten Informationen anzeigen und gleichzeitig Befehle an die Kamera senden, wenn eines der Felder vom Benutzer bearbeitet wird.

Preact kann die Ergebnisse verändern und das DOM nur für die geänderten Teile der Benutzeroberfläche aktualisieren, ohne den Seitenfokus oder den Bearbeitungsstatus zu beeinträchtigen. Ein Problem, das bleibt, ist der bidirektionale Datenfluss. Frameworks wie React und Preact wurden für den unidirektionalen Datenfluss entwickelt, da es viel einfacher ist, Daten zu analysieren und zwischen Wiederholungen zu vergleichen. Ich übertrete jedoch diese Erwartung, indem ich einer externen Quelle – der Kamera – erlaube, die Benutzeroberfläche für Einstellungen jederzeit zu aktualisieren.

Ich habe die Aktualisierungen der Benutzeroberfläche für alle Eingabefelder deaktiviert, die derzeit vom Nutzer bearbeitet werden, um dieses Problem zu umgehen:

/**
 * 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 immer nur einen Inhaber für ein Feld. Entweder bearbeitet der Nutzer das Bild gerade und wird durch die aktualisierten Werte der Kamera nicht beeinträchtigt oder die Kamera aktualisiert den Feldwert, während er unscharf ist.

Erstellen eines Live-Videos Feed

Während der Pandemie sind viele Menschen zu Onlinemeetings gewechselt. Dies führte unter anderem zu einem Mangel am Webcam-Markt. Um eine bessere Videoqualität im Vergleich zu den integrierten Kameras in Laptops zu erhalten, und als Reaktion auf diesen Mangel suchten viele Besitzer von digitalen Spiegelreflexkameras und spiegellosen Kameras nach Möglichkeiten, ihre Fotokameras als Webcams zu verwenden. Einige Kameraanbieter haben sogar offizielle Dienstprogramme für diesen Zweck versendet.

Wie die offiziellen Tools unterstützt gPhoto2 das Streamen von Videos von der Kamera in eine lokal gespeicherte Datei oder direkt an eine virtuelle Webcam. Ich wollte diese Funktion in meiner Demo verwenden, um einen Livestream zu zeigen. Obwohl es im Dienstprogramm der Console verfügbar ist, konnte ich es nirgendwo in den libgphoto2-Bibliotheks-APIs finden.

Im Quellcode der entsprechenden Funktion im Konsolen-Dienstprogramm habe ich festgestellt, dass eigentlich gar kein Video abgerufen wird. Stattdessen abruft sie die Kameravorschau weiter als einzelne JPEG-Bilder in einer Endlosschleife und schreibt sie nacheinander aus, um einen M-JPEG-Stream zu bilden:

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 ich den Eindruck von flüssigen Echtzeitvideos bekommt. Noch skeptischer war ich, dass ich auch in der Webanwendung mit allen zusätzlichen Abstraktionen und der Asynchronität dieselbe Leistung erzielen konnte. Ich habe mich jedoch entschlossen, es trotzdem zu versuchen.

In C++ habe ich eine Methode namens capturePreviewAsBlob() verfügbar gemacht, 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 gPhoto2, die Vorschaubilder immer wieder als Blobs abruft, sie im Hintergrund mit createImageBitmap decodiert und sie im nächsten Animationsframe in den Canvas überträgt:

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

Durch die Verwendung dieser modernen APIs wird sichergestellt, dass die gesamte Decodierung im Hintergrund ausgeführt wird und der Canvas nur dann aktualisiert wird, wenn sowohl das Bild als auch der Browser vollständig für das Zeichnen vorbereitet sind. Dadurch erreichte ich auf meinem Laptop konstante 30 fps, was der nativen Leistung von gPhoto2 und der offiziellen Sony-Software entspricht.

USB-Zugriff synchronisieren

Wenn eine USB-Datenübertragung angefordert wird, während ein anderer Vorgang bereits ausgeführt wird, führt dies häufig zur Meldung "Gerät ist ausgelastet". Fehler. 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, haben sich solche Konflikte zwischen verschiedenen Vorgängen als sehr häufig herausgestellt.

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 verkettee 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, während kritische (unerwartete) Fehler die gesamte Kette als abgelehntes Promise markieren und sichergestellt wird, 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 die context zuzugreifen, ohne den schedule()-Aufruf auszuführen.

Um die Elemente zu verknüpfen, muss jetzt jeder Zugriff auf den Gerätekontext in einem schedule()-Aufruf zusammengefasst werden, der so aussieht:

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 mich auch bei Marcus Meißner für die Wartung von gPhoto2 und für seine Rezensionen zu meinen vorgelagerten PRs bedanken.

Wie in diesen Posts gezeigt, bieten WebAssembly, Asyncify und Fugu APIs selbst für die komplexesten Anwendungen ein leistungsfähiges Kompilierungsziel. Sie ermöglichen es Ihnen, eine Bibliothek oder eine Anwendung, die zuvor für eine einzelne Plattform erstellt wurde, ins Web zu übertragen, sodass sie einer weitaus größeren Anzahl von Nutzern auf Desktop- und Mobilgeräten verfügbar ist.