Portare le applicazioni USB sul Web. Parte 2: gPhoto2

Scopri come gPhoto2 è stato portato a 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 le fotocamere DSLR e mirrorless tramite USB da un'applicazione web. In questo post approfondirò i dettagli tecnici alla base della porta gPhoto2.

Indicare ai sistemi di compilazione i fork personalizzati

Poiché il mio target era WebAssembly, non potevo utilizzare libusb e libgphoto2 forniti dalle distribuzioni di sistema. Invece, avevo bisogno che la mia applicazione usasse il mio fork personalizzato di libgphoto2, mentre questo fork di libgphoto2 doveva usare il mio fork personalizzato di libusb.

Inoltre, libgphoto2 utilizza libtool per caricare i plug-in dinamici e, anche se non ho dovuto eseguire il fork di libtool come le altre due librerie, ho comunque dovuto compilarlo in WebAssembly e indirizzare libgphoto2 a quella compilazione personalizzata anziché al pacchetto di sistema.

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

Un diagramma mostra "l'app" che dipende da "libgphoto2 fork", che dipende da "libtool". Il blocco "libtool" dipende dinamicamente da "libgphoto2 ports" e "libgphoto2 camlibs". Infine, "libgphoto2 ports" dipende in modo statico dal "fork libusb".

La maggior parte dei sistemi di compilazione basati su configurazione, inclusi quelli utilizzati in queste librerie, consente di eseguire l'override dei percorsi per le dipendenze tramite vari flag, quindi è quello che ho provato a fare per primo. Tuttavia, quando il grafo delle dipendenze diventa complesso, l'elenco delle sostituzioni del percorso per le dipendenze di ogni libreria diventa prolisso e soggetto a errori. Ho anche trovato alcuni bug in cui i sistemi di compilazione non erano effettivamente preparati per le dipendenze in percorsi non standard.

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

Emscripten ha già il proprio sysroot in (path to emscripten cache)/sysroot, che utilizza per le librerie di sistema, le portazioni 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, ho dovuto solo eseguire make install in ogni dipendenza, che l'ha installata in sysroot, dopodiché le librerie si sono trovate automaticamente.

Gestire il caricamento dinamico

Come accennato sopra, libgphoto2 utilizza libtool per enumerare e caricare dinamicamente le librerie della fotocamera e gli adattatori delle porte I/O. Ad esempio, il codice per il caricamento delle librerie I/O è il seguente:

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

Questo approccio presenta alcuni problemi sul web:

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

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

A quanto pare, libtool esegue l'astrazione di vari metodi di collegamento dinamico su piattaforme diverse e supporta persino la scrittura di caricatori personalizzati per altre piattaforme. Uno dei caricatori integrati supportati si chiama "Dlpreopening":

"Libtool fornisce un supporto speciale per il caricamento dinamico dei file oggetto e delle librerie libtool, in modo che i relativi simboli possano essere risolti anche su piattaforme senza funzioni dlopen e dlsym.

Libtool emula -dlopen sulle piattaforme statiche collegando gli 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 utilizzando i flag -dlopen o -dlpreopen quando esegui il linking del 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 non risolve è l'enumerazione delle librerie dinamiche. L'elenco di questi elementi deve ancora essere 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 PTP/IP, accesso seriale o modalità del dispositivo USB.
  • Per quanto riguarda le camlib, esistono vari plug-in specifici del fornitore che potrebbero fornire alcune funzioni specializzate, ma per il controllo delle impostazioni generali e l'acquisizione è sufficiente utilizzare il Picture Transfer Protocol, rappresentato dalla camlib ptp2 e supportato da quasi tutte le fotocamere sul mercato.

Ecco l'aspetto del diagramma di dipendenza aggiornato con tutto collegato in modo statico:

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

Ecco cosa ho 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 autoconf, ora devo aggiungere -dlpreopen con entrambi i file come flag di collegamento per tutti gli eseguibili (esempi, test e la mia app di dimostrazione), come segue:

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 a quale libreria appartiene ciascun simbolo. Per farlo, 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 impedisce anche i conflitti di nomi nel caso in cui in futuro decida di collegare plug-in specifici per le videocamere nella stessa app.

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

Generazione dell'interfaccia utente delle impostazioni

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

  • Window: contenitore di configurazione di primo livello
    • Sezioni: gruppi di altri widget con nome
    • Campi dei pulsanti
    • Campi di testo
    • Campi numerici
    • Campi data
    • Pulsanti di attivazione/disattivazione
    • Pulsanti di opzione

È possibile eseguire query (e, in caso di valori, anche modificare) su nome, tipo, elementi secondari e tutte le altre proprietà pertinenti di ogni widget tramite l'API C esposta. Insieme, forniscono una base per la generazione automatica dell'interfaccia utente delle impostazioni 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 possono essere di sola lettura e anche lo stato di sola lettura stesso dipende dalla modalità della fotocamera e da altre impostazioni. Ad esempio, tempo di esposizione è un campo numerico modificabile in M (modalità manuale), ma diventa un campo informativo di sola lettura in P (modalità programmata). In modalità P, anche il valore della velocità dell'otturatore sarà dinamico e cambierà continuamente in base alla luminosità della scena inquadrata dalla fotocamera.

Tutto sommato, è importante mostrare sempre informazioni aggiornate della videocamera connessa nell'interfaccia utente, consentendo al contempo all'utente di modificare queste impostazioni dalla stessa interfaccia utente. Questo flusso di dati bidirezionale è più complesso da gestire.

