Przenoszenie aplikacji USB do internetu. Część 2. gPhoto2

Dowiedz się, jak pakiet gPhoto2 został przeniesiony do WebAssembly, aby można było sterować zewnętrznymi aparatami przez USB z aplikacji internetowej.

W poprzednim poście pokazaliśmy, jak biblioteka libusb została przeniesiona do sieci przy użyciu narzędzi WebAssembly / Emscripten, Asyncify i WebUSB.

Przedstawiłem też prezentację opracowaną w aplikacji gPhoto2, która pozwala sterować lustrzanką cyfrową i aparatami bezlusterkowymi przez USB z poziomu aplikacji internetowej. W tym poście bardziej szczegółowo omówię techniczne porty gPhoto2.

Kierowanie systemów kompilacji na niestandardowe widelce

Kieruję kod na WebAssembly, więc nie mogłem korzystać z bibliotek libusb i libgphoto2 dostępnych w dystrybucjach systemowych. Aplikacja używała własnego rozwidlenia „libgphoto2”, a rozwijanego „libgphoto2” – własnego rozwidlenia „libusb”.

Dodatkowo libgphoto2 używa narzędzia libtool do wczytywania dynamicznych wtyczek i mimo że nie musiałem tworzyć takiego narzędzia jak pozostałe 2 biblioteki, musiałem utworzyć go w WebAssembly i wskazać w narzędziu libgphoto2 tę niestandardową kompilację zamiast pakietu systemowego.

Oto przybliżony diagram zależności (linie przerywane oznaczają linki dynamiczne):

Diagram przedstawiający aplikację w zależności od obiektu „libgphoto2 fork”, który zależy od obiektu „libtool”. „libtool” blok zależy dynamicznie od portów „libgphoto2” i „libgphoto2 camlibs”. Na koniec „porty libgphoto2”. jest statycznie zależna od „libusb fork”.

Większość systemów kompilacji opartych na konfiguracji, w tym te używane w tych bibliotekach, umożliwia zastępowanie ścieżek zależności za pomocą różnych flag, więc to właśnie próbowałem zrobić w pierwszej kolejności. Jednak gdy graf zależności staje się skomplikowany, lista zastąpień ścieżek dla zależności każdej biblioteki staje się szczegółowa i podatna na błędy. Natknęłam się też na błędy, które powodowały, że systemy kompilacji nie były w rzeczywistości przygotowane na to, że ich zależności funkcjonują w niestandardowych ścieżkach.

Zamiast tego łatwiej jest utworzyć oddzielny folder jako niestandardowy katalog główny systemu (często skrócony do „sysroot”) i skierować do niego wszystkie zaangażowane systemy kompilacji. Dzięki temu podczas kompilacji każda biblioteka będzie szukać swoich zależności w określonym sysroot podczas kompilacji, a także zainstaluje się w tym samym katalogu, by inni mogli ją łatwiej znaleźć.

Plik Emscripten ma już własny katalog Sysroot w domenie (path to emscripten cache)/sysroot, którego używa na potrzeby bibliotek systemowych, portów Emscripten oraz narzędzi, takich jak CMake i pkg-config. Określiłem(-am) ten sam system Sysroot także dla moich zależności.

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

Przy takiej konfiguracji wystarczyło uruchomić polecenie make install w każdej zależności, które zainstalowało je w systemie sysroot, a biblioteki automatycznie się znalazły.

Radzenie sobie z ładowaniem dynamicznym

Jak wspomnieliśmy wcześniej, narzędzie libgphoto2 wykorzystuje narzędzie libtool do wyliczania i dynamicznego ładowania adapterów portów wejścia-wyjścia oraz bibliotek aparatu. Na przykład kod wczytywania bibliotek wejścia-wyjścia wygląda tak:

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

Takie podejście w internecie wiąże się z kilkoma problemami:

  • Nie ma standardowej obsługi dynamicznego łączenia modułów WebAssembly. Emscripten ma niestandardową implementację, która może symulować interfejs API dlopen() używany w libtool, ale wymaga utworzenia obiektu „main” i „bok” modułów z różnymi flagami, a w szczególności dla dlopen() – w celu wstępnego ładowania modułów bocznych do emulowanego systemu plików podczas uruchamiania aplikacji. Integracja tych flag i dostosowanie do obecnego systemu kompilacji Autoconf z wieloma bibliotekami dynamicznymi może być trudny.
  • Nawet jeśli dlopen() jest zaimplementowany, nie można wymienić wszystkich bibliotek dynamicznych w określonym folderze w internecie, ponieważ większość serwerów HTTP nie ujawnia list katalogów ze względów bezpieczeństwa.
  • Łączenie bibliotek dynamicznych w wierszu poleceń zamiast wyliczania w czasie działania może też prowadzić do problemów (na przykład do problemu z duplikatami symboli) z powodu różnic między reprezentacją bibliotek udostępnionych w Emscripten i na innych platformach.

