Chuyển các ứng dụng USB sang web. Phần 2: gPhoto2

Tìm hiểu cách chuyển gPhoto2 sang WebAssembly để điều khiển các máy ảnh bên ngoài qua USB từ một ứng dụng web.

Trong bài đăng trước, tôi đã trình bày cách chuyển thư viện libusb để chạy trên web bằng WebAssembly / Emscripten, Asyncify và WebUSB.

Tôi cũng đã chia sẻ một bản minh hoạ được xây dựng bằng gPhoto2 có thể dùng một ứng dụng web để điều khiển máy ảnh DSLR và máy ảnh không gương lật qua USB. Trong bài đăng này, tôi sẽ đi sâu hơn vào chi tiết kỹ thuật đằng sau cổng gPhoto2.

Trỏ hệ thống xây dựng đến nhánh phát triển tuỳ chỉnh

Vì tôi đang nhắm mục tiêu WebAssembly, nên tôi không thể sử dụng libusb và libgphoto2 do hệ thống phân phối cung cấp. Thay vào đó, tôi cần ứng dụng của mình để sử dụng nhánh tuỳ chỉnh của libgphoto2, trong khi nhánh đó của libgphoto2 phải sử dụng nhánh tuỳ chỉnh của libusb.

Ngoài ra, libgphoto2 sử dụng libtool để tải các plugin động và mặc dù tôi không phải phát triển libtool như hai thư viện khác, tôi vẫn phải xây dựng nó lên WebAssembly và trỏ libgphoto2 vào bản dựng tùy chỉnh đó thay vì gói hệ thống.

Dưới đây là sơ đồ gần đúng cho phần phụ thuộc (các đường nét đứt biểu thị đường liên kết động):

Sơ đồ cho thấy "ứng dụng" phụ thuộc vào 'libgphoto2 fork', phụ thuộc vào 'libtool'. "libtool" khối phụ thuộc động vào "cổng libgphoto2" và "libgphoto2 camlibs". Cuối cùng là "cổng libgphoto2" phụ thuộc tĩnh vào "nĩa nĩa libusb".

Hầu hết hệ thống xây dựng dựa trên cấu hình, bao gồm cả những hệ thống được sử dụng trong các thư viện này, cho phép ghi đè đường dẫn cho các phần phụ thuộc thông qua nhiều cờ. Vì vậy, đó là điều tôi đã thử thực hiện đầu tiên. Tuy nhiên, khi biểu đồ phần phụ thuộc trở nên phức tạp, danh sách ghi đè đường dẫn cho các phần phụ thuộc của mỗi thư viện sẽ trở nên chi tiết và dễ gặp lỗi. Tôi cũng phát hiện một số lỗi trong đó hệ thống xây dựng thực sự chưa được chuẩn bị để các phần phụ thuộc của chúng hoạt động trong các đường dẫn không chuẩn.

Thay vào đó, cách dễ dàng hơn là tạo một thư mục riêng làm thư mục gốc của hệ thống tuỳ chỉnh (thường được viết tắt là "sysroot") và trỏ tất cả hệ thống xây dựng có liên quan đến thư mục đó. Bằng cách đó, mỗi thư viện sẽ vừa tìm kiếm các phần phụ thuộc của nó trong sysroot được chỉ định trong quá trình tạo, đồng thời thư viện cũng sẽ tự cài đặt trong cùng một sysroot để người khác có thể tìm thấy dễ dàng hơn.

Emscripten đã có sysroot riêng trong (path to emscripten cache)/sysroot. sysroot này dùng cho thư viện hệ thống, Cổng Emscripten cũng như các công cụ như CMake và pkg-config. Tôi cũng đã chọn sử dụng lại cùng một sysroot cho các phần phụ thuộc.

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

Với cấu hình như vậy, tôi chỉ cần chạy make install trong mỗi phần phụ thuộc (được cài đặt trong sysroot), sau đó các thư viện tự động tìm thấy nhau.

Xử lý quá trình tải động

Như đã đề cập ở trên, libgphoto2 sử dụng libtool để liệt kê và tải động bộ chuyển đổi cổng I/O cũng như thư viện máy ảnh. Ví dụ: mã để tải thư viện I/O sẽ có dạng như sau:

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

