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.

J'ai également présenté une démonstration créée avec gPhoto2, qui permet de contrôler des appareils photo reflex et 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.

Comme je ciblais WebAssembly, je ne pouvais pas utiliser libusb et libgphoto2 fournis par les distributions système. Au lieu de cela, mon application devait utiliser ma fourchette personnalisée de libgphoto2, tandis que cette fourchette de libgphoto2 devait utiliser ma fourchette personnalisée de libusb.

De plus, libgphoto2 utilise libtool pour charger des plug-ins dynamiques. Même si je n'ai pas eu à créer une fourchette de libtool comme pour les deux autres bibliothèques, je devais quand même le compiler en WebAssembly et diriger libgphoto2 vers cette compilation personnalisée au lieu du paquet 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 dynamiquement des ports libgphoto2 et des camlibs libgphoto2. Enfin, les ports libgphoto2 dépendent de manière statique de la "fourchette libusb".

La plupart des systèmes de compilation basés sur la configuration, y compris ceux utilisés dans ces bibliothèques, permettent de remplacer les chemins d'accès des dépendances à l'aide de divers indicateurs. C'est donc ce que j'ai essayé de faire en premier. Toutefois, lorsque le graphe des dépendances devient complexe, la liste des forçages de chemin d'accès pour les dépendances de chaque bibliothèque devient longue 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.

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 cette configuration, il ne m'a suffi que d'exécuter make install dans chaque dépendance, ce qui l'a installé sous le 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 dispose de son implémentation personnalisée qui peut simuler l'API dlopen() utilisée par libtool, mais vous devez créer des modules "principals" et "secondaires" avec des indicateurs différents, et, spécifiquement pour dlopen(), également précharger les modules secondaires dans le système de fichiers émulé au démarrage de l'application. Il peut être difficile d'intégrer ces indicateurs et ajustements dans un système de compilation autoconf existant avec 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 certain dossier sur le Web, car la plupart des serveurs HTTP n'exposent pas les listes de répertoires pour des raisons de sécurité.
  • L'association de 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, tels que le problème de symboles en double, qui sont causés par des différences entre la 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 pendant la compilation. Toutefois, une méthode encore plus simple pour résoudre tous ces problèmes consiste à éviter la liaison dynamique dès le départ.

Il s'avère que libtool gère 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 qu'il prend en charge 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 des 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 le contrôle et la capture des 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 de "camlibs: libptp2". "ports: libusb1" dépend de la "fourche libusb".

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'un arbre 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 sur l'appareil photo lui-même. De plus, certains widgets peuvent être en lecture seule, et même l'état en lecture seule dépend du mode de l'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 que la caméra observe.

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 pas de mécanisme permettant de ne récupérer que les paramètres modifiés, mais uniquement l'ensemble de l'arborescence ou des 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 peux obtenir que l'UI des paramètres affiche toujours les dernières informations, tout en envoyant des commandes à la caméra 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'UI pour tous les champs de saisie actuellement 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. Pour obtenir une meilleure qualité vidéo que celle des webcams intégrées aux ordinateurs portables et en réponse à ces pénuries, de nombreux propriétaires d'appareils photo reflex et sans miroir ont commencé à chercher des moyens d'utiliser leurs appareils photo comme webcams. 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 console, j'ai constaté qu'elle n'obtenait pas du tout de vidéo, mais qu'elle continue à récupérer l'aperçu de l'appareil photo sous forme d'images JPEG individuelles dans une boucle sans fin, et à les écrire 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 sur le chemin. 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 est effectué en arrière-plan et que le canevas n'est mis à jour que lorsque l'image et le navigateur sont entièrement prêts à dessiner. J'ai ainsi obtenu plus de 30 FPS de manière constante sur mon ordinateur portable, ce qui correspondait aux performances natives 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éder accidentellement à 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 ses examens de mes PR 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 prendre une bibliothèque ou une application précédemment créée pour une seule plate-forme et de la porter sur le Web, la rendant disponible pour un nombre beaucoup plus important d'utilisateurs sur les ordinateurs de bureau et les appareils mobiles.