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

Dowiedz się, jak przenieśliśmy gPhoto2 do WebAssembly, aby sterować zewnętrznymi kamerami przez USB z aplikacji internetowej.

W poprzednim poście pokazałem, w jaki sposób biblioteka libusb działa w internecie przy użyciu takich rozwiązań jak WebAssembly / Emscripten, Asyncify i WebUSB.

Pokazałam też prezentację utworzoną przy użyciu gPhoto2, która umożliwia sterowanie lustrzanką cyfrową i aparatami bezlusterkowymi przez USB z poziomu aplikacji internetowej. W tym poście omówię szczegóły techniczne portu gPhoto2.

Kierowanie systemów kompilacji na niestandardowe widelce

Ponieważ ustawiam kierowanie na WebAssembly, nie mogę użyć bibliotek libusb ani libgphoto2 dostępnych w rozkładach systemu. W tej aplikacji potrzebowałam mojego niestandardowego widelca libgphoto2, natomiast w tym rozszerzeniu biblioteki libgphoto2 trzeba było korzystać z mojego niestandardowego widelca biblioteki libusb.

Dodatkowo libgphoto2 używa narzędzia libtool do wczytywania wtyczek dynamicznych i chociaż nie musiałem tworzyć przedrostka libtool tak jak w przypadku pozostałych 2 bibliotek, trzeba było go utworzyć w WebAssembly i wskazać w libgphoto2 taką kompilację niestandardową zamiast pakietu systemowego.

Oto przybliżony schemat zależności (linie przerywane oznaczają połączenie dynamiczne):

Diagram przedstawia „aplikację” w zależności od pliku „libgphoto2 fork”, który zależy od „libtool”. Blok „libtool” zależy dynamicznie od portów „libgphoto2” i „libgphoto2 camlibs”. I wreszcie, port „libgphoto2” zależy statycznie od „rozwidlenia libusb”.

Większość systemów kompilacji opartych na konfiguracji, w tym te używane w tych bibliotekach, zezwala na zastępowanie ścieżek zależności za pomocą różnych flag. To właśnie próbowałem zrobić w pierwszej kolejności. Gdy jednak wykres zależności staje się skomplikowany, lista zastąpień ścieżek w przypadku zależności poszczególnych bibliotek staje się szczegółowa i podatna na błędy. Znalazłam też kilka błędów, w których systemy kompilacji nie były przygotowane na działanie zależności w niestandardowych ścieżkach.

Łatwiejszym sposobem jest utworzenie osobnego folderu jako niestandardowego katalogu głównego systemu (często skracanego do „sysroot”) i skierowanie do niego wszystkich zaangażowanych systemów kompilacji. Dzięki temu każda biblioteka będzie szukać zależności w określonym pliku sysroot podczas kompilacji, a także zainstaluje się w tym samym katalogu, aby inne osoby mogły ją łatwiej znaleźć.

