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

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

W poprzednim poście pokazałem, jak biblioteka libusb została przeniesiona do działania w sieci przy użyciu WebAssembly / Emscripten, Asyncify i WebUSB.

Pokazałem też demo stworzone za pomocą gPhoto2, które umożliwia sterowanie lustrzankami cyfrowymi i bezlusterkowcami przez USB z aplikacji internetowej. W tym poście omówię szczegółowo kwestie techniczne związane z portowaniem gPhoto2.

Ukierunkowanie systemów kompilacji na niestandardowe gałęzie

Ponieważ mój projekt był kierowany na WebAssembly, nie mogłem użyć bibliotek libusb i libgphoto2 udostępnianych przez dystrybucje systemu. Zamiast tego musiałem użyć w aplikacji mojej własnej gałęzi libgphoto2, która musiała używać mojej własnej gałęzi libusb.

Dodatkowo libgphoto2 używa libtool do ładowania dynamicznych wtyczek i chociaż nie musiałem tworzyć gałęzi pobocznej libtool tak jak w przypadku pozostałych dwóch bibliotek, musiałem ją skompilować do WebAssembly i wskazać libgphoto2 ten niestandardowy kompilowany kod zamiast pakietu systemowego.

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

Diagram pokazuje, że „aplikacja” zależy od „odgałęzi libgphoto2”, która z kolei zależy od „libtool”. Blok „libtool” zależy dynamicznie od „libgphoto2 ports” i „libgphoto2 camlibs”. Na koniec „libgphoto2 ports” zależy statycznie od „libusb fork”.

Większość systemów kompilacji opartych na konfiguracji, w tym te używane w tych bibliotekach, umożliwia zastąpienie ścieżek zależności za pomocą różnych flag, więc najpierw spróbowałem tego. Gdy jednak wykres zależności staje się złożony, lista zastąpień ścieżek dla zależności każdej biblioteki staje się długa i podatna na błędy. Znalazłem też kilka błędów, w których systemy kompilacji nie były przygotowane na to, że ich zależności będą znajdować się na ścieżkach niestandardowych.

Łatwiej jest utworzyć osobny folder jako niestandardowy katalog główny systemu (często nazywany „sysroot”) i wskazać go wszystkim systemom kompilacji. Dzięki temu każda biblioteka będzie podczas kompilacji szukać swoich zależności w określonym systemie sysroot, a także sama się w nim zainstaluje, aby inni mogli ją łatwiej znaleźć.

Emscripten ma już własny system sysroot w katalogu (path to emscripten cache)/sysroot, który jest używany do bibliotek systemowych, portów Emscripten i narzędzi takich jak CMake i pkg-config. Postanowiłem też użyć tego samego systemu sysroot dla 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ć make install w każdej zależności, co zainstalowało ją w katalogu sysroot, a potem biblioteki znalazły się automatycznie.

Zarządzanie wczytywaniem dynamicznym

Jak wspomniano powyżej, libgphoto2 używa libtool do wyliczania i dynamicznego wczytywania interfejsów portów we/wy i bibliotek aparatów. Na przykład kod wczytujący biblioteki wejścia/wyjścia wygląda tak:

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

W internecie jest kilka problemów z tym podejściem:

  • Nie ma standardowego wsparcia dla dynamicznego łączenia modułów WebAssembly. Emscripten ma swoją niestandardową implementację, która może symulować interfejs API dlopen() używany przez libtool, ale wymaga skompilowania modułów „głównego” i „bocznego” za pomocą różnych flag. W przypadku dlopen() musisz też wstępnie wczytać moduły boczne do emulowanego systemu plików podczas uruchamiania aplikacji. Może być trudno zintegrować te flagi i ustawienia z dotychczasowym systemem kompilacji autoconf z wiele bibliotekami dynamicznymi.
  • Nawet jeśli dlopen() jest zaimplementowana, nie ma możliwości wyliczenia wszystkich bibliotek dynamicznych w określonym folderze w internecie, ponieważ większość serwerów HTTP ze względów bezpieczeństwa nie udostępnia list katalogów.
  • Łączenie bibliotek dynamicznych na wierszu poleceń zamiast ich wyliczania w czasie wykonywania może też powodować problemy, np. problem z duplikatami symboli, które wynikają z różnic między reprezentacją bibliotek współdzielonych w Emscripten i na innych platformach.

