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 đã giải thích cách chuyển thư viện libusb để chạy trên web bằng WebAssembly / Emscripten, Asyncify và WebUSB.

Tôi cũng cho thấy một bản minh hoạ được tích hợp 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 trong một ứng dụng web. Trong bài đăng này, tôi sẽ tìm hiểu sâu hơn về các chi tiết kỹ thuật đằng sau cổng gphoto2.

Trỏ hệ thống xây dựng tới các nhánh tuỳ chỉnh

Vì tôi đang nhắm mục tiêu WebAssembly, tôi không thể sử dụng libusb và libgphoto2 do 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 nĩa libgphoto2 tùy chỉnh, trong khi nĩa libgphoto2 đó phải sử dụng nĩa libusb tùy chỉnh của tôi.

Ngoài ra, libgphoto2 sử dụng libtool để tải các plugin động và mặc dù tôi không phải chia sẻ libtool như hai thư viện khác, nhưng 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à biểu đồ phần phụ thuộc gần đúng (các đường nét đứt biểu thị tính năng liên kết động):

Một sơ đồ cho thấy "ứng dụng" phụ thuộc vào " nhánh phát triển libgphoto2", phụ thuộc vào "libtool". Khối 'libtool' phụ thuộc động vào 'cổng libgphoto2' và 'libgphoto2 camlibs'. Cuối cùng, "cổng libgphoto2" phụ thuộc tĩnh vào "nĩa 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ả 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 phần phụ thuộc thông qua nhiều cờ, vì vậy đó là những gì 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 các phần phụ thuộc của từng thư viện sẽ trở nên chi tiết và dễ xảy ra lỗi. Tôi cũng phát hiện 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 tồn tại trong các đường dẫn không chuẩn.

Thay vào đó, phương pháp dễ hơn là tạo một thư mục riêng làm thư mục gốc tuỳ chỉnh của hệ thống (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ác phần phụ thuộc trong sysroot được chỉ định trong quá trình xây dựng, đồng thời cũng sẽ cài đặt chính nó trong cùng một sysroot để những 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. Hệ thống này sử 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.

# 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ài đặt phần phụ thuộc này trong sysroot, sau đó các thư viện sẽ tự động tìm lẫn 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 bộ chuyển đổi cổng I/O và 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ó hỗ trợ tiêu chuẩn nào cho tính năng liên kết động các mô-đun WebAssembly. Emscripten có phương thức triển khai tuỳ chỉnh có thể mô phỏng API dlopen() mà libtool sử dụng, nhưng yêu cầu bạn phải tạo các mô-đun "main" và "side" với các cờ khác nhau, đồng thời 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.dlopen() Có thể khó có thể 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 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 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 đề về biểu tượng trùng lặp. Vấn đề này xảy ra do sự khác biệt giữa cách biểu diễn 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 theo những khác biệt đó và mã hoá cứng danh sách trình bổ trợ động ở đâu đó trong quá trình tạo bản dựng, nhưng cách dễ dàng hơn để giải quyết tất cả những vấn đề đó là tránh liên kết động khi bắt đầu.

Hoá ra, libtool loại bỏ nhiều phương thức liên kết động trên nhiều nền tảng và thậm chí còn hỗ trợ viết trình tải tuỳ chỉnh cho những người 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 tính năng hỗ trợ đặc biệt cho các tệp đối tượng dlopening libtool và tệp thư viện libtool để có thể phân giải các ký hiệu của chúng ngay cả trên các nền tảng không có hàm dlopen và dlsym.
...
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 đối tượng mà bạn muốn ứng dụng của mình dlopen bằng cách sử dụng cờ -dlopen hoặc -dlpreopen khi bạn liên kết chương trình (xem phần Chế độ liên kết)".

Cơ chế này cho phép mô phỏng quá trình tải động ở cấp libtool thay vì Emscripten, đồng thời liên kết mọi thứ theo cách tĩnh 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 những URL đó vẫn cần được mã hoá cứng ở nơi nào đó. May mắn là bộ plugin tôi cần cho ứng dụng có số lượng tối thiểu:

  • Ở phía cổng, tôi chỉ quan tâm đến kết nối camera dựa trên libusb chứ không quan tâm đến PTP/IP, truy cập nối tiếp hoặc chế độ ổ đĩa USB.
  • Về phía camlibs, có nhiều trình bổ trợ 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à nắm bắt các chế độ cài đặt chung là đủ để sử dụng Picture Transfer Protocol (Giao thức truyền hình ảnh), được biểu thị bằng camlib ptp2 và được hầu hết mọi máy ảnh trên thị trường hỗ trợ.

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

Một sơ đồ cho thấy "ứng dụng" phụ thuộc vào " nhánh phát triển libgphoto2", phụ thuộc vào "libtool". "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ã 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, vì giờ đây tất cả các ký hiệu đều được liên kết tĩnh trong một thư viện duy nhất, libtool cần có cách để xác định ký hiệu nào thuộc 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 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 tất cả những thay đổi này được triển khai, tôi có thể tạo ứng dụng thử nghiệm và tải các trình bổ trợ thành công.

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

gẢnh2 cho phép các thư viện máy ảnh xác định các chế độ cài đặt riêng ở dạng cây tiện ích. Hệ thống phân cấp 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 tiện ích khác được đặt tên
    • 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à cũng được sửa đổi nếu là giá trị) 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 cho các chế độ 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 các 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à 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à trường số có thể ghi trong M (chế độ thủ công), nhưng trở thành trường chỉ đọc thông tin trong P (chế độ chương trình). Ở chế độ P, giá trị của tốc độ màn trập cũng sẽ độ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.

