gPhoto2'nin, harici kameraları bir web uygulamasından USB üzerinden kontrol etmek için WebAssembly'e nasıl taşındığını öğrenin.
Ö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, gPhoto2 ile oluşturulan ve DSLR ile aynasız kameraları bir web uygulamasından USB üzerinden kontrol edebilen bir demo da gösterdim. Bu gönderide, gPhoto2 bağlantı noktasının arkasındaki teknik ayrıntıları ele alacağız.
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. Uygulamamın bunun yerine özel libgphoto2 çatalımı kullanması, libgphoto2 çatalının ise özel libusb çatalımı kullanması gerekiyordu.
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ı ifade eder):
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 denedim. Ancak bağımlılık grafiği karmaşık hale geldiğinde, her kitaplığın bağımlılıklarına yönelik 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, 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 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'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 make install
işlemini her bağımlılıkta çalıştırmam gerekiyordu. Bu da onu sysroot altına yükledi ve kitaplıklar birbirlerini 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şımla ilgili birkaç sorun vardır:
- WebAssembly modüllerinin dinamik olarak bağlanması için standart bir destek yoktur. Emscripten, libtool tarafından kullanılan
dlopen()
API'sini simüle edebilen bir özel uygulama sunar. Ancak farklı bayraklarla "ana" ve "yan" modüller oluşturmanızı, ayrıca özellikledlopen()
için, uygulamanın başlatılırken emüle edilmiş dosya sistemine yan modülleri önceden yüklemenizi gerektirir. 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ı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ükleyiciler yazmayı desteklediği ortaya çıktı. 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 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 etmeye 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 ayarlar 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ğlı olduğu güncellenmiş bağımlılık diyagramı şu şekilde görünü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, 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 şöyle 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ü oluşturma
gPhoto2, kamera kitaplıklarının kendi ayarlarını widget ağacı şeklinde 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'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 otomatik olarak ayar kullanıcı arayüzü 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, 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ının değeri de dinamiktir 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, 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. 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 ve ihtiyacım olan her şeyi yaptığı için Preact'i tercih ettim.
C++ tarafında artık, daha önce bağlantılı olan C API'yi kullanarak ayarlar ağacını almam ve yinelemeli bir şekilde 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
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ırma yapmayı ç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üncel değerlerden rahatsız olmaz veya kamera, odak dışındayken alan değerini günceller.
Canlı "video" feed'i oluşturma
Pandemi döneminde çok sayıda kişi online toplantılara geçti. 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, 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 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ı videonun sorunsuz bir şekilde oynatıldığı izlenimi verecek kadar verimli çalıştığını görünce şaşırdım. 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 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
s 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 ç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, dizüstü bilgisayarımda istikrarlı bir 30+ FPS elde etti. Bu da hem gPhoto2'nin hem de resmi Sony yazılımının doğal performansına uyum sağladı.
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 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.
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ü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.
Tüm bunları bir araya getirmek için 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. 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.