Melakukan porting aplikasi USB ke web. Bagian 2: gPhoto2

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

Di postingan sebelumnya, saya menunjukkan cara library libusb ditransfer untuk berjalan di web dengan WebAssembly / Emscripten, Asyncify, dan WebUSB.

Saya juga menampilkan demo yang dibuat dengan gPhoto2 yang dapat mengontrol kamera DSLR dan mirrorless melalui USB dari aplikasi web. Dalam posting ini saya akan membahas lebih dalam detail teknis di balik porta gPhoto2.

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 mengharuskan aplikasi menggunakan garpu kustom libgphoto2, sedangkan garpu libgphoto2 harus menggunakan garpu kustom libusb.

Selain itu, libgphoto2 menggunakan libtool untuk memuat plugin dinamis, dan meskipun saya tidak perlu melakukan fork libtool seperti dua library lainnya, saya masih harus membangunnya ke WebAssembly, dan mengarahkan libgphoto2 ke build kustom itu alih-alih paket sistem.

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

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

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

Sebaliknya, pendekatan yang lebih mudah adalah membuat folder terpisah sebagai root sistem kustom (sering disingkat menjadi "sysroot") dan mengarahkan semua sistem build yang terkait ke folder tersebut. Dengan begitu, setiap library akan mencari dependensi dalam sysroot yang ditentukan selama proses build, dan library juga akan menginstal sendiri dalam sysroot yang sama sehingga library lain dapat menemukannya dengan lebih mudah.

Emscripten sudah memiliki sysroot sendiri pada (path to emscripten cache)/sysroot, yang digunakan untuk library sistem, port Emscripten, serta alat seperti CMake dan pkg-config. Saya memilih untuk menggunakan kembali {i>sysroot<i} 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 seperti itu, saya hanya perlu menjalankan make install di setiap dependensi, yang menginstalnya di bawah sysroot, lalu library menemukan satu sama lain secara otomatis.

Menangani pemuatan dinamis

Seperti 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 mengharuskan Anda untuk membangun modul "main'' dan "side" dengan flag yang berbeda, dan, khususnya untuk dlopen(), juga untuk memuat modul samping ke dalam sistem file yang diemulasikan selama proses startup aplikasi. Mungkin sulit untuk mengintegrasikan flag dan penyesuaian tersebut ke dalam sistem build autoconf yang ada dengan banyak library dinamis.
  • Meskipun dlopen() itu sendiri diimplementasikan, tidak ada cara untuk menghitung semua library dinamis dalam folder tertentu di web, karena sebagian besar server HTTP tidak mengekspos listingan direktori demi alasan keamanan.
  • Menautkan library dinamis pada command line dan bukan menghitung dalam runtime juga dapat menyebabkan masalah, seperti masalah simbol duplikat yang disebabkan oleh perbedaan antara representasi library bersama di Emscripten dan di platform lainnya.

Sistem build dapat disesuaikan dengan perbedaan tersebut dan melakukan hardcode pada daftar plugin dinamis di suatu tempat selama build, tetapi cara yang lebih mudah untuk menyelesaikan semua masalah tersebut adalah dengan menghindari penautan dinamis.

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

“Libtool memberikan dukungan khusus untuk membuka file objek libtool dan library libtool, sehingga simbolnya dapat diselesaikan, bahkan pada platform tanpa fungsi dlopen dan dlsym.
...
Libtool mengemulasi -dlopen pada 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 Anda inginkan untuk dlopen aplikasi dengan menggunakan flag -dlopen atau -dlpreopen saat menautkan program (lihat Mode link).”

Mekanisme ini memungkinkan emulasi pemuatan dinamis pada level libtool, bukan Emscripten, sekaligus menautkan semuanya secara statis ke satu library.

Satu-satunya masalah yang tidak dipecahkan oleh hal ini adalah enumerasi library dinamis. Daftar yang namanya masih perlu di-hardcode di suatu tempat. Untungnya, set plugin yang saya butuhkan untuk aplikasi ini minimal:

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

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

Diagram menunjukkan &#39;aplikasi&#39;, bergantung pada &#39;garpu libgphoto2&#39;, yang bergantung pada &#39;libtool&#39;. &#39;libtool&#39; bergantung pada &#39;ports: libusb1&#39; dan &#39;camlibs: libptp2&#39;. &#39;ports: libusb1&#39; bergantung pada &#39;libusb fork&#39;.

Jadi, itulah yang saya hardcode 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, saya sekarang 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 tertentu. 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 di masa mendatang.

Setelah semua perubahan ini diterapkan, saya dapat membangun aplikasi pengujian dan memuat plugin dengan sukses.

Membuat UI setelan

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

  • Jendela - penampung konfigurasi tingkat atas
    • Bagian - grup bernama dari widget lain
    • Kolom tombol
    • Kolom teks
    • Bidang numerik
    • Kolom tanggal
    • Tombol
    • Tombol pilihan

Nama, jenis, turunan, dan semua properti lain yang relevan dari setiap widget dapat dikueri (dan, jika ada nilai, juga diubah) melalui API C yang diekspos. Bersama-sama, keduanya memberikan dasar untuk secara otomatis membuat UI setelan 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, kecepatanshutter adalah kolom angka yang dapat ditulis dalam M (mode manual), tetapi menjadi kolom hanya baca informasi di P (mode program). Dalam mode P, nilai kecepatan shutter juga akan dinamis dan terus berubah bergantung pada kecerahan pemandangan yang dilihat kamera.

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

