Portare le applicazioni USB sul Web. Parte 2: gPhoto2

Scopri come gFoto2 è stato trasferito in WebAssembly per controllare le fotocamere esterne tramite USB da un'app web.

Ingvar Stepanyan
Ingvar Stepanyan

Nel post precedente ho mostrato come è stata eseguita la portabilità della libreria libusb per l'esecuzione sul web con WebAssembly / Emscripten, Asyncify e WebUSB.

Ho anche mostrato una demo realizzata con gPhoto2 in grado di controllare le fotocamere DSLR e mirrorless tramite USB da un'applicazione web. In questo post analizzerò in dettaglio i dettagli tecnici del trasferimento di gFoto2.

Indirizzare i sistemi di build a fork personalizzati

Dato che stavo scegliendo come target WebAssembly, non ho potuto usare libusb e libgphoto2 forniti dalle distribuzioni del sistema. Invece, avevo bisogno della mia applicazione per usare la mia forcella personalizzata di libgphoto2, mentre quella forchetta di libgphoto2 doveva usare la mia forchetta personalizzata di libusb.

Inoltre, libgphoto2 utilizza libtool per caricare i plug-in dinamici e, anche se non ho dovuto fork di libtool come le altre due librerie, dovevo comunque crearlo in WebAssembly e puntare libgphoto2 a quella build personalizzata invece che al pacchetto di sistema.

Ecco un diagramma approssimativo delle dipendenze (le linee tratteggiate indicano i collegamenti dinamici):

Un diagramma mostra "l'app" in base a "libgphoto2 fork", che dipende da "libtool". Il blocco "libtool" dipende in modo dinamico da "libgphoto2 port" e "libgphoto2 camlibs". Infine, il valore "libgphoto2 porta" dipende in modo statico dalla "forcella libusb".

La maggior parte dei sistemi di compilazione basati sulla configurazione, inclusi quelli utilizzati in queste librerie, consente l'override dei percorsi per le dipendenze mediante vari flag. Ho provato a eseguire questo passaggio per primo. Tuttavia, quando il grafico delle dipendenze diventa complesso, l'elenco degli override dei percorsi per le dipendenze di ogni libreria diventa dettagliato e soggetto a errori. Ho anche rilevato alcuni bug in cui i sistemi di build non erano effettivamente preparati per le loro dipendenze in percorsi non standard.

Un approccio più semplice consiste nel creare una cartella separata come root di sistema personalizzata (spesso abbreviata in "sysroot") e indirizzare tutti i sistemi di build coinvolti. In questo modo, durante la build ogni libreria cercherà le proprie dipendenze nel sysroot specificato e si installerà anche nello stesso sysroot, in modo che altri possano trovarlo più facilmente.

Emscripten ha già un proprio file sysroot in (path to emscripten cache)/sysroot, che utilizza per le librerie di sistema, le porte di Emscripten e strumenti come CMake e pkg-config. Ho scelto di riutilizzare lo stesso sysroot anche per le mie dipendenze.

# 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 questa configurazione era sufficiente eseguire make install in ogni dipendenza, installandolo sotto il file sysroot e in seguito le librerie si trovavano automaticamente a vicenda.

Gestire il caricamento dinamico

Come accennato in precedenza, libgphoto2 utilizza libtool per enumerare e caricare dinamicamente gli adattatori delle porte di I/O e le librerie delle fotocamere. Ad esempio, il codice per caricare le librerie di I/O è simile al seguente:

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

Questo approccio presenta alcuni problemi sul web:

  • Non è disponibile alcun supporto standard per il collegamento dinamico dei moduli WebAssembly. Emscripten ha la sua implementazione personalizzata che può simulare l'API dlopen() utilizzata da libtool, ma richiede di creare moduli "main" e "side" con flag diversi e, in particolare per dlopen(), anche precaricare i moduli laterali nel file system emulato durante l'avvio dell'applicazione. Può essere difficile integrare questi flag e modifiche in un sistema di compilazione autoconf esistente con molte librerie dinamiche.
  • Anche se l'elemento dlopen() è implementato, non è possibile enumerare tutte le librerie dinamiche in una determinata cartella sul web, perché la maggior parte dei server HTTP non espone gli elenchi di directory per motivi di sicurezza.
  • Anche collegare le librerie dinamiche sulla riga di comando anziché enumerare in runtime può causare problemi, come il problema di simboli duplicati, causati dalle differenze tra la rappresentazione delle librerie condivise in Emscripten e su altre piattaforme.