Emscripten ma już własny plik sysroot w (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. Zdecydowałem się też na ponowne używanie tego samego zasobu sysroot z moimi zależnościami.

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

W przypadku takiej konfiguracji wystarczyło uruchamiać make install w każdej zależności, co powodowało jej zainstalowanie w katalogu sysroot, a następnie biblioteki znalazły się nawzajem automatycznie.

Radzenie sobie z ładowaniem dynamicznym

Jak już wspomnieliśmy, libgphoto2 korzysta z narzędzia libtool do wyliczania i dynamicznego wczytywania adapterów portów wejścia-wyjścia oraz bibliotek kamer. 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 ();

Istnieje kilka problemów z tym podejściem w internecie:

  • Nie ma standardowej obsługi dynamicznego łączenia modułów WebAssembly. Emscripten ma swoją niestandardową implementację, która może symulować interfejs API dlopen() używany przez libtool, ale wymaga utworzenia modułów „głównych” i „bocznych” z różnymi flagami, a szczególnie w przypadku dlopen()wstępne wczytanie modułów bocznych do emulowanego systemu plików podczas uruchamiania aplikacji. Integracja tych flag i poprawek z istniejącym systemem kompilacji Autoconf z mnóstwem dynamicznych bibliotek może być trudna.
  • Nawet jeśli zaimplementowany jest sam obiekt dlopen(), nie ma sposobu na wyliczenie 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.
  • Połączenie bibliotek dynamicznych w wierszu poleceń zamiast numerowania w czasie działania może też powodować problemy, takie jak problem z duplikatami symboli wynikający z różnic między reprezentacją bibliotek udostępnionych w Emscripten i na innych platformach.

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

Okazuje się, że libtool wyodrębnia różne dynamiczne metody łączenia na różnych platformach, a nawet obsługuje zapisywanie niestandardowych modułów ładowania dla innych. Jeden z obsługiwanych przez nie wbudowanych modułów ładowania nosi nazwę „Dlpreopening”:

„Libtool zapewnia specjalną obsługę dlopening obiektów libtool i plików bibliotek libtool, dzięki czemu ich symbole są rozpoznawane nawet na platformach bez żadnych funkcji dlopen i dlsym.
...
Libtool emuluje polecenie -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 mają być otwierane przez aplikację, używając flag -dlopen lub -dlpreopen podczas łączenia programu (patrz Tryb linku)”.

Ten mechanizm umożliwia emulację dynamicznego wczytywania na poziomie libtool zamiast Emscripten, a jednocześnie umożliwia statyczne łączenie wszystkich elementów w jedną bibliotekę.

Jedynym problemem, który nie rozwiąże tego problemu, jest wyliczenie bibliotek dynamicznych. Ich listę nadal należy zakodować na stałe. Na szczęście liczba wtyczek potrzebnych do uruchomienia aplikacji jest minimalny:

  • Jeśli chodzi o porty, interesuje mnie tylko połączenie z kamerą przy użyciu biblioteki libusb, a nie PTP/IP, dostęp szeregowy czy tryby dysku USB.
  • Po stronie Camlibs są dostępne różne wtyczki specyficzne dla dostawców, które mogą oferować pewne specjalistyczne funkcje. Jednak do ogólnego zarządzania ustawieniami i przechwytywania danych wystarczy użyć protokołu Picture Transfer Protocol reprezentowanego przez kod PPT2 i obsługiwanego przez praktycznie wszystkie aparaty na rynku.

Tak wygląda zaktualizowany diagram zależności, gdy wszystkie elementy są ze sobą połączone statycznie:

Diagram przedstawia „aplikację” w zależności od pliku „libgphoto2 fork”, który zależy od „libtool”. Parametr „libtool” zależy od atrybutów „ports: libusb1” i „camlibs: libptp2”. Wartość „ports: libusb1” zależy od pliku „libusb fork”.

Oto co zakodowałem na stałe dla 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ć plik -dlpreopen z obydwoma tymi plikami jako flagi linków dla wszystkich plików wykonywalnych (przykłady, testy i moja własna aplikacja demonstracyjna):

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

Teraz gdy wszystkie symbole są powiązane statycznie w jednej bibliotece, libtool potrzebuje sposobu na określenie, który symbol należy do której biblioteki. W tym celu deweloperzy muszą zmienić nazwy wszystkich ujawnionych symboli, takich jak {function name}, na {library name}_LTX_{function name}. Najłatwiejszym sposobem jest użycie polecenia #define w celu przedefiniowania 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>
// …

Ten schemat nazewnictwa zapobiega też konfliktom nazw, na wypadek gdyby w przyszłości zdecyduję się połączyć w tej samej aplikacji wtyczki związane z kamerą.

Po wprowadzeniu wszystkich zmian mogę utworzyć aplikację testową i ładować wtyczki.

Generowanie interfejsu ustawień

gPhoto2 umożliwia bibliotekom aparatów definiowanie własnych ustawień w postaci drzewa widżetów. Hierarchia typów widżetów składa się z tych elementów:

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

Zapytanie o nazwę, typ, elementy podrzędne i wszystkie inne istotne właściwości każdego widżetu można wykonywać (a w przypadku wartości również modyfikować) za pomocą udostępnionego interfejsu API C. Razem stanowią podstawę do automatycznego generowania interfejsu ustawień w dowolnym języku, który obsługuje język C.

Ustawienia można zmienić w dowolnym momencie za pomocą programu gPhoto2 lub samego aparatu. Poza tym niektóre widżety mogą być tylko do odczytu, a nawet stan tylko do odczytu zależy od trybu aparatu i innych ustawień. Na przykład Szybkość migawki to pole liczbowe do zapisu w M (tryb ręczny), ale w trybie P staje się informacyjnym polem tylko do odczytu. W trybie P wartość szybkości migawki też jest dynamiczna i stale się zmieniała w zależności od jasności sceny, na którą patrzy aparat.

Przede wszystkim ważne jest, aby informacje z podłączonej kamery były zawsze aktualne, a użytkownik mógł edytować te ustawienia w tym samym interfejsie. Taki dwukierunkowy przepływ danych jest bardziej skomplikowany w obsłudze.

W programie gPhoto2 nie ma mechanizmu pobierania tylko zmienionych ustawień, a jedynie całego drzewa lub poszczególnych widżetów. Aby interfejs użytkownika był aktualny bez migotania, utraty zaznaczenia danych wejściowych lub przewijania, potrzebowałem sposobu na rozróżnianie drzew widżetów między wywołaniami i aktualizowanie tylko zmienionych właściwości interfejsu. Na szczęście ten problem jest rozwiązany w internecie i jest to kluczowa funkcja platform takich jak React czy Preact. W tym projekcie skorzystałem z Preact, ponieważ jest on znacznie lżejszy i spełnia wszystkie wymagania.

Po stronie C++ trzeba było teraz pobrać drzewo ustawień i rekurencyjnie przejść przez wcześniej połączony interfejs C API, a następnie 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ć configToJS, przejść do zwróconej reprezentacji drzewa ustawień JavaScript i utworzyć interfejs użytkownika 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 powtarzaniu tej funkcji w nieskończonej pętli zdarzeń mogę skonfigurować interfejs ustawień tak, aby zawsze pokazywał najnowsze informacje, a jednocześnie wysyłał polecenia do kamery, gdy użytkownik zmieni jedno z pól.

Wstępne wypełnianie pozwala stosować różnicowanie wyników i aktualizować DOM tylko w przypadku zmienionych elementów interfejsu, nie zakłócając wskazywania strony ani jej stanów edycji. Pozostaje jedynie dwukierunkowy przepływ danych. Platformy takie jak React i Preact zaprojektowano z myślą o jednokierunkowym przepływie danych, ponieważ bardzo ułatwiają analizowanie danych i porównywanie ich pomiędzy kolejnymi powtórkami. Nie spełniamy tych oczekiwań, zezwalając źródłom zewnętrznym – kamerze – na aktualizowanie interfejsu ustawień w dowolnym momencie.

Udało mi się obejść ten problem, rezygnując z aktualizacji interfejsu użytkownika w polach do wprowadzania danych, które jest aktualnie 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 pola ma zawsze tylko 1 właściciel. Użytkownik obecnie go edytuje i nie przeszkadza im zaktualizowane wartości z kamery lub aktualizuje wartość pola, gdy jest nieostre.

Tworzenie kanału „wideo” na żywo

Podczas pandemii wiele osób przeniosło się do spotkań online. Doprowadziło to między innymi do braków na rynku kamer internetowych. Aby uzyskać lepszą jakość obrazu niż kamery wbudowane w laptopy, a w odpowiedzi na te niedobory, wielu właścicieli lustrzanek cyfrowych i aparatów bezlusterkowych zaczęło szukać sposobów na wykorzystanie swoich aparatów jako kamer internetowych. Kilku dostawców aparatów fotograficznych wysłało w tym celu nawet oficjalne narzędzia.

Podobnie jak oficjalne narzędzia, gPhoto2 obsługuje strumieniowe przesyłanie wideo z kamery do pliku zapisanego lokalnie lub bezpośrednio do wirtualnej kamery internetowej. Chciałem(-am) skorzystać z tej funkcji, aby w mojej wersji demonstracyjnej wyświetlać podgląd na żywo. Ta funkcja jest dostępna w konsoli, ale nie udało mi się jej znaleźć w interfejsach API biblioteki libgphoto2.

Po sprawdzeniu kodu źródłowego odpowiedniej funkcji w konsoli zauważyłem, że film w ogóle nie jest przesyłany. Zamiast tego pobiera podgląd z aparatu jako pojedyncze obrazy JPEG w niekończącej się pętli i zapisując je pojedynczo w strumieniu M-JPEG:

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

Byłam zaskoczona, że to podejście jest wystarczająco skuteczne, aby uzyskać wrażenie płynnej obsługi filmów w czasie rzeczywistym. Byłam jeszcze bardziej sceptyczna, jeśli chodzi o możliwość dopasowania takiej samej wydajności w aplikacji internetowej z dodatkowymi abstrakcjami i funkcją Asyncify. Mimo to postanowiłem spróbować.

Po stronie C++ ujawniłem metodę capturePreviewAsBlob(), która wywołuje tę samą funkcję gp_camera_capture_preview() i konwertuje wynikowy plik pamięci na plik 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));
  });
}

