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

Harici kameraları bir web uygulamasından USB üzerinden kontrol etmek için gPhoto2'nin WebAssembly'ye nasıl bağlandığı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, bir web uygulamasından DSLR ve aynasız kameraları USB üzerinden kontrol edebilen gPhoto2 ile oluşturulmuş bir demo da sergiledim. Bu gönderide, gPhoto2 bağlantı noktasının ardındaki teknik ayrıntıları daha ayrıntılı olarak ele alacağım.

Derleme sistemlerini özel çatallara yönlendirme

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

Buna ek olarak, libgphoto2, dinamik eklentileri yüklemek için libtool'u kullanıyor. Diğer iki kitaplık gibi libtool'u çatallamam gerekmemesine rağmen yine de WebAssembly'ye derlemem ve libgphoto2 öğesini sistem paketi yerine bu özel derlemeye yönlendirmem gerekti.

Aşağıda yaklaşık bir bağımlılık diyagramı verilmiştir (kesikli çizgiler, dinamik bağlantıları belirtir):

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

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

Bunun yerine, özel sistem kökü (genellikle "sysroot" olarak kısaltılır) olarak ayrı bir klasör oluşturup ilgili tüm derleme sistemlerini bu klasöre yönlendirmek daha kolay bir yaklaşımdır. Bu şekilde, her kitaplık hem derleme sırasında belirtilen sysroot'da bağımlılıklarını arar hem de diğer kullanıcıların daha kolay bulabilmesi için kendisini aynı sistem köküne yükler.

Emscripten'ın (path to emscripten cache)/sysroot altında kendi sistem rootu, sistem kitaplıkları, Emscripten bağlantı noktaları, ayrıca CMake ve pkg-config gibi araçlar için kullanıyor. Bağımlılıklarım için de aynı sistem kökünü 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) # …

Böyle bir yapılandırmada, yalnızca her bağımlılıkta make install çalıştırmam gerekti. O da dosyayı sysroot'un altına yükledi ve ardından kitaplıklar birbirini otomatik olarak buldu.

Dinamik yüklemeyle başa çıkma

Yukarıda belirtildiği gibi libgphoto2, G/Ç bağlantı noktası adaptörlerini ve kamera kitaplıklarını numaralandırmak ve dinamik olarak yüklemek için libtool'u kullanır. Örneğin, G/Ç kitaplıklarını yüklemek için gereken kod 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şımla ilgili birkaç sorun vardır:

  • WebAssembly modüllerinin dinamik bağlantısı için standart bir destek yoktur. Emscripten'ın, libtool tarafından kullanılan dlopen() API'sini simüle edebilen özel uygulaması vardır. Ancak bunun için farklı işaretlerle "main" ve "side" modüllerini oluşturmanız, özellikle de dlopen() için uygulamayı başlatırken emüle edilen dosya sistemine önceden yüklemeniz gerekir. Bu işaretlerin ve ince ayarların, çok sayıda dinamik kitaplık içeren mevcut bir otomatik yapılandırma derleme sistemine entegre edilmesi zor olabilir.
  • Çoğu HTTP sunucusu güvenlik nedeniyle dizin girişlerini göstermediğinden, dlopen() öğesinin kendisi uygulanmış olsa bile, web'deki belirli bir klasördeki tüm dinamik kitaplıkları numaralandırmanın yolu yoktur.
  • Dinamik kitaplıkların çalışma zamanında numaralandırılması yerine komut satırında bağlanması da, paylaşılan kitaplıkların Emscripten ve diğer platformlardaki temsilleri arasındaki farklılıklardan kaynaklanan yinelenen simge sorunu gibi sorunlara yol açabilir.

Derleme sistemini bu farklılıklara uyarlamak ve derleme sırasında dinamik eklentiler listesinin kodunu koda gömmek mümkündür. Ancak, tüm bu sorunları çözmenin daha da kolay bir yolu, başlangıçta dinamik bağlantı oluşturmaktan kaçınmaktır.

libtool'un farklı platformlardaki çeşitli dinamik bağlantı yöntemlerini soyutladığı, 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 nesnesi ve libtool kitaplık dosyalarının dlopen'ı için özel destek sağlar, böylece bunların sembolleri herhangi bir dlopen ve dlsym işlevi olmayan platformlarda bile çözümlenebilir.
...
Libtool, derleme zamanında nesneleri programa bağlayarak ve programın sembol tablosunu temsil eden veri yapıları oluşturarak statik platformlarda -dlopen emülasyonunu yapar. 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 beyan etmeniz gerekir (bkz. Bağlantı modu)."

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

