Melakukan porting aplikasi USB ke web. Bagian 2: gPhoto2

Pelajari cara gPhoto2 di-porting ke WebAssembly untuk mengontrol kamera eksternal melalui USB dari aplikasi web.

Dalam postingan sebelumnya, saya menunjukkan cara library libusb di-port untuk dijalankan di web dengan WebAssembly / Emscripten, Asyncify, dan WebUSB.

Saya juga menunjukkan demo yang dibuat dengan gPhoto2 yang dapat mengontrol kamera DSLR dan mirrorless melalui USB dari aplikasi web. Dalam postingan ini, saya akan membahas detail teknis di balik port gPhoto2 secara lebih mendalam.

Mengarahkan sistem build ke fork kustom

Karena saya menargetkan WebAssembly, saya tidak dapat menggunakan libusb dan libgphoto2 yang disediakan oleh distribusi sistem. Sebagai gantinya, saya perlu aplikasi saya menggunakan fork libgphoto2 kustom, sementara fork libgphoto2 tersebut harus menggunakan fork libusb kustom saya.

Selain itu, libgphoto2 menggunakan libtool untuk memuat plugin dinamis, dan meskipun saya tidak perlu melakukan fork libtool seperti dua library lainnya, saya tetap harus mem-build-nya ke WebAssembly, dan mengarahkan libgphoto2 ke build kustom tersebut, bukan paket sistem.

Berikut adalah perkiraan diagram dependensi (garis putus-putus menunjukkan penautan dinamis):

Diagram menunjukkan 'aplikasi' yang bergantung pada 'fork libgphoto2', yang bergantung pada 'libtool'. Blok 'libtool' secara dinamis bergantung pada 'port libgphoto2' dan 'camlibs libgphoto2'. Terakhir, 'port libgphoto2' bergantung secara statis pada 'libusb fork'.

Sebagian besar sistem build berbasis konfigurasi, termasuk yang digunakan di library ini, memungkinkan penggantian jalur untuk dependensi melalui berbagai flag, jadi itulah yang saya coba lakukan terlebih dahulu. Namun, saat grafik dependensi menjadi kompleks, daftar penggantian jalur untuk setiap dependensi library menjadi panjang dan rentan error. Saya juga menemukan beberapa bug saat sistem build tidak benar-benar disiapkan agar dependensinya berada di jalur non-standar.

Sebagai gantinya, pendekatan yang lebih mudah adalah membuat folder terpisah sebagai root sistem kustom (sering disingkat menjadi "sysroot") dan mengarahkan semua sistem build yang terlibat ke folder tersebut. Dengan begitu, setiap library akan menelusuri dependensinya di sysroot yang ditentukan selama build, dan juga akan menginstal dirinya sendiri di sysroot yang sama sehingga orang lain dapat menemukannya dengan lebih mudah.

Emscripten sudah memiliki sysroot sendiri di (path to emscripten cache)/sysroot, yang digunakan untuk library sistem, port Emscripten, dan alat seperti CMake dan pkg-config. Saya juga memilih untuk menggunakan kembali sysroot yang sama untuk dependensi saya.

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

Dengan konfigurasi tersebut, saya hanya perlu menjalankan make install di setiap dependensi, yang menginstalnya di sysroot, lalu library akan menemukan satu sama lain secara otomatis.

Menangani pemuatan dinamis

Seperti yang disebutkan di atas, libgphoto2 menggunakan libtool untuk menghitung dan memuat adaptor port I/O dan library kamera secara dinamis. Misalnya, kode untuk memuat library I/O terlihat seperti ini:

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

Ada beberapa masalah dengan pendekatan ini di web:

  • Tidak ada dukungan standar untuk penautan dinamis modul WebAssembly. Emscripten memiliki implementasi kustom yang dapat menyimulasikan dlopen() API yang digunakan oleh libtool, tetapi Anda harus mem-build modul "utama" dan "samping" dengan flag yang berbeda, dan, khusus untuk dlopen(), juga untuk memuat modul samping ke dalam sistem file yang diemulasi selama pengaktifan aplikasi. Sulit untuk mengintegrasikan flag dan tweak tersebut ke dalam sistem build autoconf yang ada dengan banyak library dinamis.
  • Meskipun dlopen() itu sendiri diterapkan, tidak ada cara untuk menghitung semua library dinamis di folder tertentu di web, karena sebagian besar server HTTP tidak mengekspos listingan direktori karena alasan keamanan.
  • Menautkan library dinamis di command line, bukan melakukan enumerasi dalam runtime, juga dapat menyebabkan masalah, seperti masalah simbol duplikat, yang disebabkan oleh perbedaan antara representasi library bersama di Emscripten dan di platform lain.

