USB 애플리케이션을 웹에 포팅 파트 2: gPhoto2

웹 앱에서 USB를 통해 외장 카메라를 제어하기 위해 gPhoto2를 WebAssembly로 포팅한 방법을 알아보세요.

이전 게시물에서는 libusb 라이브러리가 WebAssembly / Emscripten, Asyncify, WebUSB를 사용하여 웹에서 실행되도록 포팅된 방법을 보여드렸습니다.

또한 웹 애플리케이션에서 USB를 통해 DSLR과 미러리스 카메라를 제어할 수 있는 gPhoto2로 빌드된 데모도 보여드렸습니다. 이 게시물에서는 gPhoto2 포트에 숨겨진 기술 세부정보를 자세히 살펴보겠습니다.

빌드 시스템을 맞춤 포크로 가리키기

WebAssembly를 타겟팅했기 때문에 시스템 배포에서 제공하는 libusb 및 libgphoto2를 사용할 수 없었습니다. 대신 애플리케이션에서 libgphoto2의 맞춤 포크를 사용해야 하는 반면 libgphoto2의 포크는 libusb의 맞춤 포크를 사용해야 했습니다.

또한 libgphoto2는 동적 플러그인을 로드하는 데 libtool을 사용합니다. 다른 두 라이브러리처럼 libtool을 포크할 필요는 없었지만, 여전히 WebAssembly로 빌드하고 libgphoto2가 시스템 패키지 대신 해당 맞춤 빌드로 가리켜야 했습니다.

다음은 대략적인 종속 항목 다이어그램입니다 (점선은 동적 연결을 나타냄).

'앱'을 보여주는 다이어그램 'libgphoto2 fork'에 종속됩니다. 이는 'libtool'에 종속됩니다. 'libtool' 블록이 'libgphoto2 port'에 동적으로 종속됨 'libgphoto2 camlibs'입니다. 마지막으로 'libgphoto2하신 포트'가 'libusb fork'에 정적으로 종속됩니다.

이러한 라이브러리에서 사용되는 시스템을 포함하여 대부분의 구성 기반 빌드 시스템은 다양한 플래그를 통해 종속 항목의 경로를 재정의할 수 있습니다. 그렇기 때문에 먼저 하려고 했던 작업입니다. 그러나 종속 항목 그래프가 복잡해지면 각 라이브러리의 종속 항목에 대한 경로 재정의 목록이 상세해지고 오류가 발생하기 쉽습니다. 또한 빌드 시스템이 종속 항목이 비표준 경로에 위치할 준비가 되지 않은 버그도 발견했습니다.

대신 더 쉬운 접근 방법은 별도의 폴더를 맞춤 시스템 루트 (종종 "sysroot"로 단축됨)로 만들어 관련된 모든 빌드 시스템을 가리키는 것입니다. 이렇게 하면 각 라이브러리는 빌드 중에 지정된 sysroot에서 종속 항목을 검색하고 다른 사람들이 더 쉽게 찾을 수 있도록 동일한 sysroot에 자체적으로 설치됩니다.

Emscripten에는 이미 (path to emscripten cache)/sysroot 아래에 자체 sysroot가 있으며 시스템 라이브러리, Emscripten 포트, CMake 및 pkg-config와 같은 도구에 사용됩니다. 내 종속 항목에도 동일한 sysroot를 재사용했습니다.

# 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을 사용하여 I/O 포트 어댑터 및 카메라 라이브러리를 열거하고 동적으로 로드합니다. 예를 들어 I/O 라이브러리를 로드하는 코드는 다음과 같습니다.

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

