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 chi tiết 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):
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ả cá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ợ 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 phải tạo các mô-đun "chính" và "bên" với nhiều cờ khác nhau, đặc biệt đối vớidlopen()
, 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 các 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à 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 việc dlopen đối tượng libtool và tệp thư viện libtool, 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.
…
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à 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ác tệp đó 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 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, 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:
Đó 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 ();
và
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, 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>
// …
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
- 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ùng nhau, các lớp này 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à 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.
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ế 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à đệ 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;
}
// …
Ở 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.
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. 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.
Xây dựng nguồn cấp dữ liệu "video" 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 đó, 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 ngạc nhiên rằng 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 createImageBitmap
và chuyể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ả 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 đề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.
Đồ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". 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 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. 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ả đượ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 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());
và
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
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, 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.