USB uygulamaları web'e bağlanıyor. 2. Bölüm: gPhoto2

gPhoto2'nin, harici kameraları bir web uygulamasından USB üzerinden kontrol etmek için WebAssembly'e nasıl taşındığını öğrenin.

Ingvar Stepanyan
Ingvar Stepanyan

Önceki yayında, libusb kitaplığının WebAssembly / Emscripten, Asyncify ve WebUSB ile web'de çalışacak şekilde nasıl taşındığını göstermiştim.

Ayrıca, gPhoto2 ile oluşturulan ve DSLR ile aynasız kameraları bir web uygulamasından USB üzerinden kontrol edebilen bir demo da gösterdim. Bu yayında, gPhoto2 bağlantı noktasının teknik ayrıntılarını daha ayrıntılı olarak ele alacağım.

Derleme sistemlerini özel çatallara yönlendirme

WebAssembly'i hedeflediğim için sistem dağıtımları tarafından sağlanan libusb ve libgphoto2'yi kullanamadım. Bunun yerine, uygulamamın libgphoto2'nin özel çatalını kullanması gerekiyordu. Bu libgphoto2 çatalı da libusb'nin özel çatalını kullanmak zorundaydı.

Ayrıca libgphoto2, dinamik eklentileri yüklemek için libtool'ü kullanır. Diğer iki kitaplıkta olduğu gibi libtool'ü çatallamam gerekmese de yine de WebAssembly için derlemem ve libgphoto2'yi sistem paketi yerine bu özel derlemeye yönlendirmem gerekiyordu.

Aşağıda yaklaşık bir bağımlılık diyagramı verilmiştir (kesikli çizgiler dinamik bağlantıyı gösterir):

Bir diyagramda, "libtool"a bağlı olan "libgphoto2 çatalı"na bağlı "uygulama" gösterilmektedir. "libtool" bloğu, "libgphoto2 ports" ve "libgphoto2 camlibs" dosyalarına dinamik olarak bağlıdır. Son olarak, "libgphoto2 ports" statik olarak "libusb fork"a bağlıdır.

Bu kitaplıklarda kullanılanlar da dahil olmak üzere yapılandırma tabanlı derleme sistemlerinin çoğu, çeşitli işaretler aracılığıyla bağımlılıkların yollarının geçersiz kılınmasına izin verir. Bu nedenle, ilk olarak bunu yapmaya çalıştım. Ancak bağımlılık grafiği karmaşık hale geldiğinde, her kitaplığın bağımlılıklarının yol geçersiz kılma listesi ayrıntılı ve hataya açık hale gelir. Ayrıca, derleme sistemlerinin bağımlılıkları standart olmayan yollarda bulunmaya hazır olmadığı bazı hatalar da buldum.

Bunun yerine, özel sistem kökü olarak ayrı bir klasör (genellikle "sysroot" olarak kısaltılır) oluşturmak ve ilgili tüm derleme sistemlerini bu klasöre yönlendirmek daha kolay bir yaklaşımdır. Bu sayede her kitaplık, derleme sırasında bağımlılıkları belirtilen sistem kökünde arar ve diğerlerinin daha kolay bulabilmesi için kendisini aynı sistem köküne yükler.

Emscripten'in (path to emscripten cache)/sysroot altında zaten kendi sysroot'ü vardır. Bu sysroot, sistem kitaplıkları, Emscripten bağlantı noktaları ve CMake ile pkg-config gibi araçlar için kullanılır. Bağımlılıklarım için de aynı sysroot'u yeniden kullanmayı seçtim.

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

Bu tür bir yapılandırmayla, her bağımlılıkta make install çalıştırmam yeterliydi. Bu işlem, bağımlılığı sistem kökü altında yükledi ve ardından kitaplıklar birbirini otomatik olarak buldu.

Dinamik yüklemeyle ilgili işlemler

Yukarıda belirtildiği gibi libgphoto2, G/Ç bağlantı noktası bağdaştırıcıların ve kamera kitaplıklarının listesini oluşturup dinamik olarak yüklemek için libtool'ü kullanır. Örneğin, G/Ç kitaplıklarını yükleme kodu aşağıdaki gibi görünür:

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

