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 em um app da Web.

Na postagem anterior, mostrei como a biblioteca libusb foi transferida para execução na Web com o WebAssembly / Emscripten, o Asyncify e o WebUSB.

Também mostrei uma demonstração criada com o gPhoto2 que pode controlar DSLR e câmeras espelhadas via USB usando um aplicativo da Web. Nesta postagem, vou mostrar mais detalhes técnicos sobre a portabilidade do gPhoto2.

Apontar sistemas de build para bifurcações personalizadas

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

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

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

Um diagrama mostra "o app" dependendo de "libgphoto2 fork", que depende de "libtool". "libtool" depende dinamicamente de "portas libgphoto2" e "libgphoto2 camlibs". Por fim, "libgphoto2 portas" depende 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, e foi isso que tentei fazer primeiro. No entanto, quando o gráfico de dependências fica complexo, a lista de substituições de caminhos para as dependências de cada biblioteca fica detalhada e propensa a erros. Também encontramos alguns bugs em que os sistemas de build não estavam preparados para que as dependências ficassem 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 como "sysroot") e apontar todos os sistemas de compilação envolvidos para ela. Dessa forma, cada biblioteca vai procurar as dependências no sysroot especificado durante a compilação e se instalar na mesma sysroot para que outras pessoas possam encontrá-la com mais facilidade.

O Emscripten já tem o próprio sysroot em (path to emscripten cache)/sysroot, que ele usa para as bibliotecas do sistema, portas Emscripten e ferramentas como CMake e pkg-config. Também optei por 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, só era necessário executar make install em cada dependência, que o instalou sob a sysroot, e 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 portas de E/S e bibliotecas de câmera. Por exemplo, o código para carregar bibliotecas de E/S é semelhante a este:

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 "main" e "lateral" módulos com flags diferentes e, especificamente para dlopen(), também para pré-carregar os módulos secundários no sistema de arquivos emulado durante a inicialização do aplicativo. Pode ser difícil integrar essas flags e ajustes a um sistema de compilação autoconf existente com muitas bibliotecas dinâmicas.
  • Mesmo que o próprio dlopen() seja implementado, não será possível 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 causar problemas, como o problema de símbolos duplicados, que são causados 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 no código a lista de plug-ins dinâmicos em algum lugar durante a compilação. No entanto, 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 outros. Um dos carregadores integrados compatíveis é chamado "Dlpreopening":

"O Libtool oferece suporte especial para o objeto libtool e arquivos de biblioteca do libtool para que os símbolos possam ser resolvidos mesmo em plataformas sem nenhuma função dlopen e dlsym.
...
O Libtool emula o -dlopen em plataformas estáticas vinculando objetos ao programa no tempo de compilação e criando estruturas de dados que representam a tabela de símbolos do programa. Para usar esse recurso, é necessário declarar os objetos que você quer que o aplicativo libere 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 do libtool em vez do Emscripten e vincula tudo estaticamente a uma única biblioteca.

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

  • Quanto às portas, meu foco só é a conexão da câmera baseada em libusb, e não PTP/IP, acesso serial ou modos de drive USB.
  • No lado do camlibs, existem vários plug-ins específicos para fornecedores que podem fornecer algumas funções especializadas. No entanto, para o controle e a captura gerais das configurações, basta usar o Picture Transfer Protocol (em inglês), que é representado por ptp2 camlib e tem suporte de praticamente todas as câmeras no mercado.

Veja como fica o diagrama de dependência 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 para 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 ();

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 tive que adicionar -dlpreopen com esses dois arquivos como sinalizações de link para todos os executáveis (exemplos, testes e meu próprio app de demonstração), desta forma:

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

Por fim, agora que todos os símbolos estão vinculados estaticamente em uma única biblioteca, o libtool precisa de uma maneira para 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 superior 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 da câmera no mesmo aplicativo no futuro.

Após a implementação de todas essas mudanças, pude criar o aplicativo de teste e carregar os plug-ins corretamente.

Gerar a interface de configurações

O gPhoto2 permite que as bibliotecas de câmera definam configurações próprias em forma de árvore de widgets. A hierarquia de tipos de widget 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) por meio da API C exposta. Juntos, eles fornecem uma base para gerar automaticamente a interface de configurações em qualquer linguagem que possa interagir com C.

