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 realizó la portabilidad de 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 DSLR y sin espejo mediante USB desde una aplicación web. En esta publicación, profundizaré en los detalles técnicos detrás de la portabilidad de gPhoto2.

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

Como estaba orientando a WebAssembly, no podía usar los archivos libusb y 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 debía 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.

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

Un diagrama muestra “la app” según "libgphoto2 fork", que depende de "libtool". "libtool" El bloque depende dinámicamente de los "puertos de libgphoto2" y "libgphoto2 camlibs". Por último, los puertos libgphoto2 depende 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 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 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 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 de sistema, puertos de Emscripten y herramientas como CMake y pkg-config. 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 necesitaba ejecutar make install en cada dependencia, que lo instaló en el sysroot. Luego, las bibliotecas se encontraron automáticamente.

Cómo abordar la carga dinámica

Como se mencionó anteriormente, libgphoto2 usa libtool para enumerar y cargar de forma dinámica los adaptadores de puertos de E/S y las bibliotecas de cámara. Por ejemplo, el código para cargar bibliotecas de E/S tiene el siguiente aspecto:

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 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 "main" y "lateral" módulos con diferentes marcas y, específicamente para dlopen(), también precargar 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, causados por 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 forma aún más fácil de resolver todos esos problemas es evitar la vinculación dinámica desde el principio.

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. Uno de los cargadores integrados compatibles se denomina "Dlpreopening":

"Libtool proporciona compatibilidad especial para dlopening de objetos libtool y archivos de biblioteca 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 quieres que tu aplicación dlopen con las marcas -dlopen o -dlpreopen cuando vincules el programa (consulta Modo de vínculo)”.

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

El único problema que esto no resuelve es la enumeración de bibliotecas dinámicas. La lista aún debe estar codificada en algún lugar. Afortunadamente, 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 de PTP/IP, acceso en serie ni unidades 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.

Así se ve el diagrama de dependencias actualizado con todo vinculado estáticamente:

Un diagrama muestra “la app” según "libgphoto2 fork", que depende de "libtool". "libtool" depende de "ports: libusb1" y "camlibs: libptp2". "ports: libusb1" depende de la "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 sola biblioteca, libtool necesita una forma de determinar qué símbolo pertenece a cada 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 sus propios parámetros de configuración en forma de árbol de widgets. La jerarquía de los tipos de widgets consta de lo siguiente:

  • 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 caso de valores, también modificar) a través de la API de C expuesta. Juntos, proporcionan una base para generar automáticamente la IU de configuración en cualquier idioma que pueda interactuar con C.

Los ajustes pueden modificarse en cualquier momento con gPhoto2 o en la propia cámara. Además, algunos widgets pueden ser de solo lectura, e incluso el estado de solo lectura depende del modo de la cámara y otras configuraciones. Por ejemplo, velocidad del obturador es un campo numérico que admite escritura 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 del obturador también será dinámico y cambiará continuamente según el brillo de la escena que mire la cámara.

En definitiva, es importante mostrar siempre información actualizada de la cámara conectada en la IU y, al mismo tiempo, permitir que el usuario edite esa 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. Por suerte, este es un problema resuelto en la Web y es la funcionalidad principal de frameworks como React o Preact. Elegí Preact para este proyecto, ya que es mucho más ligero y hace todo lo que necesito.

En cuanto 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, y 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 puedo 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 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;
  }
  // …

Ejecutar esta función de forma repetida en un bucle de eventos infinitos permite 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 edita uno de los campos.

Preact puede encargarse de 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, 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 para un 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 actualiza el valor del campo mientras está fuera de foco.

Cómo crear un "video" en vivo feed

Durante la pandemia, muchas personas pasaron a 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 laptops y, en respuesta a esta escasez, muchos propietarios de cámaras DSLR y sin espejo empezaron a buscar formas de usar sus cámaras de fotografía 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, aunque está disponible en la utilidad de la consola, no pude encontrarlo 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 lo suficientemente eficaz como para obtener una impresión de videos fluidos en tiempo real. Yo también era más escéptico con respecto a poder 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 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 tanto la imagen como el navegador estén completamente preparados para dibujar. Esto logró una velocidad constante de 30 FPS o más en mi laptop, que igualaba el rendimiento nativo de gPhoto2 y del software oficial de Sony.

Cómo sincronizar el acceso USB

Cuando se solicita una transferencia de datos por USB mientras otra operación ya está en curso, por lo general, hace que el dispositivo esté ocupado. . Dado que la vista previa y la IU de configuración se actualizan regularmente, y es posible que el usuario intente capturar una imagen o modificar la configuración al mismo tiempo, estos conflictos entre las diferentes operaciones resultaron ser muy frecuentes.

Para evitarlos, debía 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 encadenado 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.

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

Para vincular las cosas, 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 ejecutaron correctamente sin conflictos.

Conclusión

Puedes 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 sus revisiones de mis PR de nivel superior.

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