Портирование USB-приложений в Интернет. Часть 2: gPhoto2

Узнайте, как gPhoto2 был портирован на WebAssembly для управления внешними камерами через USB из веб-приложения.

В предыдущем посте я показал, как библиотека libusb была портирована для работы в сети с помощью WebAssembly/ Emscripten , Asyncify и WebUSB .

Я также показал демо-версию, созданную с помощью gPhoto2 , которая может управлять зеркальными и беззеркальными камерами через USB из веб-приложения. В этом посте я более подробно расскажу о технических деталях порта gPhoto2.

Указание систем сборки на пользовательские вилки

Поскольку я ориентировался на WebAssembly, я не мог использовать библиотеки libusb и libgphoto2, входящие в состав системных дистрибутивов. Вместо этого мне нужно было, чтобы мое приложение использовало мою собственную вилку libgphoto2, а эта вилка libgphoto2 должна была использовать мою собственную вилку libusb.

Кроме того, libgphoto2 использует libtool для загрузки динамических плагинов, и хотя мне не пришлось создавать ответвление libtool, как две другие библиотеки, мне все равно пришлось собрать ее в WebAssembly и указать libgphoto2 на эту пользовательскую сборку вместо системного пакета.

Вот приблизительная диаграмма зависимостей (пунктирные линии обозначают динамическое связывание):

На диаграмме показано «приложение» в зависимости от «вилки libgphoto2», которая зависит от «libtool». Блок libtool динамически зависит от портов libgphoto2 и camlibs libgphoto2. Наконец, «порты libgphoto2» статически зависят от «вилки libusb».

Большинство систем сборки на основе configure, включая те, что используются в этих библиотеках, позволяют переопределять пути для зависимостей с помощью различных флагов, и именно это я и попытался сделать в первую очередь. Однако когда граф зависимостей становится сложным, список переопределений путей для зависимостей каждой библиотеки становится многословным и подверженным ошибкам. Я также обнаружил несколько ошибок, из-за которых системы сборки на самом деле не были готовы к тому, чтобы их зависимости находились в нестандартных путях.

Вместо этого более простой подход — создать отдельную папку в качестве пользовательского корня системы (часто сокращается до «sysroot») и указать на нее все задействованные системы сборки. Таким образом, каждая библиотека будет искать свои зависимости в указанном системном корне во время сборки, а также устанавливать себя в том же системном корне, чтобы другим было легче ее найти.

У Emscripten уже есть собственный sysroot (path to emscripten cache)/sysroot , который он использует для своих системных библиотек , портов Emscripten и таких инструментов, как CMake и pkg-config. Я решил повторно использовать один и тот же системный корень для своих зависимостей.

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

При такой конфигурации мне нужно было всего лишь запустить в каждой зависимости make install , который установил ее под sysroot, а дальше библиотеки нашли друг друга автоматически.

Работа с динамической нагрузкой

Как упоминалось выше, libgphoto2 использует libtool для перечисления и динамической загрузки адаптеров портов ввода-вывода и библиотек камер. Например, код загрузки библиотек ввода-вывода выглядит так:

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

В Интернете с этим подходом есть несколько проблем:

  • Стандартной поддержки динамического связывания модулей WebAssembly не существует. У Emscripten есть своя собственная реализация , которая может имитировать API dlopen() используемый libtool, но она требует от вас создания «основных» и «боковых» модулей с разными флагами, а также, особенно для dlopen() , предварительной загрузки побочных модулей. в эмулируемую файловую систему во время запуска приложения. Может быть сложно интегрировать эти флаги и настройки в существующую систему сборки autoconf с множеством динамических библиотек.
  • Даже если реализована сама функция dlopen() , невозможно перечислить все динамические библиотеки в определенной папке в Интернете, поскольку большинство HTTP-серверов не раскрывают списки каталогов по соображениям безопасности.
  • Связывание динамических библиотек в командной строке вместо перечисления во время выполнения также может привести к проблемам, таким как проблема дублирования символов , вызванная различиями между представлением общих библиотек в Emscripten и на других платформах.

Можно адаптировать систему сборки к этим различиям и жестко запрограммировать список динамических плагинов где-нибудь во время сборки, но еще более простой способ решить все эти проблемы — с самого начала избегать динамического связывания.

Оказывается, libtool абстрагирует различные методы динамического связывания на разных платформах и даже поддерживает написание пользовательских загрузчиков для других. Один из поддерживаемых встроенных загрузчиков называется «Dlpreopening» :

«Libtool предоставляет специальную поддержку для раскрытия объектов libtool и файлов библиотеки libtool, так что их символы могут быть разрешены даже на платформах без каких-либо функций dlopen и dlsym.

Libtool эмулирует -dlopen на статических платформах, связывая объекты с программой во время компиляции и создавая структуры данных, которые представляют таблицу символов программы. Чтобы использовать эту функцию, вы должны объявить объекты, которые вы хотите, чтобы ваше приложение открывало, используя флаги -dlopen или -dlpreopen при компоновке вашей программы (см. Режим компоновки )».

