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

Découvrez comment gPhoto2 a été transféré vers WebAssembly pour contrôler des caméras externes via USB depuis une application Web.

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

J'ai également montré une démonstration intégrant gPhoto2, qui permet de contrôler les appareils photo reflex numériques et sans miroir via USB depuis une application Web. Dans ce post, je vais vous présenter plus en détail les détails techniques concernant le port gPhoto2.

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

Comme je ciblais WebAssembly, je n'ai pas pu 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 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 à dupliquer libtool comme pour les deux autres bibliothèques, je devais quand même le créer vers WebAssembly et pointer libgphoto2 vers ce build personnalisé plutôt que vers le package système.

Voici un diagramme des dépendances approximatifs (les lignes en pointillés représentent les liens dynamiques):

Schéma représentant "l'application" en fonction de "libgphoto2 fork", qui dépend de "libtool". Le bloc "libtool" dépend de manière dynamique de "libgphoto2 ports" et de "libgphoto2 camlibs". Pour finir, "libgphoto2 ports" dépend de la "bibliothèque libusb" de manière statique.

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 aux 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 chemin pour les dépendances de chaque bibliothèque devient détaillée et source d'erreurs. J'ai également trouvé des bugs où les systèmes de compilation n'étaient pas préparés pour 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 "sysroot") et à y faire pointer tous les systèmes de compilation impliqués. Ainsi, chaque bibliothèque recherchera ses dépendances dans le sysroot spécifié lors de la compilation et s'installera également dans le même répertoire sysroot afin que les autres puissent la trouver plus facilement.

Emscripten dispose déjà de son propre sysroot sous (path to emscripten cache)/sysroot. Il l'utilise pour ses bibliothèques système, ses ports Emscripten et ses 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, qui l'a installé sous sysroot. Les bibliothèques se sont ensuite retrouvées automatiquement.

Gérer le chargement dynamique

Comme indiqué ci-dessus, libgphoto2 utilise libtool pour énumérer et charger dynamiquement les adaptateurs de ports d'E/S et les bibliothèques d'appareils photo. Par exemple, le code pour charger les 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 quelques problèmes sur le Web:

  • L'association dynamique des modules WebAssembly n'est pas standard. Emscripten dispose d'une implémentation personnalisée qui permet de simuler l'API dlopen() utilisée par libtool. Cependant, elle nécessite de créer des modules "main" et "side" avec des indicateurs différents et, spécifiquement pour dlopen(), de 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 comportant 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 de 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 dans l'environnement d'exécution peut également entraîner des problèmes (par exemple, le problème de symboles en double), qui sont 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 pendant la compilation, mais un moyen encore plus simple de résoudre tous ces problèmes consiste à éviter les associations 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 assistance spéciale pour les fichiers d'objets libtool et dlopening et les fichiers de bibliothèques libtool, ce qui permet de résoudre leurs symboles 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 rendre dlopen à l'aide des 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 que dans Emscripten, tout en reliant tout de manière statique au sein d'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 m’intéresse uniquement à la connexion de la caméra avec libusb, et non aux modes PTP/IP, accès série ou clé USB.
  • Du côté de camlibs, il existe plusieurs 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 Picture Transfer Protocol, représenté par ptp2 camlib et pris en charge par presque tous les appareils photo du marché.

Voici à quoi ressemble le nouveau diagramme des dépendances, avec tous les éléments liés de manière statique:

Schéma représentant "l'application" en fonction de "libgphoto2 fork", qui dépend de "libtool". "libtool" dépend de "ports: libusb1" et de "camlibs: libptp2". "ports: libusb1" dépend de "libusb fork".

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 associés de manière statique dans une seule 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 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 de dénomination évite également les conflits de noms au cas où je déciderais d'associer à l'avenir des plug-ins spécifiques à l'appareil photo dans la même application.

Une fois toutes ces modifications effectué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 comprend les éléments suivants:

  • Fenêtre : conteneur de configuration de premier niveau
    • Sections : groupes nommés d'autres widgets
    • Champs de boutons
    • 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 une base pour générer automatiquement une UI de paramètres dans n'importe quel langage pouvant interagir avec C.

