Portabilidade de aplicativos USB para a Web. Parte 2: gPhoto2

Saiba como o gPhoto2 foi transferido para o WebAssembly para controlar câmeras externas via USB a partir de um app da Web.

Na postagem anterior, mostrei como a biblioteca libusb foi transferida para ser executada na Web com WebAssembly / Emscripten, Asyncify e WebUSB.

Também apresentei uma demonstração criada com o gPhoto2, que controla câmeras DSLR e sem espelho por USB usando um aplicativo da Web. Nesta postagem, vamos nos aprofundar nos detalhes técnicos por trás da porta do gPhoto2.

Como apontar sistemas de compilação para bifurcações personalizadas

Como meu objetivo era criar o WebAssembly, não consegui usar o libusb e o libgphoto2 fornecidos pelas distribuições do sistema. Em vez disso, eu precisava que meu aplicativo usasse meu garfo personalizado do libgphoto2, enquanto esse garfo de libgphoto2 tinha que usar meu garfo personalizado do libusb.

Além disso, o libgphoto2 usa o libtool para carregar plug-ins dinâmicos e, embora eu não tenha que bifurcar a libtool como as outras duas bibliotecas, ainda tive que criá-la para o WebAssembly e apontar o libgphoto2 para esse build personalizado em vez de para o pacote do sistema.

Veja um diagrama de dependência aproximado (linhas tracejadas indicam vinculação dinâmica):

Um diagrama mostra "o app" dependendo de "libgphoto2 fork", que depende de "libtool". O bloco "libtool" depende dinamicamente de "portas libgphoto2" e "libgphoto2 camlibs". Por fim, as "portas libgphoto2" dependem estaticamente do "libusb fork".

A maioria dos sistemas de build baseados em configuração, incluindo os usados nessas bibliotecas, permite substituir caminhos para dependências usando várias flags. Foi isso que tentei fazer primeiro. No entanto, quando o gráfico de dependências se torna complexo, a lista de substituições de caminho para as dependências de cada biblioteca se torna detalhada e propensa a erros. Também encontrei alguns bugs em que os sistemas de build não estavam preparados para as dependências em caminhos não padrão.

Em vez disso, uma abordagem mais fácil é criar uma pasta separada como uma raiz de sistema personalizada (geralmente abreviada para "sysroot") e apontar todos os sistemas de compilação envolvidos para ela. Dessa forma, cada biblioteca buscará as dependências na sysroot especificada durante a criação e também se instalará no mesmo sysroot para que outros possam encontrá-la com mais facilidade.

O Emscripten já tem o próprio sysroot em (path to emscripten cache)/sysroot, que é usado para as bibliotecas do sistema, portas do Emscripten e ferramentas como o CMake e o pkg-config. Também escolhi reutilizar o mesmo sysroot para minhas dependências.

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

Com essa configuração, eu só precisava executar make install em cada dependência, que a instalava no sysroot e, em seguida, as bibliotecas se encontravam automaticamente.

Como lidar com o carregamento dinâmico

Como mencionado acima, a libgphoto2 usa o libtool para enumerar e carregar dinamicamente adaptadores de porta de E/S e bibliotecas de câmera. Por exemplo, o código para carregar bibliotecas de E/S é semelhante ao seguinte:

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

Há alguns problemas com essa abordagem na Web:

  • Não há suporte padrão para a vinculação dinâmica de módulos WebAssembly. O Emscripten tem uma implementação personalizada que pode simular a API dlopen() usada pelo libtool, mas exige que você crie módulos "main" e "side" com flags diferentes e, especificamente para o dlopen(), também pré-carregue os módulos laterais no sistema de arquivos emulado durante a inicialização do aplicativo. Pode ser difícil integrar esses sinalizadores e ajustes a um sistema de compilação autoconf com muitas bibliotecas dinâmicas.
  • Mesmo que o próprio dlopen() seja implementado, não há como enumerar todas as bibliotecas dinâmicas em uma determinada pasta na Web, porque a maioria dos servidores HTTP não expõe listagens de diretórios por motivos de segurança.
  • Vincular bibliotecas dinâmicas na linha de comando em vez de enumerar no ambiente de execução também pode levar a problemas, como o problema de símbolos duplicados, que é causado por diferenças entre a representação de bibliotecas compartilhadas no Emscripten e em outras plataformas.

É possível adaptar o sistema de build a essas diferenças e fixar a lista de plug-ins dinâmicos no código em algum lugar durante a criação, mas uma maneira ainda mais fácil de resolver todos esses problemas é evitar a vinculação dinâmica.

Acontece que o libtool abstrai vários métodos de vinculação dinâmica em diferentes plataformas e até oferece suporte à criação de carregadores personalizados para outras pessoas. Um dos carregadores integrados compatíveis é chamado de Dlpreopening:

"O Libtool oferece suporte especial para a abertura de dl de objetos e de arquivos da biblioteca libtool para que os símbolos possam ser resolvidos mesmo em plataformas sem funções dlopen e dlsym.
...
O Libtool emula -dlopen em plataformas estáticas vinculando objetos ao programa no momento da compilação e criando estruturas de dados que representam a tabela de símbolos do programa. Para usar esse recurso, declare os objetos que serão abertos pelo aplicativo usando as sinalizações -dlopen ou -dlpreopen ao vincular o programa. Consulte Modo de vinculação."