웹에서 이 접근 방식에는 몇 가지 문제가 있습니다.

  • WebAssembly 모듈의 동적 연결에 대한 표준 지원은 없습니다. Emscripten에는 libtool에서 사용하는 dlopen() API를 시뮬레이션할 수 있는 맞춤 구현이 있지만 'main'을 빌드해야 합니다. 및 '측면' 다양한 플래그가 있는 모듈(특히 dlopen()의 경우)은 애플리케이션 시작 중에 에뮬레이션된 파일 시스템에 부차 모듈을 미리 로드합니다. 이러한 플래그와 변경 사항을 동적 라이브러리가 많은 기존 autoconf 빌드 시스템에 통합하기는 어려울 수 있습니다.
  • dlopen() 자체가 구현되어 있어도 웹의 특정 폴더에 모든 동적 라이브러리를 열거할 방법이 없습니다. 대부분의 HTTP 서버는 보안상의 이유로 디렉터리 목록을 노출하지 않기 때문입니다.
  • 또한 런타임에 열거하는 대신 명령줄에서 동적 라이브러리를 연결하면 Emscripten과 다른 플랫폼에서의 공유 라이브러리 표현 간의 차이로 인해 중복 기호 문제와 같은 문제가 발생할 수 있습니다.

빌드 시스템을 이러한 차이점에 맞게 조정하고 빌드 중 어딘가에 동적 플러그인 목록을 하드코딩할 수 있지만, 이러한 모든 문제를 해결하는 훨씬 더 쉬운 방법은 처음부터 동적 링크를 피하는 것입니다.

libtool은 서로 다른 플랫폼에서 다양한 동적 링크 메서드를 추상화하고 다른 플랫폼을 위한 맞춤 로더 작성도 지원합니다. 지원되는 내장 로더 중 하나는 'Dlpreopening'입니다.

“Libtool은 libtool 객체 및 libtool 라이브러리 파일의 dlopening을 위한 특별 지원을 제공하므로 dlopen 및 dlsym 함수가 없는 플랫폼에서도 기호를 결정할 수 있습니다.
...
Libtool은 컴파일 시점에 객체를 프로그램에 연결하고 프로그램의 기호 테이블을 나타내는 데이터 구조를 생성하여 정적 플랫폼에서 -dlopen을 에뮬레이트합니다. 이 기능을 사용하려면 프로그램을 연결할 때 -dlopen 또는 -dlpreopen 플래그를 사용하여 애플리케이션이 dlopen할 객체를 선언해야 합니다 (링크 모드 참조).

이 메커니즘을 통해 Emscripten 대신 libtool 수준에서 동적 로드를 에뮬레이션하는 동시에 모든 것을 단일 라이브러리에 정적으로 연결할 수 있습니다.

이 방법으로 해결할 수 없는 유일한 문제는 동적 라이브러리를 열거하는 것입니다. 이러한 목록은 어딘가에 하드코딩되어야 합니다. 다행히 앱에 필요한 플러그인 집합은 매우 적습니다.

  • 포트 측면에서는 libusb 기반 카메라 연결에만 관심이 있고 PTP/IP, 직렬 액세스 또는 USB 드라이브 모드는 신경 쓰지 않습니다.
  • Camlibs 측면에서는 일부 특수한 기능을 제공할 수 있는 다양한 공급업체별 플러그인이 있지만, 일반 설정 제어 및 캡처의 경우 ptp2 camlib로 표시되고 시중의 거의 모든 카메라에서 지원하는 사진 전송 프로토콜을 사용하는 것으로 충분합니다.

다음은 모든 항목이 정적으로 연결된 업데이트된 종속 항목 다이어그램입니다.

'앱'을 보여주는 다이어그램 'libgphoto2 fork'에 종속됩니다. 이는 'libtool'에 종속됩니다. 'libtool' 'ports: libusb1'에 종속됨 및 'camlibs: libptp2'. '포트: libusb1' 'libusb fork'에 따라 달라집니다

이것이 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>
// …

또한 이러한 이름 지정 체계는 향후 동일한 앱에 카메라 전용 플러그인을 연결하려는 경우 이름 충돌을 방지합니다.

이러한 변경 사항을 모두 구현한 후에는 테스트 애플리케이션을 빌드하고 플러그인을 성공적으로 로드할 수 있었습니다.

설정 UI 생성

