Portage d'applications USB sur le Web Partie 2: gPhoto2

Découvrez comment gPhoto2 a été porté vers WebAssembly pour contrôler des appareils photo externes via USB à partir d'une application Web.

Dans l'article précédent, j'ai expliqué comment la bibliothèque libusb a été portée pour s'exécuter sur le Web avec WebAssembly/Emscripten, Asyncify et WebUSB.

Je vous ai également montré une démonstration créée avec gPhoto2 qui permet de contrôler un reflex numérique et des appareils photo sans miroir via USB à partir d'une application Web. Dans cet article, je vais vous présenter plus en détail les détails techniques du port gPhoto2.

Pointage des systèmes de compilation vers des fourches personnalisées

Comme je ciblais WebAssembly, je ne pouvais pas utiliser libusb et libgphoto2 fournis par les distributions système. Au lieu de cela, j'avais besoin que mon application utilise ma copie personnalisée de libgphoto2, tandis que celle de libgphoto2 devait utiliser ma copie personnalisée de libusb.

De plus, libgphoto2 utilise libtool pour charger des plug-ins dynamiques, et même si je n'ai pas eu à dupliquer libtool comme les deux autres bibliothèques, j'ai quand même dû le compiler dans WebAssembly et faire pointer libgphoto2 vers cette version personnalisée au lieu du package système.

Voici un diagramme approximatif des dépendances (les lignes en pointillés indiquent le lien dynamique) :

Un diagramme montre que "l'application" dépend de "libgphoto2 fork", qui dépend de "libtool". Le bloc "libtool" dépend de manière dynamique des ports "libgphoto2" et "libgphoto2 camlibs". Enfin, "ports libgphoto2" dépend de manière statique de la "fork libusb".

La plupart des systèmes de compilation basés sur la configuration, y compris ceux utilisés dans ces bibliothèques, autorisent le remplacement des chemins d'accès pour les dépendances via différents indicateurs. C'est donc ce que j'ai essayé de faire en premier. Toutefois, lorsque le graphique des dépendances devient complexe, la liste des remplacements de chemins d'accès pour les dépendances de chaque bibliothèque devient détaillée et sujette aux erreurs. J'ai également trouvé des bugs où les systèmes de compilation n'étaient pas préparés à ce que leurs dépendances résident dans des chemins non standards.

À la place, une approche plus simple consiste à créer un dossier distinct en tant que racine système personnalisée (souvent abrégée en "sysroot") et à y faire pointer tous les systèmes de compilation concernés. De cette manière, chaque bibliothèque recherche ses dépendances dans le sysroot spécifié lors de la compilation et s'installe également dans le même sysroot afin que d'autres puissent la trouver plus facilement.

Emscripten dispose déjà de son propre sysroot sous (path to emscripten cache)/sysroot, qu'il utilise pour ses bibliothèques système, ses ports Emscripten et des outils tels que CMake et pkg-config. J'ai également choisi de réutiliser le même sysroot pour mes dépendances.

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

Avec une telle configuration, il me suffisait d'exécuter make install dans chaque dépendance, ce qui l'a installé sous sysroot, puis les bibliothèques se sont trouvées automatiquement.

Gérer le chargement dynamique

Comme indiqué ci-dessus, libgphoto2 utilise libtool pour énumérer et charger dynamiquement les adaptateurs de port d'E/S et les bibliothèques d'appareils photo. Par exemple, le code de chargement des bibliothèques d'E/S se présente comme suit :

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

Cette approche présente plusieurs problèmes sur le Web :

  • Aucune compatibilité standard n'est assurée pour l'association dynamique des modules WebAssembly. Emscripten propose une implémentation personnalisée qui peut simuler l'API dlopen() utilisée par libtool. Toutefois, vous devez créer des modules "main" et "side" avec des indicateurs différents et, en particulier pour dlopen(), également précharger les modules latéraux dans le système de fichiers émulé lors du démarrage de l'application. Il peut être difficile d'intégrer ces options et ajustements dans un système de compilation autoconf existant doté de nombreuses bibliothèques dynamiques.
  • Même si dlopen() est implémenté, il n'existe aucun moyen d'énumérer toutes les bibliothèques dynamiques d'un dossier donné sur le Web, car la plupart des serveurs HTTP n'exposent pas les listes de répertoires pour des raisons de sécurité.
  • Associer des bibliothèques dynamiques sur la ligne de commande au lieu de les énumérer au moment de l'exécution peut également entraîner des problèmes, comme le problème de symboles en double, causés par les différences de représentation des bibliothèques partagées dans Emscripten et sur d'autres plates-formes.