Można dostosować system kompilacji do tych różnic i zakodować na stałe listę wtyczek dynamicznych na etapie kompilacji, ale jeszcze łatwiejszym sposobem rozwiązania wszystkich tych problemów jest unikanie na początku dynamicznych linków.

Okazuje się, że libtool wyklucza różne metody tworzenia linków dynamicznych na różnych platformach, a nawet obsługuje tworzenie niestandardowych modułów ładujących. Jeden z wbudowanych modułów ładujących nosi nazwę „Dlpreopening”:

„Libtool zapewnia specjalną obsługę plików dlopening libtool i bibliotek libtool, dzięki czemu można rozpoznać ich symbole nawet na platformach bez funkcji dlopen i dlsym.
...
Libtool emuluje parametr -dlopen na platformach statycznych, łącząc obiekty z programem w czasie kompilacji i tworząc struktury danych reprezentujące tabelę symboli programu. Aby korzystać z tej funkcji, musisz zadeklarować obiekty, które ma otwierać aplikacja, przy użyciu flag -dlopen lub -dlpreopen podczas łączenia programu (patrz Tryb linku)”.

Ten mechanizm umożliwia emulację dynamicznego wczytywania na poziomie narzędzia libtool zamiast Emscripten, jednocześnie łącząc wszystkie elementy w statyczny sposób w jedną bibliotekę.

Jedynym problemem, którego nie można rozwiązać, jest wyliczanie bibliotek dynamicznych. Ich listę nadal musi być gdzieś zakodowaną na stałe. Na szczęście zestaw wtyczek potrzebnych mi do działania aplikacji jest minimalny:

  • Jeśli chodzi o porty, interesuje mnie tylko podłączenie kamery oparte na Libusb, a nie PTP/IP, dostęp szeregowy czy tryby dysku USB.
  • Na stronie Camlibs dostępne są różne wtyczki konkretnych dostawców, które mogą oferować pewne wyspecjalizowane funkcje. Jednak do ogólnej kontroli ustawień i rejestrowania ich wystarczy użyć protokołu Picture Transfer Protocol, który jest reprezentowany przez ptp2 i jest obsługiwany przez praktycznie wszystkie aparaty na rynku.

Tak wygląda zaktualizowany diagram zależności z powiązanymi statycznie wszystkimi elementami:

Diagram przedstawiający aplikację w zależności od obiektu „libgphoto2 fork”, który zależy od obiektu „libtool”. „libtool” zależy od „ports: libusb1” i 'camlibs: libptp2'. „ports: libusb1” zależy od „libusb fork”.

Oto kod zakodowany na stałe do kompilacji 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 ();

i

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

W systemie kompilacji Autoconf musiałem dodać -dlpreopen z obydwoma plikami jako flagi linków do wszystkich plików wykonywalnych (przykładów, testów i mojej aplikacji demonstracyjnej):

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

Teraz gdy wszystkie symbole są połączone statycznie w jednej bibliotece, libtool potrzebuje sposobu na określenie, który symbol należy do danej biblioteki. Aby to osiągnąć, deweloperzy muszą zmienić nazwy wszystkich widocznych symboli, np. {function name}, na {library name}_LTX_{function name}. Najłatwiej to zrobić, używając polecenia #define do ponownego definiowania nazw symboli na górze pliku implementacji:

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

Takie nazewnictwo zapobiega też konfliktom nazw, jeśli w przyszłości zechcę połączyć wtyczki kamery w tej samej aplikacji.

Po wprowadzeniu wszystkich zmian mogłem skompilować aplikację testową i prawidłowo załadować wtyczki.

Generowanie interfejsu ustawień

gPhoto2 umożliwia bibliotekom aparatu definiowanie własnych ustawień w formie drzewa widżetów. Hierarchia typów widżetów obejmuje:

  • Okno – kontener konfiguracji najwyższego poziomu
    • Sekcje – nazwane grupy innych widżetów
    • Pola przycisku
    • Pola tekstowe
    • Pola numeryczne
    • Pola daty
    • Przełącza
    • Opcje

