Melakukan porting aplikasi USB ke web. Bagian 2: gPhoto2

Pelajari cara gPhoto2 ditransfer ke WebAssembly untuk mengontrol kamera eksternal melalui USB dari aplikasi web.

Pada postingan sebelumnya, saya telah menunjukkan cara library libusb di-port untuk berjalan 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 lebih dalam detail teknis terkait port gPhoto2.

Mengarahkan sistem build ke fork kustom

Karena saya menargetkan WebAssembly, saya tidak bisa menggunakan libusb dan libgphoto2 yang disediakan oleh distribusi sistem. Sebagai gantinya, saya membutuhkan aplikasi saya untuk 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 harus melakukan fork libtool seperti dua pustaka lainnya, saya masih harus membangunnya ke WebAssembly, dan mengarahkan libgphoto2 ke build khusus itu, bukan paket sistem.

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

Diagram menampilkan 'aplikasi' tergantung pada 'libgphoto2 fork', yang bergantung pada 'libtool'. 'libtool' blokir bergantung secara dinamis pada 'libgphoto2 ports' dan 'libgphoto2 camlibs'. Terakhir, 'libgphoto2 ports' secara statis bergantung 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 dependensi setiap library menjadi panjang dan rentan error. Saya juga menemukan beberapa bug di mana sistem build sebenarnya tidak disiapkan untuk dependensinya agar dapat berada 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 terlibat ke folder tersebut. Dengan begitu, setiap library akan mencari dependensinya di sysroot yang ditentukan selama build, dan juga akan menginstalnya di sysroot yang sama sehingga library lain dapat menemukannya dengan lebih mudah.

Emscripten sudah memiliki sysroot sendiri di (path to emscripten cache)/sysroot, yang digunakannya 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 bawah sysroot, lalu library 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 akan 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 API dlopen() yang digunakan oleh libtool, tetapi Anda harus membangun "main'' dan "sisi" modul dengan berbagai tanda, dan, khususnya untuk dlopen(), juga untuk melakukan pramuat modul samping ke sistem file yang diemulasi selama startup aplikasi. Mengintegrasikan flag dan penyesuaian tersebut ke dalam sistem build autoconf yang ada dengan banyak library dinamis bisa jadi sulit.
  • Meskipun dlopen() itu sendiri diimplementasikan, tidak ada cara untuk menghitung semua library dinamis pada folder tertentu di web, karena sebagian besar server HTTP tidak mengekspos listingan direktori untuk alasan keamanan.
  • Menautkan library dinamis di command line, 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 lain.

Anda dapat mengadaptasikan sistem build 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 mengabstraksi berbagai metode penautan dinamis pada berbagai platform, dan bahkan mendukung penulisan loader kustom untuk platform lain. Salah satu loader bawaan yang didukungnya disebut "Dlpreopening":

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

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

Satu-satunya masalah yang tidak terselesaikan adalah enumerasi library dinamis. Daftar semua itu masih perlu di-hardcode di suatu tempat. Untungnya, set plugin yang saya perlukan untuk aplikasi tidak terlalu banyak:

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

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

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

Jadi, itulah yang saya lakukan 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, 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 {i>library<i}, libtool memerlukan cara untuk menentukan simbol mana yang dimiliki oleh {i>library<i}. Untuk mencapai hal ini, 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 bentrokan nama jika saya memutuskan untuk menautkan plugin khusus kamera pada aplikasi yang sama pada masa mendatang.

Setelah semua perubahan ini diimplementasikan, saya dapat membangun 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 - container konfigurasi tingkat atas
    • Bagian - kelompok 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, untuk nilai, juga diubah) melalui C API terekspos. Bersama-sama, keduanya memberikan fondasi untuk menghasilkan UI setelan secara otomatis dalam bahasa apa pun yang dapat berinteraksi dengan C.

Setelan dapat diubah melalui gPhoto2, atau pada 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 rana adalah kolom numerik yang dapat ditulis dalam M (mode manual), tetapi menjadi kolom hanya baca yang informatif di P (mode program). Dalam mode P, nilai kecepatan shutter juga akan dinamis dan terus berubah tergantung pada kecerahan adegan yang dilihat kamera.

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