System kompilacji można dostosować do tych różnic i w ramach kompilacji zakodować na stałe listę dynamicznych wtyczek, ale jeszcze łatwiejszym sposobem na rozwiązanie wszystkich tych problemów jest unikanie linkowania dynamicznego.

Okazuje się, że libtool abstrakcyjnie obsługuje różne metody linkowania dynamicznego na różnych platformach, a nawet umożliwia pisanie niestandardowych ładowarek dla innych. Jednym z wbudowanych ładowaczy jest „Dlpreopening”:

„Libtool zapewnia obsługę dlopeningu obiektów libtool i plików biblioteki libtool, dzięki czemu ich symbole mogą być rozwiązywane nawet na platformach bez funkcji dlopen i dlsym.

Libtool emuluje -dlopen na platformach statycznych, łącząc obiekty z programem w czasie kompilacji i tworząc struktury danych, które reprezentują tabelę symboli programu. Aby korzystać z tej funkcji, musisz zadeklarować obiekty, które aplikacja ma dlopen, używając flag -dlopen lub -dlpreopen podczas łączenia programu (patrz Tryb łączenia).

Ten mechanizm umożliwia emulowanie ładowania dynamicznego na poziomie libtool zamiast Emscripten, a jednocześnie łączenie wszystkiego statycznie w jednej bibliotece.

Jedynym problemem, którego to nie rozwiązuje, jest enumeracja bibliotek dynamicznych. Lista tych wartości musi być gdzieś zakodowana na stałe. Na szczęście zestaw wtyczek potrzebnych do aplikacji jest minimalny:

  • W przypadku portów interesuje mnie tylko połączenie kamery oparte na libusb, a nie PTP/IP, dostęp szeregowy ani tryby napędu USB.
  • W przypadku camlibs istnieją różne wtyczki dla poszczególnych dostawców, które mogą zapewniać niektóre funkcje specjalistyczne, ale do ogólnego sterowania ustawieniami i przechwytywania wystarczy użycie protokołu Picture Transfer Protocol, który jest reprezentowany przez camlib ptp2 i obsługiwany przez praktycznie wszystkie aparaty na rynku.

Oto jak wygląda zaktualizowany diagram zależności z wszystkimi połączonymi statycznie elementami:

Diagram pokazuje, że „aplikacja” zależy od „odgałęzi libgphoto2”, która z kolei zależy od „libtool”. 'libtool' zależy od 'ports: libusb1' i 'camlibs: libptp2'. 'ports: libusb1' zależy od 'libusb fork'.

Oto, co zakodowałem na stałe w wersjach 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 teraz dodać -dlpreopen z obu tymi plikami jako flagami linkowania dla wszystkich plików wykonywalnych (przykładów, testów i własnej aplikacji demonstracyjnej), tak jak tutaj:

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 musi mieć sposób na określenie, który symbol należy do której biblioteki. Aby to osiągnąć, deweloperzy muszą zmienić nazwy wszystkich symboli, takich jak {function name}, na {library name}_LTX_{function name}. Najłatwiej jest to zrobić, używając elementu #define do zdefiniowania nazw symboli u góry 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>
// …

Taki schemat nazewnictwa zapobiega też konfliktom nazw, gdybym w przyszłości zdecydował się połączyć w tej samej aplikacji wtyczki dla konkretnych kamer.

Po wprowadzeniu wszystkich tych zmian mogłem skompilować testową aplikację i pomyślnie wczytać 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

Nazwa, typ, elementy podrzędne i wszystkie inne odpowiednie właściwości każdego widżetu można wyszukiwać (a w przypadku wartości także modyfikować) za pomocą interfejsu C API. Razem stanowią podstawę do automatycznego generowania interfejsu ustawień w dowolnym języku, który może wchodzić w interakcje z C.

