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

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

이전 게시물에서는 WebAssembly / Emscripten, Asyncify 및 WebUSB를 사용하여 웹에서 실행되도록 libusb 라이브러리를 포팅한 방법을 설명했습니다.

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

빌드 시스템이 맞춤 포크를 가리키도록 하기

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

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

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

'libtool'에 종속되는 'libgphoto2 fork'에 따른 '앱'을 보여주는 다이어그램 'libtool' 블록은 'libgphoto2 port' 및 'libgphoto2 camlibs'에 동적으로 종속됩니다. 마지막으로 'libgphoto2 port'는 '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'과 'side' 모듈을 다른 플래그를 사용하여 빌드해야 하며, 특히 dlopen()의 경우 애플리케이션 시작 중에 에뮬레이션된 파일 시스템에 사이드 모듈을 미리 로드해야 합니다. 이러한 플래그와 조정을 많은 동적 라이브러리가 있는 기존 autoconf 빌드 시스템에 통합하기 어려울 수 있습니다.
  • dlopen() 자체가 구현되더라도 웹의 특정 폴더에 있는 모든 동적 라이브러리를 열거할 방법은 없습니다. 대부분의 HTTP 서버는 보안상의 이유로 디렉터리 목록을 노출하지 않기 때문입니다.
  • 런타임에서 열거하는 대신 명령줄에서 동적 라이브러리를 연결하면 Emscripten과 다른 플랫폼의 공유 라이브러리 표현 간의 차이로 인해 발생하는 중복 기호 문제와 같은 문제가 발생할 수 있습니다.

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

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

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

이 메커니즘을 사용하면 모든 항목을 정적으로 단일 라이브러리에 링크하면서 Emscripten이 아닌 libtool 수준에서 동적 로드를 에뮬레이션할 수 있습니다.

이 방법으로 해결되지 않는 유일한 문제는 동적 라이브러리의 열거입니다. 이러한 목록은 여전히 어딘가에 하드코딩되어야 합니다. 다행히 앱에 필요한 플러그인 세트가 최소화되었습니다.

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

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

'libtool'에 종속되는 'libgphoto2 fork'에 따른 '앱'을 보여주는 다이어그램 'libtool'은 'ports: libusb1' 및 'camlibs: libptp2'에 종속됩니다. 'ports: 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를 사용하면 카메라 라이브러리가 위젯 트리 형태로 자체 설정을 정의할 수 있습니다. 위젯 유형의 계층 구조는 다음으로 구성됩니다.

  • 창 - 최상위 구성 컨테이너
    • 섹션 - 다른 위젯의 이름이 지정된 그룹
    • 버튼 필드
    • 입력란
    • 숫자 입력란
    • 날짜 필드
    • 전환 버튼
    • 라디오 버튼

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

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

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

gPhoto2에는 변경된 설정만 검색하는 메커니즘이 없고 전체 트리 또는 개별 위젯만 가져옵니다. 깜박이거나 입력 포커스 또는 스크롤 위치를 잃지 않고 UI를 최신 상태로 유지하려면 호출 간에 위젯 트리를 구분하고 변경된 UI 속성만 업데이트하는 방법이 필요했습니다. 다행히 이는 웹에서 해결된 문제이며 ReactPreact와 같은 프레임워크의 핵심 기능입니다. 이 프로젝트에서는 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() 함수를 호출하는 capturePreviewAsBlob()라는 메서드를 노출했습니다. 이 메서드는 결과 메모리 내 파일을 다른 웹 API에 더 쉽게 전달할 수 있는 Blob로 변환합니다.

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을 검토해 주신 Marcus Meissner님께도 감사의 말씀을 전하고 싶습니다.

이 게시물에서 볼 수 있듯이 WebAssembly, Asyncify 및 Fugu API는 가장 복잡한 애플리케이션에서도 유용한 컴파일 대상을 제공합니다. 이를 통해 이전에 단일 플랫폼용으로 빌드한 라이브러리나 애플리케이션을 웹으로 포팅하여 데스크톱과 휴대기기 모두에서 수많은 사용자에게 제공할 수 있습니다.