Esse mecanismo permite emular o carregamento dinâmico no nível da libtool em vez do Emscripten, enquanto vincula tudo estaticamente a uma única biblioteca.

O único problema que isso não resolve é a enumeração de bibliotecas dinâmicas. A lista desses elementos ainda precisa estar fixada no código em algum lugar. Felizmente, o conjunto de plug-ins necessário para o aplicativo é mínimo:

  • Com relação às portas, o que importa é a conexão da câmera com base em libusb, e não os modos PTP/IP, acesso serial ou drive USB.
  • No lado do camlibs, há vários plug-ins específicos do fornecedor que podem fornecer algumas funções especializadas, mas, para controle e captura de configurações gerais, basta usar o Picture Transfer Protocol, que é representado pelo ptp2 camlib e compatível com praticamente todas as câmeras no mercado.

Veja como fica o diagrama de dependências atualizado com tudo vinculado estaticamente:

Um diagrama mostra "o app" dependendo de "libgphoto2 fork", que depende de "libtool". "libtool" depende de "ports: libusb1" e "camlibs: libptp2". "ports: libusb1" depende do "libusb fork".

Foi isso que fixei no código das versões 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 ();

e

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

No sistema de build autoconf, agora eu precisava adicionar -dlpreopen com esses dois arquivos como flags de link para todos os executáveis (exemplos, testes e meu próprio app de demonstração), da seguinte forma:

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

Finalmente, agora que todos os símbolos estão vinculados estaticamente em uma única biblioteca, o libtool precisa de uma maneira de determinar qual símbolo pertence a qual biblioteca. Para isso, é necessário que os desenvolvedores renomeiem todos os símbolos expostos, como {function name}, para {library name}_LTX_{function name}. A maneira mais fácil de fazer isso é usando #define para redefinir os nomes de símbolos na parte de cima do arquivo de implementação:

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

Esse esquema de nomenclatura também evita conflitos de nomes caso eu decida vincular plug-ins específicos de câmera no mesmo aplicativo no futuro.

Depois que todas essas alterações foram implementadas, consegui criar o aplicativo de teste e carregar os plug-ins.

Como gerar a interface de configurações

O gPhoto2 permite que as bibliotecas de câmera definam suas próprias configurações em uma forma de árvore de widgets. A hierarquia dos tipos de widgets consiste em:

  • Janela: contêiner de configuração de nível superior
    • Seções: grupos nomeados de outros widgets
    • Campos do botão
    • Campos de texto
    • Campos numéricos
    • Campos de data
    • Alternadores
    • Botões de opção

O nome, o tipo, os filhos e todas as outras propriedades relevantes de cada widget podem ser consultados (e, no caso de valores, também modificados) pela API C exposta. Juntas, elas fornecem uma base para gerar automaticamente a interface de configurações em qualquer linguagem que possa interagir com o C.

As configurações podem ser alteradas por meio do gPhoto2 ou na própria câmera a qualquer momento. Além disso, alguns widgets podem ser somente leitura e até mesmo o próprio estado somente leitura depende do modo da câmera e de outras configurações. Por exemplo, a velocidade do obturador é um campo numérico gravável em M (modo manual), mas se torna um campo somente leitura informativo no P (modo de programa). No modo P, o valor da velocidade do obturador também é dinâmico e muda continuamente, dependendo do brilho do cenário.

De modo geral, é importante sempre mostrar informações atualizadas da câmera conectada na interface e, ao mesmo tempo, permitir que o usuário edite essas configurações na mesma interface. Esse fluxo de dados bidirecional é mais complexo de lidar.

O gPhoto2 não tem um mecanismo para recuperar apenas as configurações alteradas, somente a árvore inteira ou widgets individuais. Para manter a interface atualizada sem oscilar e perder o foco de entrada ou a posição de rolagem, eu precisava de uma maneira de diferenciar as árvores de widgets entre as invocações e atualizar apenas as propriedades alteradas da interface. Felizmente, esse é um problema resolvido na Web e é a funcionalidade principal de frameworks como React ou Preact (links em inglês). Usei o Preact para este projeto, já que ele é muito mais leve e faz tudo o que eu preciso.

No lado do C++, agora eu precisava recuperar e percorrer recursivamente a árvore de configurações pela API C vinculada anteriormente e converter cada widget em um 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;
    }
    // …

No lado do JavaScript, agora é possível chamar configToJS, analisar a representação JavaScript retornada da árvore de configurações e criar a IU usando a função 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;
  }
  // …

Ao executar essa função repetidamente em um loop de eventos infinito, é possível fazer com que a interface de configurações mostre sempre as informações mais recentes, além de enviar comandos para a câmera sempre que um dos campos for editado pelo usuário.