Anda dapat menyesuaikan sistem build dengan perbedaan tersebut dan melakukan hardcode pada daftar plugin dinamis di suatu tempat selama build, tetapi cara yang lebih mudah untuk mengatasi semua masalah tersebut adalah dengan menghindari penautan dinamis sejak awal.

Ternyata, libtool memisahkan berbagai metode penautan dinamis di berbagai platform, dan bahkan mendukung penulisan loader kustom untuk orang lain. Salah satu loader bawaan yang didukungnya disebut "Dlpreopening":

“Libtool menyediakan dukungan khusus untuk dlopening objek libtool dan file library libtool, sehingga simbolnya dapat di-resolve bahkan di platform tanpa fungsi dlopen dan dlsym.

Libtool mengemulasi -dlopen di platform statis dengan menautkan objek ke dalam program pada waktu kompilasi, dan membuat struktur data yang mewakili tabel simbol program. Untuk menggunakan fitur ini, Anda harus mendeklarasikan objek yang ingin di-dlopen oleh aplikasi menggunakan flag -dlopen atau -dlpreopen saat menautkan program (lihat Mode link).

Mekanisme ini memungkinkan emulasi pemuatan dinamis di tingkat libtool, bukan Emscripten, sekaligus menautkan semuanya secara statis ke dalam satu library.

Satu-satunya masalah yang tidak dapat diatasi adalah enumerasi library dinamis. Daftar tersebut masih perlu di-hardcode di suatu tempat. Untungnya, kumpulan plugin yang saya perlukan untuk aplikasi ini minimal:

  • Di sisi port, saya hanya peduli dengan koneksi kamera berbasis libusb, bukan PTP/IP, akses serial, atau mode drive USB.
  • Di sisi camlib, ada berbagai plugin khusus vendor yang mungkin menyediakan beberapa fungsi khusus, tetapi untuk kontrol setelan umum dan pengambilan, cukup gunakan Picture Transfer Protocol, yang diwakili oleh camlib ptp2 dan didukung oleh hampir semua kamera di pasaran.

Berikut tampilan diagram dependensi yang diperbarui dengan semua yang ditautkan secara statis:

Diagram menunjukkan 'aplikasi' yang bergantung pada 'fork libgphoto2', yang bergantung pada 'libtool'. 'libtool' bergantung pada 'ports: libusb1' dan 'camlibs: libptp2'. 'ports: libusb1' bergantung pada 'libusb fork'.

Jadi, itulah yang saya hard code untuk build 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
();

dan

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

Dalam sistem build autoconf, sekarang saya harus menambahkan -dlpreopen dengan kedua file tersebut sebagai flag link untuk semua file yang dapat dieksekusi (contoh, pengujian, dan aplikasi demo saya sendiri), seperti ini:

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

Terakhir, setelah semua simbol ditautkan secara statis dalam satu library, libtool memerlukan cara untuk menentukan simbol mana yang termasuk dalam library mana. Untuk melakukannya, developer harus mengganti nama semua simbol yang ditampilkan seperti {function name} menjadi {library name}_LTX_{function name}. Cara termudah untuk melakukannya adalah dengan menggunakan #define untuk menentukan ulang nama simbol di bagian atas file implementasi:

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

Skema penamaan ini juga mencegah konflik nama jika saya memutuskan untuk menautkan plugin khusus kamera di aplikasi yang sama pada masa mendatang.

Setelah semua perubahan ini diterapkan, saya dapat mem-build aplikasi pengujian dan berhasil memuat plugin.

Membuat UI setelan

gPhoto2 memungkinkan library kamera menentukan setelannya sendiri dalam bentuk hierarki widget. Hierarki jenis widget terdiri dari:

  • Jendela - penampung konfigurasi tingkat teratas
    • Bagian - grup widget lain yang diberi nama
    • Kolom tombol
    • Kolom teks
    • Bidang numerik
    • Kolom tanggal
    • Tombol
    • Tombol pilihan