Il est possible d'adapter le système de compilation à ces différences et de coder en dur la liste des plug-ins dynamiques quelque part lors de la compilation. Toutefois, une méthode encore plus simple pour résoudre tous ces problèmes consiste à éviter le lien dynamique dès le départ.

Il s'avère que libtool gère de manière abstraite différentes méthodes de liaison dynamique sur différentes plates-formes et permet même d'écrire des chargeurs personnalisés pour d'autres. L'un des chargeurs intégrés compatibles est appelé "Dlpreopening" :

"Libtool fournit une prise en charge spéciale pour le dlopen des fichiers d'objet libtool et de bibliothèque libtool, afin que leurs symboles puissent être résolus même sur les plates-formes sans fonctions dlopen et dlsym.
...
Libtool émule -dlopen sur les plates-formes statiques en associant des objets au programme au moment de la compilation et en créant des structures de données qui représentent la table de symboles du programme. Pour utiliser cette fonctionnalité, vous devez déclarer les objets que votre application doit dlopen à l'aide des options -dlopen ou -dlpreopen lorsque vous associez votre programme (voir Mode d'association).

Ce mécanisme permet d'émuler le chargement dynamique au niveau de libtool plutôt qu'Emscripten, tout en associant tout de manière statique dans une seule bibliothèque.

Le seul problème que cette méthode ne résout pas est l'énumération des bibliothèques dynamiques. La liste de ces éléments doit toujours être codée en dur quelque part. Heureusement, l'ensemble de plug-ins dont j'avais besoin pour l'application était minimal :

  • En ce qui concerne les ports, je ne m'intéresse qu'à la connexion de la caméra basée sur libusb, et non aux modes PTP/IP, d'accès série ou de clé USB.
  • Du côté des camlibs, il existe différents plug-ins spécifiques au fournisseur qui peuvent fournir des fonctions spécialisées. Toutefois, pour contrôler et capturer les paramètres généraux, il suffit d'utiliser le protocole de transfert d'images, représenté par la camlib ptp2 et compatible avec pratiquement toutes les caméras du marché.

Voici à quoi ressemble le diagramme de dépendance mis à jour, avec tout lié de manière statique :

Un diagramme montre que "l'application" dépend de "libgphoto2 fork", qui dépend de "libtool". "libtool" dépend de "ports: libusb1" et "camlibs: libptp2". "ports: libusb1" dépend de "libusb fork".

Voici ce que j'ai codé en dur pour les builds 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 ();

et

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

Dans le système de compilation autoconf, j'ai dû ajouter -dlpreopen avec ces deux fichiers en tant qu'options de liaison pour tous les exécutables (exemples, tests et ma propre application de démonstration), comme suit :

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

Enfin, maintenant que tous les symboles sont liés de manière statique dans une seule bibliothèque, libtool doit pouvoir déterminer à quelle bibliothèque chaque symbole appartient. Pour ce faire, les développeurs doivent renommer tous les symboles exposés, comme {function name}, en {library name}_LTX_{function name}. Le moyen le plus simple de procéder consiste à utiliser #define pour redéfinir les noms de symboles en haut du fichier d'implémentation :

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

Ce schéma d'attribution de noms évite également les conflits de noms si je décide d'associer des plug-ins spécifiques à la caméra dans la même application à l'avenir.

Une fois toutes ces modifications implémentées, j'ai pu créer l'application de test et charger les plug-ins.

Générer l'UI des paramètres

gPhoto2 permet aux bibliothèques d'appareils photo de définir leurs propres paramètres sous la forme d'une arborescence de widgets. La hiérarchie des types de widgets se compose des éléments suivants :

  • Fenêtre : conteneur de configuration de niveau supérieur
    • Sections : groupes nommés d'autres widgets
    • Champs de bouton
    • Champs de texte
    • Champs numériques
    • Champs de date
    • Boutons d'activation/de désactivation
    • Cases d'option