Bunun çözmediği tek sorun, dinamik kitaplıkların numaralandırılmasıdır. Bu kişileri içeren listenin koda gömülmesi gerekir. Neyse ki uygulama için gereken eklenti seti minimum düzeyde:

  • Bağlantı noktaları olarak ise PTP/IP, seri erişim veya USB sürücü modlarıyla değil, yalnızca Libusb tabanlı kamera bağlantısıyla ilgileniyorum.
  • Camlibs tarafında, bazı özel işlevler sağlayabilecek, tedarikçiye özel çeşitli eklentiler vardır ancak genel ayar kontrolü ve çekim için ptp2 camlib ile temsil edilen ve pazardaki hemen hemen her kamera tarafından desteklenen Picture Transfer Protocol'un kullanılması yeterlidir.

Aşağıda, her şey statik olarak birbirine bağlanmış olan güncellenmiş bağımlılık şemasının nasıl göründüğü verilmiştir:

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

Emscripten derlemeleri için kod gömdüğüm kod buydu:

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

Otomatik yapılandırma derleme sisteminde, bu dosyaların her ikisini de içeren -dlpreopen dosyasını tüm yürütülebilir dosyalar (örnekler, testler ve kendi demo uygulamam) için bağlantı işareti olarak eklemem gerekiyordu. Örneğin:

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

Son olarak, artık tüm semboller tek bir kitaplıkta statik olarak bağlandığından, libtool hangi sembolün hangi kitaplığa ait olduğunu belirleyebilecek bir yönteme ihtiyaç duyar. Bunu sağlamak için geliştiricilerin {function name} gibi gösterilen tüm simgeleri {library name}_LTX_{function name} olarak yeniden adlandırması gerekir. Bunu yapmanın en kolay yolu, uygulama dosyasının en üstünde simge adlarını yeniden tanımlamak için #define öğesini 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 düzeni, ileride aynı uygulama içinde kameraya özgü eklentileri bağlamaya karar vermem durumunda ad çakışmalarını da önler.

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

Ayarlar kullanıcı arayüzü oluşturuluyor

gPhoto2, kamera kitaplıklarının bir widget ağacı biçiminde kendi ayarlarını tanımlamasına olanak tanır. Widget türlerinin hiyerarşisi şu unsurlardan oluşur:

  • Pencere - üst düzey yapılandırma kapsayıcısı
    • Bölümler - diğer widget'ların adlandırılmış grupları
    • Düğme alanları
    • Metin alanları
    • Sayısal alanlar
    • Tarih alanları
    • Açar/Kapatır
    • Radyo düğmeleri

Her widget'ın adı, türü, alt öğeleri ve diğer tüm ilgili özellikleri açık C API üzerinden sorgulanabilir (ve değerlerde değişiklik yapılabilir). Birlikte, C ile etkileşim kurabilecek tüm dillerde kullanıcı arayüzünü otomatik olarak oluşturmak için bir temel sağlarlar.

Ayarlar, gPhoto2 aracılığıyla veya dilediğiniz zaman kameranın kendisinden değiştirilebilir. Ayrıca, bazı widget'lar salt okunur olabilir ve hatta salt okunur durumun kendisi kamera moduna ve diğer ayarlara bağlıdır. Örneğin shutter hızı, M'de (manuel mod) yazılabilir bir sayısal alandır ancak P (program modu) modunda bilgi amaçlı salt okunur alan haline gelir. P modunda deklanşör hızı değeri de dinamik olur ve kameranın baktığı sahnenin parlaklığına bağlı olarak sürekli değişir.

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

gPhoto2'nin yalnızca değiştirilen ayarları getiren bir mekanizması yoktur, yalnızca ağacın tamamını veya bağımsız widget'ları alır. Titremeden ve giriş odağını veya kaydırma konumunu kaybetmeden kullanıcı arayüzünü güncel tutmak için widget ağaçlarını çağrılar arasında farklılaştırmak ve yalnızca değiştirilen kullanıcı arayüzü özelliklerini güncellemek için bir yönteme ihtiyacım vardı. Neyse ki bu web'de çözülmüş bir problemdir ve React ya da Preact gibi çerçevelerin temel işlevidir. Bu proje için Preact seçeneğini tercih ettim, çünkü çok daha hafifti ve ihtiyacım olan her şeyi yaptı.

C++ tarafında, şimdi önceki bağlantılı C API'sı yoluyla ayarlar ağacını alıp tekrarlı bir şekilde uygulamam 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 çağırabilir, ayar ağacının döndürülen JavaScript temsilini inceleyebilir ve kullanıcı arayüzünü Preact işlevi h aracılığıyla 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 etkinlik döngüsü içinde tekrar tekrar çalıştırarak, ayarlar kullanıcı arayüzünün her zaman en son bilgileri göstermesini ve alanlardan biri kullanıcı tarafından her düzenlendiğinde kameraya komutlar göndermesini sağlayabiliyorum.