Nama, jenis, turunan, dan semua properti relevan lainnya dari setiap widget dapat dikueri (dan, jika nilai, juga diubah) melalui C API yang diekspos. Bersama-sama, keduanya menyediakan dasar untuk membuat UI setelan secara otomatis dalam bahasa apa pun yang dapat berinteraksi dengan C.

Setelan dapat diubah melalui gPhoto2, atau di kamera itu sendiri kapan saja. Selain itu, beberapa widget dapat bersifat hanya baca, dan bahkan status hanya baca itu sendiri bergantung pada mode kamera dan setelan lainnya. Misalnya, kecepatan shutter adalah kolom numerik yang dapat ditulis di M (mode manual), tetapi menjadi kolom informasi yang hanya dapat dibaca di P (mode program). Dalam mode P, nilai kecepatan shutter juga akan dinamis dan terus berubah bergantung pada kecerahan scene yang dilihat kamera.

Secara keseluruhan, penting untuk selalu menampilkan informasi terbaru dari kamera yang terhubung di UI, sekaligus memungkinkan pengguna mengedit setelan tersebut dari UI yang sama. Aliran data dua arah tersebut lebih kompleks untuk ditangani.

gPhoto2 tidak memiliki mekanisme untuk mengambil setelan yang diubah saja, hanya seluruh hierarki atau widget individual. Agar UI tetap terbaru tanpa berkedip dan kehilangan fokus input atau posisi scroll, saya memerlukan cara untuk membandingkan hierarki widget di antara pemanggilan dan hanya memperbarui properti UI yang diubah. Untungnya, ini adalah masalah yang telah dipecahkan di web, dan merupakan fungsi inti framework seperti React atau Preact. Saya memilih Preact untuk project ini karena jauh lebih ringan dan melakukan semua yang saya perlukan.

Di sisi C++, sekarang saya perlu mengambil dan menelusuri hierarki setelan secara rekursif melalui C API yang ditautkan sebelumnya, dan mengonversi setiap widget menjadi objek 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;
   
}
   
// …

Di sisi JavaScript, saya kini dapat memanggil configToJS, menelusuri representasi JavaScript yang ditampilkan dari hierarki setelan, dan mem-build UI melalui fungsi 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;
 
}
 
// …

Dengan menjalankan fungsi ini berulang kali dalam loop peristiwa tanpa batas, saya bisa membuat UI setelan selalu menampilkan informasi terbaru, sekaligus mengirim perintah ke kamera setiap kali salah satu kolom diedit oleh pengguna.

Preact dapat menangani perbedaan hasil dan memperbarui DOM hanya untuk bit UI yang diubah, tanpa mengganggu fokus halaman atau status edit. Satu masalah yang masih ada adalah aliran data dua arah. Framework seperti React dan Preact dirancang berdasarkan aliran data searah, karena hal ini mempermudah alasan data dan membandingkannya di antara pemutaran ulang, tetapi saya melanggar ekspektasi tersebut dengan mengizinkan sumber eksternal - kamera - untuk memperbarui UI setelan kapan saja.

Saya mengatasi masalah ini dengan memilih tidak ikut pembaruan UI untuk kolom input yang saat ini sedang diedit oleh pengguna:

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

Dengan cara ini, hanya ada satu pemilik untuk setiap kolom tertentu. Pengguna saat ini sedang mengeditnya, dan tidak akan terganggu oleh nilai yang diperbarui dari kamera, atau kamera memperbarui nilai kolom saat tidak fokus.

Membuat feed "video" live

Selama pandemi, banyak orang beralih ke rapat online. Di antara hal lainnya, hal ini menyebabkan kekurangan di pasar webcam. Untuk mendapatkan kualitas video yang lebih baik dibandingkan dengan kamera bawaan di laptop, dan sebagai respons atas kekurangan tersebut, banyak pemilik kamera DSLR dan mirrorless mulai mencari cara untuk menggunakan kamera fotografi mereka sebagai webcam. Beberapa vendor kamera bahkan mengirimkan utilitas resmi untuk tujuan ini.