O Preact pode ajudar a diferenciar os resultados e atualizar o DOM somente para os bits alterados da IU, sem interromper o foco da página nem os estados de edição. Um problema que permanece é o fluxo de dados bidirecional. Frameworks como React e Preact foram projetados com base no fluxo de dados unidirecional, porque assim fica muito mais fácil entender e comparar os dados entre execuções repetidas. No entanto, estou quebrando essa expectativa permitindo que uma fonte externa, a câmera, atualize a interface de configurações a qualquer momento.

Contornei esse problema desativando as atualizações da interface para todos os campos de entrada que estavam sendo editados pelo usuário:

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

Dessa forma, sempre haverá apenas um proprietário de qualquer campo. Ou o usuário está editando o arquivo e não é interrompido pelos valores atualizados da câmera, ou ela está atualizando o valor do campo enquanto está fora de foco.

Como criar um feed de "vídeo" ao vivo

Durante a pandemia, muitas pessoas passaram a fazer reuniões on-line. Entre outras coisas, isso levou à escassez no mercado de webcams. Para ter uma qualidade de vídeo melhor do que as câmeras integradas em laptops, e em resposta a essa escassez, muitos proprietários de câmeras DSLR e sem espelho começaram a procurar maneiras de usar suas câmeras de fotografia como webcams. Vários fornecedores de câmeras até enviaram utilitários oficiais para essa finalidade.

Como as ferramentas oficiais, o gPhoto2 oferece suporte a transmissão de vídeo da câmera para um arquivo armazenado localmente ou diretamente para uma webcam virtual. Eu queria usar esse recurso para fornecer uma imagem ao vivo na minha demonstração. No entanto, embora esteja disponível no utilitário de console, não consegui encontrá-lo em nenhum lugar nas APIs da biblioteca libgphoto2.

Analisando o código-fonte da função correspondente no utilitário de console, notei que ele não está realmente recebendo um vídeo, mas continua recuperando a visualização da câmera como imagens JPEG individuais em um loop infinito e gravando-as uma a uma para formar um stream M-JPEG:

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

Fiquei surpreso de que esta abordagem funciona de forma eficiente o bastante para ter uma impressão de um vídeo sem problemas em tempo real. Eu estava ainda mais desconfiada sobre a possibilidade de encontrar o mesmo desempenho no aplicativo da Web também, com todas as abstrações extras e o uso da maneira assíncrona. No entanto, decidi tentar mesmo assim.

No lado do C++, expomos um método chamado capturePreviewAsBlob(), que invoca a mesma função gp_camera_capture_preview() e converte o arquivo resultante na memória em um Blob que pode ser transmitido para outras APIs da Web com mais facilidade:

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

No lado do JavaScript, tenho um loop, semelhante ao do gPhoto2, que continua recuperando imagens de visualização como Blobs, decodifica-as em segundo plano com createImageBitmap e as transfere para a tela no próximo frame de animação:

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

O uso dessas APIs modernas garante que todo o trabalho de decodificação seja feito em segundo plano, e a tela seja atualizada somente quando a imagem e o navegador estiverem totalmente preparados para o desenho. Isso alcançou uma taxa consistente de mais de 30 QPS no meu laptop, o que correspondeu ao desempenho nativo do gPhoto2 e do software oficial da Sony.

Como sincronizar o acesso por USB

Quando uma transferência de dados USB é solicitada enquanto outra operação já está em andamento, isso geralmente resulta no erro "dispositivo está ocupado". Como a visualização e a interface de configurações são atualizadas regularmente, e o usuário pode tentar capturar uma imagem ou modificar as configurações ao mesmo tempo, esses conflitos entre operações diferentes acabaram sendo muito frequentes.

Para evitá-los, precisei sincronizar todos os acessos no aplicativo. Para isso, criei uma fila assíncrona baseada em promessas:

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

Ao encadear cada operação em um callback then() da promessa queue existente e armazenar o resultado encadeado como o novo valor de queue, é possível garantir que todas as operações sejam executadas uma a uma, em ordem e sem sobreposições.

Quaisquer erros de operação são retornados ao autor da chamada, enquanto erros críticos (inesperados) marcam toda a cadeia como uma promessa rejeitada e garantem que nenhuma nova operação seja agendada posteriormente.

Ao manter o contexto do módulo em uma variável particular (não exportada), estou minimizando os riscos de acessar o context por acidente em outro lugar do app sem passar pela chamada schedule().

Agora, cada acesso ao contexto do dispositivo precisa ser unido em uma chamada schedule() como esta:

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

e

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

Depois disso, todas as operações foram executadas sem conflitos.

Conclusão

Fique à vontade para consultar a base de código no GitHub (link em inglês) para mais insights de implementação. Também quero agradecer a Marcus Meissner pela manutenção do gPhoto2 e pelas avaliações dos meus PRs upstream.

Como mostrado nessas postagens, as APIs WebAssembly, Asyncify e Fugu fornecem um destino de compilação eficiente até mesmo para os aplicativos mais complexos. Com elas, você transfere para a Web uma biblioteca ou um aplicativo criado anteriormente para uma única plataforma, disponibilizando-o para um número muito maior de usuários, tanto em computadores quanto em dispositivos móveis.