È possibile adattare il sistema di compilazione a queste differenze e impostare come hardcoded l'elenco di plug-in dinamici da qualche parte durante la build, ma un modo ancora più semplice per risolvere tutti questi problemi è evitare i collegamenti dinamici fin dall'inizio.

In questo modo libtool astrae completamente i vari metodi di collegamento dinamico su diverse piattaforme e supporta persino la scrittura di caricatori personalizzati per altre piattaforme. Uno dei caricatori integrati che supporta è chiamato "Dlpreopening":

"Libtool offre un supporto speciale per l'apertura dei file di oggetti libtool e delle librerie libtool, in modo che i relativi simboli possano essere risolti anche su piattaforme senza le funzioni dlopen e dlsym.
...
Libtool emula -dlopen sulle piattaforme statiche, collegando oggetti al programma in fase di compilazione e creando strutture di dati che rappresentano la tabella dei simboli del programma. Per utilizzare questa funzionalità, devi dichiarare gli oggetti che vuoi che l'applicazione dlopen utilizzi i flag -dlopen o -dlpreopen quando colleghi il programma (vedi Modalità collegamento)."

Questo meccanismo consente di emulare il caricamento dinamico a livello di libtool invece che di Emscripten, collegando tutto in modo statico in un'unica libreria.

L'unico problema che non risolve è l'enumerazione delle librerie dinamiche. L'elenco di questi elementi deve ancora essere impostato come hardcoded. Fortunatamente, l'insieme di plug-in di cui ho bisogno per l'app è minimo:

  • Per quanto riguarda le porte, mi interessa solo la connessione della videocamera basata su libusb e non le modalità PTP/IP, accesso seriale o unità USB.
  • Per quanto riguarda camlibs, ci sono vari plug-in specifici del fornitore che potrebbero fornire alcune funzioni specializzate, ma per il controllo e l'acquisizione delle impostazioni generali è sufficiente utilizzare il Picture Transfer Protocol, rappresentato da ptp2 camlib e supportato praticamente da tutte le fotocamere sul mercato.

Ecco come si presenta il diagramma delle dipendenze aggiornato, con tutti i contenuti collegati in modo statico:

Un diagramma mostra "l'app" in base a "libgphoto2 fork", che dipende da "libtool". "libtool" dipende da "ports: libusb1" e "camlibs: libptp2". "ports: libusb1" dipende dalla "forchetta libusb".

Ecco cosa ho impostato come hardcoded per le build 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 ();

Nel sistema di compilazione con autoconf, ora dovevo aggiungere -dlpreopen con entrambi questi file come flag di collegamento per tutti gli eseguibili (esempi, test e la mia app demo), in questo modo:

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

Infine, ora che tutti i simboli sono collegati in modo statico in un'unica libreria, libtool ha bisogno di un modo per determinare quale simbolo appartiene a quale libreria. Per raggiungere questo obiettivo, gli sviluppatori devono rinominare tutti i simboli esposti come {function name} in {library name}_LTX_{function name}. Il modo più semplice per farlo è utilizzare #define per ridefinire i nomi dei simboli nella parte superiore del file di implementazione:

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

Questo schema di denominazione evita anche conflitti tra i nomi nel caso in cui in futuro decido di collegare plug-in specifici della fotocamera nella stessa app.

Dopo aver implementato tutte queste modifiche, ho potuto creare l'applicazione di test e caricare correttamente i plug-in.

Generazione dell'interfaccia utente delle impostazioni in corso...

gFoto2 consente alle librerie delle fotocamere di definire le proprie impostazioni sotto forma di albero di widget. La gerarchia dei tipi di widget è costituita da:

  • Finestra: container di configurazione di primo livello
    • Sezioni: gruppi denominati di altri widget
    • Campi pulsante
    • Campi di testo
    • Campi numerici
    • Campi delle date
    • Pulsanti di attivazione/disattivazione
    • Pulsanti di opzione