gPhoto2를 사용하면 카메라 라이브러리에서 위젯 트리 형태로 자체 설정을 정의할 수 있습니다. 위젯 유형의 계층 구조는 다음으로 구성됩니다.

  • 창 - 최상위 구성 컨테이너 <ph type="x-smartling-placeholder">
      </ph>
    • 섹션 - 이름이 지정된 다른 위젯 그룹
    • 버튼 필드
    • 입력란
    • 숫자 입력란
    • 날짜 필드
    • 전환
    • 라디오 버튼

각 위젯의 이름, 유형, 하위 요소, 기타 모든 관련 속성은 노출된 C API를 통해 쿼리할 수 있습니다 (값의 경우 수정도 가능). 이 둘은 C와 상호작용할 수 있는 모든 언어로 설정 UI를 자동으로 생성하기 위한 기반을 제공합니다.

설정은 언제든지 gPhoto2를 통해 또는 카메라 자체에서 변경할 수 있습니다. 또한 일부 위젯은 읽기 전용일 수 있으며 읽기 전용 상태 자체도 카메라 모드 및 기타 설정에 따라 달라집니다. 예를 들어 셔터 속도M (수동 모드)에서는 쓰기 가능한 숫자 필드이지만, P (프로그램 모드)에서는 정보 읽기 전용 필드가 됩니다. P 모드에서는 셔터 속도 값도 동적이며 카메라가 보고 있는 장면의 밝기에 따라 계속 변경됩니다.

대체로 UI에 연결된 카메라의 최신 정보를 항상 표시하는 동시에 사용자가 동일한 UI에서 이러한 설정을 수정할 수 있도록 허용하는 것이 중요합니다. 이러한 양방향 데이터 흐름은 처리하기가 더 복잡합니다.

gPhoto2에는 변경된 설정만 가져오는 메커니즘이 없으며 전체 트리 또는 개별 위젯만 가져올 수 있습니다. 깜박이고 입력 포커스나 스크롤 위치를 잃지 않고 UI를 최신 상태로 유지하려면 호출 간에 위젯 트리를 구별하고 변경된 UI 속성만 업데이트할 방법이 필요했습니다. 다행히 이 문제는 웹에서 해결되었으며 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를 통해 UI를 빌드할 수 있습니다.

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

이 함수를 무한 이벤트 루프에서 반복적으로 실행하면 설정 UI가 항상 최신 정보를 표시하도록 하는 동시에 사용자가 필드 중 하나를 수정할 때마다 카메라에 명령을 전송할 수 있습니다.

Preact는 페이지 포커스나 편집 상태를 중단하지 않고도 결과 비교를 처리하고 UI의 변경된 비트에 대해서만 DOM을 업데이트할 수 있습니다. 그러나 여전히 남아있는 문제는 양방향 데이터 흐름입니다. React 및 Preact와 같은 프레임워크는 단방향 데이터 흐름을 중심으로 설계되었습니다. 왜냐하면 데이터를 추론하고 재실행할 때 데이터를 비교하기가 훨씬 쉽기 때문입니다. 하지만 외부 소스인 카메라가 언제든지 설정 UI를 업데이트하도록 허용하여 이러한 기대를 깨고 있습니다.

현재 사용자가 수정하고 있는 모든 입력란의 UI 업데이트를 선택 해제하여 이 문제를 해결했습니다.

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

이렇게 하면 지정된 필드의 소유자가 항상 한 명만 있게 됩니다. 사용자가 현재 값을 수정하고 있어 카메라의 업데이트된 값으로 인해 방해를 받지 않거나 초점이 맞지 않을 때 카메라가 필드 값을 업데이트하고 있습니다.

라이브 '동영상' 빌드 피드

팬데믹 기간에는 많은 사람이 온라인 회의로 전환했습니다. 무엇보다도 이로 인해 웹캠 시장에 대한 부족이 발생했습니다. 노트북에 내장된 카메라보다 더 나은 동영상 품질을 얻기 위해, 그리고 이러한 부족에 대응하기 위해 많은 DSLR 및 미러리스 카메라 소유자는 사진 카메라를 웹캠으로 사용할 방법을 찾기 시작했습니다. 몇몇 카메라 공급업체에서는 이러한 목적으로 공식 유틸리티를 배송하기도 했습니다.

