Portabilidad de aplicaciones USB a la Web Parte 2: gPhoto2

Descubre cómo se transfirió gPhoto2 a WebAssembly para controlar cámaras externas mediante USB desde una app web.

En la publicación anterior, mostré cómo se portó la biblioteca libusb para que se ejecute en la Web con WebAssembly / Emscripten, Asyncify y WebUSB.

También mostré una demostración creada con gPhoto2 que puede controlar cámaras DSLR y sin espejo mediante USB desde una aplicación web. En esta publicación, profundizaré en los detalles técnicos del puerto de gPhoto2.

Cómo apuntar sistemas de compilación a bifurcaciones personalizadas

Como estaba apuntando a WebAssembly, no pude usar libusb ni libgphoto2 que proporcionan las distribuciones del sistema. En cambio, necesitaba que mi aplicación usara la bifurcación personalizada de libgphoto2, mientras que la bifurcación de libgphoto2 tenía que usar la bifurcación personalizada de libusb.

Además, libgphoto2 usa libtool para cargar complementos dinámicos y, aunque no tuve que bifurcar libtool como las otras dos bibliotecas, tuve que compilarlo en WebAssembly y apuntar libgphoto2 a esa compilación personalizada en lugar del paquete del sistema.

Este es un diagrama de dependencia aproximado (las líneas punteadas denotan vínculos dinámicos):

Un diagrama muestra "la app" según "bifurca de libgphoto2", que depende de "libtool". El bloque "libtool" depende de forma dinámica de "puertos de libgphoto2" y "libgphoto2 camlibs". Por último, los puertos de libgphoto2 dependen estáticamente de la bifurcación libusb.

La mayoría de los sistemas de compilación basados en la configuración, incluidos los que se usan en estas bibliotecas, permiten anular rutas de acceso para dependencias a través de varias marcas, así que eso es lo que intenté hacer primero. Sin embargo, cuando el gráfico de dependencias se vuelve complejo, la lista de anulaciones de rutas de acceso para las dependencias de cada biblioteca se vuelve detallada y propensa a errores. También encontré algunos errores en los que los sistemas de compilación no estaban preparados para que sus dependencias vivieran en rutas no estándar.

En cambio, un enfoque más sencillo es crear una carpeta separada como una raíz de sistema personalizada (a menudo abreviada como "sysroot") y apuntar a ella todos los sistemas de compilación involucrados. De esta manera, cada biblioteca buscará sus dependencias en el sysroot especificado durante la compilación y también se instalará en el mismo sysroot para que otros puedan encontrarla más fácilmente.

Emscripten ya tiene su propio sysroot en (path to emscripten cache)/sysroot, que usa para sus bibliotecas de sistema, puertos de Emscripten y herramientas como CMake y pkg-config. También elegí reutilizar el mismo sysroot para mis dependencias.

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

Con esa configuración, solo tuve que ejecutar make install en cada dependencia, que lo instaló en sysroot y, luego, las bibliotecas se encontraron entre sí automáticamente.

Cómo abordar la carga dinámica

Como se mencionó anteriormente, libgphoto2 usa libtool para enumerar y cargar dinámicamente adaptadores de puertos de E/S y bibliotecas de cámaras. Por ejemplo, el código para cargar bibliotecas de E/S se ve de la siguiente manera:

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

En la Web, este enfoque presenta algunos problemas:

  • No hay compatibilidad estándar para la vinculación dinámica de módulos de WebAssembly. Emscripten tiene su implementación personalizada que puede simular la API de dlopen() que usa libtool, pero requiere que compiles módulos "principal" y "lateral" con diferentes marcas y, específicamente para dlopen(), también que precargas los módulos laterales en el sistema de archivos emulado durante el inicio de la aplicación. Puede resultar difícil integrar esos indicadores y ajustes en un sistema de compilación de autoconf existente con muchas bibliotecas dinámicas.
  • Incluso si se implementa dlopen(), no hay forma de enumerar todas las bibliotecas dinámicas de una carpeta determinada en la Web, ya que la mayoría de los servidores HTTP no exponen listas de directorios por motivos de seguridad.
  • Vincular bibliotecas dinámicas en la línea de comandos en lugar de enumerarlas en el tiempo de ejecución también puede generar problemas, como el problema de símbolos duplicados, que se deben a diferencias entre la representación de bibliotecas compartidas en Emscripten y otras plataformas.