Có một vài vấn đề với phương pháp này trên web:

  • Không có tính năng hỗ trợ tiêu chuẩn cho tính năng liên kết động của các mô-đun WebAssembly. Emscripten có cách triển khai tuỳ chỉnh có thể mô phỏng API dlopen() mà libtool sử dụng, nhưng bạn cần phải tạo "main" và "side" các mô-đun có cờ khác nhau và dành riêng cho dlopen() cũng để tải trước các mô-đun bên vào hệ thống tệp được mô phỏng trong quá trình khởi động ứng dụng. Bạn có thể gặp khó khăn khi tích hợp những cờ và tinh chỉnh đó vào một hệ thống xây dựng autoconf hiện có với nhiều thư viện động.
  • Ngay cả khi chính dlopen() được triển khai, không có cách nào để liệt kê tất cả thư viện động trong một thư mục nhất định trên web, vì hầu hết máy chủ HTTP đều không hiển thị danh sách thư mục vì lý do bảo mật.
  • Việc liên kết các thư viện động trên dòng lệnh thay vì liệt kê trong thời gian chạy cũng có thể dẫn đến các vấn đề, chẳng hạn như vấn đề biểu tượng trùng lặp. Nguyên nhân là do sự khác biệt giữa cách trình bày các thư viện dùng chung trong Emscripten và trên các nền tảng khác.

Bạn có thể điều chỉnh hệ thống xây dựng cho phù hợp với những khác biệt đó và mã hoá cứng danh sách trình bổ trợ động ở một vị trí nào đó trong quá trình tạo bản dựng. Tuy nhiên, một cách dễ dàng hơn nữa để giải quyết tất cả những vấn đề đó là tránh liên kết động ngay từ đầu.

Kết quả là libtool tóm tắt nhiều phương thức liên kết động trên nhiều nền tảng, thậm chí còn hỗ trợ viết trình tải tuỳ chỉnh cho những nền tảng khác. Một trong những trình tải tích hợp mà tính năng này hỗ trợ có tên là "Dlpreopening":

“Libtool cung cấp sự hỗ trợ đặc biệt cho các tệp đối tượng libtool và tệp thư viện libtool để các biểu tượng có thể được phân giải ngay cả trên nền tảng mà không cần bất kỳ hàm dlopen và dlsym nào.
...
Libtool mô phỏng -dlopen trên các nền tảng tĩnh bằng cách liên kết các đối tượng vào chương trình tại thời điểm biên dịch và tạo cấu trúc dữ liệu đại diện cho bảng biểu tượng của chương trình. Để sử dụng tính năng này, bạn phải khai báo các đối tượng mà bạn muốn ứng dụng của mình mở dlopen bằng cách sử dụng cờ -dlopen hoặc -dlpreopen khi bạn liên kết chương trình của mình (xem Chế độ liên kết).”

Cơ chế này cho phép mô phỏng tính năng tải động ở cấp libtool thay vì Emscripten, đồng thời liên kết tĩnh mọi thứ vào một thư viện duy nhất.

Vấn đề duy nhất mà tính năng này không giải quyết được là liệt kê các thư viện động. Danh sách của những thành phần đó vẫn cần phải được cố định giá trị trong mã ở một nơi nào đó. May mắn thay, bộ trình bổ trợ tôi cần cho ứng dụng này rất nhỏ:

  • Về phía cổng, tôi chỉ quan tâm đến kết nối máy ảnh dựa trên libusb chứ không quan tâm đến chế độ PTP/IP, truy cập nối tiếp hoặc ổ USB.
  • Về phía camlibs, có nhiều trình bổ trợ dành riêng cho nhà cung cấp có thể cung cấp một số chức năng chuyên biệt. Tuy nhiên, để kiểm soát và chụp các chế độ cài đặt chung, bạn chỉ cần sử dụng Giao thức truyền hình ảnh (được biểu thị bằng ptp2 camlib) và được hỗ trợ bởi hầu hết mọi máy ảnh trên thị trường.

Dưới đây là giao diện của sơ đồ phần phụ thuộc mới cập nhật với mọi thứ được liên kết tĩnh với nhau:

Sơ đồ cho thấy "ứng dụng" phụ thuộc vào 'libgphoto2 fork', phụ thuộc vào 'libtool'. "libtool" phụ thuộc vào "ports: libusb1" và "camlibs: libptp2". "ports: libusb1" phụ thuộc vào "nĩa libusb".

Đó là những gì tôi mã hoá cứng cho các bản dựng 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 ();

Trong hệ thống xây dựng autoconf, giờ đây tôi phải thêm -dlpreopen với cả hai tệp đó dưới dạng cờ liên kết cho tất cả các tệp thực thi (ví dụ, bài kiểm thử và ứng dụng minh hoạ của riêng tôi), như sau:

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

Cuối cùng, hiện tại tất cả các biểu tượng được liên kết theo phương thức tĩnh trong một thư viện duy nhất, libtool cần có cách để xác định biểu tượng nào thuộc về thư viện nào. Để đạt được điều này, nhà phát triển phải đổi tên tất cả biểu tượng được hiển thị như {function name} thành {library name}_LTX_{function name}. Cách dễ nhất để thực hiện việc này là sử dụng #define để xác định lại tên biểu tượng ở đầu tệp triển khai:

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

Cách đặt tên này cũng giúp ngăn chặn trường hợp xung đột tên trong trường hợp tôi quyết định liên kết các trình bổ trợ dành riêng cho máy ảnh trong cùng một ứng dụng trong tương lai.

Sau khi triển khai tất cả những thay đổi này, tôi có thể tạo ứng dụng thử nghiệm và tải thành công các trình bổ trợ.

Tạo giao diện người dùng cài đặt

gPhoto2 cho phép các thư viện máy ảnh xác định các chế độ cài đặt riêng dưới dạng cây tiện ích. Hệ phân cấp của các loại tiện ích bao gồm:

  • Cửa sổ – vùng chứa cấu hình cấp cao nhất
    • Mục – các nhóm được đặt tên của tiện ích khác
    • Trường nút
    • Trường văn bản
    • Trường số
    • Trường ngày
    • Tuỳ chọn bật/tắt
    • Nút chọn

Bạn có thể truy vấn tên, loại, phần tử con và tất cả các thuộc tính có liên quan khác của mỗi tiện ích (và trong trường hợp các giá trị, cũng được sửa đổi) thông qua API C hiển thị. Cả hai đều cung cấp nền tảng để tự động tạo giao diện người dùng cài đặt bằng bất kỳ ngôn ngữ nào có thể tương tác với C.

Bạn có thể thay đổi chế độ cài đặt qua gPhoto2 hoặc trên chính máy ảnh bất cứ lúc nào. Ngoài ra, một số tiện ích có thể ở chế độ chỉ đọc và thậm chí cả trạng thái chỉ đọc cũng phụ thuộc vào chế độ máy ảnh và các chế độ cài đặt khác. Ví dụ: tốc độ màn trập là một trường số có thể ghi ở M (chế độ thủ công), nhưng sẽ trở thành trường chỉ đọc thông tin ở P (chế độ chương trình). Ở chế độ P, giá trị của tốc độ màn trập cũng sẽ thay đổi liên tục và tuỳ thuộc vào độ sáng của cảnh mà máy ảnh đang xem.

Nói chung, điều quan trọng là phải luôn hiển thị thông tin mới nhất từ máy ảnh đã kết nối trong giao diện người dùng, đồng thời cho phép người dùng chỉnh sửa các chế độ cài đặt đó trên cùng một giao diện người dùng. Luồng dữ liệu hai chiều như vậy phức tạp hơn khi xử lý.

gPhoto2 không có cơ chế truy xuất các chế độ cài đặt đã thay đổi, mà chỉ truy xuất toàn bộ cây hoặc tiện ích riêng lẻ. Để luôn cập nhật giao diện người dùng mà không bị nhấp nháy và mất tiêu điểm nhập hoặc vị trí cuộn, tôi cần một cách để làm khác biệt cây tiện ích giữa các lệnh gọi và chỉ cập nhật các thuộc tính giao diện người dùng đã thay đổi. May mắn thay, đây là một vấn đề đã được giải quyết trên web và là chức năng cốt lõi của các khung như React (Phản ứng) hoặc Preact (Dự đoán). Tôi sử dụng Preact cho dự án này vì tính năng này nhẹ hơn nhiều và thực hiện được mọi thứ tôi cần.