Le nom, le type, les enfants et toutes les autres propriétés pertinentes de chaque widget peuvent être interrogés (et, en cas de valeurs, également modifiés) via l'API C exposée. Ensemble, ils constituent la base permettant de générer automatiquement l'UI des paramètres dans n'importe quelle langue pouvant interagir avec C.

Vous pouvez modifier les paramètres à tout moment via gPhoto2 ou directement sur l'appareil photo. En outre, certains widgets peuvent être en lecture seule, et même l'état "lecture seule" lui-même dépend du mode Appareil photo et d'autres paramètres. Par exemple, la vitesse d'obturation est un champ numérique en écriture en mode M (manuel), mais devient un champ informatif en lecture seule en mode P (programme). En mode P, la valeur de la vitesse d'obturation est également dynamique et change en permanence en fonction de la luminosité de la scène filmée.

Dans l'ensemble, il est important d'afficher toujours les informations à jour de la caméra connectée dans l'UI, tout en permettant à l'utilisateur de modifier ces paramètres à partir de la même UI. Ce flux de données bidirectionnel est plus complexe à gérer.

gPhoto2 ne dispose d'aucun mécanisme permettant de récupérer uniquement les paramètres modifiés, uniquement l'arborescence entière ou les widgets individuels. Pour que l'UI reste à jour sans clignoter, sans perdre le focus de saisie ni la position de défilement, j'avais besoin d'un moyen de comparer les arborescences de widgets entre les invocations et de ne mettre à jour que les propriétés de l'UI modifiées. Heureusement, ce problème est résolu sur le Web et constitue la fonctionnalité de base de frameworks tels que React ou Preact. J'ai choisi Preact pour ce projet, car il est beaucoup plus léger et répond à tous mes besoins.

Côté C++, je devais maintenant récupérer et parcourir de manière récursive l'arborescence des paramètres via l'API C précédemment associée, et convertir chaque widget en objet 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;
    }
    // …

Côté JavaScript, je peux maintenant appeler configToJS, parcourir la représentation JavaScript renvoyée de l'arborescence des paramètres et créer l'UI via la fonction Preact h :

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

En exécutant cette fonction de manière répétée dans une boucle d'événements infinie, je pourrais faire en sorte que l'interface utilisateur des paramètres affiche toujours les dernières informations, tout en envoyant des commandes à l'appareil photo chaque fois que l'un des champs est modifié par l'utilisateur.

Preact peut gérer la comparaison des résultats et la mise à jour du DOM uniquement pour les éléments modifiés de l'UI, sans perturber le focus de la page ni les états de modification. Un problème persiste : le flux de données bidirectionnel. Les frameworks tels que React et Preact ont été conçus autour d'un flux de données unidirectionnel, car il est beaucoup plus facile de raisonner sur les données et de les comparer entre les rediffusions. Je contredis cette attente en permettant à une source externe (la caméra) de mettre à jour l'UI des paramètres à tout moment.

J'ai contourné ce problème en désactivant les mises à jour de l'interface utilisateur pour tous les champs de saisie en cours de modification par l'utilisateur:

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

Ainsi, un champ donné ne peut avoir qu'un seul propriétaire. L'utilisateur est en train de le modifier et ne sera pas perturbé par les valeurs mises à jour par la caméra, ou la caméra met à jour la valeur du champ alors qu'elle est floue.

Créer un flux "vidéo" en direct

Pendant la pandémie, de nombreuses personnes ont commencé à organiser des réunions en ligne. Cela a notamment entraîné des pénuries sur le marché des webcams. Afin de profiter d'une meilleure qualité vidéo que les appareils photo intégrés dans les ordinateurs portables, et en réponse à ces pénuries, de nombreux propriétaires de reflex numériques et d'appareils photo sans miroir ont commencé à chercher des moyens d'utiliser leurs appareils photo comme webcam. Plusieurs fournisseurs d'appareils photo ont même expédié des utilitaires officiels à cette fin.