Ön işlem, sayfa odağını veya düzenleme durumlarını kesintiye uğratmadan yalnızca kullanıcı arayüzünün değiştirilen bitleri için sonuçların farkını ortaya koymalı ve DOM'u güncelleme işini üstlenebilir. Kalan sorunlardan biri de çift yönlü veri akışıdır. React ve Preact gibi çerçeveler, verileri akıl yürütmeyi ve tekrarlar arasında karşılaştırmayı çok daha kolay hale getirdiği için tek yönlü veri akışı çerçevesinde tasarlanmıştır. Ancak harici bir kaynağın (kamera) her zaman ayarlar kullanıcı arayüzünü güncellemesine izin vererek bu beklentiyi kırıyorum.

Şu anda kullanıcı tarafından düzenlenmekte olan 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 şekilde, belirli bir alanın her zaman yalnızca tek bir sahibi olur. Kullanıcı şu anda alanı düzenliyordur ve kameradaki güncellenen değerler nedeniyle kesintiye uğramaz veya kamera odak dışındayken alan değerini güncelliyordur.

Canlı "video" feed'i oluşturma

Pandemi sırasında birçok kişi online toplantılara geçti. Bunun yanı sıra, web kamerası pazarında eksiklikler yaşandı. Dizüstü bilgisayarların yerleşik kameralarına kıyasla daha iyi bir video kalitesi elde etmek amacıyla ve bu yetersizlik nedeniyle 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, tam da bu amaçla resmi yardımcı programları gönderdi.

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

Konsol yardımcı programındaki ilgili işlevin kaynak koduna baktığımda, aslında hiç video almadığını, bunun yerine, sonsuz bir döngü halinde bağımsız JPEG resimleri olarak kameranın önizlemesini almaya devam ettiğini ve bunları bir M-JPEG akışı oluşturmak üzere 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ı videoların akıcı bir şekilde gösterildiği izlenimi oluşturacak kadar verimli çalışmasına çok şaşırmıştım. Web uygulamasında da aynı performansı, tüm ekstra soyutlamalarla ve Eşzamansız hale getirdikçe bunu karşılayabildiğinden daha da şüpheleniyordum. Ancak 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 geçirilebilecek bir Blob dönüştüren capturePreviewAsBlob() adlı bir yöntem gösterdim:

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, önizleme resimlerini Blob olarak almaya devam eden, bu resimleri arka planda createImageBitmap ile çözen ve bir sonraki animasyon karesinde tuvale aktaran gPhoto2'dekine benzer bir döngüm var:

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 işleminin arka planda yapılmasını ve tuvalin yalnızca hem resim hem de tarayıcı çizim için tamamen hazır olduğunda güncellenmesini sağlar. Bu şekilde dizüstü bilgisayarımda tutarlı bir şekilde 30+ FPS elde etti. Bu da hem gPhoto2'nin hem de resmi Sony yazılımının yerel performansıyla aynıydı.

USB erişimini senkronize etme

Devam eden başka bir işlem varken USB veri aktarımı istendiğinde genellikle "cihaz meşgul" hatasına neden olur. Önizleme ve ayarlar kullanıcı arayüzü düzenli olarak güncellendiğinden ve kullanıcı aynı anda bir resim yakalamaya ya da ayarları değiştirmeye çalışıyor olabileceğinden, farklı işlemler arasındaki bu tür çakışmaların çok sık yaşandığı ortaya çıktı.

Bunları önlemek için uygulama içindeki tüm erişimleri senkronize etmem gerekiyordu. Bunun için, söze dayalı olarak eş zamansız bir 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 bir then() geri çağırmasındaki her işlemi zincirleme olarak ve zincirlenmiş sonucu queue öğesinin yeni değeri olarak saklayarak tüm işlemlerin sırayla ve çakışma olmadan tek tek yürütülmesini sağlayabilirim.

Tüm işlem hataları çağrıya döndürülür. Kritik (beklenmeyen) hatalar ise tüm zinciri reddedilen sözü olarak işaretler ve daha sonra yeni bir işlemin planlanmamasını sağlar.

Modül bağlamını gizli (dışa aktarılmayan) bir değişkende tutarak context öğesine schedule() çağrısından geçmeden uygulamada başka bir yerde yanlışlıkla erişme riskini en aza indiriyorum.

Öğeleri birbirine bağlamak için artık cihaz bağlamına her erişimin aşağıdaki gibi bir schedule() çağrısıyla sarmalanması gerekiyor:

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

ve

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

Bundan sonra tüm işlemler çakışma olmadan başarıyla yürütülüyordu.

Sonuç

Uygulamayla ilgili daha fazla bilgi için GitHub'daki kod tabanına göz atabilirsiniz. Ayrıca gPhoto2'nin bakımı için ve yukarı yayın PR'lerimle ilgili incelemelerinden dolayı Marcus Meissner'a teşekkür etmek istiyorum.

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