Web'de bu yaklaşımın birkaç sorunu vardır:

  • WebAssembly modüllerinin dinamik olarak bağlanması için standart bir destek yoktur. Emscripten, libtool tarafından kullanılan dlopen() API'sini taklit edebilecek özel bir uygulamaya sahiptir ancak bu uygulama için "ana" ve "yan" modüllerini farklı işaretlerle derlemeniz ve özellikle dlopen() için de uygulama başlatılırken yan modülleri taklit edilen dosya sistemine önceden yüklemeniz gerekir. Bu işaretleri ve ince ayarlamaları, çok sayıda dinamik kitaplığa sahip mevcut bir autoconf derleme sistemine entegre etmek zor olabilir.
  • dlopen() uygulanmış olsa bile çoğu HTTP sunucusu güvenlik nedeniyle dizin girişlerini göstermediğinden, web'deki belirli bir klasördeki tüm dinamik kitaplıkların listelenmesinin bir yolu yoktur.
  • Dinamik kitaplıkları çalışma zamanında numaralandırmak yerine komut satırında bağlamak da Emscripten'deki ve diğer platformlardaki paylaşılan kitaplıkların gösterimi arasındaki farklılıklardan kaynaklanan yinelenen simge sorunu gibi sorunlara neden olabilir.

Derleme sistemini bu farklılıklara uyarlamak ve derleme sırasında dinamik eklentilerin listesini bir yere kodlamak mümkündür ancak tüm bu sorunları çözmenin daha da kolay bir yolu, baştan dinamik bağlantıdan kaçınmaktır.

libtool'un, farklı platformlardaki çeşitli dinamik bağlantı yöntemlerini soyutladığı ve hatta diğerleri için özel yükleyiciler yazmayı desteklediği ortaya çıktı. Desteklediği yerleşik yükleyicilerden biri "Dlpreopening" olarak adlandırılır:

"Libtool, libtool nesne ve libtool kitaplık dosyalarının dlopen'i için özel destek sağlar. Böylece, dlopen ve dlsym işlevleri olmayan platformlarda bile simgelerinin çözülmesi sağlanır.

Libtool, derleme zamanında nesneleri programa bağlayarak ve programın simge tablosunu temsil eden veri yapıları oluşturarak statik platformlarda -dlopen işlevini taklit eder. Bu özelliği kullanmak için programınızı bağlarken -dlopen veya -dlpreopen işaretlerini kullanarak uygulamanızın dlopen yapmasını istediğiniz nesneleri belirtmeniz gerekir (Bağlantı modu bölümüne bakın)."

Bu mekanizma, her şeyi statik olarak tek bir kitaplığa bağlarken dinamik yüklemeyi Emscripten yerine libtool düzeyinde taklit etmenize olanak tanır.

Bu yöntemin çözemediği tek sorun dinamik kitaplıkların numaralandırılmasıdır. Bu öğelerin listesinin hâlâ bir yere kodlanması gerekiyor. Neyse ki uygulama için ihtiyaç duyduğum eklenti sayısı minimumdu:

  • Bağlantı noktaları tarafında, PTP/IP, seri erişim veya USB sürücü modları değil, yalnızca libusb tabanlı kamera bağlantısı önemlidir.
  • Camlibs tarafında, bazı özel işlevler sağlayabilecek çeşitli tedarikçiye özel eklentiler vardır ancak genel ayar kontrolü ve yakalama için ptp2 camlib tarafından temsil edilen ve piyasadaki hemen hemen her kamera tarafından desteklenen Resim Aktarım Protokolü'nü kullanmak yeterlidir.

Her şeyin statik olarak birbirine bağlandığı güncellenmiş bağımlılık şeması aşağıdaki gibi görünür:

Bir diyagramda, "libtool"a bağlı olan "libgphoto2 çatalı"na bağlı "uygulama" gösterilmektedir. "libtool", "ports: libusb1" ve "camlibs: libptp2"ye bağlıdır. "ports: libusb1", "libusb çatalına" bağlıdır.

Emscripten derlemeleri için sabit kod olarak şunu ekledim:

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

ve

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

autoconf derleme sisteminde, tüm yürütülebilir dosyalar (örnek uygulamalar, testler ve kendi demo uygulamam) için bağlantı işaretleri olarak bu iki dosyayı da içeren -dlpreopen dosyasını eklemem gerekiyordu. Bu işlem şu şekilde yapıldı:

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

Son olarak, tüm semboller tek bir kitaplıkta statik olarak bağlandığından libtool'un hangi sembolün hangi kitaplığa ait olduğunu belirlemesi gerekir. Bunu başarmak için geliştiricilerin {function name} gibi tüm açık simgelerin adını {library name}_LTX_{function name} olarak değiştirmesi gerekir. Bunu yapmanın en kolay yolu, uygulama dosyasının üst kısmındaki simge adlarını yeniden tanımlamak için #define kullanmaktır:

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

Bu adlandırma şeması, gelecekte aynı uygulamada kameraya özel eklentileri bağlamaya karar vermem durumunda ad çakışmalarını da önler.

Tüm bu değişiklikler uygulandıktan sonra test uygulamasını derleyip eklentileri başarıyla yükleyebildim.

Ayarlar kullanıcı arayüzünü oluşturma