Seperti alat resmi, gPhoto2 mendukung streaming video dari kamera ke file yang disimpan secara lokal atau langsung ke webcam virtual. Saya ingin menggunakan fitur tersebut untuk memberikan tampilan live dalam demo saya. Namun, meskipun tersedia di utilitas konsol, saya tidak dapat menemukannya di mana pun di API library libgphoto2.

Melihat kode sumber fungsi yang sesuai di utilitas konsol, saya mendapati bahwa kode tersebut tidak benar-benar mendapatkan video, tetapi terus mengambil pratinjau kamera sebagai gambar JPEG individual dalam loop tanpa henti, dan menulisnya satu per satu untuk membentuk streaming M-JPEG:

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

Saya terkejut bahwa pendekatan ini bekerja cukup efisien untuk mendapatkan kesan video real-time yang lancar. Saya bahkan lebih skeptis tentang kemampuan untuk mencocokkan performa yang sama di aplikasi web, dengan semua abstraksi tambahan dan Asyncify yang menghalangi. Namun, saya memutuskan untuk mencobanya.

Di sisi C++, saya mengekspos metode yang disebut capturePreviewAsBlob() yang memanggil fungsi gp_camera_capture_preview() yang sama, dan mengonversi file dalam memori yang dihasilkan menjadi Blob yang dapat diteruskan ke API web lainnya dengan lebih mudah:

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

Di sisi JavaScript, saya memiliki loop, mirip dengan yang ada di gPhoto2, yang terus mengambil gambar pratinjau sebagai Blob, mendekodenya di latar belakang dengan createImageBitmap, dan mentransfernya ke kanvas pada frame animasi berikutnya:

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

Penggunaan API modern tersebut memastikan bahwa semua pekerjaan decoding dilakukan di latar belakang, dan kanvas hanya diperbarui saat gambar dan browser sepenuhnya siap untuk menggambar. Hal ini menghasilkan 30+ FPS yang konsisten di laptop saya, yang cocok dengan performa native gPhoto2 dan software Sony resmi.

Menyinkronkan akses USB

Jika transfer data USB diminta saat operasi lain sedang berlangsung, biasanya akan terjadi error "perangkat sibuk". Karena pratinjau dan UI setelan diperbarui secara berkala, dan pengguna mungkin mencoba mengambil gambar atau mengubah setelan secara bersamaan, konflik tersebut antara berbagai operasi ternyata sangat sering terjadi.

Untuk menghindarinya, saya perlu menyinkronkan semua akses dalam aplikasi. Untuk itu, saya telah membuat antrean asinkron berbasis promise:

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

Dengan merantai setiap operasi dalam callback then() dari promise queue yang ada, dan menyimpan hasil yang dirantai sebagai nilai baru queue, saya dapat memastikan bahwa semua operasi dieksekusi satu per satu, secara berurutan, dan tanpa tumpang-tindih.

Setiap error operasi ditampilkan kepada pemanggil, sedangkan error kritis (tidak terduga) menandai seluruh rantai sebagai promise yang ditolak, dan memastikan bahwa tidak ada operasi baru yang akan dijadwalkan setelahnya.

Dengan mempertahankan konteks modul dalam variabel pribadi (tidak diekspor), saya meminimalkan risiko mengakses context secara tidak sengaja di tempat lain dalam aplikasi tanpa melalui panggilan schedule().

Untuk menggabungkan semuanya, sekarang setiap akses ke konteks perangkat harus digabungkan dalam panggilan schedule() seperti ini:

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

dan

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

Setelah itu, semua operasi berhasil dijalankan tanpa konflik.

Kesimpulan

Jangan ragu untuk menjelajahi codebase di GitHub untuk mendapatkan insight implementasi lainnya. Saya juga ingin berterima kasih kepada Marcus Meissner atas pemeliharaan gPhoto2 dan atas peninjauannya terhadap PR upstream saya.

Seperti yang ditunjukkan dalam postingan ini, WebAssembly, Asyncify, dan Fugu API menyediakan target kompilasi yang andal bahkan untuk aplikasi yang paling kompleks. Dengannya, Anda dapat mengambil library atau aplikasi yang sebelumnya dibuat untuk satu platform, dan mem-port-nya ke web, sehingga tersedia untuk lebih banyak pengguna di desktop dan perangkat seluler.