Zapytanie o nazwę, typ, elementy podrzędne i wszystkie pozostałe odpowiednie właściwości każdego widżetu można wysyłać (a w przypadku wartości – także modyfikować) za pomocą ujawnionego interfejsu C API. Razem stanowią podstawę do automatycznego generowania interfejsu ustawień w dowolnym języku, który obsługuje C.

Ustawienia można w dowolnym momencie zmienić w gPhoto2 lub w samym aparacie. Dodatkowo niektóre widżety mogą być dostępne w trybie tylko do odczytu, a nawet sam stan tylko do odczytu zależy od trybu aparatu i innych ustawień. Na przykład Szybkość migawki jest polem liczbowym w M (tryb ręczny), ale w P (trybie programu) staje się polem informacyjnym tylko do odczytu. W trybie P wartość szybkości migawki jest też dynamiczna i stale zmienia się w zależności od jasności kadru.

Ważne jest, aby w interfejsie użytkownika były zawsze aktualne informacje z połączonej kamery, jednocześnie umożliwiając użytkownikowi edytowanie tych ustawień w tym samym interfejsie. Obsługa takiego dwukierunkowego przepływu danych jest bardziej skomplikowana.

Program gPhoto2 nie ma mechanizmu pobierania samych zmienionych ustawień, a jedynie całego drzewa lub poszczególnych widżetów. Aby zachować aktualność interfejsu użytkownika bez migotania i utraty zaznaczenia danych wejściowych lub pozycji przewijania, potrzebowałem sposobu na rozróżnienie drzew widżetów między wywołaniami i zaktualizowanie tylko zmienionych właściwości interfejsu. Na szczęście ten problem został rozwiązany w internecie i stanowi podstawową funkcjonalność platform takich jak React czy Preact. W tym projekcie wybrałem firmę Preact, która jest o wiele lżejsza i ma wszystko, czego potrzebuję.

Po stronie C++ muszę teraz pobrać i cyklicznie przejść drzewo ustawień za pomocą wcześniej połączonego interfejsu C API oraz przekonwertować każdy widżet na obiekt 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;
    }
    // …

Po stronie JavaScriptu mogę teraz wywołać funkcję configToJS, przejść do zwróconej reprezentacji JavaScriptu drzewa ustawień i skompilować interfejs za pomocą funkcji 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;
  }
  // …

Dzięki wielokrotnemu uruchamianiu tej funkcji w nieskończonej pętli zdarzeń mogę mieć możliwość wyświetlania w interfejsie ustawień najnowszych informacji, a jednocześnie wysyłania poleceń do kamery za każdym razem, gdy użytkownik zmodyfikuje jedno z pól.

Preact może uwzględniać różnice w wynikach i aktualizować DOM tylko w przypadku zmienionych elementów UI, bez zakłócania fokusu na stronie ani stanów edycji. Jednym z pozostałych problemów jest dwukierunkowy przepływ danych. Platformy takie jak React i Preact zostały zaprojektowane z myślą o jednokierunkowym przepływie danych, ponieważ ułatwia to wyciąganie wniosków i porównywanie danych między powtórzeniami. Przełamuję to oczekiwania, zezwalając źródłom zewnętrznym – kamerze – na aktualizowanie interfejsu ustawień w dowolnym momencie.

Aby rozwiązać ten problem, zrezygnowałem z aktualizacji interfejsu dotyczące pól do wprowadzania danych, które są obecnie edytowane przez użytkownika:

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

Dzięki temu dane pole zawsze ma tylko 1 właściciela. Użytkownik aktualnie go edytuje, a zaktualizowane wartości uzyskane przez kamerę nie będą zakłócić działania kamery albo kamera aktualizuje wartość pola, gdy jest ono nieostre.

Tworzenie „filmu” na żywo kanał

W trakcie pandemii wiele osób przeniosło się na spotkania online. Doprowadziło to m.in. do niedoborów na rynku kamer internetowych. Aby uzyskać lepszą jakość wideo w porównaniu z kamerami wbudowanymi w laptopy oraz w odpowiedzi na te niedobory, wielu właścicieli lustrzanek cyfrowych i aparatów bezlusterkowych zaczęło szukać sposobów na używanie aparatów fotograficznych jako kamer internetowych. W tym celu niektórzy dostawcy aparatów wysłali nawet oficjalne narzędzia komunalne.

