Узнайте, как gPhoto2 был портирован на WebAssembly для управления внешними камерами через USB из веб-приложения.
В предыдущем посте я показал, как библиотека libusb была портирована для работы в сети с помощью WebAssembly/ Emscripten , Asyncify и WebUSB .
Я также показал демо-версию, созданную с помощью gPhoto2 , которая может управлять зеркальными и беззеркальными камерами через USB из веб-приложения. В этом посте я более подробно расскажу о технических деталях порта gPhoto2.
Указание систем сборки на пользовательские вилки
Поскольку я ориентировался на WebAssembly, я не мог использовать библиотеки libusb и libgphoto2, входящие в состав системных дистрибутивов. Вместо этого мне нужно было, чтобы мое приложение использовало мою собственную вилку libgphoto2, а эта вилка libgphoto2 должна была использовать мою собственную вилку libusb.
Кроме того, libgphoto2 использует libtool для загрузки динамических плагинов, и хотя мне не пришлось создавать ответвление libtool, как две другие библиотеки, мне все равно пришлось собрать ее в WebAssembly и указать libgphoto2 на эту пользовательскую сборку вместо системного пакета.
Вот приблизительная диаграмма зависимостей (пунктирные линии обозначают динамическое связывание):
Большинство систем сборки на основе 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 и поддерживается практически каждой камерой на сервере. рынок.
Вот как выглядит обновленная диаграмма зависимостей, когда все статически связано друг с другом:
Итак, вот что я жестко запрограммировал для сборок 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 предоставляют эффективную цель компиляции даже для самых сложных приложений. Они позволяют вам взять библиотеку или приложение, ранее созданное для одной платформы, и перенести его в Интернет, сделав его доступным для гораздо большего числа пользователей как на настольных, так и на мобильных устройствах.