Comme les outils officiels, gPhoto2 prend en charge le streaming vidéo de la caméra vers un fichier stocké localement ou directement vers une webcam virtuelle. Je voulais utiliser cette fonctionnalité pour fournir une vue en direct dans ma démonstration. Cependant, bien qu'il soit disponible dans l'utilitaire de console, je ne l'ai trouvé nulle part dans les API de la bibliothèque libgphoto2.

En examinant le code source de la fonction correspondante dans l'utilitaire de la console, j'ai constaté qu'elle n'obtenait pas de vidéo, mais qu'elle continue à récupérer l'aperçu de la caméra sous forme d'images JPEG individuelles dans une boucle sans fin et les écrit une par une pour former un flux M-JPEG:

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

J'ai été étonné de constater que cette approche fonctionne suffisamment efficacement pour donner l'impression d'une vidéo fluide en temps réel. J'étais encore plus sceptique quant à la possibilité d'obtenir les mêmes performances dans l'application Web, avec toutes les abstractions supplémentaires et Asyncify. Cependant, j'ai décidé de tenter ma chance.

Côté C++, j'ai exposé une méthode appelée capturePreviewAsBlob() qui appelle la même fonction gp_camera_capture_preview() et convertit le fichier en mémoire qui en résulte en Blob, qui peut être transmis plus facilement à d'autres API Web :

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

Côté JavaScript, j'ai une boucle, semblable à celle de gPhoto2, qui continue de récupérer des images d'aperçu en tant que Blob, les décode en arrière-plan avec createImageBitmap et les transfère sur le canevas au prochain frame d'animation :

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

L'utilisation de ces API modernes garantit que tout le travail de décodage s'effectue en arrière-plan. Le canevas n'est mis à jour que lorsque l'image et le navigateur sont entièrement préparés pour le dessin. Mon ordinateur portable a ainsi atteint un nombre d'images par seconde plus stable de plus de 30 FPS, soit des performances équivalentes à celles de gPhoto2 et du logiciel officiel de Sony.

Synchroniser l'accès USB

Lorsqu'un transfert de données USB est demandé alors qu'une autre opération est déjà en cours, un message d'erreur "L'appareil est occupé" s'affiche généralement. Étant donné que l'aperçu et l'interface utilisateur des paramètres sont mis à jour régulièrement, et que l'utilisateur peut essayer de prendre une photo ou de modifier les paramètres en même temps, de tels conflits entre différentes opérations se sont révélés très fréquents.

Pour les éviter, j'ai dû synchroniser tous les accès dans l'application. Pour ce faire, j'ai créé une file d'attente asynchrone basée sur des promesses :

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

En enchaînant chaque opération dans un rappel then() de la promesse queue existante et en stockant le résultat enchaîné en tant que nouvelle valeur de queue, je peux m'assurer que toutes les opérations sont exécutées une par une, dans l'ordre et sans chevauchement.

Toutes les erreurs d'opération sont renvoyées à l'appelant, tandis que les erreurs critiques (inattendues) marquent l'ensemble de la chaîne comme une promesse refusée et s'assurent qu'aucune nouvelle opération ne sera planifiée par la suite.

En conservant le contexte du module dans une variable privée (non exportée), je minimise les risques d'accès accidentel à context ailleurs dans l'application sans passer par l'appel schedule().

Pour lier les éléments, chaque accès au contexte de l'appareil doit désormais être encapsulé dans un appel schedule() comme suit :

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

et

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

Par la suite, toutes les opérations s'exécutaient correctement, sans conflit.

Conclusion

N'hésitez pas à parcourir le codebase sur GitHub pour en savoir plus sur l'implémentation. Je tiens également à remercier Marcus Meissner pour la maintenance de gPhoto2 et pour les commentaires qu'il a reçus sur mes relations publiques en amont.

Comme indiqué dans ces posts, les API WebAssembly, Asyncify et Fugu constituent une cible de compilation efficace, même pour les applications les plus complexes. Ils vous permettent de transférer une bibliothèque ou une application créée précédemment pour une plate-forme unique sur le Web, afin de les rendre accessibles à un bien plus grand nombre d'utilisateurs sur ordinateur et sur appareil mobile.