Es posible adaptar el sistema de compilación a esas diferencias y codificar la lista de complementos dinámicos en algún lugar durante la compilación, pero una manera aún más fácil de resolver todos estos problemas es evitar la vinculación dinámica desde el principio.

Al parecer, libtool abstrae varios métodos de vinculación dinámica en diferentes plataformas e incluso admite la escritura de cargadores personalizados para otras. Uno de los cargadores integrados que admite se denomina "Dlpreopening":

"Libtool brinda compatibilidad especial para dlopening de objetos libtool y archivos de bibliotecas libtool, por lo que sus símbolos se pueden resolver incluso en plataformas sin funciones dlopen y dlsym.
...
Libtool emula -dlopen en plataformas estáticas mediante la vinculación de objetos con el programa en el tiempo de compilación y la creación de estructuras de datos que representan la tabla de símbolos del programa. Si quieres usar esta función, debes declarar los objetos que quieres que tu aplicación use como dlopen mediante las marcas -dlopen o -dlpreopen cuando vincules tu programa (consulta Modo de vínculo)".

Este mecanismo permite emular la carga dinámica a nivel de libtool en lugar de usar Emscripten y, al mismo tiempo, vincula todo de forma estática en una única biblioteca.

El único problema que esto no resuelve es la enumeración de bibliotecas dinámicas. La lista de esos todavía debe estar codificada en alguna parte. Por suerte, el conjunto de complementos que necesitaba para la aplicación es mínimo:

  • En cuanto a los puertos, solo me importa la conexión de la cámara basada en libusb y no los modos PTP/IP, acceso en serie ni unidad USB.
  • En lo que respecta a las camlibs, existen varios complementos específicos de proveedores que pueden proporcionar algunas funciones especializadas, pero para controlar y capturar imágenes en general, basta con usar el Protocolo de transferencia de imágenes, que se representa con camlib ptp2 y es compatible prácticamente con todas las cámaras del mercado.

Así se ve el diagrama de dependencia actualizado con todo vinculado de forma estática:

Un diagrama muestra "la app" según "bifurca de libgphoto2", que depende de "libtool". "libtool" depende de "ports: libusb1" y "camlibs: libptp2". “ports: libusb1” depende de “bifurcación libusb”.

Eso es lo que codifiqué para las compilaciones de 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 ();

y

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

En el sistema de compilación de autoconf, ahora tuve que agregar -dlpreopen con ambos archivos como marcas de vínculo para todos los ejecutables (ejemplos, pruebas y mi propia app de demostración), de la siguiente manera:

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

Por último, ahora que todos los símbolos están vinculados estáticamente en una única biblioteca, libtool necesita una forma de determinar qué símbolo pertenece a qué biblioteca. Para lograrlo, los desarrolladores deben cambiar el nombre de todos los símbolos expuestos, como {function name}, a {library name}_LTX_{function name}. La forma más fácil de hacerlo es con #define para redefinir los nombres de símbolos en la parte superior del archivo de implementación:

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

Este esquema de nomenclatura también evita conflictos de nombres en caso de que decida vincular complementos específicos de la cámara en la misma app en el futuro.

Después de implementar todos estos cambios, pude compilar la aplicación de prueba y cargar los complementos correctamente.

Cómo generar la IU de configuración

gPhoto2 permite que las bibliotecas de cámara definan sus propias opciones de configuración en una forma de árbol de widgets. La jerarquía de tipos de widgets consta de los siguientes elementos:

  • Ventana: contenedor de configuración de nivel superior
    • Secciones: grupos con nombre de otros widgets
    • Campos de botones
    • Campos de texto
    • Campos numéricos
    • Campos de fecha
    • Botones de activación
    • Botones de selección