È possibile eseguire query sul nome, sul tipo, sugli elementi secondari e su tutte le altre proprietà pertinenti di ogni widget (e, nel caso di valori, anche modificate) tramite l'API C esposta. Insieme, forniscono una base per la generazione automatica di impostazioni UI in qualsiasi linguaggio in grado di interagire con il linguaggio C.

Le impostazioni possono essere modificate tramite gFoto2 o dalla fotocamera stessa in qualsiasi momento. Inoltre, alcuni widget possono essere di sola lettura e anche lo stato di sola lettura dipende dalla modalità della fotocamera e da altre impostazioni. Ad esempio, la velocità dell'otturatore è un campo numerico scrivibile in M (modalità manuale), ma diventa un campo di sola lettura informativo in P (modalità di programmazione). In modalità P, anche il valore del tempo di esposizione sarà dinamico e cambierà continuamente a seconda della luminosità della scena inquadrata.

Nel complesso, è importante mostrare sempre informazioni aggiornate sulla videocamera connessa nell'interfaccia utente, permettendo allo stesso tempo all'utente di modificare queste impostazioni dalla stessa UI. Questo flusso di dati bidirezionale è più complesso da gestire.

gFoto2 non dispone di un meccanismo per recuperare solo le impostazioni modificate, ma soltanto l'intero albero o i singoli widget. Per mantenere l'interfaccia utente aggiornata senza sfarfallio e perdere lo stato attivo dell'input o la posizione di scorrimento, avevo bisogno di un modo per differenziare le strutture dei widget tra le chiamate e aggiornare solo le proprietà dell'interfaccia utente modificate. Fortunatamente, si tratta di un problema risolto sul web ed è la funzionalità di base di framework come React o Preact. Ho scelto Preact per questo progetto, che è molto più leggero e fa tutto ciò di cui ho bisogno.

Sul lato C++ ora dovevo recuperare ed eseguire in modo ricorsivo l'albero delle impostazioni tramite l'API C collegata in precedenza e convertire ogni widget in un oggetto 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;
    }
    // …

Per quanto riguarda JavaScript, ora posso chiamare configToJS, esaminare la rappresentazione JavaScript restituita dell'albero delle impostazioni e creare l'interfaccia utente tramite la funzione 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;
  }
  // …

Eseguendo questa funzione ripetutamente in un loop di eventi infinito, posso fare in modo che l'interfaccia utente delle impostazioni mostri sempre le informazioni più recenti, inviando al contempo comandi alla videocamera ogni volta che un campo viene modificato dall'utente.

Preact può occuparsi di differenziare i risultati e aggiornare il DOM solo per i bit modificati dell'interfaccia utente, senza interrompere lo stato attivo della pagina o gli stati di modifica. Un problema che rimane è il flusso di dati bidirezionale. Framework come React e Preact sono stati progettati intorno al flusso di dati unidirezionali, perché semplifica notevolmente il ragionamento dei dati e il confronto tra le repliche, ma non rispetto a queste aspettative consentendo a una sorgente esterna, la fotocamera, di aggiornare l'interfaccia utente delle impostazioni in qualsiasi momento.

Ho risolto questo problema disattivando gli aggiornamenti dell'interfaccia utente per tutti i campi di immissione attualmente modificati dall'utente:

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

In questo modo, esiste sempre un solo proprietario per un determinato campo. oppure l'utente lo sta modificando e non sarà interrotto dai valori aggiornati della fotocamera oppure la fotocamera sta aggiornando il valore del campo quando è sfocato.

Creare un feed "video" live

Durante la pandemia, molte persone si sono passate a riunioni online. Tra le altre cose, questo ha causato una carenza di prodotti nel mercato delle webcam. Per ottenere una qualità video migliore rispetto alle fotocamere integrate nei laptop e in risposta a dette carenze, molti proprietari di fotocamere DSLR e mirrorless hanno iniziato a cercare modi per utilizzare le fotocamere come webcam. A questo scopo, diversi fornitori di fotocamere hanno anche spedito utility ufficiali.