Về phía C++, giờ đây tôi cần truy xuất và đệ quy đi bộ cây cài đặt thông qua API C được liên kết trước đó và chuyển đổi từng tiện ích thành một đối tượng 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;
    }
    // …

Về phía JavaScript, giờ đây tôi có thể gọi configToJS, xem qua bản trình bày JavaScript được trả về của cây cài đặt và xây dựng giao diện người dùng thông qua hàm 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;
  }
  // …

Bằng cách chạy hàm này nhiều lần trong một vòng lặp sự kiện vô hạn, tôi có thể khiến giao diện người dùng cài đặt luôn cho thấy thông tin mới nhất, đồng thời gửi lệnh đến camera bất cứ khi nào người dùng chỉnh sửa một trong các trường.

Tính năng trước có thể đảm nhận việc làm khác biệt kết quả và chỉ cập nhật DOM cho các bit đã thay đổi của giao diện người dùng mà không làm gián đoạn tiêu điểm trang hoặc trạng thái chỉnh sửa. Một vấn đề còn tồn đọng là luồng dữ liệu hai chiều. Các khung như React và Preact được thiết kế xoay quanh luồng dữ liệu một chiều, vì nó giúp bạn dễ dàng giải thích về dữ liệu và so sánh dữ liệu giữa các lần chạy lại. Tuy nhiên, tôi đã phá vỡ kỳ vọng đó bằng cách cho phép nguồn bên ngoài (máy ảnh) cập nhật chế độ cài đặt giao diện người dùng bất cứ lúc nào.

Tôi giải quyết vấn đề này bằng cách chọn không cập nhật giao diện người dùng cho bất kỳ trường nhập dữ liệu nào hiện đang được người dùng chỉnh sửa:

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

Theo cách này, trường nào cũng chỉ có một chủ sở hữu duy nhất. Người dùng hiện đang chỉnh sửa trường này và các giá trị được cập nhật từ máy ảnh sẽ không bị gián đoạn, hoặc máy ảnh đang cập nhật giá trị trường khi không rõ nét.

Tạo "video" trực tiếp nguồn cấp dữ liệu

Trong thời gian đại dịch, rất nhiều người đã chuyển sang hình thức họp trực tuyến. Ngoài ra, điều này dẫn đến sự thiếu hụt trên thị trường webcam. Để có được chất lượng video tốt hơn so với máy ảnh tích hợp sẵn trong máy tính xách tay, cũng như để đáp ứng tình trạng thiếu hụt nói trên, nhiều chủ sở hữu máy ảnh DSLR và máy ảnh không gương lật đã bắt đầu tìm cách sử dụng máy ảnh chụp ảnh làm webcam. Một số nhà cung cấp máy ảnh thậm chí còn chuyển giao các tiện ích chính thức cho mục đích này.

Giống như các công cụ chính thức, gPhoto2 hỗ trợ phát trực tuyến video từ máy ảnh lên tệp được lưu trữ cục bộ hoặc trực tiếp tới webcam ảo. Tôi muốn sử dụng tính năng đó để cung cấp chế độ xem trực tiếp trong bản minh hoạ của mình. Tuy nhiên, mặc dù tiện ích này có trong tiện ích bảng điều khiển, nhưng tôi không tìm thấy tính năng này ở bất cứ đâu trong API thư viện libgphoto2.

Nhìn vào mã nguồn của hàm tương ứng trong tiện ích bảng điều khiển, tôi nhận thấy thực tế là nó không hề tải video, mà thay vào đó tiếp tục truy xuất bản xem trước của máy ảnh dưới dạng từng ảnh JPEG trong một vòng lặp vô tận và viết lần lượt từng ảnh để tạo thành luồng M-JPEG:

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

