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 taşındığını öğrenin.

Ingvar Stepanyan
Ingvar Stepanyan

Önceki gönderide, 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, DSLR ve aynasız kameraları USB üzerinden bir web uygulamasından kontrol edebilen gPhoto2 ile oluşturulmuş bir tanıtım yaptım. Bu gönderide, gPhoto2 bağlantı noktasının ardındaki teknik ayrıntıları ele alacağız.

Yapı sistemlerini özel çatallara yönlendirme

WebAssembly'yi hedeflediğim için sistem dağıtımlarının sağladığı libusb ve libgphoto2 değerlerini kullanamadım. Uygulamamın bunun yerine özel libgphoto2 çatalımı kullanması, libgphoto2 çatalının da özel libusb çatalımı kullanması gerekiyordu.

Buna ek olarak, libgphoto2 dinamik eklentileri yüklemek için libtool'u kullanır. Diğer iki kitaplık gibi libtool'u çatallamam gerekmese de onu WebAssembly'de 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ı ifade eder):

Bir diyagramda "uygulama" Bu, "libtool"a bağlı "libgphoto2 fork"a bağlıdır. "libtool" blok, dinamik olarak "libgphoto2 bağlantı noktalarına" bağlıdır "libgphoto2 camlibs" gibi. Son olarak, "libgphoto2 bağlantı noktaları" statik olarak "libusb çatal"a bağlıdır.

Bu kitaplıklarda kullanılanlar da dahil olmak üzere yapılandırma tabanlı derleme sistemlerinin çoğu, çeşitli flag'ler aracılığıyla bağımlılıklar için yolların geçersiz kılınmasına olanak tanır. İlk olarak bunu yapmaya çalıştım. 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ılmaları listesi ayrıntılı ve hataya açık hale gelir. Ayrıca, derleme sistemlerinin bağımlılıklarının standart olmayan yollarda çalışmaya hazır olmadığı bazı hatalar da buldum.

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

Emscripten'in (path to emscripten cache)/sysroot altında halihazırda kendi sysroot'u vardır. Bu sistem kendi sistem kitaplıkları, Emscripten bağlantı noktaları, ayrıca CMake ve 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) # …

Böyle bir yapılandırmayla sadece her bağımlılıkta make install çalıştırmam gerekiyordu, böylece sistem sysroot altında yüklendi ve kitaplıklar birbirlerini 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ü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şımla ilgili birkaç sorun vardır:

  • WebAssembly modüllerinin dinamik olarak bağlanması için standart destek yoktur. Emscripten, libtool tarafından kullanılan dlopen() API'sini simüle edebilecek bir özel uygulama sunar ancak "main" öğesini derlemenizi gerektirir ve "side" özellikle dlopen() için, uygulama başlatılırken emüle edilmiş dosya sistemine yan modüllerin önceden yüklenmesini sağlar. Bu işaretleri ve ince ayarları, çok sayıda dinamik kitaplık içeren mevcut bir otomatik yapılandırma derleme sistemine entegre etmek zor olabilir.
  • Çoğu HTTP sunucusu güvenlik nedeniyle dizin girişlerini göstermediğinden, dlopen() uygulanmış olsa bile web'deki belirli bir klasördeki tüm dinamik kitaplıkları numaralandırmak mümkün değildir.
  • Dinamik kitaplıkların çalışma zamanında numaralandırmak yerine komut satırında bağlanması, yinelenen simgeler sorunu gibi sorunlara da yol açabilir. Bu sorun, paylaşılan kitaplıkların Emscripten'de ve diğer platformlarda temsil edilmesi arasındaki farklılıklardan kaynaklanır.

Derleme sistemini bu farklılıklara uyarlayabilir ve derleme sırasında dinamik eklenti listesini sabit bir şekilde kodlayabilirsiniz. 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ığı ve hatta diğerleri için özel yükleyicilerin yazmayı desteklediği sonucuna varılır. Desteklediği yerleşik yükleyicilerden biri "Dlpreopening" (Dlpre Açma) olarak adlandırılır:

"Libtool, libtool nesnesi ve libtool kitaplık dosyalarını boşaltma konusunda özel destek sağlar. Böylece simgeleri herhangi bir dlopen ve dlsym işlevi olmayan platformlarda bile çözümlenebilirler.
. ...
Libtool, derleme sırasında nesneleri programa bağlayarak ve programın simge tablosunu temsil eden veri yapıları oluşturarak statik platformlarda -dlopen emülasyonu 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 bildirmeniz gerekir (bkz. Bağlantı modu)."

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