gPhoto2, kamera kitaplıklarının kendi ayarlarını widget ağacı şeklinde tanımlamasına olanak tanır. Widget türleri hiyerarşisi şunları içerir:

  • Pencere: Üst düzey yapılandırma kapsayıcısı
    • Bölümler: Diğer widget'lardan oluşan adlandırılmış gruplar
    • Düğme alanları
    • Metin alanları
    • Sayısal alanlar
    • Tarih alanları
    • Açma/kapatma düğmeleri
    • Radyo düğmeleri

Her widget'ın adı, türü, alt öğeleri ve diğer tüm ilgili özellikleri herkese açık C API aracılığıyla sorgulanabilir (ve değerler söz konusu olduğunda değiştirilebilir). Bu iki bileşen birlikte, C ile etkileşime geçebilen herhangi bir dilde ayarlar kullanıcı arayüzünü otomatik olarak oluşturmak için bir temel sağlar.

Ayarlar, gPhoto2 üzerinden veya kameranın kendisinde dilediğiniz zaman değiştirilebilir. Ayrıca bazı widget'lar salt okunur olabilir. Hatta salt okunur durum bile kamera moduna ve diğer ayarlara bağlıdır. Örneğin, deklanşör hızı M (manuel mod)'da yazılabilir bir sayısal alandır ancak P (program modu)'nda bilgi amaçlı salt okunur bir alana dönüşür. P modunda, deklanşör hızının değeri de dinamiktir ve kameranın baktığı sahnenin parlaklığına bağlı olarak sürekli değişir.

Sonuç olarak, bağlı kameradan gelen güncel bilgilerin her zaman kullanıcı arayüzünde gösterilmesi ve aynı zamanda kullanıcının bu ayarları aynı kullanıcı arayüzünden düzenlemesine izin verilmesi önemlidir. Bu tür iki yönlü veri akışının işlenmesi daha karmaşıktır.

gPhoto2, yalnızca değiştirilen ayarları değil, yalnızca ağacın tamamını veya tek tek widget'ları alma mekanizmasına sahip değildir. Kullanıcı arayüzünü titremeden ve giriş odağını veya kaydırma konumunu kaybetmeden güncel tutmak için çağrılar arasındaki widget ağaçlarını karşılaştırıp yalnızca değişen kullanıcı arayüzü özelliklerini güncellemem gerekiyordu. Neyse ki bu sorun web'de çözüldü ve React ya da Preact gibi çerçevelerin temel işlevidir. Bu proje için çok daha hafif ve ihtiyacım olan her şeyi yaptığı için Preact'i tercih ettim.

C++ tarafında, daha önce bağlanan C API'si aracılığıyla ayarlar ağacını alıp yinelemeli olarak incelemem ve her widget'ı bir JavaScript nesnesine dönüştürmem gerekiyordu:

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

JavaScript tarafında artık configToJS işlevini çağırabilir, ayarlar ağacının döndürülen JavaScript temsilini inceleyebilir ve Preact işlevi h aracılığıyla kullanıcı arayüzünü oluşturabilirim:

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

Bu işlevi sonsuz bir etkinlik döngüsünde tekrar tekrar çalıştırarak ayarlar kullanıcı arayüzünün her zaman en son bilgileri göstermesini sağlayabilir ve kullanıcı alanlardan birini düzenlediğinde kameraya komut gönderebilirim.

Preact, sayfa odağını veya düzenleme durumlarını bozmadan sonuçların karşılaştırılmasını ve DOM'un yalnızca kullanıcı arayüzünün değişen bölümleri için güncellenmesini sağlayabilir. Karşılaştığımız bir sorun da iki yönlü veri akışı. React ve Preact gibi çerçeveler, veriler hakkında akıl yürütmeyi ve yeniden yayınlar arasında karşılaştırmayı çok daha kolay hale getirdiği için tek yönlü veri akışı etrafında tasarlanmıştır. Ancak ben, harici bir kaynağın (kamera) ayarlar kullanıcı arayüzünü istediği zaman güncellemesine izin vererek bu beklentiyi yıkıyorum.

Şu anda kullanıcı tarafından düzenlenmekte olan tüm giriş alanları için kullanıcı arayüzü güncellemelerini devre dışı bırakarak bu sorunu çözdüm:

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

Bu sayede, belirli bir alanın her zaman yalnızca bir sahibi olur. Kullanıcı o anda alanı düzenlediği için kameradan gelen güncellenmiş değerlerden rahatsız olmaz veya kamera, odak dışındayken alan değerini günceller.

Canlı "video" feed'i oluşturma

