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

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

Dans le post précédent, j'ai expliqué comment la bibliothèque libusb a été transposée pour fonctionner 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 approfondir les détails techniques liés au port gPhoto2.

Faire pointer des systèmes de compilation vers des duplications personnalisées

Étant donné que je ciblais WebAssembly, je n'ai pas pu utiliser les paramètres 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 sur WebAssembly et faire pointer libgphoto2 vers cette version personnalisée au lieu du package système.

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

Un schéma montre "l'application" en fonction de "libgphoto2 fork", qui dépend de "libtool". "libtool" le bloc dépend dynamiquement des ports "libgphoto2" et "libgphoto2 camlibs". Enfin, "libgphoto2 ports" 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 réellement préparés à ce que leurs dépendances vivent dans des chemins d'accès 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. Ainsi, chaque bibliothèque recherchera à la fois ses dépendances dans le répertoire sysroot spécifié lors de la compilation et s'installera également dans le même sysroot afin que les 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 permettant de charger 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 sur le Web présente quelques problèmes:

  • La liaison dynamique des modules WebAssembly n'est pas prise en charge de manière standard. Emscripten dispose d'une implémentation personnalisée qui peut simuler l'API dlopen() utilisée par libtool, mais nécessite que vous créiez "main" et "côté" modules avec des indicateurs différents et, en particulier pour dlopen(), également pour 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 le dlopen() lui-même 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.

Vous pouvez adapter le système de compilation à ces différences et coder en dur la liste des plug-ins dynamiques pendant la compilation, mais pour résoudre tous ces problèmes encore plus facilement, évitez d'abord les liens dynamiques.

Il s'avère que libtool élimine diverses méthodes de liens dynamiques sur différentes plates-formes et prend même en charge l'écriture de chargeurs personnalisés pour d'autres. L'un des chargeurs intégrés compatibles est appelé Dlpreopening:

"Libtool offre une prise en charge spéciale pour le déployage d'objets libtool et de fichiers de bibliothèque libtool, de sorte que leurs symboles peuvent être résolus même sur les plates-formes sans fonctions dlopen et dlsym.
...
Libtool émule -dlopen sur des 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 abandonner en utilisant les indicateurs -dlopen ou -dlpreopen lorsque vous associez votre programme (voir Mode Lien)."

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

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

  • Côté 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 en série ou de clé USB.
  • Du côté des camlibs, il existe plusieurs plug-ins spécifiques au fournisseur qui peuvent fournir des fonctions spécifiques. Toutefois, pour le contrôle et la capture des paramètres généraux, il suffit d'utiliser le protocole de transfert d'image, représenté par le camlib ptp2 et pris en charge par presque toutes les caméras du marché.

Voici à quoi ressemble le diagramme de dépendances mis à jour, avec tous les éléments reliés de manière statique ensemble:

Un schéma montre "l'application" en fonction de "libgphoto2 fork", qui dépend de "libtool". "libtool" dépend de "ports: libusb1" et "camlibs: libptp2". "ports: libusb1" dépend de la "fork libusb".

C'est 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, je devais maintenant ajouter -dlpreopen avec ces deux fichiers en tant qu'indicateurs de lien pour tous les exécutables (exemples, tests et ma propre application de démonstration), comme ceci:

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 même bibliothèque, libtool a besoin d'un moyen de déterminer quel symbole appartient à quelle bibliothèque. Pour ce faire, les développeurs doivent renommer tous les symboles exposés, tels que {function name}, en {library name}_LTX_{function name}. Pour ce faire, le moyen le plus simple consiste à utiliser #define pour redéfinir les noms des 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 de nommage évite également les conflits de noms au cas où je déciderais 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'interface utilisateur 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 premier niveau <ph type="x-smartling-placeholder">
      </ph>
    • Sections : groupes nommés d'autres widgets
    • Champs du 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, dans le cas de valeurs, également modifiés) via l'API C exposée. Ensemble, ils fournissent une base pour générer automatiquement une UI de paramètres dans n'importe quel langage 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 accessible en écriture en M (mode manuel), mais devient un champ informatif en lecture seule en P (mode de programmation). 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 en permanence dans l'interface utilisateur les informations à jour provenant de la caméra connectée, tout en permettant à l'utilisateur de modifier ces paramètres depuis cette même interface. 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 maintenir l'interface utilisateur à jour sans clignoter ni perdre le curseur ou la position de défilement, j'avais besoin d'un moyen de différencier les arborescences de widgets entre les appels et de ne mettre à jour que les propriétés de l'interface utilisateur modifiées. Heureusement, il s'agit d'un problème résolu sur le Web. Il s'agit de la fonctionnalité de base des frameworks tels que React ou Preact. J'ai choisi Preact pour ce projet, car il est beaucoup plus léger et répond à tout ce dont j'ai besoin.