Bu sorunun çözmediği tek sorun, dinamik kitaplıkların numaralandırılmasıdır. Bunların listesinin hâlâ bir yere kodlanması gerekir. Neyse ki uygulama için gereken eklenti seti çok az:

  • Bağlantı noktaları söz konusu olduğunda 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 satıcıya özgü çeşitli eklentiler bulunur ancak genel ayarlar kontrolü ve yakalama açısından, ptp2 camlib ile temsil edilen ve piyasadaki hemen hemen her kamera tarafından desteklenen Resim Aktarım Protokolü'nü kullanmak yeterlidir.

Her şeyin statik olarak birbirine bağlı olduğu güncellenmiş bağımlılık diyagramı şu şekilde görünür:

Bir diyagramda "uygulama" Bu, "libtool"a bağlı "libgphoto2 fork"a bağlıdır. "libtool" 'bağlantı noktaları: libusb1'e bağlıdır ve "camlibs: libptp2" ifadeleri kullanılır. "ports: libusb1" "libusb çatal"a bağlıdır.

Emscripten derlemeleri için şu koda yazdım:

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 şimdi -dlpreopen dosyasını, tüm yürütülebilir dosyalar (örnekler, testler ve kendi demo uygulamam) için bağlantı işareti olarak bu iki dosyayla birlikte eklemem gerekti. Ö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 simgeler tek bir kitaplıkta statik olarak bağlantılı olduğundan, libtool'da hangi simgenin hangi kitaplığa ait olduğunu belirleyecek bir yönteme ihtiyaç vardır. Bunu yapabilmek için geliştiricilerin, {function name} gibi açığa çıkan tüm simgeleri {library name}_LTX_{function name} olarak yeniden adlandırmaları 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 eklentiler 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ılı bir şekilde yükleyebildim.

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

gPhoto2, kamera kitaplıklarının kendi ayarlarını widget ağacı biçiminde tanımlamasına olanak tanır. Widget türlerinin hiyerarşisi şunlardan 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 ilgili diğer tüm özellikleri, gösterilen C API aracılığıyla sorgulanabilir (ve değerler söz konusu olduğunda, değiştirilebilir). Birlikte, C ile etkileşim kurabilecek herhangi bir dilde otomatik olarak ayarlar kullanıcı arayüzü oluşturmak için bir temel sağlarlar.

Ayarlar gPhoto2'den veya istediğiniz zaman kameranın kendisinden değiştirilebilir. Buna ek olarak, bazı widget'lar salt okunur olabilir ve salt okunur durumunun kendisi bile kamera moduna ve diğer ayarlara bağlıdır. Örneğin, shutter hızı, M (manuel mod) sürümünde yazılabilir sayısal bir alandır, P (program modu) ise bilgi amaçlı bir 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, kullanıcı arayüzünde bağlı kameradan gelen güncel bilgileri her zaman göstermek ve aynı zamanda kullanıcının bu ayarları aynı kullanıcı arayüzünden düzenlemesine izin vermek önemlidir. Bu tür iki yönlü veri akışının işlenmesi daha karmaşıktır.

gPhoto2'nin yalnızca değiştirilen ayarları değil, yalnızca ağacın tamamını veya tek tek widget'ları getiren bir mekanizması yoktur. Titreme olmadan ve giriş odağını ya da kaydırma konumunu kaybetmeden kullanıcı arayüzünü güncel tutmak için, widget ağaçlarını çağrılar arasında ayıracak ve yalnızca değiştirilen kullanıcı arayüzü özelliklerini güncelleyecek bir yola ihtiyacım vardı. Neyse ki bu web'deki çözülmüş bir problem ve React ya da Preact gibi çerçevelerin temel işlevidir. Bu proje için çok daha hafif olduğu ve ihtiyacım olan her şeyi yaptığı için Preact'i kullandım.

C++ tarafında artık ayarlar ağacını daha önceki bağlantılı C API'si yoluyla almam, yinelemeli olarak yürütmem 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 öğesini çağırabilir, ayarlar ağacının döndürülen JavaScript gösterimini inceleyebilir ve h Preact işlevi 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ğlarken alanlardan biri kullanıcı tarafından her düzenlendiğinde kameraya komutlar da gönderebiliyorum.