gPhoto2 tidak memiliki mekanisme untuk hanya mengambil setelan yang diubah, hanya seluruh pohon atau widget individual. 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, masalah ini dapat diatasi di web, dan merupakan fungsi inti framework seperti React atau Preact. Saya memilih Preact untuk project ini, karena jauh lebih ringan dan dapat melakukan semua yang saya perlukan.

Di sisi C++, saya sekarang perlu mengambil dan secara rekursif berjalan hierarki setelan melalui C API tertaut 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, menelusuri representasi JavaScript yang ditampilkan dari hierarki setelan, dan membangun 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 yang tak terbatas, saya bisa mendapatkan UI setelan untuk 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 mengedit status. Satu masalah yang tersisa adalah aliran data dua arah. Framework seperti React dan Preact dirancang berdasarkan aliran data searah, karena lebih memudahkan untuk memahami data dan membandingkannya dengan proses dijalankan ulang, tetapi saya menghilangkan ekspektasi tersebut dengan mengizinkan sumber eksternal - kamera - untuk memperbarui UI setelan kapan saja.

Saya mengatasi masalah ini dengan memilih tidak ikut update UI untuk kolom input apa pun 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 kolom tertentu. Pengguna sedang mengeditnya, dan tidak akan terganggu oleh nilai yang diperbarui dari kamera, atau kamera memperbarui nilai kolom saat tidak fokus.

Membuat "video" live umpan

Selama pandemi, banyak orang beralih ke pertemuan online. Antara lain, hal ini menyebabkan kurangnya di pasar webcam. Untuk mendapatkan kualitas video yang lebih baik dibandingkan dengan kamera bawaan pada 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 telah 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 tayangan live dalam demo saya. Namun, meskipun tersedia di utilitas konsol, saya tidak dapat menemukannya di mana pun di API library libgphoto2.

Dengan melihat kode sumber dari fungsi yang sesuai dalam utilitas konsol, saya menemukan bahwa kode tersebut tidak mendapatkan video sama sekali, tetapi terus mengambil pratinjau kamera sebagai gambar JPEG individual dalam loop tanpa akhir, dan menuliskannya 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 karena pendekatan ini cukup efisien untuk mendapatkan tayangan video real time yang lancar. Saya bahkan lebih skeptis ketika dapat mencocokkan kinerja yang sama di aplikasi web, dengan semua abstraksi tambahan dan Asyncify yang ada di sana. Namun, saya memutuskan untuk tetap mencoba.

Di sisi C++, saya menampilkan 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 diupdate saat gambar dan browser sepenuhnya siap untuk menggambar. Ini mencapai 30+ FPS yang konsisten di laptop saya, yang menyamai kinerja asli gPhoto2 dan perangkat lunak resmi Sony.

Menyinkronkan akses USB

Jika transfer data USB diminta saat operasi lain sedang berlangsung, biasanya hal ini akan mengakibatkan "perangkat sibuk" {i>error<i}. Karena UI setelan dan pratinjau diupdate secara teratur, dan pengguna mungkin mencoba mengambil gambar atau mengubah setelan secara bersamaan, konflik antara operasi yang berbeda tersebut 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 merangkai 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.

Semua error operasi akan ditampilkan ke pemanggil, sedangkan error kritis (tidak terduga) menandai seluruh rantai sebagai promise yang ditolak, dan memastikan 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

Jangan ragu untuk menjelajahi codebase di GitHub untuk mendapatkan insight penerapan lebih lanjut. Saya juga ingin berterima kasih kepada Marcus Meissner atas pemeliharaan gPhoto2 dan atas ulasannya tentang PR upstream saya.

Seperti ditunjukkan dalam postingan ini, WebAssembly, Asyncify, dan Fugu API menyediakan target kompilasi yang mumpuni untuk aplikasi yang paling kompleks sekalipun. Dengan begitu, Anda dapat mengambil library atau aplikasi yang sebelumnya dibuat untuk satu platform, lalu mentransfernya ke web, sehingga tersedia bagi lebih banyak pengguna di berbagai perangkat desktop dan seluler.