Po stronie JavaScriptu mam pętlę, podobną do tej w gPhoto2, która pobiera obrazy podglądu w formacie Blob, dekoduje je w tle za pomocą funkcji createImageBitmap i przenosi do obszaru roboczego przy następnej ramce 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) {
    // …
  }
}

Te nowoczesne interfejsy API zapewniają, że wszystkie dekodowanie odbywa się w tle, a obszar roboczy jest aktualizowany tylko wtedy, gdy zarówno obraz, jak i przeglądarka są w pełni gotowe do rysowania. W ten sposób udało mi się uzyskać na laptopie 30+ FPS, co odpowiada rodzimej wydajności zarówno gPhoto2, jak i oficjalnego oprogramowania Sony.

Synchronizowanie dostępu do urządzeń USB

Jeśli żądanie przesłania danych przez USB jest wykonywane w trakcie innej operacji, często powoduje to błąd „Urządzenie jest zajęte”. Ponieważ podgląd i interfejs ustawień są regularnie aktualizowane, a użytkownik może próbować jednocześnie zrobić zdjęcie lub zmienić ustawienia, takie konflikty między operacjami pojawiały się bardzo często.

Aby ich uniknąć, musiałem zsynchronizować wszystkie dostępy w aplikacji. Utworzyłem w tym celu opartą na obietnicach kolejkę asynchroniczną:

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