Vous pouvez à tout moment modifier les paramètres via gPhoto2 ou directement sur l'appareil photo. De plus, certains widgets peuvent être en lecture seule, et même l'état de 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 accessible en écriture en M (mode manuel), mais devient un champ d'information en lecture seule dans P (mode 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 par l'appareil photo.

Dans l'ensemble, il est important que l'interface utilisateur affiche toujours des informations à jour provenant de la caméra connectée, tout en permettant à l'utilisateur de modifier ces paramètres depuis la même interface. Ce flux de données bidirectionnel est plus complexe à gérer.

gPhoto2 ne dispose pas d'un mécanisme permettant de récupérer uniquement les paramètres modifiés, mais uniquement l'arborescence entière ou des widgets individuels. Pour maintenir l'UI à jour sans clignoter ni perdre la sélection d'entrée 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 d'interface utilisateur modifiées. Heureusement, ce problème est résolu sur le Web, et il s'agit de la fonctionnalité de base des frameworks tels que React ou Preact. Pour ce projet, j'ai choisi Preact, car il est beaucoup plus léger et répond à tous mes besoins.

Du côté C++, je devais maintenant récupérer et parcourir l'arborescence de paramètres de manière récursive via l'API C lié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é de JavaScript, je peux maintenant appeler configToJS, parcourir la représentation JavaScript renvoyée pour 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 plusieurs fois dans une boucle d'événements infinie, j'ai pu faire en sorte que l'interface utilisateur des paramètres affiche toujours les informations les plus récentes, tout en envoyant des commandes à l'appareil photo chaque fois que l'un des champs est modifié par l'utilisateur.

Preact peut différencier les résultats et mettre à jour le DOM uniquement pour les bits modifiés de l'interface utilisateur, sans perturber la mise au point de la page ni les états de modification. Un problème subsiste : 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 les données et de les comparer entre les rediffusions. Mais je rompre cette attente en autorisant une source externe, l'appareil photo, à mettre à jour l'interface utilisateur des paramètres à tout moment.

Pour contourner ce problème, nous avons désactivé 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 par champ. Soit l'utilisateur est en train de le modifier, et les valeurs mises à jour par l'appareil photo ne sont pas perturbées, soit l'appareil photo met à jour la valeur du champ alors qu'il n'est pas focalisé.

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

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. Pour améliorer la qualité vidéo par rapport aux appareils photo intégrés dans les ordinateurs portables, et en réponse à ces pénuries, de nombreux propriétaires d'appareils photo reflex numériques et sans miroir ont commencé à chercher des solutions pour utiliser leurs appareils photo en tant que webcams. Plusieurs fournisseurs d'appareils photo ont même expédié des fournisseurs officiels à cet effet.

Tout comme les outils officiels, gPhoto2 prend en charge le streaming vidéo de l'appareil photo vers un fichier stocké en local ou directement vers une webcam virtuelle. Je voulais utiliser cette fonctionnalité pour visionner en direct ma démo. Toutefois, bien qu'il soit disponible dans l'utilitaire de la 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'au lieu de cela, il continue à récupérer l'aperçu de la caméra 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é surpris de constater que cette approche fonctionne suffisamment bien pour donner l'impression de fluidité de la vidéo 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.

Du 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, lequel 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é de JavaScript, j'ai une boucle, semblable à celle de gPhoto2, qui continue de récupérer les images d'aperçu en tant que Blobs, les décode en arrière-plan avec createImageBitmap et 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 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éparés pour le dessin. L'ordinateur portable a ainsi obtenu plus de 30 FPS, ce qui correspond aux performances natives de gPhoto2 et du logiciel Sony officiel.

Synchroniser l'accès USB

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

Pour éviter cela, 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 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 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 limite les risques d'accès accidentel à context ailleurs dans l'application, sans passer par l'appel schedule().

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

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

et

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

Ensuite, toutes les opérations se sont correctement exécutées, sans conflits.

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 l'entretien de gPhoto2 et les avis qu'il a publiés sur mes relations publiques en amont.

Comme le montrent ces articles, les API WebAssembly, Asyncify et Fugu fournissent une cible de compilation performante, même pour les applications les plus complexes. Ils vous permettent de transférer sur le Web une bibliothèque ou une application précédemment conçue pour une plate-forme unique, afin de la rendre accessible à un plus grand nombre d'utilisateurs, sur ordinateur comme sur appareil mobile.