Tak jak oficjalne narzędzia, gPhoto2 obsługuje strumieniowanie wideo z aparatu do pliku zapisanego lokalnie lub bezpośrednio na wirtualną kamerę internetową. Zależało mi na tej funkcji, aby przeprowadzić podgląd na żywo w mojej wersji demonstracyjnej. Nie mogę jej jednak znaleźć w interfejsach API biblioteki libgphoto2, mimo że jest dostępna w konsoli.

Przeglądając kod źródłowy odpowiedniej funkcji w narzędziu w konsoli, stwierdzam, że w rzeczywistości w ogóle nie pojawia się film, ale zapisuje podgląd z kamery w formie pojedynczych obrazów JPEG w niekończącej się pętli, a potem zapisuję je pojedynczo w celu utworzenia strumienia M-JPEG:

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

Byłem zaskoczony, że to rozwiązanie działa tak skutecznie, by stworzyć wrażenie płynnego wyświetlania filmów w czasie rzeczywistym. Byłem jeszcze sceptyczny, co do tego, że można osiągnąć taką samą wydajność również w aplikacji internetowej, z tymi wszystkimi dodatkowymi abstrakcjami i pominięciem funkcji Asyncify. Postanowiłem jednak spróbować.

Po stronie języka C++ udostępniliśmy metodę capturePreviewAsBlob(), która wywołuje tę samą funkcję gp_camera_capture_preview() i konwertuje wynikowy plik w pamięci na format Blob, który można łatwiej przekazać do innych internetowych interfejsów API:

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

Jeśli chodzi o JavaScript, mam pętlę, podobną do tej w gPhoto2, która pobiera obrazy podglądu jako pliki Blob, dekoduje je w tle za pomocą narzędzia createImageBitmap i przenosi je do obszaru roboczego przy następnej klatce animacji:

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

Dzięki tym nowoczesnym interfejsom API cała dekodowanie odbywa się w tle, a obszar roboczy jest aktualizowany tylko wtedy, gdy zarówno obraz, jak i przeglądarka są w pełni przygotowane do rysowania. W rezultacie na laptopie udało się uzyskać stałą szybkość ponad 30 FPS, co odpowiada rzeczywistej wydajności zarówno gPhoto2, jak i oficjalnego oprogramowania Sony.

Synchronizowanie dostępu do urządzeń USB

Żądanie przesłania danych przez USB w trakcie wykonywania innej operacji zwykle skutkuje komunikatem „urządzenie jest zajęte”. . Ponieważ podgląd i interfejs ustawień regularnie się aktualizują, a użytkownik może próbować zrobić zdjęcie lub zmienić ustawienia w tym samym czasie, konflikty między różnymi operacjami pojawiały się bardzo często.

Aby ich uniknąć, musiałem zsynchronizować wszystkie ustawienia dostępu w aplikacji. Do tego celu stworzyłem asynchroniczną kolejkę opartą na obietnicach:

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

Łącząc każdą operację w wywołaniu zwrotnym then() z dotychczasowej obietnicy queue i przechowując łańcuchowy wynik jako nową wartość queue, mogę mieć pewność, że wszystkie operacje będą wykonywane po kolei w odpowiedniej kolejności i bez nakładania się.

Wszystkie błędy operacji są zwracane do elementu wywołującego, a błędy krytyczne (nieoczekiwane) oznaczają cały łańcuch jako odrzuconą obietnicę i zapewniają, że później nie będą zaplanowane żadne nowe operacje.

Trzymając kontekst modułu w prywatnej (nieeksportowanej) zmiennej, minimalizuję ryzyko przypadkowego dostępu do context w innym miejscu w aplikacji bez użycia wywołania schedule().

Aby wszystko było możliwe, każdy dostęp do kontekstu urządzenia musi zostać zawarty w wywołaniu schedule() w ten sposób:

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

i

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

Po tym czasie wszystkie operacje zostały wykonane bez konfliktów.

Podsumowanie

Aby dowiedzieć się więcej o wdrożeniu, możesz przejrzeć bazę kodu na GitHubie. Dziękuję też Marcusowi Meissnerowi za utrzymanie projektu gPhoto2 i jego opinie na temat moich wcześniejszych działów PR.

Jak widać w tych postach, interfejsy WebAssembly oraz Asyncify i Fugu API zapewniają wydajną kompilację nawet dla najbardziej złożonych aplikacji. Umożliwiają przeniesienie biblioteki lub aplikacji stworzonej wcześniej na jedną platformę i przeniesienie ich do internetu, dzięki czemu są one dostępne dla znacznie większej liczby użytkowników komputerów i urządzeń mobilnych.