El nombre, el tipo, los elementos secundarios y todas las demás propiedades relevantes de cada widget se pueden consultar (y, en el caso de los valores, también modificarse) a través de la API de C expuesta. En conjunto, proporcionan una base para generar automáticamente la IU de configuración en cualquier lenguaje que pueda interactuar con C.

Los parámetros de configuración se pueden cambiar a través de gPhoto2 o desde la cámara en cualquier momento. Además, algunos widgets pueden ser de solo lectura, e incluso el estado de solo lectura depende del modo de cámara y otros parámetros de configuración. Por ejemplo, velocidad de obturación es un campo numérico que admite escritura en M (modo manual), pero se convierte en un campo de solo lectura informativo en P (modo de programa). En el modo P, el valor de la velocidad del obturador también será dinámico y cambiará de forma continua según el brillo de la escena que esté mirando la cámara.

En general, es importante mostrar siempre información actualizada de la cámara conectada en la IU y, al mismo tiempo, permitir que el usuario edite esos parámetros de configuración desde la misma IU. Este flujo bidireccional de datos es más complejo de manejar.

gPhoto2 no tiene un mecanismo para recuperar solo las configuraciones modificadas, solo el árbol completo o los widgets individuales. Para mantener la IU actualizada sin parpadear ni perder el enfoque de entrada o la posición de desplazamiento, necesitaba una forma de diferenciar los árboles de widgets entre las invocaciones y actualizar solo las propiedades de la IU modificadas. Afortunadamente, se trata de un problema resuelto en la Web y es la funcionalidad principal de frameworks como React o Preact. En este proyecto me decidí por Preact, ya que es mucho más ligero y hace todo lo que necesito.

En lo que respecta a C++, ahora necesitaba recuperar y recorrer de manera recursiva el árbol de configuración a través de la API de C vinculada anteriormente, además de convertir cada widget en un objeto de 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;
    }
    // …

En el lado de JavaScript, ahora podía llamar a configToJS, revisar la representación de JavaScript que se muestra del árbol de configuración y compilar la IU mediante la función de 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;
  }
  // …

Si ejecuto esta función de forma repetida en un bucle de eventos infinito, puedo hacer que la IU de configuración muestre siempre la información más reciente y, al mismo tiempo, envíe comandos a la cámara cada vez que el usuario edite uno de los campos.

Preact puede diferenciar los resultados y actualizar el DOM solo para los bits modificados de la IU, sin interrumpir el enfoque de la página ni los estados de edición. Un problema que persiste es el flujo bidireccional de datos. Los frameworks como React y Preact se diseñaron en torno al flujo unidireccional de datos, ya que facilita mucho razonar sobre los datos y compararlos entre repeticiones, pero rompo esa expectativa al permitir que una fuente externa, la cámara, actualice la IU de configuración en cualquier momento.

Solucioné este problema inhabilitando las actualizaciones de la IU para los campos de entrada que el usuario está editando actualmente:

/**
 * 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 esta manera, siempre hay un solo propietario para un campo determinado. El usuario lo está editando y no se verá interrumpido por los valores actualizados de la cámara, o bien la cámara está actualizando el valor del campo mientras está fuera de foco.

Cómo crear un feed de "video" en vivo

Durante la pandemia, muchas personas comenzaron a realizar reuniones en línea. Entre otras cosas, esto produjo una escasez en el mercado de las cámaras web. Para obtener una mejor calidad de video en comparación con las cámaras integradas en las laptops, y en respuesta a la escasez de esas cámaras, muchos propietarios de cámaras DSLR y sin espejo comenzaron a buscar formas de usar sus cámaras fotográficas como cámaras web. Varios proveedores de cámaras incluso enviaron servicios públicos oficiales para ese fin.

Al igual que las herramientas oficiales, gPhoto2 admite la transmisión de video desde la cámara a un archivo almacenado localmente o directamente a una cámara web virtual. Quería usar esa función para brindar una visualización en vivo en mi demostración. Sin embargo, si bien está disponible en la utilidad de la consola, no la encuentro en ninguna parte de las APIs de la biblioteca libgphoto2.

Al revisar el código fuente de la función correspondiente en la utilidad de la consola, descubrí que en realidad no obtiene un video, sino que sigue recuperando la vista previa de la cámara como imágenes JPEG individuales en un bucle infinito y escribiéndolas una por una para formar una transmisión M-JPEG:

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

Me sorprendió que este enfoque funcione con la eficiencia suficiente como para generar una impresión de videos en tiempo real sin problemas. Tenía aún más sus dudas sobre la posibilidad de hacer coincidir el mismo rendimiento en la aplicación web, con todas las abstracciones adicionales y Asyncify en el camino. Sin embargo, decidí intentarlo de todas formas.

En el lado de C++, expuse un método llamado capturePreviewAsBlob() que invoca la misma función gp_camera_capture_preview() y convierte el archivo en la memoria resultante en un Blob que se puede pasar a otras APIs web con mayor facilidad:

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

En el lado de JavaScript, tengo un bucle, similar al de gPhoto2, que sigue recuperando las imágenes de vista previa como Blob, las decodifica en segundo plano con createImageBitmap y las transfiere al lienzo en el siguiente fotograma de animación:

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

El uso de esas APIs modernas garantiza que todo el trabajo de decodificación se realice en segundo plano, y que el lienzo se actualice solo cuando la imagen y el navegador estén completamente preparados para dibujar. Esto consiguió una velocidad uniforme de más de 30 FPS en mi laptop, lo que coincidió con el rendimiento nativo de gPhoto2 y del software oficial de Sony.

Sincronización del acceso USB

Cuando se solicita una transferencia de datos por USB mientras otra operación ya está en curso, por lo general, se muestra un error que indica que el dispositivo está ocupado. Debido a que la vista previa y la IU de configuración se actualizan con regularidad, y es posible que el usuario esté intentando capturar una imagen o modificar la configuración al mismo tiempo, estos conflictos entre diferentes operaciones resultaron ser muy frecuentes.

Para evitarlos, tuve que sincronizar todos los accesos dentro de la aplicación. Para eso, creé una cola asíncrona basada en promesas:

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

Si encadenas cada operación en una devolución de llamada then() de la promesa queue existente y almacenas el resultado en cadena como el nuevo valor de queue, puedo asegurarme de que todas las operaciones se ejecuten una por una, en orden y sin superposiciones.

Cualquier error de operación se muestra al llamador, mientras que los errores críticos (inesperados) marcan toda la cadena como una promesa rechazada y garantizan que no se programe ninguna operación nueva más adelante.

Cuando se mantiene el contexto del módulo en una variable privada (no exportada), minimiza los riesgos de acceder a context por accidente en otro lugar de la app sin pasar por la llamada schedule().

Para vincular todo, ahora cada acceso al contexto del dispositivo debe unirse en una llamada schedule() de la siguiente manera:

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

y

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

Después de eso, todas las operaciones se estaban ejecutando correctamente sin conflictos.

Conclusión

Explora la base de código en GitHub para obtener más información sobre la implementación. También quiero agradecer a Marcus Meissner por el mantenimiento de gPhoto2 y por sus opiniones sobre mis PR anteriores.

Como se muestra en estas publicaciones, las APIs de WebAssembly, Asyncify y Fugu proporcionan un objetivo de compilación capaz de compilar incluso las aplicaciones más complejas. Te permiten tomar una biblioteca o aplicación compilada previamente para una única plataforma y transferirla a la Web, lo que permite que esté disponible para una gran cantidad de usuarios en computadoras de escritorio y dispositivos móviles por igual.