Ustawienia można zmienić w dowolnym momencie za pomocą gPhoto2 lub w samej kamerze. Dodatkowo niektóre widżety mogą być tylko do odczytu, a nawet ten stan zależy od trybu aparatu i innych ustawień. Na przykład szybkość migawki to pole liczbowe, które można zapisywać w trybie M (ręcznym), ale które staje się polem tylko do odczytu w trybie P (programowym). W trybie P wartość czasu otwarcia migawki będzie też dynamiczna i będzie się stale zmieniać w zależności od jasności sceny, na którą skierowany jest aparat.

Podsumowując, ważne jest, aby w interfejsie zawsze wyświetlać aktualne informacje z podłączonego aparatu, a zarazem umożliwić użytkownikowi edytowanie tych ustawień w tym samym interfejsie. Taki dwukierunkowy przepływ danych jest trudniejszy do zarządzania.

gPhoto2 nie ma mechanizmu umożliwiającego pobieranie tylko zmienionych ustawień, tylko całego drzewa lub poszczególnych widżetów. Aby utrzymać interfejs w aktualnym stanie bez migotania i utraty fokusa nawigacyjnego lub pozycji przewijania, potrzebowałem sposobu na porównanie drzewek widżetów między wywołaniami i zaktualizowanie tylko zmienionych właściwości interfejsu. Na szczęście jest to rozwiązany problem w internecie i jest to główna funkcja frameworków takich jak React czy Preact. W tym projekcie użyłem Preact, ponieważ jest to znacznie lżejszy framework, który spełnia wszystkie moje potrzeby.

Po stronie C++ musiałem pobrać i rekursywnie przejść po drzewie ustawień za pomocą wcześniej połączonego interfejsu C API, a potem przekonwertować każdy widget 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ść przez zwróconą reprezentację drzewa ustawień w JavaScriptzie 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 wielokrotnemu uruchamianiu tej funkcji w nieskończonym pętli zdarzeń interfejs ustawień zawsze wyświetlał najnowsze informacje, a dodatkowo wysyłałem polecenia do kamery, gdy użytkownik edytował jedno z pol.

Preact może zajmować się porównywaniem wyników i aktualizowaniem DOM tylko w przypadku zmienionych części interfejsu użytkownika, bez zakłócania stanu fokusowania strony ani stanu edycji. Pozostaje jeden problem, którym jest dwukierunkowy przepływ danych. Frameworki takie jak React i Preact zostały zaprojektowane z myślą o jednokierunkowym przepływie danych, ponieważ znacznie ułatwia to analizowanie danych i ich porównywanie między kolejnymi odtworzeniami. Ja jednak odstępuję od tego założenia, pozwalając zewnętrznemu źródłu (kamerze) na aktualizowanie interfejsu ustawień w dowolnym momencie.

Problem rozwiązałem, wyłączając aktualizacje interfejsu w przypadku wszystkich pól 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 każde pole ma zawsze tylko jednego właściciela. Użytkownik może je właśnie edytować, więc nie będzie przeszkadzać mu aktualizowanie wartości przez kamerę, albo kamera aktualizuje wartość pola, gdy jest nieostra.

Tworzenie „wideo” na żywo

Podczas pandemii wiele osób przeszło na spotkania online. Między innymi doprowadziło to do niedoborów na rynku kamer internetowych. Aby uzyskać lepszą jakość obrazu niż w przypadku wbudowanych kamer w laptopach, a także w odpowiedzi na wspomniane niedobory, wielu właścicieli lustrzanek i bezlusterk zaczęło szukać sposobów na używanie swoich aparatów fotograficznych jako kamer internetowych. Niektórzy producenci kamer nawet wysyłali oficjalne narzędzia do tego celu.

Podobnie jak oficjalne narzędzia, gPhoto2 obsługuje strumieniowe przesyłanie wideo z kamery do przechowywanego lokalnie pliku lub bezpośrednio do wirtualnej kamery internetowej. Chciałem użyć tej funkcji, aby wyświetlić widok na żywo w moim pokazie. Jest ona jednak dostępna w konsoli, ale nie udało mi się jej znaleźć w interfejsach API biblioteki libgphoto2.