Preact, sayfa odağını veya düzenleme durumlarını kesintiye uğratmadan sonuçları ayrıştırmayı ve DOM'yi yalnızca kullanıcı arayüzünün değiştirilen bitleri için güncelleme işlemini gerçekleştirebilir. Hâlâ karşılaşılan sorunlardan biri, çift yönlü veri akışıdır. React ve Preact gibi çerçeveler, veriler hakkında akıl yürütüp tekrar çalıştırmalar 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 harici bir kaynağın (kamera) her zaman ayarlar kullanıcı arayüzünü güncellemesine izin vererek bu beklentiyi bozuyorum.

Bu sorunu çözmek için, şu anda kullanıcı tarafından düzenlenen giriş alanları için kullanıcı arayüzü güncellemelerini devre dışı bıraktık:

/**
 * 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 bir sahibi olur. Kullanıcı düzenleme işlemi yapıyor ve kameradan alınan güncellenmiş değerler bu çalışmasını kesintiye uğratmıyor ya da kamera odak dışıyken alan değerini güncelliyor.

Canlı bir "video" oluşturma özet akışı

Pandemi döneminde çok sayıda kişi online toplantılara geçti. Diğer nedenlerinin yanı sıra bu, web kamerası pazarının yeterli olmamasına yol açtı. Dizüstü bilgisayarlardaki yerleşik kameralara kıyasla daha iyi bir video kalitesi elde etmek ve söz konusu eksiklikler üzerine birçok DSLR ve aynasız kamera sahibi, fotoğraf makinelerini web kamerası olarak kullanmanın yollarını aramaya başladı. Hatta bazı kamera tedarikçileri, tam da bu amaçla resmî yardımcı programlar göndermiştir.

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 istiyordum. Ancak konsol yardımcı programında mevcut olmasına rağmen, libgphoto2 kitaplık API'lerinde herhangi bir yerde bulamadım.

Konsol yardımcı programındaki karşılık gelen işlevin kaynak koduna baktığımda aslında bir video almadığını, bunun yerine kameranın önizlemesinin sonsuz bir döngüde tek tek JPEG resimleri olarak almaya devam ettiğini ve M-JPEG akışı oluşturmak üzere bunları tek tek yazdığını fark ettim:

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

Bu yaklaşımın akıcı gerçek zamanlı video izlenimi verecek kadar verimli bir şekilde çalışması beni şaşırttı. Tüm ekstra soyutlamalar ve Asyncify'la birlikte web uygulamasında aynı performansı elde edebilme 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 bir şekilde iletilebilecek bir Blob öğesine dönüştüren capturePreviewAsBlob() adlı bir yöntemi 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, gPhoto2'dekine benzer bir döngüm var. Bu döngü, önizleme resimlerini Blobs olarak almaya devam eder, arka planda createImageBitmap ile şifrelerini çözer ve bunları bir sonraki animasyon karesindeki 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 işleminin arka planda yapılmasını sağlar ve tuval yalnızca, hem resim hem de tarayıcı çizim için tamamen hazır olduğunda güncellenir. Bu, dizüstü bilgisayarımda tutarlı 30'dan fazla FPS elde etti. Bu da hem gPhoto2'nin hem de resmi Sony yazılımının doğal performansıyla aynıydı.

USB erişimini senkronize etme

Devam eden başka bir işlem varken USB veri aktarımı isteğinde bulunulursa bu genellikle "cihaz meşgul" şeklinde sonuçlanır. hatası. Önizleme ve ayarlar kullanıcı arayüzü düzenli olarak güncellendiğinden ve kullanıcı aynı anda bir görüntü yakalamaya veya ayarları değiştirmeye çalışıyor olabileceğinden, farklı işlemler arasındaki bu tür çakışmalar çok sık yaşanmıştır.

Bunlardan kaçınmak için uygulama içindeki 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 bir işlemi mevcut queue taahhüdünün then() geri çağırmasına ekleyerek 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üldüğünden emin olabiliyorum.

Tüm işlem hataları arayana döndürülürken kritik (beklenmeyen) hatalar, zincirin tamamını reddedilen bir taahhüt olarak işaretler ve sonrasında yeni işlem planlanmamasını sağlar.

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

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

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

ve

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

Bundan sonra tüm işlemler çakışmalar olmadan başarıyla yürütülmüştür.

Sonuç

Uygulamayla ilgili daha fazla bilgi için GitHub'daki kod tabanına göz atabilirsiniz. gPhoto2'nin bakımı ve yayın öncesi PR'lerimle ilgili yorumları için Marcus Meissner'a da 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 sayesinde bir kitaplığı veya daha önce tek bir platform için oluşturulmuş bir uygulamayı alıp web'e taşıyarak hem masaüstü bilgisayarlarda hem de mobil cihazlarda çok daha fazla sayıda kullanıcıya ulaşabilirler.