Portare le applicazioni USB sul Web. Parte 2: gPhoto2

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

Ingvar Stepanyan
Ingvar Stepanyan

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

Ho anche mostrato una demo realizzata con gPhoto2 che può controllare fotocamere DSLR e mirrorless tramite USB da un'applicazione web. In questo post approfondiremo i dettagli tecnici della porta gPhoto2.

Indirizzamento dei sistemi di build a fork personalizzati

Dato che avevo scelto come target WebAssembly, non ho potuto usare libusb e libgphoto2 forniti dalle distribuzioni del sistema. Invece, avevo bisogno che la mia applicazione utilizzasse il mio fork personalizzato di libgphoto2, mentre quel fork di libgphoto2 doveva usare la mia fork personalizzata di libusb.

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

Ecco un diagramma delle dipendenze approssimativo (le linee tratteggiate indicano il collegamento dinamico):

Un diagramma mostra "l'app" a seconda di "libgphoto2 fork", che dipende da "libtool". "libtool" Il blocco dipende dinamicamente dalle porte "libgphoto2" e "libgphoto2 camlibs". Infine, le "porte libgphoto2" dipende staticamente dal "libusb fork".

La maggior parte dei sistemi di build basati sulla configurazione, compresi quelli utilizzati in queste librerie, consente di eseguire l'override dei percorsi per le dipendenze tramite vari flag, perciò è quello che ho provato a fare prima. Tuttavia, quando il grafico delle dipendenze diventa complesso, l'elenco degli override del percorso per le dipendenze di ogni libreria diventa dettagliato e soggetto a errori. Ho anche trovato 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 a questa cartella. In questo modo, ogni libreria cercherà le proprie dipendenze nel sysroot specificato durante la build e si installerà anche nello stesso sysroot in modo che gli altri possano trovarla più facilmente.

Emscripten ha già la propria directory sysroot in (path to emscripten cache)/sysroot, che utilizza per le sue librerie di sistema, le porte 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, mi bastava eseguire make install in ciascuna dipendenza, che l'ha installata nel file sysroot. Quindi le librerie si sono trovate automaticamente.

Gestire il caricamento dinamico

Come già detto, libgphoto2 utilizza libtool per enumerare e caricare dinamicamente gli adattatori delle porte 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 sul web presenta alcuni problemi:

  • Non è disponibile un supporto standard per il collegamento dinamico dei moduli WebAssembly. Emscripten ha la sua implementazione personalizzata in grado di simulare l'API dlopen() utilizzata da libtool, ma richiede la creazione di "main" e "di lato" moduli con flag diversi e, in particolare per dlopen(), anche per precaricare i moduli laterali nel file system emulato durante l'avvio dell'applicazione. Può essere difficile integrare questi flag e le modifiche in un sistema di compilazione autoconf esistente con molte librerie dinamiche.
  • Anche se lo stesso 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 il collegamento delle librerie dinamiche alla riga di comando anziché l'enumerazione 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 dei plug-in dinamici in qualche punto durante la creazione, ma un modo ancora più semplice per risolvere tutti questi problemi è evitare per cominciare il collegamento dinamico.

È emerso che libtool astrae 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 dlopening dei file di oggetti libtool e di libreria libtool, in modo che i relativi simboli possano essere risolti anche su piattaforme senza funzioni dlopen e dlsym.
...
Libtool emula -dlopen su piattaforme statiche collegando gli oggetti al programma al momento della compilazione e creando strutture di dati che rappresentano la tabella dei simboli del programma. Per utilizzare questa funzionalità, devi dichiarare gli oggetti che devono essere dlopen dall'applicazione utilizzando i flag -dlopen o -dlpreopen quando colleghi il programma (vedi Modalità di collegamento).

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

L'unico problema che questo non risolve è l'enumerazione delle librerie dinamiche. L'elenco di questi modelli deve ancora essere impostato come hardcoded da qualche parte. Fortunatamente, il set di plug-in di cui avevo bisogno per l'app è minimo:

  • Per quanto riguarda le porte, mi interessa solo la connessione della fotocamera basata su libusb e non le modalità PTP/IP, accesso seriale o unità USB.
  • Per quanto riguarda le camlibs, ci sono vari plug-in specifici del produttore 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 appare il diagramma delle dipendenze aggiornato con tutti gli elementi collegati in modo statico:

Un diagramma mostra "l'app" a seconda di "libgphoto2 fork", che dipende da "libtool". "libtool" dipende da "ports: libusb1" e "camlibs: libpt2". "ports: libusb1" dipende dal "libusb fork".

Ecco cosa ho impostato come hardcoded per le build di 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 autoconf, ora devo aggiungere -dlpreopen con entrambi questi file come flag di link 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 una determinata libreria. A questo scopo, richiede agli sviluppatori di 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 impedisce anche conflitti di nomi nel caso in cui in futuro decidessi di collegare plug-in specifici della videocamera nella stessa app.

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

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

