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

Saiba como o gPhoto2 foi transferido para o WebAssembly para controlar câmeras externas por 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 câmeras DSLR e sem espelho por USB em um aplicativo da Web. Nesta postagem, vou me aprofundar nos detalhes técnicos da porta gPhoto2.

Como 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, precisava que meu aplicativo usasse minha bifurcação personalizada de libgphoto2, enquanto essa bifurcação de libgphoto2 precisava usar minha bifurcação personalizada de libusb.

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

Confira um diagrama de dependência aproximado (as linhas tracejadas denotam vinculação dinâmica):

Um diagrama mostra que "o app" depende 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 da "bifurcação libusb".

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 fica complexo, a lista de substituições de caminhos para as dependências de cada biblioteca fica detalhada e propensa a erros. Também encontrei 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 do sistema personalizada (geralmente abreviada para "sysroot") e apontar todos os sistemas de build envolvidos para ela. Dessa forma, cada biblioteca vai procurar as dependências no sysroot especificado durante o build e também vai se instalar no mesmo sysroot para que outras 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, só era necessário executar make install em cada dependência, que o instalou sob o sysroot, e as bibliotecas se encontravam automaticamente.

Como lidar com o carregamento dinâmico

Como mencionado acima, o 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 a este:

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

Essa abordagem tem alguns problemas 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 dlopen(), também para 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 tempo de execução também pode causar problemas, como o problema de símbolos duplicados, causado por diferenças entre a representação de bibliotecas compartilhadas no Emscripten e em outras plataformas.

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

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

"O Libtool oferece suporte especial para dlopening libtool object e arquivos de biblioteca libtool, para que os símbolos possam ser resolvidos mesmo em plataformas sem funções dlopen e dlsym.
...
O Libtool emula o -dlopen em plataformas estáticas vinculando objetos ao programa durante a compilação e criando estruturas de dados que representam a tabela de símbolos do programa. Para usar esse recurso, declare os objetos que você quer que o aplicativo dlopen usando as flags -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, vinculando tudo de forma estática em uma única biblioteca.

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

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

Veja como é o diagrama de dependência atualizado com tudo vinculado estaticamente:

Um diagrama mostra que "o app" depende de "libgphoto2 fork", que depende de "libtool". O "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 do autoconf, agora tive que adicionar -dlpreopen com os dois arquivos como flags de vinculação para todos os executáveis (exemplos, testes e meu próprio app de demonstração), assim:

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 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}, como {library name}_LTX_{function name}. A maneira mais fácil de fazer isso é usar #define para redefinir os nomes dos 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 da câmera no mesmo aplicativo no futuro.

Depois que todas essas mudanças foram implementadas, pude criar o aplicativo de teste e carregar os plug-ins.

Gerar a interface de configurações

O gPhoto2 permite que as bibliotecas de câmera definam as 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 de 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. 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 usando o gPhoto2 ou a 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, velocidade do obturador é um campo numérico gravável em M (modo manual), mas se torna um campo informativo somente 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 que a câmera está observando.

Em geral, é importante mostrar sempre informações atualizadas da câmera conectada na interface, permitindo que o usuário edite essas configurações na mesma interface. Esse fluxo de dados bidirecional é mais complexo de processar.

O gPhoto2 não tem um mecanismo para recuperar apenas as configurações alteradas, apenas 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 o React ou o Preact. Eu escolhi o Preact para este projeto, porque 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 posso chamar configToJS, percorrer a representação do JavaScript retornada da árvore de configurações e criar a interface usando a função h do 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;
  }
  // …

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

O Preact pode cuidar da diferença entre os resultados e atualizar o DOM apenas para os bits alterados da interface, sem interromper o foco da página ou editar estados. Um problema que permanece é o fluxo de dados bidirecional. Frameworks como React e 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 repetições. Mas estou quebrando essa expectativa ao permitir que uma fonte externa, a câmera, atualize a interface de configurações a qualquer momento.

Para contornar esse problema, desativei 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. Ou o usuário está editando o campo e não será interrompido pelos valores atualizados da câmera, ou a câmera está atualizando o valor do campo enquanto está fora de foco.

Como criar um feed "vídeo" ao vivo

Durante a pandemia, muitas pessoas passaram a fazer reuniões on-line. Isso levou a dificuldades no mercado de webcams, entre outras coisas. Para ter uma qualidade de vídeo melhor em comparação com as câmeras integradas em laptops e em resposta às mencionadas dificuldades, muitos proprietários de câmeras DSLR e sem espelho começaram a procurar maneiras de usar as câmeras de fotografia como webcams. Vários fornecedores de câmeras até enviaram utilitários oficiais para esse fim.

Assim como as ferramentas oficiais, o gPhoto2 oferece suporte ao streaming de vídeo da câmera para um arquivo armazenado localmente ou diretamente para uma webcam virtual. Queria usar esse recurso para mostrar uma visualização 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 estava recebendo um vídeo, mas continuava recuperando a visualização da câmera como imagens JPEG individuais em um loop infinito e gravando-as uma por 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 sobre a possibilidade de alcançar o mesmo desempenho no aplicativo da Web, com todas as abstrações extras e o Asyncify no caminho. No entanto, decidi tentar.

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, decodificando-as em segundo plano com createImageBitmap e transferindo para a tela no próximo frame da 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ó é atualizada quando a imagem e o navegador estão totalmente preparados para a exibição. Isso alcançou uma taxa consistente de mais de 30 QPS no meu laptop, 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, geralmente resulta em um erro "dispositivo 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 se tornaram muito frequentes.

Para evitar isso, 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, 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 vincular as coisas, agora cada acesso ao contexto do dispositivo precisa ser envolvido 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

Confira mais insights sobre a implementação na base de código do GitHub. Também agradeço a Marcus Meissner pela manutenção do gPhoto2 e pelas avaliações das minhas 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.