Tôi ngạc nhiên vì phương pháp này hoạt động đủ hiệu quả để tạo ấn tượng với video mượt mà theo thời gian thực. Tôi thậm chí còn hoài nghi hơn về việc có thể đạt được hiệu suất tương tự trong ứng dụng web, với tất cả các yếu tố trừu tượng bổ sung và Không đồng bộ hoá. Tuy nhiên, tôi vẫn quyết định thử.

Về phía C++, tôi đã hiển thị một phương thức có tên là capturePreviewAsBlob(). Phương thức này gọi ra cùng một hàm gp_camera_capture_preview() và chuyển đổi tệp kết quả trong bộ nhớ thành Blob có thể được truyền đến các API web khác dễ dàng hơn:

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

Về phía JavaScript, tôi có một vòng lặp, tương tự như vòng lặp trong gPhoto2, cho phép truy xuất hình ảnh xem trước dưới dạng Blob, giải mã chúng trong nền bằng createImageBitmapchuyển chúng sang canvas trên khung ảnh động tiếp theo:

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

Việc sử dụng các API hiện đại đó đảm bảo rằng tất cả tác vụ giải mã đều được thực hiện trong nền và canvas chỉ được cập nhật khi cả hình ảnh và trình duyệt đều được chuẩn bị đầy đủ để vẽ. Điều này đã đạt được hơn 30 FPS ổn định trên máy tính xách tay của tôi, phù hợp với hiệu suất gốc của cả gPhoto2 và phần mềm chính thức của Sony.

Đang đồng bộ hoá quyền truy cập vào USB

Khi yêu cầu chuyển dữ liệu USB trong khi một thao tác khác đang diễn ra, điều này thường dẫn đến "thiết bị đang bận" . Do bản xem trước và giao diện người dùng chế độ cài đặt thường xuyên cập nhật và có thể người dùng đang tìm cách chụp ảnh hoặc sửa đổi chế độ cài đặt cùng lúc, nên những xung đột như vậy giữa các thao tác diễn ra rất thường xuyên.

Để tránh bị hạn chế, tôi cần phải đồng bộ hoá tất cả các quyền truy cập trong ứng dụng. Do đó, tôi đã xây dựng một hàng đợi không đồng bộ dựa trên lời hứa:

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

Bằng cách tạo chuỗi cho từng thao tác trong lệnh gọi lại then() của lời hứa queue hiện có và lưu trữ kết quả theo chuỗi dưới dạng giá trị mới của queue, tôi có thể đảm bảo rằng tất cả thao tác được thực thi từng thao tác một, theo thứ tự và không bị trùng lặp.

Mọi lỗi hoạt động đều được trả về cho phương thức gọi, trong khi các lỗi nghiêm trọng (không mong muốn) sẽ đánh dấu toàn bộ chuỗi là một lời hứa bị từ chối và đảm bảo rằng không có hoạt động mới nào được lên lịch sau đó.

Bằng cách giữ ngữ cảnh mô-đun ở chế độ riêng tư (không xuất), tôi sẽ giảm thiểu rủi ro khi vô tình truy cập vào context ở một nơi khác trong ứng dụng mà không thực hiện lệnh gọi schedule().

Để liên kết mọi thứ với nhau, giờ đây, mỗi quyền truy cập vào ngữ cảnh của thiết bị phải được gói trong lệnh gọi schedule() như sau:

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

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

Sau đó, tất cả các thao tác đều được thực thi thành công mà không có xung đột.

Kết luận

Vui lòng duyệt qua cơ sở mã trên GitHub để biết thêm thông tin chi tiết về cách triển khai. Tôi cũng muốn cảm ơn Marcus Meissner vì đã duy trì gPhoto2 và vì những bài đánh giá của anh ấy về các PR ngược dòng của tôi.

Như đã trình bày trong các bài đăng này, API WebAssembly, Asyncify và Fugu cung cấp một mục tiêu biên dịch mạnh mẽ cho cả những ứng dụng phức tạp nhất. Chúng cho phép bạn lấy một thư viện hoặc ứng dụng trước đây được xây dựng cho một nền tảng duy nhất rồi chuyển thư viện hoặc ứng dụng đó lên web, giúp thư viện hoặc ứng dụng đó tiếp cận được với một số lượng lớn người dùng như nhau trên cả máy tính để bàn cũng như thiết bị di động.