Come per gli strumenti ufficiali, gFoto2 supporta lo streaming video dalla fotocamera a un file archiviato localmente o direttamente a una webcam virtuale. Volevo usare questa funzionalità per fornire una visione in diretta nella mia demo. Tuttavia, sebbene sia disponibile nell'utilità della console, non l'ho trovato nelle API della libreria libgphoto2.

Osservando il codice sorgente della funzione corrispondente nell'utility della console, ho scoperto che in realtà non si ottiene affatto un video, ma che continua a recuperare l'anteprima della fotocamera come singole immagini JPEG in un loop infinito e a scriverle una alla volta per formare uno stream M-JPEG:

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

Sono rimasta sorpresa dal fatto che questo approccio funzionasse in modo abbastanza efficiente da avere un'idea di video in tempo reale fluidi. Ero ancora più scettica riguardo al fatto di poter garantire le stesse prestazioni anche nell'applicazione web, con tutte le astrazioni extra e Asyncify. Tuttavia, ho deciso di provare comunque.

Sul lato C++ ho esposto un metodo chiamato capturePreviewAsBlob() che richiama la stessa funzione gp_camera_capture_preview() e converte il file in memoria risultante in un file Blob che può essere trasmesso più facilmente ad altre API web:

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

Sul lato JavaScript, ho un loop, simile a quello in gFoto2, che continua a recuperare le immagini di anteprima come Blob, le decodifica nello sfondo con createImageBitmap e le trasferisce al canvas nel successivo frame dell'animazione:

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

L'utilizzo di queste moderne API garantisce che tutto il lavoro di decodifica venga eseguito in background e che il canvas venga aggiornato solo quando sia l'immagine che il browser sono completamente preparati per il disegno. Questo ha ottenuto più di 30 FPS costanti sul mio laptop, corrispondenti alle prestazioni native sia di gFoto2 che del software ufficiale Sony.

Sincronizzazione dell'accesso USB

Quando viene richiesto un trasferimento di dati USB mentre è già in corso un'altra operazione, si verifica l'errore "Dispositivo occupato". Poiché l'anteprima e la UI delle impostazioni si aggiornano regolarmente e l'utente potrebbe tentare di acquisire un'immagine o modificare le impostazioni contemporaneamente, questi conflitti tra operazioni diverse si sono rivelati molto frequenti.

Per evitarli, dovevo sincronizzare tutti gli accessi all'interno dell'applicazione. Per farlo, ho creato una coda asincrona basata su promesse:

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

Concatenando ogni operazione in un callback then() della promessa queue esistente e memorizzando il risultato concatenato come nuovo valore di queue, posso assicurarmi che tutte le operazioni vengano eseguite una alla volta, in ordine e senza sovrapposizioni.

Gli eventuali errori delle operazioni vengono restituiti al chiamante, mentre gli errori critici (imprevisti) contrassegnano l'intera catena come promessa rifiutata e assicurano che in seguito non verranno pianificate nuove operazioni.

Mantenendo il contesto del modulo in una variabile privata (non esportata), posso ridurre al minimo i rischi di accedere a context per sbaglio da qualche altra parte nell'app senza dover eseguire la chiamata schedule().

Per unire gli elementi, ora ogni accesso al contesto del dispositivo deve essere aggregato in una chiamata schedule() come la seguente:

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

e

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

Dopodiché, tutte le operazioni sono state eseguite correttamente senza conflitti.

Conclusione

Non esitare a sfogliare il codebase su GitHub per ulteriori insight sull'implementazione. Voglio ringraziare anche Marcus Meissner per la manutenzione di gFoto2 e per le sue recensioni dei miei PR upstream.

Come mostrato in questi post, le API WebAssembly, Asyncify e Fugu forniscono un efficace target di compilazione anche per le applicazioni più complesse. Ti permettono di trasferire una libreria o un'applicazione precedentemente creata per un'unica piattaforma e di portarla sul web, rendendola disponibile per un numero molto più ampio di utenti su computer e dispositivi mobili.