تعرَّف على كيفية نقل gPhoto2 إلى WebAssembly للتحكّم في الكاميرات الخارجية عبر USB من تطبيق ويب.
في المشاركة السابقة، أوضحت كيفية نقل مكتبة libusb لتشغيلها على الويب باستخدام WebAssembly / Emscripten وAsyncify وWebUSB.
لقد عرضتُ أيضًا عرضًا توضيحيًا تم إنشاؤه باستخدام gPhoto2 ويمكنه التحكّم في كاميرات DSLR والكاميرات غير المزوّدة بمرآة عبر USB من تطبيق ويب. في هذه المشاركة، سأتناول بالتفصيل التفاصيل الفنية وراء منفذ gPhoto2.
توجيه أنظمة الإنشاء إلى الإصدارات المشتقة المخصّصة
بما أنّني كنت أستهدف WebAssembly، لم أتمكّن من استخدام libusb وlibgphoto2 المقدَّمين من توزيعات النظام. بدلاً من ذلك، كنت بحاجة إلى أن يستخدم تطبيقي نسخة مخصّصة من libgphoto2، بينما كان على هذه النسخة من libgphoto2 استخدام نسخة مخصّصة من libusb.
بالإضافة إلى ذلك، تستخدم libgphoto2 مكتبة libtool لتحميل المكوّنات الإضافية الديناميكية، ومع أنّني لم أضطر إلى إنشاء مكتبة libtool مثل المكتبتَين الأخرتَين، كان لا يزال عليّ إنشاؤها باستخدام WebAssembly، وتوجيه libgphoto2 إلى هذا الإصدار المخصّص بدلاً من حزمة النظام.
في ما يلي مخطّط تقريبي للتبعية (تشير الخطوط المنقطة إلى الربط الديناميكي):
تسمح معظم أنظمة الإنشاء المستندة إلى الإعدادات، بما في ذلك تلك المستخدَمة في هذه المكتبات، بإلغاء مسارات الملحقات من خلال علامات مختلفة، لذا حاولتُ إجراء ذلك أولاً. ومع ذلك، عندما يصبح الرسم البياني للتبعيات معقّدًا، تصبح قائمة عمليات إلغاء المسار لكل تبعيات المكتبة مفصّلة وقابلة للخطأ. لقد عثرتُ أيضًا على بعض الأخطاء التي لم تكن فيها أنظمة الإنشاء مُعدّة لعرض الملحقات في مسارات غير عادية.
بدلاً من ذلك، يمكنك إنشاء مجلد منفصل كجذر نظام مخصّص (غالبًا ما يتم اختصاره إلى "sysroot") وتوجيه جميع أنظمة الإنشاء المعنيّة إليه. بهذه الطريقة، ستبحث كل مكتبة عن التبعيات في نظام الجذر المحدّد أثناء عملية الإنشاء، وستثبّت نفسها أيضًا في نظام الجذر نفسه ليتمكّن الآخرون من العثور عليها بسهولة أكبر.
يحتوي Emscripten على نظام جذر خاص به ضمن (path to emscripten cache)/sysroot
، ويستخدمه لمكتبات النظام ومنافذ Emscripten وأدوات مثل CMake وpkg-config. اخترت إعادة استخدام sysroot نفسه مع التبعيات أيضًا.
# 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) # …
باستخدام هذا الإعداد، لم يكن عليّ سوى تشغيل make install
في كل تبعية، ما أدّى إلى تثبيتها ضمن sysroot، ثم عثرت المكتبات على بعضها تلقائيًا.
التعامل مع التحميل الديناميكي
كما ذكرنا أعلاه، يستخدم libgphoto2 مكتبة libtool لتعداد محولات منافذ الإدخال/الإخراج وتحميل مكتبات الكاميرا ديناميكيًا. على سبيل المثال، يبدو رمز تحميل مكتبات الإدخال/الإخراج على النحو التالي:
lt_dlinit ();
lt_dladdsearchdir (iolibs);
result = lt_dlforeachfile (iolibs, foreach_func, list);
lt_dlexit ();
هناك بعض المشاكل في هذا النهج على الويب:
- لا تتوفّر إمكانية ربط ديناميكي وحدات WebAssembly بشكل عادي. يحتوي Emscripten على تنفيذ مخصّص يمكنه محاكاة واجهة برمجة التطبيقات
dlopen()
المستخدَمة في libtool، ولكنّه يتطلّب منك إنشاء الوحدات "الأساسية" و"الإضافية" باستخدام علامات مختلفة، وبالنسبة إلىdlopen()
على وجه التحديد، عليك أيضًا تحميل الوحدات الإضافية مسبقًا في نظام الملفات المحاكي أثناء بدء تشغيل التطبيق. قد يكون من الصعب دمج هذه العلامات والتعديلات في نظام إنشاء autoconf حالي يحتوي على الكثير من المكتبات الديناميكية. - حتى في حال تنفيذ
dlopen()
نفسه، لا تتوفّر طريقة لتعداد جميع المكتبات الديناميكية في مجلد معيّن على الويب، لأنّ معظم خوادم HTTP لا تعرض بيانات الأدلة لأسباب تتعلق بالأمان. - يمكن أن يؤدي ربط المكتبات الديناميكية على سطر الأوامر بدلاً من سردها في وقت التشغيل إلى حدوث مشاكل، مثل مشكلة الرموز المكرّرة، والتي تحدث بسبب الاختلافات بين تمثيل المكتبات المشتركة في Emscripten وعلى المنصات الأخرى.
من الممكن تكييف نظام الإنشاء مع هذه الاختلافات وبرمجة قائمة المكوّنات الإضافية الديناميكية في مكان ما أثناء عملية الإنشاء، ولكن هناك طريقة أسهل لحلّ كل هذه المشاكل وهي تجنُّب الربط الديناميكي من البداية.
تبيّن أنّ libtool تُنشئ طرق ربط ديناميكية مختلفة على منصات مختلفة، بل تتيح أيضًا كتابة أداة تحميل مخصّصة للآخرين. يُطلق على أحد أداة التحميل المضمّنة التي تتوافق معها اسم "Dlpreopening":
"يوفّر Libtool دعمًا خاصًا لفتح ملفّات ثنائية libtool ومكتبة libtool باستخدام dlopen، حتى يمكن حلّ رموزها حتى على الأنظمة الأساسية التي لا تتضمّن أيّ وظائف dlopen وdlsym.
…
يحاكي Libtool دالة -dlopen على الأنظمة الأساسية الثابتة من خلال ربط العناصر بالبرنامج في وقت الترجمة، وإنشاء هياكل بيانات تمثّل جدول رموز البرنامج. لاستخدام هذه الميزة، يجب الإفصاح عن العناصر التي تريد أن يفعّلها تطبيقك باستخدام علامة -dlopen أو -dlpreopen عند ربط برنامجك (راجِع وضع الربط)."
تسمح هذه الآلية بمحاكاة التحميل الديناميكي على مستوى libtool بدلاً من Emscripten، مع ربط كل شيء بشكل ثابت في مكتبة واحدة.
المشكلة الوحيدة التي لا يتم حلّها هي تعداد المكتبات الديناميكية. لا تزال قائمة هذه العناصر بحاجة إلى الترميز الثابت في مكان ما. لحسن الحظ، كانت مجموعة المكوّنات الإضافية التي احتاجتها للتطبيق محدودة:
- من ناحية المنافذ، لا يهمّني سوى اتصال الكاميرا المستنِد إلى libusb وليس PTP/IP أو الوصول التسلسلي أو أوضاع محرك USB.
- من جهة مكتبة camlibs، هناك العديد من المكوّنات الإضافية الخاصة بالمورّدين والتي قد توفّر بعض الوظائف المتخصّصة، ولكن بالنسبة إلى التحكّم في الإعدادات العامة والتقاط الصور، يكفي استخدام بروتوكول نقل الصور الذي يمثّله مكتبة camlib ptp2 وتتوافق معه كل الكاميرات تقريبًا في السوق.
في ما يلي شكل مخطّط التبعية المعدَّل مع ربط كل العناصر ببعضها بشكل ثابت:
في ما يلي الرمز غير القابل للتغيير الذي أضفته إلى عمليات إنشاء 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 ();
و
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، كان عليّ الآن إضافة -dlpreopen
مع هذين الملفَّين كعلامات ربط لجميع الملفات التنفيذية (الأمثلة والاختبارات والتطبيق التجريبي الخاص بي)، على النحو التالي:
if HAVE_EMSCRIPTEN
LDADD += -dlpreopen $(top_builddir)/libgphoto2_port/usb1.la \
-dlpreopen $(top_builddir)/camlibs/ptp2.la
endif
أخيرًا، بعد ربط جميع الرموز بشكل ثابت في مكتبة واحدة، تحتاج libtool إلى طريقة لتحديد الرمز الذي ينتمي إلى المكتبة. لتحقيق ذلك، على المطوّرين إعادة تسمية جميع الرموز المعروضة، مثل {function name}
إلى {library name}_LTX_{function name}
. إنّ أسهل طريقة لإجراء ذلك هي استخدام #define
لإعادة تعريف أسماء الرموز في أعلى ملف التنفيذ:
// …
#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>
// …
ويمنع مخطّط التسمية هذا أيضًا حدوث تعارض في الأسماء في حال قرّرت ربط مكوّنات إضافية خاصة بالكاميرا في التطبيق نفسه في المستقبل.
بعد تنفيذ كل هذه التغييرات، تمكّنت من إنشاء التطبيق التجريبي وتحميل المكوّنات الإضافية بنجاح.
إنشاء واجهة مستخدم الإعدادات
يسمح gPhoto2 لمكتبات الكاميرا بتحديد إعداداتها الخاصة في شكل شجرة تطبيقات مصغّرة. يتألّف المخطط الهرمي لأنواع التطبيقات المصغّرة من:
- النافذة: حاوية الإعدادات ذات المستوى الأعلى
- الأقسام: مجموعات مُسمّاة من التطبيقات المصغّرة الأخرى
- حقول الأزرار
- الحقول النصية
- الحقول الرقمية
- حقول التاريخ
- مفاتيح التبديل
- أزرار الاختيار
يمكن الاستعلام عن الاسم والنوع والعناصر الفرعية وجميع الخصائص الأخرى ذات الصلة لكل تطبيق مصغّر (وتعديل القيم أيضًا) من خلال واجهة برمجة التطبيقات C المعروضة. ويوفّران معًا أساسًا لإنشاء واجهة مستخدم الإعدادات تلقائيًا بأي لغة يمكنها التفاعل مع C.
يمكن تغيير الإعدادات في أي وقت من خلال gPhoto2 أو على الكاميرا نفسها. بالإضافة إلى ذلك، يمكن أن تكون بعض التطبيقات المصغّرة للأجهزة قابلة للقراءة فقط، وحتى حالة القراءة فقط نفسها تعتمد على وضع الكاميرا والإعدادات الأخرى. على سبيل المثال، سرعة الغالق هي حقل رقمي قابل للكتابة في M (الوضع اليدوي)، ولكنّه يصبح حقلًا معلوماتيًا للقراءة فقط في P (الوضع الآلي). في وضع P، ستكون قيمة سرعة الغالق ديناميكية أيضًا وتتغيّر باستمرار استنادًا إلى سطوع المشهد الذي ترصده الكاميرا.
بوجهٍ عام، من المهم عرض المعلومات المحدّثة دائمًا من الكاميرا المتصلة في واجهة المستخدم، مع السماح للمستخدم في الوقت نفسه بتعديل هذه الإعدادات من واجهة المستخدم نفسها. إنّ تدفق البيانات الثنائي الاتجاه هذا أكثر تعقيدًا في التعامل معه.
لا يتضمّن gPhoto2 آلية لاسترداد الإعدادات التي تم تغييرها فقط، بل الشجرة بأكملها أو التطبيقات المصغّرة الفردية فقط. للحفاظ على حداثة واجهة المستخدم بدون وميض أو فقدان تركيز الإدخال أو موضع الانتقال، كنت بحاجة إلى طريقة للمقارنة بين أشجار التطبيقات المصغّرة بين عمليات الاستدعاء وتعديل سمات واجهة المستخدم التي تم تغييرها فقط. لحسن الحظ، تم حلّ هذه المشكلة على الويب، وهي الوظيفة الأساسية لإطارات العمل مثل React أو Preact. لقد اخترتُ Preact لهذا المشروع، لأنّه أخف وزنًا بكثير ويوفّر كل ما أحتاجه.
من جانب C++، كنت بحاجة الآن إلى استرداد شجرة الإعدادات والتنقّل فيها بشكل تسلسلي من خلال واجهة برمجة التطبيقات C API المرتبطة سابقًا، وتحويل كل عنصر واجهة مستخدم إلى عنصر 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;
}
// …
من جانب JavaScript، يمكنني الآن استدعاء configToJS
والانتقال إلى تمثيل JavaScript الذي تم إرجاعه لشجرة الإعدادات وإنشاء واجهة المستخدم من خلال دالة 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;
}
// …
من خلال تشغيل هذه الدالة بشكل متكرّر في حلقة أحداث لا نهائية، تمكّنت من جعل واجهة مستخدم الإعدادات تعرض دائمًا أحدث المعلومات، مع إرسال أوامر إلى الكاميرا كلما عدّل المستخدم أحد الحقول.
يمكن أن تتولى Preact معالجة الاختلافات في النتائج وتعديل DOM فقط للقطع التي تم تغييرها في واجهة المستخدم، بدون إيقاف تركيز الصفحة أو حالات التعديل. تبقى مشكلة واحدة وهي تدفّق البيانات الثنائي الاتجاه. تم تصميم إطارات العمل، مثل React وPreact، استنادًا إلى تدفق البيانات أحادي الاتجاه، لأنّ ذلك يسهّل كثيرًا فهم البيانات ومقارنتها بين عمليات إعادة التشغيل، ولكنّني أخالف هذا التوقع من خلال السماح لمصدر خارجي، وهو الكاميرا، بتعديل واجهة مستخدم الإعدادات في أي وقت.
لقد تجنّبت هذه المشكلة من خلال إيقاف تحديثات واجهة المستخدم لأي حقول إدخال يعدّلها المستخدم حاليًا:
/**
* 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}));
}
}
بهذه الطريقة، يكون هناك مالك واحد فقط لأيّ حقل معيّن. إما أنّ المستخدم يعدّله حاليًا، ولن تتداخل القيم المعدَّلة من الكاميرا معه، أو تعدّل الكاميرا قيمة الحقل عندما لا يكون الهدف في التركيز.
إنشاء خلاصة "فيديو" مباشرةً
خلال الجائحة، انتقل الكثير من الأشخاص إلى الاجتماعات على الإنترنت. وقد أدّى ذلك، من بين أمور أخرى، إلى نقص في سوق كاميرات الويب. للحصول على جودة فيديو أفضل مقارنةً بالكاميرات المدمجة في أجهزة الكمبيوتر المحمول، ونتيجةً للنقص المذكور، بدأ العديد من مالكي الكاميرات الرقمية ذات العدسة الأحادية العاكسة والكاميرات غير المزوّدة بمرآة في البحث عن طرق لاستخدام كاميرات التصوير الخاصة بهم ككاميرات ويب. وقد شحَّن العديد من مورّدي الكاميرات أدوات مساعدة رسمية لهذا الغرض تحديدًا.
مثل الأدوات الرسمية، يتوافق برنامج gPhoto2 مع بث الفيديو من الكاميرا إلى ملف مخزّن على الجهاز أو مباشرةً إلى كاميرا ويب افتراضية. أردت استخدام هذه الميزة لتوفير عرض مباشر في العرض الترويجي. ومع أنّه متاح في أداة وحدة التحكّم، لم أتمكّن من العثور عليه في أي مكان في واجهات برمجة تطبيقات مكتبة libgphoto2.
عند الاطّلاع على رمز المصدر للدالة المقابلة في أداة وحدة التحكّم، تبيّن لي أنّها لا تحصل على فيديو على الإطلاق، بل تستمر في استرداد معاينة الكاميرا كصور JPEG فردية في حلقة لا تنتهي، وكتابتها واحدة تلو الأخرى لإنشاء بث M-JPEG:
while (1) {
const char *mime;
r = gp_camera_capture_preview (p->camera, file, p->context);
// …
لقد ذهلت عندما تبيّن لي أنّ هذا النهج يعمل بكفاءة كافية لتوفير تجربة مشاهدة سلسة للفيديوهات في الوقت الفعلي. كنتُ أشكّك أكثر في إمكانية تحقيق الأداء نفسه في تطبيق الويب أيضًا، مع كل الوظائف المجردة الإضافية وAsyncify في الطريق. ومع ذلك، قرّرتُ المحاولة على أي حال.
من جانب C++، عرضت طريقة تُسمى capturePreviewAsBlob()
تستدعي دالة gp_camera_capture_preview()
نفسها، وتحوّل الملف الناتج في الذاكرة إلى Blob
يمكن تمريره إلى واجهات برمجة تطبيقات الويب الأخرى بسهولة أكبر:
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، لديّ حلقة مشابهة للحلقة في gPhoto2، وهي تستمر في استرداد صور المعاينة بتنسيق Blob
، وفك ترميزها في الخلفية باستخدام createImageBitmap
، ونقلها إلى اللوحة في إطار الصورة المتحركة التالي:
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) {
// …
}
}
يضمن استخدام واجهات برمجة التطبيقات الحديثة هذه تنفيذ جميع عمليات فك التشفير في الخلفية، ولا يتم تعديل اللوحة إلا عندما تكون كل من الصورة والمتصفّح مستعدّين بالكامل للرسم. وقد حقّق هذا الإجراء معدلًا ثابتًا يتجاوز 30 لقطة في الثانية على الكمبيوتر المحمول، ما كان مطابقًا للأداء الأصلي لكل من gPhoto2 وبرنامج Sony الرسمي.
مزامنة إمكانية الوصول عبر USB
عند طلب نقل بيانات عبر USB أثناء إجراء عملية أخرى، سيؤدي ذلك عادةً إلى ظهور خطأ "الجهاز مشغول". بما أنّ واجهة المستخدم الخاصة بالإعدادات والمعاينة يتم تعديلها بانتظام، وقد يحاول المستخدم التقاط صورة أو تعديل الإعدادات في الوقت نفسه، تبيّن أنّ هذه التعارضات بين العمليات المختلفة تحدث بشكل متكرر للغاية.
ولتجنُّب حدوثها، كان عليّ مزامنة جميع عمليات الوصول داخل التطبيق. لهذا الغرض، أنشأت قائمة انتظار غير متزامنة تستند إلى الوعد:
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;
}
من خلال ربط كل عملية في دالة استدعاء then()
لوعد queue
الحالي، وتخزين النتيجة المرتبطة كقيمة جديدة لـ queue
، يمكنني التأكّد من تنفيذ جميع العمليات واحدة تلو الأخرى، بالترتيب وبدون تداخلات.
يتم عرض أي أخطاء في العملية للمتصل، في حين تضع الأخطاء الملحّة (غير المتوقّعة) علامة على السلسلة بأكملها كوعد مرفوض، وتؤكّد عدم جدولة أي عملية جديدة بعد ذلك.
من خلال إبقاء سياق الوحدة في متغيّر خاص (غير مُصدَّر)، يمكنني الحدّ من مخاطر الوصول إلى context
عن طريق الخطأ في مكان آخر في التطبيق بدون إجراء طلب schedule()
.
لتوضيح الأمر، يجب الآن تضمين كل عملية وصول إلى سياق الجهاز في طلب schedule()
على النحو التالي:
let config = await this.connection.schedule((context) => context.configToJS());
و
this.connection.schedule((context) => context.captureImageAsFile());
بعد ذلك، تم تنفيذ جميع العمليات بنجاح بدون أي تعارضات.
الخاتمة
يمكنك تصفُّح قاعدة الرموز على Github للحصول على مزيد من إحصاءات التنفيذ. أريد أيضًا أن أشكر ماركوس ميسنر على صيانة gPhoto2 وعلى مراجعاته لطلبات الدمج في الإصدارات العلنية.
كما هو موضّح في هذه المشاركات، توفّر واجهات برمجة التطبيقات WebAssembly وAsyncify وFugu هدف تجميع فعّالًا حتى للتطبيقات الأكثر تعقيدًا. تتيح لك هذه الأدوات نقل مكتبة أو تطبيق تم إنشاؤهما سابقًا لمنصّة واحدة إلى الويب، ما يجعلهما متاحَين لعدد أكبر بكثير من المستخدمين على أجهزة الكمبيوتر المكتبي والأجهزة الجوّالة على حد سواء.