Nhìn chung, điều quan trọng là phải luôn hiển thị thông tin cập nhật từ máy ảnh được 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 sẽ phức tạp hơn khi xử lý.

gẢnh2 không có cơ chế để chỉ truy xuất các cài đặt đã thay đổi, chỉ truy xuất toàn bộ cây hoặc các tiện ích riêng lẻ. Để giữ cho giao diện người dùng luôn cập nhật mà không nhấp nháy và mất tiêu điểm nhập hoặc vị trí cuộn, tôi cần cách phân 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ư Phản hồi hoặc Dự đoán. Tôi đã dùng 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++, bây giờ tôi cần truy xuất và đi bộ đệ quy cây cài đặt qua C API C được liên kết trước đó và chuyển đổi mỗi 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;
    }
    // …

Về phía JavaScript, giờ đây tôi có thể gọi configToJS, xem qua cách biểu diễn 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 vòng lặp sự kiện vô hạn, tôi có thể làm cho 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 bất cứ khi nào người dùng chỉnh sửa một trong các trường.

Hoạt động trước có thể đảm nhiệm việc phân biệt kết quả và chỉ cập nhật DOM cho các phần được 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 lý giải 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 cho chế độ 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 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}));
  }
}

Bằng cách này, luôn chỉ có một chủ sở hữu của một trường nhất định. Người dùng hiện đang chỉnh sửa trường đó và sẽ không bị làm gián đoạn bởi các giá trị được 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 nằm ngoài tiêu điểm.

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

Trong thời gian đại dịch, rất nhiều người chuyển sang sử dụng các cuộc họp trực tuyến. Một số nguyên nhân khác dẫn đến tình trạng 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 trong máy tính xách tay và để đối phó với tình trạng thiếu hụt đó, 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 của mình làm webcam. Một số nhà cung cấp máy ảnh thậm chí còn vận chuyển 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, gẢnh2 hỗ trợ phát trực tuyến video từ máy ảnh sang 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ù tính năng này có sẵn trong tiện ích bảng điều khiển, nhưng tôi không tìm thấy ứng dụ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 console, tôi thấy rằng thực ra ứng dụng này không hoàn toàn không có video, mà thay vào đó liên tục truy xuất bản xem trước của camera dưới dạng các hình ảnh JPEG riêng lẻ trong một vòng lặp vô tận và ghi lần lượt từng ảnh một để 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 rằng phương pháp này hiệu quả đến mức cần thiết để video theo thời gian thực mượt mà. 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ả yếu tố trừu tượng bổ sung và tính năng Không đồng bộ hoá trong quá trình đó. 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 là capturePreviewAsBlob() gọi cùng một hàm gp_camera_capture_preview() và chuyển đổi tệp trong bộ nhớ thu được 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 gẢnh2, liên tục truy xuất hình ảnh xem trước dưới dạng Blob, giải mã hình ảnh trong nền bằng createImageBitmapchuyển chúng vào canvas trong 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 này đảm bảo rằng tất cả công việc 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 đã sẵn sàng để vẽ. Điều này đã đạt được tốc độ 30 khung hình/giây trở lên nhất quán 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ả gẢnh2 và phần mềm chính thức của Sony.

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

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

Để tránh chúng, 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 xâu chuỗi mỗi toán tử 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ả các toán tử đều được thực thi lần lượt từng toán tử theo thứ tự và không bị 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 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 ở biến riêng tư (không xuất), tôi đã giảm thiểu rủi ro 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ị đều phải được gói trong một 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 hoạt động đề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ì đã bảo trì gẢnh2 và vì những bài đánh giá mà anh ấy đánh giá về các công ty quan hệ công chú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 có khả năng xử lý cho cả những ứng dụng phức tạp nhất. Chúng cho phép bạn lấy thư viện hoặc ứng dụng được tạo trước đây cho một nền tảng duy nhất và chuyển lên web, giúp ứng dụng tiếp cận được một lượng lớn người dùng trên cả máy tính để bàn cũng như thiết bị di động.