gPhoto2 non dispone di un meccanismo per recuperare solo le impostazioni modificate, ma solo l'intera struttura o i singoli widget. Per mantenere l'interfaccia utente aggiornata senza sfarfallii e senza perdere lo stato attivo dell'input o la posizione di scorrimento, avevo bisogno di un modo per confrontare le strutture ad albero dei widget tra le invocazioni 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. Per questo progetto ho scelto Preact, perché è molto più leggero e fa tutto ciò di cui ho bisogno.

Sul lato C++, ora dovevo recuperare ed eseguire la ricerca ricorsiva della struttura ad 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;
   
}
   
// …

Lato 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 ciclo di eventi infinito, ho potuto fare in modo che l'interfaccia utente delle impostazioni mostri sempre le informazioni più recenti, inviando contemporaneamente comandi alla videocamera ogni volta che uno dei campi viene modificato dall'utente.

Preact può occuparsi della differenza tra i risultati e dell'aggiornamento del DOM solo per le parti modificate dell'interfaccia utente, senza interrompere lo stato di modifica o di messa a fuoco della pagina. Un problema che rimane è il flusso di dati bidirezionale. Framework come React e Preact sono stati progettati in base al flusso di dati unidirezionale, perché semplificano molto la comprensione dei dati e il loro confronto tra le repliche, ma sto rompendo questa aspettativa consentendo a un'origine esterna, la videocamera, di aggiornare l'interfaccia utente delle impostazioni in qualsiasi momento.

Ho risolto il problema disattivando gli aggiornamenti dell'interfaccia utente per tutti i campi di immissione attualmente in fase di modifica da parte dell'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 ogni campo. L'utente lo sta modificando e non verrà interrotto dai valori aggiornati della fotocamera oppure la fotocamera sta aggiornando il valore del campo mentre non è a fuoco.

Creare un feed "video" dal vivo

Durante la pandemia, molte persone sono passate alle riunioni online. Tra le altre cose, ciò ha portato a carenze sul mercato delle webcam. Per ottenere una qualità video migliore rispetto alle fotocamere integrate nei laptop e in risposta a queste carenze, molti proprietari di fotocamere DSLR e mirrorless hanno iniziato a cercare modi per utilizzare le loro fotocamere come webcam. Diversi produttori di videocamere hanno persino inviato utilità ufficiali per questo preciso scopo.

Come gli strumenti ufficiali, gPhoto2 supporta lo streaming video dalla fotocamera a un file archiviato localmente o direttamente a una webcam virtuale. Volevo utilizzare questa funzionalità per fornire una visualizzazione in tempo reale nella mia demo. Tuttavia, anche se è disponibile nell'utilità della console, non sono riuscito a trovarla nelle API della libreria libgphoto2.

Esaminando il codice sorgente della funzione corrispondente nell'utilità della console, ho scoperto che non riceve alcun video, ma continua a recuperare l'anteprima della fotocamera come singole immagini JPEG in un loop infinito e a scriverle una per una per formare uno stream M-JPEG:

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

Sono rimasto stupito di quanto questo approccio funzioni in modo efficiente per dare l'impressione di un video fluido in tempo reale. Ero ancora più scettico sul fatto di poter ottenere lo stesso rendimento anche nell'applicazione web, con tutte le astrazioni aggiuntive e Asyncify. Tuttavia, ho deciso di provare comunque.

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 ad altre API web più facilmente:

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

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 nella tela nel frame di 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 garantisce che tutte le operazioni di decodifica vengano eseguite in background e che la tela venga aggiornata solo quando sia l'immagine sia il browser sono completamente pronti per il disegno. Ho ottenuto una frequenza costante di oltre 30 FPS sul mio laptop, che corrispondeva alle prestazioni native sia di gPhoto2 sia del software ufficiale Sony.

Sincronizzazione dell'accesso USB

Quando viene richiesto un trasferimento di dati USB mentre è già in corso un'altra operazione, in genere viene visualizzato l'errore "Il dispositivo è occupato". Poiché l'anteprima e l'interfaccia utente delle impostazioni vengono aggiornate regolarmente e l'utente potrebbe provare a acquisire un'immagine o modificare le impostazioni contemporaneamente, questi conflitti tra operazioni diverse si sono rivelati molto frequenti.

Per evitarli, ho dovuto sincronizzare tutti gli accessi all'interno dell'applicazione. Per questo, 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;
}

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

Eventuali errori di operazione vengono restituiti al chiamante, mentre gli errori critici (imprevedibili) contrassegnano l'intera catena come una promessa rifiutata e assicurano che in seguito non venga pianificata alcuna nuova operazione.

Mantenendo il contesto del modulo in una variabile privata (non esportata), riduco al minimo i rischi di accedere accidentalmente a context in un altro punto dell'app senza passare per la chiamata a schedule().

Per riassumere, ora ogni accesso al contesto del dispositivo deve essere racchiuso in una chiamata schedule() come questa:

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

Per ulteriori approfondimenti sull'implementazione, non esitare a sfogliare il repository su GitHub. Voglio anche ringraziare Marcus Meissner per la manutenzione di gPhoto2 e per le sue revisioni delle mie 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. Ti consentono di trasferire una libreria o un'applicazione creata in precedenza per una singola piattaforma sul web, rendendola disponibile a un numero molto maggiore di utenti su computer e dispositivi mobili.