Du côté de C++, j'ai maintenant dû récupérer et parcourir de manière récursive l'arborescence des paramètres via l'API C associée précédemment, puis 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;
    }
    // …

Du 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'interface utilisateur 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 à plusieurs reprises 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 se charger de comparer les résultats et de mettre à jour le DOM uniquement pour les bits modifiés de l'interface utilisateur, sans perturber la sélection de la page ni l'état de la modification. Le problème persiste est 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 comprendre les données et de les comparer entre les réexécutions. Mais je vais aller à l'encontre de cette attente en autorisant une source externe, l'appareil photo, à mettre à jour l'interface utilisateur 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}));
  }
}

De cette façon, il n'y a toujours qu'un seul propriétaire pour un champ donné. Soit l'utilisateur est en train de modifier le champ, ce qui ne sera pas perturbé par les nouvelles valeurs de l'appareil photo, ou l'appareil photo met à jour la valeur du champ alors qu'il est flou.

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

Pendant la pandémie, de nombreuses personnes sont passées aux réunions en ligne. Cela a entraîné, entre autres, des pénuries sur le marché des webcams. Afin de bénéficier d'une meilleure qualité vidéo par rapport aux appareils photo intégrés dans les ordinateurs portables, 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 webcams en réponse à ces pénuries. Plusieurs fournisseurs d'appareils photo ont même expédié des services officiels à cette fin.

Comme les outils officiels, gPhoto2 prend en charge le streaming vidéo de l'appareil photo vers un fichier stocké en local ou directement sur une webcam virtuelle. Je voulais utiliser cette fonctionnalité pour proposer une vidéo en direct dans ma démonstration. Cependant, bien qu'elle soit disponible dans l'utilitaire de la console, je ne l'ai trouvée 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 à l'idée de pouvoir obtenir les mêmes performances dans l'application Web, avec toutes les abstractions supplémentaires et Asyncify. J'ai quand même décidé d'essayer.

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

Du côté JavaScript, j'ai une boucle, semblable à celle dans gPhoto2, qui continue de récupérer les images d'aperçu sous forme de Blob, les décode en arrière-plan avec createImageBitmap, puis les transfère vers le canevas sur l'image d'animation suivante:

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, le message "l'appareil est occupé" s'affiche généralement. . Étant donné que l'aperçu et l'interface utilisateur des paramètres sont régulièrement mis à jour, et que l'utilisateur tente peut-être de capturer une image ou de modifier des paramètres en même temps, ces 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 cela, 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 associant chaque opération dans un rappel then() de la promesse queue existante et en stockant le résultat chaî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.

Toute erreur d'opération est renvoyée à l'appelant, tandis que les erreurs critiques (inattendues) marquent l'ensemble de la chaîne comme une promesse refusée et garantissent 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 maintenant être encapsulé dans un appel schedule() comme ceci:

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

et

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

Après cela, toutes les opérations s'exécutaient correctement sans conflit.

Conclusion

N'hésitez pas à parcourir le codebase sur GitHub pour obtenir plus d'informations 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 le montre ces articles, les API WebAssembly, Asyncify et Fugu fournissent 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.