Dzięki łączeniu każdej operacji w wywołaniu zwrotnym then() istniejącej obietnicy queue i zapisywaniu łańcucha wyniku jako nowej wartości queue mogę mieć pewność, że wszystkie operacje będą wykonywane pojedynczo, w kolejności i bez pokrywania się.

Wszelkie 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ędzie zaplanowana żadna nowa operacja.

Zachowaj kontekst modułu w prywatnej (nieeksportowanej) zmiennej, aby zminimalizować ryzyko uzyskania dostępu do context przez przypadek w innym miejscu w aplikacji bez wywoływania wywołania schedule().

Aby to ze sobą powiązać, teraz każdy dostęp do kontekstu urządzenia musi zostać ujęty w wywołanie 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 były wykonywane bez konfliktów.

Podsumowanie

Więcej informacji o wdrażaniu znajdziesz w bazie kodu na GitHubie. Chcę również podziękować Marcusowi Meissnerowi za dbanie o gPhoto2 i za recenzje moich działów PR-owych.

Jak widać w tych postach, interfejsy API WebAssembly, Asyncify i Fugu zapewniają odpowiednie środowisko do kompilacji nawet najbardziej złożonych aplikacji. Pozwalają przenieść bibliotekę lub aplikację utworzoną wcześniej dla jednej platformy i przenieść ją do internetu. Dzięki temu jest ona dostępna dla znacznie większej liczby użytkowników korzystających zarówno z komputerów, jak i urządzeń mobilnych.