Portabilidad de aplicaciones USB a la Web Parte 2: gPhoto2

Descubre cómo se portó gPhoto2 a WebAssembly para controlar cámaras externas a través de 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 compilada con gPhoto2 que puede controlar cámaras réflex digitales y sin espejo a través de USB desde una aplicación web. En esta publicación, profundizaré en los detalles técnicos detrás del puerto gPhoto2.

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

Como mi objetivo era WebAssembly, no pude usar libusb ni libgphoto2 que proporcionan las distribuciones del sistema. En cambio, necesitaba que mi aplicación usara mi bifurcación personalizada de libgphoto2, mientras que esa bifurcación de libgphoto2 tenía que usar mi 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, igual tuve que compilarlo en WebAssembly y dirigir libgphoto2 a esa compilación personalizada en lugar del paquete del sistema.

A continuación, se muestra un diagrama de dependencia aproximado (las líneas punteadas indican la vinculación dinámica):

En un diagrama, se muestra que "la app" depende de "la bifurcación de libgphoto2", que depende de "libtool". El bloque "libtool" depende dinámicamente de "libgphoto2 ports" y "libgphoto2 camlibs". Por último, los "puertos de libgphoto2" dependen de forma estática de la "bifurcación de 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 las rutas de acceso de las dependencias a través de diferentes 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 ruta de acceso para las dependencias de cada biblioteca se vuelve extensa y propensa a errores. También encontré algunos errores en los que los sistemas de compilación no estaban preparados para que sus dependencias residieran en rutas no estándar.

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

Emscripten ya tiene su propio sysroot en (path to emscripten cache)/sysroot, que usa para sus bibliotecas del sistema, los puertos de Emscripten y herramientas como CMake y pkg-config. También decidí volver a usar 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 la instaló en el sysroot, y luego las bibliotecas se encontraron automáticamente.

Cómo controlar 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ámara. Por ejemplo, el código para cargar bibliotecas de E/S se ve así:

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

Este enfoque tiene algunos problemas en la Web:

  • No hay compatibilidad estándar con 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 para cargar previamente 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 en una carpeta determinada de 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 produce por las diferencias entre la representación de bibliotecas compartidas en Emscripten y en 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 forma aún más fácil de resolver todos esos problemas es evitar la vinculación dinámica en primer lugar.

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

“Libtool proporciona compatibilidad especial para la carga dinámica de objetos y archivos de bibliotecas de libtool, de modo que sus símbolos se puedan resolver incluso en plataformas sin funciones dlopen ni dlsym.

Libtool emula -dlopen en plataformas estáticas vinculando objetos al programa en el tiempo de compilación y creando estructuras de datos que representan la tabla de símbolos del programa. Para usar esta función, debes declarar los objetos que deseas que tu aplicación dlopen con las marcas -dlopen o -dlpreopen cuando vincules tu programa (consulta Modo de vinculación).

Este mecanismo permite emular la carga dinámica a nivel de libtool en lugar de Emscripten, al tiempo que vincula todo de forma estática en una sola biblioteca.

El único problema que esto no resuelve es la enumeración de bibliotecas dinámicas. La lista de esos aún debe codificarse en algún lugar. Por suerte, el conjunto de complementos que necesitaba para la app es mínimo:

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

Este es el aspecto del diagrama de dependencias actualizado con todo vinculado de forma estática:

En un diagrama, se muestra que "la app" depende de "la bifurcación de libgphoto2", que depende de "libtool". "libtool" depende de "ports: libusb1" y "camlibs: libptp2". "ports: libusb1" depende de la "bifurcación de 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 vinculación 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 de forma estática en una sola biblioteca, libtool necesita una forma de determinar qué símbolo pertenece a qué biblioteca. Para lograr esto, los desarrolladores deben cambiar el nombre de todos los símbolos expuestos, como {function name}, por {library name}_LTX_{function name}. La manera más fácil de hacerlo es usar #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 nombres 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.

Genera la IU de configuración

gPhoto2 permite que las bibliotecas de cámaras definan su propia configuración en forma de árbol de widgets. La jerarquía de tipos de widgets consta de lo siguiente:

  • Ventana: Es un 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 modificar) 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.