gPhoto2 tidak memiliki mekanisme untuk mengambil hanya setelan yang diubah, dan hanya seluruh widget atau widget secara terpisah. Agar UI tetap terbaru tanpa berkedip dan kehilangan fokus input atau posisi scroll, saya memerlukan cara untuk membedakan hierarki widget di antara pemanggilan dan hanya memperbarui properti UI yang diubah. Untungnya, hal ini telah menyelesaikan masalah di web, dan merupakan fungsi inti dari framework seperti React atau Preact. Saya menggunakan Preact untuk project ini, karena jauh lebih ringan dan melakukan semua yang saya butuhkan.

Di sisi C++, saya sekarang perlu mengambil dan menjalankan 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 sekarang dapat memanggil configToJS, berjalan di atas 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 tak terbatas, saya bisa mendapatkan UI setelan agar selalu menampilkan informasi terbaru, sekaligus mengirimkan perintah ke kamera setiap kali salah satu kolom diedit oleh pengguna.

Preact dapat menangani diffing hasil dan memperbarui DOM hanya untuk bit UI yang diubah, tanpa mengganggu fokus halaman atau status edit. Satu masalah yang tetap ada adalah aliran data dua arah. Framework seperti React dan Preact dirancang berdasarkan aliran data searah, karena memudahkan untuk memahami data dan membandingkannya di antara eksekusi ulang, tetapi saya melanggar ekspektasi itu dengan mengizinkan sumber eksternal - kamera - untuk mengupdate UI setelan kapan saja.

Saya mengatasi masalah ini dengan memilih untuk tidak menerima update UI untuk kolom input apa pun yang saat ini 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 selalu ada satu pemilik untuk kolom yang dimaksud. Entah pengguna sedang mengeditnya, dan tidak akan terganggu oleh nilai yang diperbarui dari kamera, atau kamera memperbarui nilai kolom saat tidak menjadi fokus.

Membuat feed "video" live

Selama pandemi, banyak orang beralih ke rapat online. Di antara hal ini, hal ini menyebabkan kurangnya pasar webcam. Untuk mendapatkan kualitas video yang lebih baik dibandingkan dengan kamera bawaan laptop, dan sebagai tanggapan atas kekurangan tersebut, banyak pemilik DSLR dan kamera mirrorless mulai mencari cara untuk menggunakan kamera fotografi 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 juga. Saya ingin menggunakan fitur tersebut untuk memberikan tayangan langsung dalam demo saya. Namun, meskipun tersedia di utilitas konsol, saya tidak bisa menemukannya di dalam API library libgphoto2.

Dengan melihat kode sumber dari fungsi yang sesuai di utilitas konsol, saya mendapati bahwa kode tersebut tidak mendapatkan video sama sekali, melainkan terus mengambil pratinjau kamera sebagai gambar JPEG individual dalam loop tanpa akhir, 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 kagum bahwa pendekatan ini bekerja cukup efisien untuk mendapatkan kesan video realtime yang mulus. Saya bahkan lebih skeptis terkait kemampuan untuk mencocokkan performa yang sama di aplikasi web, dengan semua abstraksi ekstra dan Asinkron. Namun, saya memutuskan untuk tetap mencobanya.

Di sisi C++, saya mengekspos metode bernama 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 lain 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) {
    // …
  }
}

Menggunakan API modern tersebut memastikan bahwa semua pekerjaan decoding dilakukan di latar belakang, dan kanvas hanya akan diupdate saat gambar dan browser benar-benar siap untuk menggambar. Ini mencapai 30+ FPS yang konsisten di laptop saya, yang cocok dengan kinerja asli gPhoto2 dan perangkat lunak Sony resmi.

Menyinkronkan akses USB

Ketika transfer data USB diminta saat operasi lain sedang berlangsung, biasanya hal itu akan menghasilkan kesalahan "perangkat sedang sibuk". Karena pratinjau dan UI setelan diperbarui secara teratur, dan pengguna mungkin mencoba mengambil gambar atau mengubah setelan secara bersamaan, konflik antara operasi yang berbeda tersebut 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 berantai sebagai nilai baru queue, saya dapat memastikan bahwa semua operasi dijalankan satu per satu, secara berurutan dan tanpa tumpang-tindih.

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

Dengan menyimpan 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, kini 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

Anda dapat menjelajahi codebase di GitHub untuk mendapatkan insight penerapan lainnya. Saya juga ingin berterima kasih kepada Marcus Meissner atas pemeliharaan gPhoto2 dan atas ulasannya tentang PR hulu saya.

Seperti yang ditunjukkan dalam postingan ini, WebAssembly, Asyncify, dan Fugu API menyediakan target kompilasi yang andal untuk aplikasi yang paling kompleks sekalipun. Mereka memungkinkan Anda untuk mengambil pustaka atau aplikasi yang sebelumnya dibangun untuk satu platform, dan mem-port-nya ke web, membuatnya tersedia untuk jumlah pengguna yang jauh lebih besar di seluruh desktop dan perangkat seluler.