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

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

Trong bài đăng trước, tôi đã cho thấ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 đã giới thiệu một bản minh hoạ được tạo bằng gPhoto2 có thể điều khiển máy ảnh DSLR và máy ảnh không gương lật qua USB từ một ứng dụng web. Trong bài đăng này, tôi sẽ đi sâu hơn vào thông tin kỹ thuật đằng sau cổng gPhoto2.

Chỉ hệ thống xây dựng đến các nhánh tuỳ chỉnh

Vì nhắm đến WebAssembly, nên tôi không thể sử dụng libusb và libgphoto2 do các bản phân phối hệ thống 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 trình bổ trợ động và mặc dù tôi không phải phân nhánh libtool như hai thư viện khác, nhưng tôi vẫn phải tạo thư viện đó thành WebAssembly và trỏ libgphoto2 đến bản dựng tuỳ chỉnh đó thay vì gói hệ thống.

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

Sơ đồ cho thấy "ứng dụng" phụ thuộc vào "phần phân tách libgphoto2", phần này phụ thuộc vào "trình trợ giúp". Khối "libtool" phụ thuộc động vào "cổng libgphoto2" và "camlibs libgphoto2". Cuối cùng, "cổng libgphoto2" phụ thuộc tĩnh vào "phần phân tách libusb".

Hầu hết các hệ thống xây dựng dựa trên cấu hình, bao gồm cả các hệ thống được 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à việc tôi đã cố gắng làm trước 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 từng phần phụ thuộc của thư viện sẽ trở nên dài dòng và dễ gặp lỗi. Tôi cũng tìm thấy một số lỗi trong đó hệ thống xây dựng không thực sự được chuẩn bị để các phần phụ thuộc của chúng nằm trong các đường dẫn không chuẩn.

Thay vào đó, bạn có thể tạo một thư mục riêng làm thư mục gốc 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ẽ tìm kiếm các phần phụ thuộc của thư viện đó trong sysroot được chỉ định trong quá trình xây dựng, đồng thời tự cài đặt thư viện đó trong cùng một sysroot để các thư viện khác có thể tìm thấy thư viện đó dễ dàng hơn.

Emscripten đã có sysroot riêng trong (path to emscripten cache)/sysroot, dùng cho thư viện hệ thống, cổng Emscripten và 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 của mình.

# 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, phần phụ thuộc này đã cài đặt make install trong sysroot, sau đó các thư viện sẽ tự động tìm thấy nhau.

Xử lý tính năng tải động

Như đã đề cập ở trên, libgphoto2 sử dụng libtool để liệt kê và tải động các 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 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ợ chuẩn nào cho việc 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 tạo các mô-đun "chính" và "bên" bằng các cờ khác nhau, và đặc biệt là đối với dlopen(), cũng cần 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. Có thể khó tích hợp các cờ và tinh chỉnh đó vào hệ thống xây dựng autoconf hiện có với nhiều thư viện động.
  • Ngay cả khi triển khai chính dlopen(), bạn cũng không thể 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 các 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 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 đề về ký hiệu trùng lặp, do sự khác biệt giữa cách trình bày 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 điểm khác biệt đó và mã hoá cứng danh sách trình bổ trợ động ở đâu đó trong quá trình xây dựng, nhưng cách dễ dàng hơn để giải quyết tất cả các vấn đề đó là tránh liên kết động ngay từ đầu.

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

“Libtool cung cấp tính năng hỗ trợ đặc biệt cho tệp thư viện libtool và đối tượng libtool dlopening, nhờ đó các biểu tượng của chúng có thể được phân giải ngay cả trên các nền tảng không có 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ác 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 dlopen bằng cách sử dụng cờ -dlopen hoặc -dlpreopen khi liên kết chương trì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 mọi thứ một cách tĩnh vào một thư viện duy nhất.

Vấn đề duy nhất mà cách này không giải quyết được là liệt kê thư viện động. Danh sách các giá trị đó vẫn cần được mã hoá cứng ở đâu đó. May mắn thay, tôi chỉ cần một bộ trình bổ trợ tối thiểu cho ứng dụng:

  • 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 PTP/IP, quyền truy cập nối tiếp hoặc chế độ ổ 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, nhưng đối với việc 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 camlib ptp2) và hầu hết mọi máy ảnh trên thị trường đều hỗ trợ giao thức này.

Dưới đây là sơ đồ phần phụ thuộc đã 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 "phần phân tách libgphoto2", phần này phụ thuộc vào "trình trợ giúp". "libtool" phụ thuộc vào "ports: libusb1" và "camlibs: libptp2". "ports: libusb1" phụ thuộc vào "libusb fork".