공식 도구와 마찬가지로 gPhoto2는 카메라에서 로컬에 저장된 파일로 또는 가상 웹캠으로 직접 스트리밍하는 동영상도 지원합니다. 이 기능을 사용해 데모에서 라이브 뷰를 제공하고 싶었습니다. 콘솔 유틸리티에서는 사용할 수 있지만 libgphoto2 라이브러리 API에서는 찾을 수 없었습니다.

콘솔 유틸리티에서 상응하는 함수의 소스 코드를 살펴보면 실제로 동영상을 전혀 가져오지 않고 대신 카메라의 미리보기를 무한 루프의 개별 JPEG 이미지로 계속 검색한 다음 하나씩 작성하여 M-JPEG 스트림을 형성한다는 것을 확인했습니다.

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

이 접근 방식이 부드러운 실시간 동영상의 인상을 줄 정도로 효율적으로 작동한다는 사실에 놀랐습니다. 하지만 모든 추가 추상화와 Asyncify를 통해 웹 애플리케이션에서도 동일한 성능을 일치시킬 수 있을지 회의적이었습니다. 그래도 시도해보기로 했습니다.

C++ 측에서는 동일한 gp_camera_capture_preview() 함수를 호출하고 결과로 반환되는 인메모리 파일을 다른 웹 API에 더 쉽게 전달할 수 있는 Blob로 변환하는 capturePreviewAsBlob()라는 메서드를 노출했습니다.

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를 사용하면 모든 디코딩 작업이 백그라운드에서 수행되며, 이미지와 브라우저가 모두 그릴 준비가 된 경우에만 캔버스가 업데이트됩니다. 노트북에서 30FPS 이상의 일관된 성능을 달성했으며, 이는 gPhoto2와 공식 Sony 소프트웨어의 기본 성능과 같았습니다.

USB 액세스 동기화

다른 작업이 이미 진행 중일 때 USB 데이터 전송을 요청하면 일반적으로 '기기 사용 중' 메시지가 표시됩니다. 오류가 발생했습니다. 미리보기와 설정 UI가 정기적으로 업데이트되고 사용자가 동시에 이미지를 캡처하거나 설정을 수정하려고 할 수 있기 때문에, 서로 다른 작업 간의 이러한 충돌은 매우 빈번하게 일어났습니다.

이를 방지하려면 애플리케이션 내의 모든 액세스를 동기화해야 했습니다. 이를 위해 프로미스 기반 비동기 큐를 빌드했습니다.

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

기존 queue 프로미스의 then() 콜백에서 각 작업을 체이닝하고 체이닝된 결과를 queue의 새 값으로 저장하면 모든 작업이 중첩 없이 순서대로 하나씩 실행되도록 할 수 있습니다.

모든 작업 오류는 호출자에게 반환되지만 심각한 (예상치 못한) 오류는 전체 체인을 거부된 프로미스로 표시하고 이후에 새 작업이 예약되지 않도록 합니다.

모듈 컨텍스트를 (내보내지 않은) 비공개 변수에 유지하면 schedule() 호출을 거치지 않고 실수로 앱의 다른 위치에서 context에 액세스하는 위험을 최소화할 수 있습니다.

여러 요소를 연결하려면 이제 기기 컨텍스트에 대한 각 액세스를 다음과 같이 schedule() 호출로 래핑해야 합니다.

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

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

이후 모든 작업이 충돌 없이 성공적으로 실행되었습니다.

결론

구현에 관한 자세한 내용은 GitHub의 코드베이스를 참고하세요. 또한 gPhoto2를 유지관리해 주시고 업스트림 PR을 검토해 주신 마커스 마이스너님께 감사드립니다.

이러한 게시물에서 볼 수 있듯이, WebAssembly, Asyncify 및 Fugu API는 가장 복잡한 애플리케이션에 대해서도 강력한 컴파일 대상을 제공합니다. 기존에 단일 플랫폼용으로 빌드되었던 라이브러리나 애플리케이션을 웹으로 포팅할 수 있게 해주며, 데스크톱과 휴대기기에서 모두 훨씬 많은 사용자가 사용할 수 있게 해 줍니다.