Этот механизм позволяет эмулировать динамическую загрузку на уровне libtool вместо Emscripten, при этом связывая все статически в одну библиотеку.

Единственная проблема, которую это не решает, — это перечисление динамических библиотек. Их список еще нужно где-то жестко запрограммировать. К счастью, набор плагинов, необходимых для приложения, минимален:

  • Что касается портов, меня волнует только подключение камеры на базе libusb, а не режимы PTP/IP, последовательного доступа или USB-накопителя.
  • Что касается camlibs, существуют различные плагины от конкретного поставщика, которые могут предоставлять некоторые специализированные функции, но для общего управления настройками и захвата достаточно использовать протокол передачи изображений , который представлен camlib ptp2 и поддерживается практически каждой камерой на сервере. рынок.

Вот как выглядит обновленная диаграмма зависимостей, когда все статически связано друг с другом:

На диаграмме показано «приложение» в зависимости от «вилки libgphoto2», которая зависит от «libtool». «libtool» зависит от «ports: libusb1» и «camlibs: libptp2». «ports: libusb1» зависит от «вилки libusb».

Итак, вот что я жестко запрограммировал для сборок 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 ();

и

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

В системе сборки autoconf мне теперь пришлось добавить -dlpreopen к обоим этим файлам в качестве флагов ссылок для всех исполняемых файлов (примеры, тесты и мое собственное демонстрационное приложение), вот так:

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

Наконец, теперь, когда все символы статически связаны в одной библиотеке, libtool нужен способ определить, какой символ какой библиотеке принадлежит. Для этого разработчикам необходимо переименовать все доступные символы, такие как {function name} в {library name}_LTX_{function name} . Самый простой способ сделать это — использовать #define для переопределения имен символов в верхней части файла реализации:

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

Эта схема именования также предотвращает конфликты имен в случае, если я решу в будущем связать плагины для конкретной камеры в одном приложении.

После того, как все эти изменения были реализованы, я смог создать тестовое приложение и успешно загрузить плагины.

Генерация пользовательского интерфейса настроек

gPhoto2 позволяет библиотекам камер определять свои собственные настройки в виде дерева виджетов. Иерархия типов виджетов состоит из:

  • Окно — контейнер конфигурации верхнего уровня.
    • Разделы — именованные группы других виджетов.
    • Поля кнопок
    • Текстовые поля
    • Числовые поля
    • Поля даты
    • Переключает
    • Радиокнопки

Имя, тип, дочерние элементы и все другие соответствующие свойства каждого виджета можно запросить (а в случае значений также изменить) через открытый C API . Вместе они обеспечивают основу для автоматического создания пользовательского интерфейса настроек на любом языке, который может взаимодействовать с C.

Настройки можно изменить либо через gPhoto2, либо на самой камере в любой момент времени. Кроме того, некоторые виджеты могут быть доступны только для чтения, и даже само состояние только для чтения зависит от режима камеры и других настроек. Например, выдержка — это записываемое числовое поле в M (ручной режим) , но становится информационным полем, доступным только для чтения, в P (программный режим) . В режиме P значение выдержки также будет динамическим и постоянно меняющимся в зависимости от яркости сцены, на которую смотрит камера.

В общем, важно всегда отображать актуальную информацию с подключенной камеры в пользовательском интерфейсе, в то же время позволяя пользователю редактировать эти настройки из того же пользовательского интерфейса. Такой двунаправленный поток данных сложнее обрабатывать.

В gPhoto2 нет механизма получения только измененных настроек, а только всего дерева или отдельных виджетов. Чтобы поддерживать актуальность пользовательского интерфейса без мерцания и потери фокуса ввода или положения прокрутки, мне нужен был способ различать деревья виджетов между вызовами и обновлять только измененные свойства пользовательского интерфейса. К счастью, в Интернете эта проблема решена, и это основная функциональность таких фреймворков, как React или Preact . Для этого проекта я выбрал Preact, так как он гораздо более легкий и делает все, что мне нужно.

Что касается C++, мне теперь нужно было получить и рекурсивно пройти по дереву настроек через связанный ранее C API, а также преобразовать каждый виджет в объект 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;
    }
    // …

Что касается JavaScript, теперь я мог вызвать configToJS , просмотреть возвращенное JavaScript-представление дерева настроек и построить пользовательский интерфейс с помощью функции 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;
  }
  // …

Запуская эту функцию несколько раз в бесконечном цикле событий, я мог заставить пользовательский интерфейс настроек всегда отображать самую свежую информацию, а также отправлять команды на камеру всякий раз, когда одно из полей редактируется пользователем.