gPhoto2 consente alle librerie della fotocamera di definire le proprie impostazioni sotto forma di struttura ad albero dei widget. La gerarchia dei tipi di widget è composta da:

  • Finestra - Container di configurazione di primo livello
    • Sezioni: gruppi denominati di altri widget
    • Campi pulsante
    • Campi di testo
    • Campi numerici
    • Campi data
    • Pulsanti di attivazione/disattivazione
    • Pulsanti di opzione

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

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

In generale, è importante mostrare sempre nell'interfaccia utente informazioni aggiornate della videocamera connessa e allo stesso tempo consentire all'utente di modificare queste impostazioni dalla stessa interfaccia utente. Un flusso di dati bidirezionale è più complesso da gestire.

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

Sul lato C++ ora dovevo recuperare e percorrere in modo ricorsivo l'albero delle impostazioni tramite la precedente API C collegata, 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;
    }
    // …

Sul lato JavaScript, ora potrei chiamare configToJS, esaminare la rappresentazione JavaScript restituita della struttura ad albero delle impostazioni e creare la UI 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, è possibile fare in modo che la UI delle impostazioni mostri sempre le informazioni più recenti, inviando al contempo comandi alla fotocamera ogni volta che uno dei campi viene modificato dall'utente.

Preact è in grado di differenziare i risultati e di aggiornare il DOM solo per i bit modificati dell'interfaccia utente, senza interrompere lo stato attivo della pagina o modificare gli stati. Un problema che rimane è il flusso di dati bidirezionale. Framework come React e Preact sono stati progettati attorno a un flusso di dati unidirezionale, perché semplifica notevolmente il ragionamento sui dati e il confronto tra le repliche, ma sto rompendo le aspettative consentendo a una sorgente esterna, la fotocamera, di aggiornare la UI 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. L'utente sta attualmente modificando il campo e non sarà disturbato dai valori aggiornati della fotocamera oppure la fotocamera sta aggiornando il valore del campo quando non è a fuoco.

Creazione di un "video" dal vivo feed

Durante la pandemia, molte persone sono passate alle riunioni online. Ciò ha causato, tra le altre cose, la carenza nel mercato delle webcam. Per ottenere una migliore qualità video rispetto alle fotocamere integrate nei laptop e, in risposta a questa carenza, molti proprietari di fotocamere DSLR e mirrorless hanno iniziato a cercare modi per utilizzare le fotocamere fotografiche come webcam. Diversi fornitori di fotocamere hanno persino fornito utility ufficiali proprio a questo scopo.

Come gli strumenti ufficiali, gPhoto2 supporta lo streaming video dalla fotocamera a un file memorizzato in locale o anche 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 da nessuna parte nelle API della libreria libgphoto2.

Osservando il codice sorgente della funzione corrispondente nell'utilità della console, ho scoperto che non riceve affatto un video, ma continua a recuperare l'anteprima della fotocamera sotto forma di singole immagini JPEG in un ciclo infinito e le scrive una alla volta per formare uno stream M-JPEG:

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

Mi ha sorpreso il fatto che questo approccio funzioni in modo abbastanza efficiente da avere un'impressione di un video in tempo reale senza interruzioni. Ero ancora più scettico sulla possibilità di ottenere le stesse prestazioni anche nell'applicazione web, con tutte le astrazioni extra e Asyncify in mezzo. 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 Blob che può essere passato 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 gPhoto2, che continua a recuperare le immagini di anteprima come Blob, le decodifica in background con createImageBitmap e le trasferisce nel canvas nel frame dell'animazione successivo:

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 API moderne assicura che tutto il lavoro di decodifica venga eseguito in background e il canvas viene aggiornato solo quando sia l'immagine sia il browser sono completamente preparati per il disegno. In questo modo, sul mio laptop è stato possibile aumentare la frequenza di oltre 30 f/s, in linea con le prestazioni native di gPhoto2 e del software Sony ufficiale.

Sincronizzazione dell'accesso USB in corso...

Quando viene richiesto un trasferimento di dati USB mentre è già in corso un'altra operazione, di solito viene visualizzato un messaggio che indica che il dispositivo è occupato . Poiché l'anteprima e l'interfaccia utente 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. A questo scopo, ho creato una coda asincrona basata su una promessa:

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 archiviando il risultato concatenato come nuovo valore di queue, posso assicurarmi che tutte le operazioni vengano eseguite una per una, in ordine e senza sovrapposizioni.

Tutti gli errori dell'operazione vengono restituiti al chiamante, mentre gli errori critici (imprevisti) contrassegnano l'intera catena come promessa rifiutata e assicurano che non verrà pianificata nessuna nuova operazione in seguito.

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

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

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

e

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

Successivamente, tutte le operazioni sono state eseguite correttamente senza conflitti.

Conclusione

Puoi sfogliare il codebase su GitHub per altri insight sull'implementazione. Voglio anche ringraziare Marcus Meissner per la manutenzione di gPhoto2 e per le sue recensioni dei miei PR a monte.

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