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.

Kierowanie systemów kompilacji na niestandardowe widelce

Kieruję reklamy na WebAssembly, więc nie mogłem korzystać z bibliotek libusb i libgphoto2 dostępnych w dystrybucjach systemowych. Zamiast tego musiałem użyć w aplikacji mojej niestandardowej wersji libgphoto2, która musiała używać mojej niestandardowej wersji 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ą jednak skompilować do WebAssembly i wskazać libgphoto2 ten niestandardowy kompilowany kod zamiast pakietu systemowego.

Oto przybliżony diagram zależności (linie przerywane oznaczają linki 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ę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. 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.

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 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źć.

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ć make install w każdej zależności, co zainstalowało ją w katalogu sysroot, a potem biblioteki znalazły się automatycznie.

Radzenie sobie z ładowaniem 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 ();

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

  • 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 tworzenia modułów „głównych” i „bocznych” z różnymi flagami, a w przypadku dlopen() – także wstępnego wczytania modułów bocznych 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 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 podczas kompilacji, ale jeszcze łatwiejszym sposobem rozwiązania wszystkich tych problemów jest unikanie na początku dynamicznych linków.

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 narzędzie -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 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 wyliczanie 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.
  • 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.

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

Diagram pokazujący „aplikację” w zależności od parametru „libgphoto2 Fork”, który zależy od narzędzia „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 widocznych symboli, np. {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>
// …

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

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 następujących elementów:

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

Nazwa, typ, elementy podrzędne i wszystkie inne odpowiednie właściwości każdego widżetu można zapytać (a w przypadku wartości także zmodyfikować) 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 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 to pole liczbowe, które można edytować w trybie M (ręcznym), ale 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 jednocześnie umożliwić użytkownikowi edytowanie tych ustawień w tym samym interfejsie. Taki dwukierunkowy przepływ danych jest trudniejszy do zarządzania.

Program gPhoto2 nie ma mechanizmu pobierania samych zmienionych ustawień, a jedynie 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 wybrałam firmę Preact, która jest o wiele lżejsza i ma wszystko, czego potrzebuję.

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 element 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 umożliwia rozbieżność wyników i aktualizowanie DOM tylko w przypadku zmienionych elementów interfejsu użytkownika bez zakłócania fokusu na stronie ani stanów 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 dostarczali 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. 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 Konsoli Play, 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 nieskończonej 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 sprawnie, że robi wrażenie płynnego wyświetlania filmów 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 spróbować mimo to.

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ą createImageBitmapprzenosi na kanwę w następnym 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) {
    // …
  }
}

Korzystanie z tych nowoczesnych interfejsów API zapewnia, że cały proces dekodowania odbywa się w tle, a płótno jest aktualizowane tylko wtedy, gdy obraz i przeglądarka są w pełni gotowe do rysowania. Dzięki temu na moim laptopie udało się uzyskać płynną liczbę klatek na poziomie ponad 30 FPS, co odpowiada natywnej wydajności zarówno gPhoto2, jak i oficjalnego oprogramowania Sony.

Synchronizowanie dostępu do urządzeń USB

Gdy żą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 wywołaniu zwrotnym then() 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ę.

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 po jej wystąpieniu nie będą planowane żadne nowe operacje.

Przechowywanie kontekstu modułu w prywatnej (nieeksportowanej) zmiennej minimalizuje ryzyko przypadkowego uzyskania dostępu do 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() w ten sposób:

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

i

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

Po tym 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 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 będzie ona dostępna dla znacznie większej liczby użytkowników na komputerach i urządzeniach mobilnych.