As configurações podem ser alteradas no gPhoto2 ou na própria câmera a qualquer momento. Além disso, alguns widgets podem ser somente leitura, e até mesmo o estado somente leitura depende do modo de 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 informativo somente para leitura em P (modo de programa). No modo P, o valor da velocidade do obturador também será dinâmico e mudará continuamente, dependendo do brilho da cena para a qual a câmera está olhando.

Resumindo, é 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 gerenciar.

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 piscar 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 da interface alteradas. Felizmente, esse é um problema resolvido na Web e é a funcionalidade principal de frameworks como React ou Preact. Usei o Preact neste projeto, já que ele é muito mais leve e faz tudo o que eu preciso.

No lado do C++, agora precisava recuperar e percorrer recursivamente a árvore de configurações por meio da 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 posso chamar configToJS, analisar a representação JavaScript retornada da árvore de configurações e criar a interface 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, posso fazer com que a interface de configurações sempre mostre as informações mais recentes, além de enviar comandos à câmera sempre que um dos campos for editado pelo usuário.

O Preact cuida de diferenciar os resultados e atualizar o DOM apenas para as partes alteradas da interface do usuário, sem interromper o foco da página nem os estados de edição. Um problema que permanece é o fluxo de dados bidirecional. Frameworks como o React e o Preact foram criados com base no fluxo de dados unidirecional, porque isso facilita muito a análise dos dados e a comparação entre as reprises. No entanto, estou quebrando essa expectativa ao permitir que uma fonte externa, a câmera, atualize a interface de configurações a qualquer momento.

Contornei esse problema recusando as atualizações da interface para todos os campos de entrada que estão 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 há apenas um proprietário de cada campo. O usuário está editando o arquivo no momento e não será interrompido pelos valores atualizados da câmera ou ela está atualizando o valor do campo enquanto está fora de foco.

Como criar um "vídeo" ao vivo se alimentam

Durante a pandemia, muitas pessoas passaram a fazer reuniões on-line. Entre outras coisas, isso levou à falta no mercado de webcams. Para ter uma melhor qualidade de vídeo em comparação com 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.

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

Analisando o código-fonte da função correspondente no utilitário do console, descobri que ele não está recebendo um vídeo. Na verdade, ele 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 com o fato de essa abordagem funcionar de forma eficiente o suficiente para causar uma impressão de um vídeo fluido em tempo real. Eu estava ainda mais cético em relação à capacidade de encontrar o mesmo desempenho no aplicativo da Web também, com todas as abstrações extras e o Asyncify no caminho. Mas decidi tentar mesmo assim.

Na parte em C++, expôs um método chamado capturePreviewAsBlob(), que invoca a mesma função gp_camera_capture_preview() e converte o arquivo na memória resultante 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, as decodifica 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 só seja atualizada quando a imagem e o navegador estiverem totalmente preparados para o desenho. Isso alcançou uma consistência de mais de 30 QPS no meu laptop, o que correspondeu ao desempenho nativo do gPhoto2 e do software oficial da Sony.

Sincronizando o acesso a USB

Quando uma transferência de dados USB é solicitada enquanto outra operação já está em andamento, geralmente o resultado é a mensagem "O dispositivo está ocupado". erro. Como a visualização e a interface de configurações são atualizadas regularmente, e o usuário pode estar tentando capturar uma imagem ou modificar as configurações ao mesmo tempo, tais conflitos entre operações diferentes se tornaram muito frequentes.

Para evitá-los, precisei sincronizar todos os acessos dentro do 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, posso garantir que todas as operações sejam executadas uma por uma, em ordem e sem sobreposições.

Qualquer erro de operação é retornado 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 privada (não exportada), estou minimizando os riscos de acessar o context por acidente em outro lugar do app sem precisar da chamada schedule().

Para juntar tudo, agora cada acesso ao contexto do dispositivo precisa ser unido em uma chamada schedule(), desta forma:

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

e

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

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

Conclusão

Navegue pela base do código no GitHub (link em inglês) para ter mais insights de implementação. Agradeço também a Marcus Meissner pela manutenção do gPhoto2 e pelas análises dos meus PRs upstream.

Como mostrado nessas postagens, as APIs WebAssembly, Asyncify e Fugu oferecem um destino de compilação eficiente até mesmo para os aplicativos mais complexos. Eles permitem que você pegue uma biblioteca ou um aplicativo criado anteriormente para uma única plataforma e transfira-o para a Web, tornando-o disponível para um número muito maior de usuários em computadores e dispositivos móveis.