Pandemi sırasında birçok kişi online toplantılara geçiş yaptı. Bu durum, diğerlerinin yanı sıra webcam pazarında yaşanan sıkıntılara yol açtı. Dizüstü bilgisayarlardaki yerleşik kameralara kıyasla daha iyi video kalitesi elde etmek ve söz konusu eksiklikleri gidermek isteyen birçok DSLR ve aynasız kamera sahibi, fotoğraf kameralarını web kamerası olarak kullanmanın yollarını aramaya başladı. Hatta bazı kamera tedarikçileri bu amaç için resmi yardımcı programları gönderiyordu.

Resmi araçlar gibi gPhoto2 de kameradan yerel olarak depolanan bir dosyaya veya doğrudan sanal bir web kamerasına video aktarmayı destekler. Demomda canlı görüntü sağlamak için bu özelliği kullanmak istedim. Ancak konsol yardımcı programında mevcut olmasına rağmen libgphoto2 kitaplık API'lerinde bulamadım.

Konsol yardımcı programındaki ilgili işlevin kaynak koduna baktığımda, aslında video almadığını, bunun yerine kameranın önizlemesini sonsuz bir döngüde tek tek JPEG resimleri olarak alıp M-JPEG akışı oluşturmak için tek tek yazdığını gördüm:

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

Bu yaklaşımın, gerçek zamanlı videoyu sorunsuz bir şekilde aktaracak kadar verimli çalıştığını görünce şaşırdım. Tüm ek soyutlamalar ve Asyncify ile web uygulamasında da aynı performansı yakalayabileceğimiz konusunda daha da şüpheciydim. Yine de denemeye karar verdim.

C++ tarafında, aynı gp_camera_capture_preview() işlevini çağıran ve elde edilen bellek içi dosyayı diğer web API'lerine daha kolay aktarılabilen bir Blob öğesine dönüştüren capturePreviewAsBlob() adlı bir yöntemi kullanıma sunduk:

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

JavaScript tarafında, gPhoto2'dekine benzer bir döngüm var. Bu döngü, önizleme resimlerini Blob olarak alıp arka planda createImageBitmap ile kodlarını çözer ve sonraki animasyon karesinde tuvale aktarır:

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

Bu modern API'lerin kullanılması, tüm kod çözme çalışmalarının arka planda yapılmasını ve tuvalin yalnızca hem resim hem de tarayıcı çizime tamamen hazır olduğunda güncellenmesini sağlar. Bu sayede dizüstü bilgisayarımda 30'un üzerinde FPS elde ettim. Bu değer, hem gPhoto2 hem de resmi Sony yazılımının doğal performansıyla aynıydı.

USB erişimini senkronize etme

Başka bir işlem devam ederken USB veri aktarımı istendiğinde genellikle "cihaz meşgul" hatası oluşur. Önizleme ve ayarlar kullanıcı arayüzü düzenli olarak güncellendiğinden ve kullanıcı aynı anda resim çekmeye veya ayarları değiştirmeye çalışıyor olabileceğinden, farklı işlemler arasındaki bu tür çatışmalar çok sık yaşanıyordu.

Bu sorunları önlemek için uygulamadaki tüm erişimleri senkronize etmem gerekiyordu. Bunun için söze dayalı bir eşzamansız sıra oluşturdum:

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

Her işlemi mevcut queue vaadinin then() geri çağırma işlevinde zincirleyerek ve zincirlenmiş sonucu queue'un yeni değeri olarak saklayarak tüm işlemlerin tek tek, sırayla ve çakışma olmadan yürütülmesini sağlayabilirim.

Tüm işlem hataları arayana döndürülür. Kritik (beklenmedik) hatalar ise zincirin tamamını reddedilen bir söz olarak işaretler ve daha sonra yeni bir işlemin planlanmamasını sağlar.

Modül bağlamını özel (dışa aktarılmamış) bir değişkende tutarak, schedule() çağrısını kullanmadan uygulamanın başka bir yerinde context öğesine yanlışlıkla erişme riskini en aza indiriyorum.

Artık cihaz bağlamına yapılan her erişimin şu şekilde bir schedule() çağrısına sarmalanması gerekir:

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

ve

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

Bunun ardından tüm işlemler çakışma olmadan başarıyla yürütüldü.

Sonuç

Daha fazla uygulama bilgisi için GitHub'daki kod tabanına göz atabilirsiniz. Ayrıca, gPhoto2'nin bakımı ve yayın öncesi PR'lerimin incelemeleri için Marcus Meissner'a teşekkür etmek isterim.

Bu yayınlarda gösterildiği gibi WebAssembly, Asyncify ve Fugu API'leri, en karmaşık uygulamalar için bile güçlü bir derleme hedefi sağlar. Bu araçlar, daha önce tek bir platform için oluşturulmuş bir kitaplığı veya uygulamayı web'e taşıyarak hem masaüstü hem de mobil cihazlarda çok daha fazla kullanıcının kullanımına sunmanıza olanak tanır.