Đó 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 đó làm cờ liên kết cho tất cả các tệp thực thi (ví dụ, 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, giờ đây khi tất cả các biểu tượng được liên kết tĩnh trong một thư viện duy nhất, libtool cần có một cách để xác định biểu tượng nào thuộc thư viện nào. Để đạt được điều này, nhà phát triển cần đổi tên tất cả các biểu tượng 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>
// …

Lược đồ đặt tên này cũng giúp tránh 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ả các thay đổi này, tôi có thể tạo ứng dụng kiểm thử và tải các trình bổ trợ thành công.

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 chế độ cài đặt của riêng mình ở 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
    • Phần – các nhóm được đặt tên của các 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 giá trị, cũng có thể sửa đổi) thông qua API C được hiển thị. Các lớp này cùng nhau 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 thông 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à ngay 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 trong M (chế độ thủ công), nhưng trở thành một trường chỉ có thể đọc thông tin trong P (chế độ chương trình). Ở chế độ P, giá trị tốc độ màn trập cũng sẽ linh động và liên tục thay đổi tuỳ thuộc vào độ sáng của cảnh mà máy ảnh đang nhìn thấy.

Tóm lại, điều quan trọng là 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 đó từ 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 để xử lý.

gPhoto2 không có cơ chế chỉ truy xuất các chế độ cài đặt đã thay đổi, mà chỉ truy xuất toàn bộ cây hoặc cá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 đầu vào hoặc vị trí cuộn, tôi cần một cách để so sánh các 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à 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 hoặc Preact. Tôi đã chọn Preact cho dự án này vì nó nhẹ hơn nhiều và làm được mọi thứ tôi cần.

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

Ở phía JavaScript, giờ đây, tôi có thể gọi configToJS, xem qua nội dung đại diện JavaScript được trả về của cây cài đặt và tạo 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 lặp lại 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 hiển thị thông tin mới nhất, đồng thời gửi lệnh đến máy ảnh mỗi khi người dùng chỉnh sửa một trong các trường.

Preact có thể xử lý việc so sánh 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. Vẫn còn một vấn đề là luồng dữ liệu hai chiều. Các khung như React và Preact được thiết kế xung quanh luồng dữ liệu một chiều, vì điều này giúp dễ dàng suy luận về dữ liệu và so sánh dữ liệu giữa các lần chạy lại, nhưng tôi sẽ phá vỡ kỳ vọng đó bằng cách cho phép một nguồn bên ngoài – máy ảnh – cập nhật giao diện người dùng cài đặt 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 mọi trường nhập mà người dùng đang 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}));
  }
}

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

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

Trong thời gian đại dịch, nhiều người đã chuyển sang tổ chức các cuộc họp trực tuyến. Ngoài ra, điều này còn dẫn đến tình trạng thiếu hàng trên thị trường webcam. Để có chất lượng video tốt hơn so với máy ảnh tích hợp trong máy tính xách tay và để giải quyết 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 phân phối 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ợ truyền trực tuyến video từ máy ảnh đến tệp được lưu trữ cục bộ hoặc trực tiếp đến 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ù có trong tiện ích bảng điều khiển, nhưng tôi không tìm thấy API này ở bất kỳ đâu trong API thư viện libgphoto2.

Khi xem xét 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 rằng hàm này thực sự không nhận được video nào cả, mà thay vào đó liên tục truy xuất bản xem trước của máy ảnh dưới dạng hình ảnh JPEG riêng lẻ trong một vòng lặp vô tận và ghi từng hình ả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 rất ngạc nhiên khi thấy phương pháp này hoạt động hiệu quả đến mức tạo ra ấn tượng về video mượt mà theo thời gian thực. Tôi còn hoài nghi hơn về khả năng có thể đạt được hiệu suất tương tự trong ứng dụng web, với tất cả các tính năng trừu tượng và Asyncify bổ sung. Tuy nhiên, tôi vẫn quyết định thử.

Ở phía C++, tôi đã hiển thị một phương thức có tên capturePreviewAsBlob() gọi 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, liên tục truy xuất hình ảnh xem trước dưới dạng Blob, giải mã các hình ảnh đó ở chế độ nền bằng createImageBitmapchuyển các hình ảnh đó 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ả công việc giải mã đều được thực hiện ở chế độ 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 tốc độ 30 khung hình/giây nhất quán trên máy tính xách tay của tôi, tương đương với hiệu suất gốc của cả gPhoto2 và phần mềm chính thức của Sony.

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

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

Để tránh những vấn đề này, tôi cần đồng bộ hoá tất cả quyền truy cập trong ứng dụng. Để làm được điều đó, tôi đã tạo 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ả được tạo 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ả các thao tác được thực thi lần lượt, theo thứ tự và không trùng lặp.

Mọi lỗi thao tác đề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 sẽ không có thao tác mới nào được lên lịch sau đó.

Bằng cách giữ ngữ cảnh mô-đun trong một biến riêng tư (không xuất), tôi đang giảm thiểu nguy cơ vô tình truy cập vào context ở nơi khác trong ứng dụng mà không cần 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 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 thực thi thành công mà không có xung đột.

Kết luận

Bạn có thể duyệt xem 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 đã duy trì gPhoto2 và xem xét các yêu cầu thay đổi ngược dòng của tôi.

Như đã trình bày trong các bài đăng này, WebAssembly, Asyncify và Fugu API cung cấp một mục tiêu biên dịch có khả năng cho ngay cả những ứng dụng phức tạp nhất. Các công cụ này cho phép bạn lấy một thư viện hoặc ứng dụng được tạo trước đó cho một nền tảng duy nhất và chuyển thư viện hoặc ứng dụng đó sang web, cung cấp cho nhiều người dùng hơn trên cả máy tính và thiết bị di động.