Preact может позаботиться о сравнении результатов и обновлении DOM только для измененных частей пользовательского интерфейса, не нарушая фокус страницы или состояния редактирования. Остается одна проблема — двунаправленный поток данных. Такие фреймворки, как React и Preact, были разработаны с учетом однонаправленного потока данных, потому что это значительно упрощает анализ данных и их сравнение при повторных запусках, но я нарушаю это ожидание, позволяя внешнему источнику — камере — обновлять настройки. Пользовательский интерфейс в любое время.

Я решил эту проблему, отказавшись от обновлений пользовательского интерфейса для всех полей ввода, которые в данный момент редактируются пользователем:

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

Таким образом, у любого поля всегда есть только один владелец. Либо пользователь в данный момент редактирует его, и его не отвлекают обновленные значения с камеры, либо камера обновляет значение поля, пока оно не в фокусе.

Создание прямой трансляции «видео»

Во время пандемии многие люди перешли на онлайн-встречи. Помимо прочего, это привело к дефициту на рынке веб-камер . Чтобы получить лучшее качество видео по сравнению со встроенными камерами в ноутбуках, а также в ответ на указанный недостаток, многие владельцы зеркальных и беззеркальных камер начали искать способы использования своих фотокамер в качестве веб-камер. Некоторые производители камер даже поставляли официальные утилиты именно для этой цели.

Как и официальные инструменты, gPhoto2 поддерживает потоковую передачу видео с камеры в локально сохраненный файл или непосредственно на виртуальную веб-камеру. Я хотел использовать эту функцию, чтобы обеспечить просмотр в реальном времени в моей демонстрации. Однако, хотя он доступен в консольной утилите, я не смог найти его нигде в API библиотеки libgphoto2.

Посмотрев исходный код соответствующей функции в консольной утилите, я обнаружил, что она на самом деле вообще не получает видео, а вместо этого продолжает получать предварительный просмотр камеры в виде отдельных изображений JPEG в бесконечном цикле и записывать их одно за другим в сформировать поток M-JPEG :

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

Я был удивлен тем, что этот подход работает достаточно эффективно, чтобы создать впечатление плавного видео в реальном времени. Я был еще более скептически настроен по поводу возможности добиться такой же производительности в веб-приложении, со всеми дополнительными абстракциями и Asyncify на пути. Однако я все равно решил попробовать.

На стороне C++ я представил метод capturePreviewAsBlob() , который вызывает ту же функцию gp_camera_capture_preview() и преобразует полученный файл в памяти в Blob , который можно проще передать другим веб-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));
  });
}

На стороне JavaScript у меня есть цикл, аналогичный тому, что есть в gPhoto2, который продолжает получать изображения предварительного просмотра в виде Blob , декодирует их в фоновом режиме с помощью createImageBitmap и переносит их на холст в следующем кадре анимации:

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

Использование этих современных API гарантирует, что вся работа по декодированию выполняется в фоновом режиме, а холст обновляется только тогда, когда и изображение, и браузер полностью готовы к рисованию. Это позволило добиться на моем ноутбуке стабильной скорости 30+ кадров в секунду, что соответствует производительности как gPhoto2, так и официального программного обеспечения Sony.

Синхронизация доступа к USB

Когда запрашивается передача данных через USB во время выполнения другой операции, это обычно приводит к ошибке «устройство занято». Поскольку пользовательский интерфейс предварительного просмотра и настроек регулярно обновляется, и пользователь может одновременно пытаться сделать снимок или изменить настройки, такие конфликты между различными операциями оказались очень частыми.

Чтобы их избежать, мне нужно было синхронизировать все доступы внутри приложения. Для этого я создал асинхронную очередь на основе обещаний:

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

Связывая каждую операцию в обратном вызове then() существующего обещания queue и сохраняя связанный результат как новое значение queue , я могу быть уверен, что все операции выполняются одна за другой, по порядку и без перекрытий.

Любые ошибки операции возвращаются вызывающей стороне, а критические (неожиданные) ошибки помечают всю цепочку как отклоненное обещание и гарантируют, что никакая новая операция не будет запланирована впоследствии.

Сохраняя контекст модуля в частной (неэкспортируемой) переменной, я минимизирую риски случайного доступа к context где-то еще в приложении без вызова метода schedule() .

Чтобы связать все воедино, теперь каждый доступ к контексту устройства должен быть заключен в вызов schedule() например:

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

и

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

После этого все операции выполнялись успешно и без конфликтов.

Заключение

Не стесняйтесь просматривать базу кода на Github для получения дополнительной информации о реализации. Я также хочу поблагодарить Маркуса Мейсснера за поддержку gPhoto2 и за его обзоры моих первоначальных PR.

Как показано в этих публикациях, API-интерфейсы WebAssembly, Asyncify и Fugu предоставляют эффективную цель компиляции даже для самых сложных приложений. Они позволяют вам взять библиотеку или приложение, ранее созданное для одной платформы, и портировать ее в Интернет, делая ее доступной значительно большему числу пользователей как на настольных, так и на мобильных устройствах.