Po zapoznaniu się ze źródłowym kodem odpowiedniej funkcji w narzędziu konsoli okazało się, że wcale nie pobiera ona filmu, tylko stale pobiera podgląd kamery jako pojedyncze obrazy JPEG w niekończonej pętli i zapisuje je jeden po drugim, tworząc strumień M-JPEG:

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

Byłem zaskoczony, że to podejście działa wystarczająco wydajnie, aby uzyskać wrażenie płynnego filmu w czasie rzeczywistym. Jeszcze bardziej sceptycznie podchodziłem do możliwości uzyskania takiej samej wydajności w aplikacji internetowej, biorąc pod uwagę wszystkie dodatkowe abstrakcje i funkcję Asyncify. Postanowiłem jednak spróbować.

Po stronie C++ udostępniłem metodę o nazwie capturePreviewAsBlob(), która wywołuje tę samą funkcję gp_camera_capture_preview() i konwertuje wynikowy plik w pamięci do Blob, który można łatwiej przekazać do innych interfejsów API w internecie:

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 JavaScript mam pętlę podobną do tej w gPhoto2, która stale pobiera obrazy podglądu jako Blob, dekoduje je w tle za pomocą funkcji createImageBitmapprzenosi na kanwę w następnym kadrze 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 dekodowanie odbywa się w tle, a płótno jest aktualizowane tylko wtedy, gdy obraz i przeglądarka są w pełni gotowe do rysowania. Na moim laptopie udało mi się uzyskać płynną liczbę klatek na poziomie ponad 30 FPS, co odpowiada natywnej wydajności zarówno gPhoto2, jak i oficjalnego oprogramowania Sony.

Synchronizacja dostępu USB

Jeśli żądanie przesyłania danych przez USB zostanie wysłane, gdy inna operacja jest już w toku, zwykle spowoduje to błąd „Urządzenie jest zajęte”. Ponieważ interfejs podglądu i ustawień jest regularnie aktualizowany, a użytkownik może jednocześnie próbować robić zdjęcia lub modyfikować ustawienia, takie konflikty między różnymi operacjami okazały się bardzo częste.

Aby ich uniknąć, musiałem zsynchronizować wszystkie dostępy w aplikacji. W tym celu utworzyłem kolejkę asynchroniczną 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 funkcji zwracającej wartość zwracaną then() w ramach istniejącej obietnicy queue i zapisując połączony wynik jako nową wartość queue, mogę mieć pewność, że wszystkie operacje są wykonywane po kolei, w kolejności i bez nakładania się.

Wszelkie błędy operacji są zwracane do wywołującego, a błędy krytyczne (nieoczekiwane) oznaczają cały łańcuch jako odrzuconą obietnicę i zapewniają, że żadna nowa operacja nie zostanie zaplanowana.

Przechowywanie kontekstu modułu w prywatnej (nieeksportowanej) zmiennej minimalizuje ryzyko przypadkowego uzyskania dostępu do funkcji context gdzie indziej w aplikacji bez wywołania funkcji schedule().

Aby wszystko było spójne, każdy dostęp do kontekstu urządzenia musi być opakowany w wywołanie schedule(), na przykład:

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

i

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

Następnie wszystkie operacje zostały wykonane bez konfliktów.

Podsumowanie

Aby uzyskać więcej informacji o implementacji, przejrzyj kod źródłowy na GitHubie. Chcę też podziękować Marcusowi Meissnerowi za konserwację gPhoto2 i za jego opinie na temat moich upstreamowych PR-ów.

Jak widać w tych postach, interfejsy API WebAssembly, Asyncify i Fugu zapewniają wydajne kompilowanie nawet najbardziej złożonych aplikacji. Umożliwiają one przeniesienie biblioteki lub aplikacji utworzonej wcześniej na jedną platformę do przeglądarki, dzięki czemu stają się one dostępne dla znacznie większej liczby użytkowników na komputerach i urządzeniach mobilnych.