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를 시스템 패키지 대신 맞춤 빌드로 가리켜야 했습니다.

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

다이어그램은 'libtool'에 종속된 'libgphoto2 포크'에 종속된 '앱'을 보여줍니다. 'libtool' 블록은 'libgphoto2 포트' 및 'libgphoto2 camlibs'에 동적으로 종속됩니다. 마지막으로 'libgphoto2 포트'는 'libusb 포크'에 정적으로 종속됩니다.

이러한 라이브러리에 사용되는 구성 기반 빌드 시스템을 비롯한 대부분의 구성 기반 빌드 시스템은 다양한 플래그를 통해 종속 항목의 경로를 재정의할 수 있으므로 먼저 이를 시도했습니다. 그러나 종속 항목 그래프가 복잡해지면 각 라이브러리 종속 항목의 경로 재정의 목록이 장황해지고 오류가 발생하기 쉽습니다. 종속 항목이 비표준 경로에 있는 경우 빌드 시스템이 실제로 준비되지 않은 버그도 발견되었습니다.

대신 별도의 폴더를 맞춤 시스템 루트('sysroot'로 줄여서 표기하는 경우가 많음)로 만들고 관련된 모든 빌드 시스템을 이 폴더로 가리키는 것이 더 쉽습니다. 이렇게 하면 각 라이브러리는 빌드 중에 지정된 sysroot에서 종속 항목을 검색하고 다른 라이브러리가 더 쉽게 찾을 수 있도록 동일한 sysroot에 자체적으로 설치됩니다.

Emscripten에는 이미 (path to emscripten cache)/sysroot 아래에 자체 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를 시뮬레이션할 수 있는 맞춤 구현이 있지만, '기본' 모듈과 '부속' 모듈을 서로 다른 플래그로 빌드해야 하며, 특히 dlopen()의 경우 애플리케이션 시작 중에 부속 모듈을 에뮬레이션된 파일 시스템에 미리 로드해야 합니다. 이러한 플래그와 조정을 다수의 동적 라이브러리가 있는 기존 autoconf 빌드 시스템에 통합하는 것은 쉽지 않을 수 있습니다.
  • dlopen() 자체가 구현되더라도 웹의 특정 폴더에 있는 모든 동적 라이브러리를 열거할 방법은 없습니다. 대부분의 HTTP 서버는 보안상의 이유로 디렉터리 목록을 노출하지 않기 때문입니다.
  • 런타임에 열거하는 대신 명령줄에서 동적 라이브러리를 연결하면 Emscripten과 다른 플랫폼의 공유 라이브러리 표현 간의 차이로 인해 발생하는 중복 기호 문제와 같은 문제가 발생할 수도 있습니다.

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

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

'Libtool은 dlopen 및 dlsym 함수가 없는 플랫폼에서도 기호를 확인할 수 있도록 libtool 객체 및 libtool 라이브러리 파일의 dlopen에 관한 특별한 지원을 제공합니다.

Libtool은 컴파일 시간에 객체를 프로그램에 연결하고 프로그램의 기호 테이블을 나타내는 데이터 구조를 만들어 정적 플랫폼에서 -dlopen을 에뮬레이션합니다. 이 기능을 사용하려면 프로그램을 연결할 때 -dlopen 또는 -dlpreopen 플래그를 사용하여 애플리케이션에서 dlopen할 객체를 선언해야 합니다 (링크 모드 참고)."

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

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

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

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

다이어그램은 'libtool'에 종속된 'libgphoto2 포크'에 종속된 '앱'을 보여줍니다. '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>
// …

또한 이 이름 지정 스키마는 나중에 동일한 앱에서 카메라별 플러그인을 연결하기로 결정한 경우 이름 충돌을 방지합니다.

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

설정 UI 생성

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

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

각 위젯의 이름, 유형, 하위 요소, 기타 모든 관련 속성은 노출된 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);
  // …

이 접근 방식이 원활한 실시간 동영상을 표시할 만큼 효율적으로 작동한다는 사실에 놀랐습니다. 웹 애플리케이션에서도 동일한 성능을 얻을 수 있을지 의심스러웠습니다. 그 이유는 추가적인 추상화와 비동기화가 방해가 되기 때문입니다. 하지만 그래도 시도해 보기로 했습니다.

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 측에는 미리보기 이미지를 Blob로 계속 가져오고, createImageBitmap를 사용하여 백그라운드에서 디코딩하고, 다음 애니메이션 프레임의 캔버스로 전송하는 gPhoto2의 루프와 유사한 루프가 있습니다.

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는 가장 복잡한 애플리케이션에도 적합한 컴파일 타겟을 제공합니다. 이를 통해 이전에 단일 플랫폼용으로 빌드된 라이브러리 또는 애플리케이션을 웹으로 포팅하여 데스크톱과 휴대기기에서 훨씬 더 많은 사용자에게 제공할 수 있습니다.