La configuración se puede cambiar a través de gPhoto2 o en la cámara en cualquier momento. Además, algunos widgets pueden ser de solo lectura, y hasta el estado de solo lectura depende del modo de la cámara y de otros parámetros de configuración. Por ejemplo, la velocidad del obturador es un campo numérico que se puede escribir en M (modo manual), pero se convierte en un campo informativo de solo lectura en P (modo de programa). En el modo P, el valor de la velocidad de 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 resumen, 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 de datos bidireccional es más complejo de controlar.

gPhoto2 no tiene un mecanismo para recuperar solo la configuración modificada, solo el árbol completo o widgets individuales. Para mantener la IU actualizada sin parpadeos y sin perder el enfoque de entrada ni la posición de desplazamiento, necesitaba una forma de comparar los árboles de widgets entre las invocaciones y actualizar solo las propiedades de la IU que cambiaron. Por suerte, este es un problema resuelto en la Web y es la funcionalidad principal de frameworks como React o Preact. Para este proyecto, elegí Preact, ya que es mucho más liviana y hace todo lo que necesito.

En el lado de C++, ahora debía recuperar y recorrer recursivamente el árbol de configuración a través de la API de C vinculada anterior, y convertir cada widget en un objeto 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 podría llamar a configToJS, recorrer la representación de JavaScript que se muestra del árbol de configuración y compilar la IU a través de la función h de Preact:

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

Cuando ejecuté esta función de forma reiterada en un bucle de eventos infinito, pude hacer que la IU de configuración siempre mostrara la información más reciente y, al mismo tiempo, enviar comandos a la cámara cada vez que el usuario editaba uno de los campos.

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

Para solucionar este problema, inhabilité las actualizaciones de la IU en todos los campos de entrada que el usuario estaba 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 habrá un solo propietario de cualquier campo determinado. El usuario lo está editando en ese momento y los valores actualizados de la cámara no lo interrumpirán, o 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 se cambiaron a las reuniones en línea. Entre otras cosas, esto provocó 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 esas carencias, muchos propietarios de cámaras réflex digitales y sin espejo comenzaron a buscar formas de usar sus cámaras fotográficas como cámaras web. Varios proveedores de cámaras incluso enviaban utilidades oficiales para este propósito.

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

Al observar el código fuente de la función correspondiente en la utilidad de la consola, descubrí que, en realidad, no recibe ningún video, sino que sigue recuperando la vista previa de la cámara como imágenes JPEG individuales en un bucle infinito y las escribe 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 suficiente eficiencia para dar la impresión de un video fluido en tiempo real. Era aún más escéptico sobre si podría lograr el mismo rendimiento en la aplicación web, con todas las abstracciones adicionales y Asyncify en el camino. Sin embargo, decidí intentarlo de todos modos.

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 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 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 logró más de 30 FPS constantes en mi laptop, que coincidían con el rendimiento nativo de gPhoto2 y el software oficial de Sony.

Sincroniza el acceso USB

Cuando se solicita una transferencia de datos por USB mientras ya se está realizando otra operación, por lo general, se produce un error que indica que el dispositivo está ocupado. Dado que la vista previa y la IU de configuración se actualizan con frecuencia, y el usuario puede intentar 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 ello, 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 valor nuevo 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 devuelve al llamador, mientras que los errores críticos (inesperados) marcan toda la cadena como una promesa rechazada y garantizan que no se programen nuevas operaciones posteriormente.

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

Para unir todo, ahora cada acceso al contexto del dispositivo debe estar unido 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 ejecutaron correctamente sin conflictos.

Conclusión

No dudes en explorar la base de código en GitHub para obtener más estadísticas de implementación. También quiero agradecer a Marcus Meissner por el mantenimiento de gPhoto2 y por revisar mis PR upstream.

Como se muestra en estas publicaciones, las APIs de WebAssembly, Asyncify y Fugu proporcionan un destino de compilación capaz incluso para las aplicaciones más complejas. Te permiten tomar una biblioteca o una aplicación compilada anteriormente para una sola plataforma y transferirla a la Web, lo que la pone a disposición de una cantidad mucho mayor de usuarios en computadoras de escritorio y dispositivos móviles.