ניוד אפליקציות USB לאינטרנט. חלק 2: gPhoto2

איך העברנו את gPhoto2 ל-WebAssembly כדי לשלוט במצלמות חיצוניות דרך USB מאפליקציית אינטרנט.

בפוסט הקודם הראיתי איך העברתי את ספריית libusb כך שתוכל לפעול באינטרנט באמצעות WebAssembly‏ / Emscripten,‏ Asyncify ו-WebUSB.

הראיתי גם דמו שנוצר באמצעות gPhoto2, שמאפשר לשלוט במצלמות DSLR ובמצלמות ללא מראה רפלקטיבית באמצעות USB מאפליקציית אינטרנט. בפוסט הזה אכנס לעומק לפרטים הטכניים שמאחורי היציאה של gPhoto2.

הפניית מערכות build להסתעפויות בהתאמה אישית

מכיוון שהטירגוט שלי היה ל-WebAssembly, לא יכולתי להשתמש ב-libusb וב-libgphoto2 שסופקו על ידי הפצות המערכת. במקום זאת, הייתי צריך להשתמש בגרסת ה-fork המותאמת אישית שלי של libgphoto2 באפליקציה, בעוד שגרסת ה-fork של libgphoto2 הזו הייתה צריכה להשתמש בגרסת ה-fork המותאמת אישית שלי של libusb.

בנוסף, ספריית libgphoto2 משתמשת ב-libtool לטעינה של יישומי פלאגין דינמיים, ואף על פי שלא הייתי צריך ליצור גרסת פורק (fork) של libtool כמו בשתי הספריות האחרות, עדיין הייתי צריך ליצור אותה ל-WebAssembly ולהפנות את libgphoto2 ל-build בהתאמה אישית במקום לחבילת המערכת.

זוהי דיאגרמת תלות משוערת (קווים מקווקווים מציינים קישור דינמי):

בתרשים מוצגת 'האפליקציה' שתלויה ב-'libgphoto2 fork', שתלויה ב-'libtool'. הבלוק 'libtool' תלוי באופן דינמי ב-'libgphoto2 ports' וב-'libgphoto2 camlibs'. לבסוף, 'libgphoto2 ports' תלויה באופן סטטי ב-'libusb fork'.

רוב מערכות ה-build שמבוססות על הגדרות מאפשרות לשנות את הנתיבים של יחסי התלות באמצעות דגלים שונים, כולל אלה שמשמשות בספריות האלה. לכן, זה מה שניסיתי לעשות קודם. עם זאת, כשתרשים יחסי התלות הופך למורכב, רשימת ההחרגות של הנתיבים לכל יחסי התלות של הספרייה הופכת למפורטת ומועדת לשגיאות. מצאתי גם כמה באגים שבהם מערכות ה-build לא היו מוכנות לכך שיחסי התלות שלהן יהיו בנתיבים לא סטנדרטיים.

במקום זאת, הגישה הקלה יותר היא ליצור תיקייה נפרדת כשורש מערכת בהתאמה אישית (הקיצור הנפוץ הוא 'sysroot') ולהפנות אליה את כל מערכות ה-build הרלוונטיות. כך, כל ספרייה תחפש את יחסי התלות שלה ב-sysroot שצוין במהלך ה-build, וגם תתקין את עצמה באותו sysroot כדי שאחרים יוכלו למצוא אותה בקלות רבה יותר.

ל-Emscripten כבר יש sysroot משלו ב-(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 יש הטמעה בהתאמה אישית שיכולה לדמות את ה-API של dlopen() ש-libtool משתמשת בו, אבל כדי לעשות זאת צריך ליצור מודולים 'ראשיים' ו'צדדיים' עם דגלים שונים, ובמיוחד עבור dlopen(), צריך גם לטעון מראש את המודולים הצדדיים למערכת הקבצים המשוכפלת במהלך הפעלת האפליקציה. יכול להיות שיהיה קשה לשלב את הדגלים והשינויים האלה במערכת build קיימת של autoconf עם הרבה ספריות דינמיות.
  • גם אם ה-dlopen() עצמו מוטמע, אין דרך להכיל את כל הספריות הדינמיות בתיקייה מסוימת באינטרנט, כי רוב שרתי ה-HTTP לא חושפים את הרשימות של הספריות מטעמי אבטחה.
  • קישור ספריות דינמיות בשורת הפקודה במקום ספירה בזמן הריצה עלול גם להוביל לבעיות, כמו הבעיה של סמלים כפולים, שנגרמת כתוצאה מהבדלים בין הייצוג של ספריות משותפות ב-Emscripten לבין הייצוג שלהן בפלטפורמות אחרות.

אפשר להתאים את מערכת ה-build להבדלים האלה ולהטמיע בקוד הקשה את רשימת הפלאגינים הדינמיים במקום כלשהו במהלך ה-build, אבל דרך קלה יותר לפתור את כל הבעיות האלה היא להימנע מלקישור דינמי מלכתחילה.

מסתבר ש-libtool מספק שיטות קישור דינמיות שונות בפלטפורמות שונות, ואפילו תומך בכתיבת מערכי טעינה מותאמים אישית עבור אחרים. אחד מהמטענים המובנים שהוא תומך בהם נקרא "Dlpreopening":

"Libtool מספקת תמיכה מיוחדת ל-dlopening של אובייקטים של libtool וקבצים של ספריות libtool, כך שניתן יהיה לפתור את הסמלים שלהם גם בפלטפורמות ללא פונקציות dlopen ו-dlsym.

Libtool מעתיק את -dlopen בפלטפורמות סטטיות על ידי קישור אובייקטים לתוכנית בזמן הידור, ויצירת מבני נתונים שמייצגים את טבלת הסמלים של התוכנית. כדי להשתמש בתכונה הזו, צריך להצהיר על האובייקטים שרוצים שהאפליקציה תפעיל באמצעות הדגלים -dlopen או -dlpreopen כשמקשרים את התוכנית (ראו מצב קישור).

המנגנון הזה מאפשר לחקות טעינת קוד דינמי ברמת libtool במקום Emscripten, תוך קישור של כל הקוד באופן סטטי לספרייה אחת.

הבעיה היחידה שלא נפתרת היא ספירה של ספריות דינמיות. עדיין צריך להטמיע את הרשימה הזו בקוד במקום כלשהו. למזלי, קבוצת הפלאגינים שנדרשו לי לאפליקציה הייתה מינימלית:

  • בצד השקעים, חשוב לי רק חיבור המצלמה שמבוסס על libusb, ולא מצבי PTP/IP, גישה טורית או כונן USB.
  • בצד ה-camlibs, יש פלאגינים שונים ספציפיים ליצרנים שעשויים לספק פונקציות מיוחדות, אבל לצורך שליטה בהגדרות כלליות וצילום, מספיק להשתמש ב-Picture Transfer Protocol, שמיוצג על ידי ה-camlib ptp2 ונתמך כמעט בכל מצלמה בשוק.

כך נראה תרשים התלות המעודכן, שבו כל הרכיבים מקושרים באופן סטטי:

בתרשים מוצגת 'האפליקציה' שתלויה ב-'libgphoto2 fork', שתלויה ב-'libtool'. 'libtool' תלוי ב-'ports: libusb1' וב-'camlibs: libptp2'. 'ports: libusb1' תלוי ב-'libusb fork'.

אז זה מה שהטמעתי בקוד ל-builds של 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 ();

במערכת ה-build של 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 מאפשר לספריות של מצלמות להגדיר הגדרות משלהם בצורת עץ ווידג'טים. ההיררכיה של סוגי הווידג'טים מורכבת מ:

  • Window (חלון) – מאגר תצורה ברמה העליונה
    • קטעים – קבוצות בעלות שם של ווידג'טים אחרים
    • שדות לחצן
    • שדות טקסט
    • שדות עם נתונים מספריים
    • שדות תאריך
    • מתגים
    • לחצני בחירה

אפשר לשלוח שאילתות לגבי השם, הסוג, הצאצאים וכל המאפיינים הרלוונטיים האחרים של כל ווידג'ט (ובמקרה של ערכים, גם לשנות אותם) באמצעות חשיפת ה-API ל-C. יחד, הם מספקים בסיס ליצירה אוטומטית של ממשק משתמש של הגדרות בכל שפה שיכולה לקיים אינטראקציה עם C.

אפשר לשנות את ההגדרות דרך gPhoto2 או במצלמה עצמה בכל שלב. בנוסף, חלק מהווידג'טים יכולים להיות לקריאה בלבד, ואפילו המצב 'לקריאה בלבד' עצמו תלוי במצב המצלמה ובהגדרות אחרות. לדוגמה, מהירות התריס היא שדה מספרי שניתן לכתיבה ב-M (מצב ידני), אבל הופך לשדה מידע לקריאה בלבד ב-P (מצב תוכנית). במצב P, גם ערך מהירות הצמצם יהיה דינמי וישתנה באופן קבוע בהתאם לבהירות הסצנה שהמצלמה מצלמת.

בסיכום, חשוב תמיד להציג בממשק המשתמש מידע עדכני מהמצלמה המקושרת, ובמקביל לאפשר למשתמש לערוך את ההגדרות האלה מאותו ממשק משתמש. קשה יותר לטפל בזרימת נתונים דו-כיוונית כזו.

ל-gPhoto2 אין מנגנון לאחזור רק הגדרות שהשתנו, אלא רק את כל העץ או ווידג'טים ספציפיים. כדי לשמור על עדכניות של ממשק המשתמש בלי הבהוב או אובדן של מוקד הקלט או מיקום הגלילה, נדרשה לי דרך להשוות בין עצי הווידג'טים בין ההפעלות ולעדכן רק את מאפייני ממשק המשתמש שהשתנו. למרבה המזל, זו בעיה שהפתרון שלה ידוע באינטרנט, והיא הפונקציונליות העיקרית של מסגרות כמו React או Preact. בחרתי ב-Preact לפרויקט הזה כי הוא קל יותר בהרבה ועושה את כל מה שאני צריך.

בצד C++‏, עכשיו הייתי צריך לאחזר את עץ ההגדרות ולעבור עליו באופן רפלוקטיבי דרך ממשק ה-API ל-C שציינתי קודם, ולהמיר כל ווידג'ט לאובייקט 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}));
  }
}

כך תמיד יהיה בעלים אחד בלבד לכל שדה נתון. יכול להיות שהמשתמש עורכים אותו כרגע, והערכים המעודכנים מהמצלמה לא יפריעו לו, או שהמצלמה מעדכנת את ערך השדה בזמן שהוא לא ממוקד.

בניית פיד 'וידאו' בשידור חי

במהלך המגפה, הרבה אנשים עברו לפגישות אונליין. בין היתר, המצב הזה הוביל למחסור בשוק מצלמות האינטרנט. כדי לקבל איכות וידאו טובה יותר בהשוואה למצלמות המובנות במחשבים ניידים, ובתגובה למחסור הזה, הרבה בעלי מצלמות DSLR ומצלמות ללא מראה התחילו לחפש דרכים להשתמש במצלמות הצילום שלהם כמצלמות אינטרנט. כמה ספקי מצלמות אפילו שלחו כלי עזר רשמיים למטרה הזו.

בדומה לכלים הרשמיים, גם ב-gPhoto2 יש תמיכה בסטרימינג של וידאו מהמצלמה לקובץ שנשמר באופן מקומי או ישירות למצלמת אינטרנט וירטואלית. רציתי להשתמש בתכונה הזו כדי להציג תצוגה בזמן אמת בהדגמה שלי. עם זאת, הוא זמין בכלי המסוף, אבל לא הצלחתי למצוא אותו בשום מקום בממשקי ה-API של ספריית libgphoto2.

בדקתי את קוד המקור של הפונקציה המתאימה בכלי של המסוף, וגיליתי שהיא לא מקבלת בכלל סרטון, אלא ממשיכה לאחזר את התצוגה המקדימה של המצלמה כתמונות JPEG נפרדות בלולאה אינסופית, ומפיקה אותן אחת אחרי השנייה כדי ליצור שידור M-JPEG:

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

נדהמתי לגלות שהגישה הזו יעילה מספיק כדי ליצור רושם של סרטון חלק בזמן אמת. הייתי ספקן עוד יותר לגבי היכולת להשיג את אותם ביצועים גם באפליקציית האינטרנט, עם כל ההפשטות הנוספות וה-Asyncify בדרך. עם זאת, החלטתי לנסות בכל זאת.

בצד C++‏, חשפתי method שנקרא capturePreviewAsBlob() שמפעיל את אותה פונקציה gp_camera_capture_preview(), וממיר את הקובץ שנוצר בזיכרון ל-Blob שקל יותר להעביר לממשקי API אחרים של אינטרנט:

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

השימוש בממשקי ה-API המודרניים האלה מבטיח שכל עבודת הפענוח מתבצעת ברקע, והלוח מתעדכן רק כשגם התמונה וגם הדפדפן מוכנים לציור. כך הצלחתי להגיע ל-30FPS ויותר באופן עקבי במחשב הנייד שלי, תוצאה שתאמה לביצועים המקוריים של 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;
}

על ידי קישור כל פעולה בקריאה חוזרת (callback) מסוג then() של ההבטחה הקיימת queue, ושמירת התוצאה המקושרת כערך החדש של queue, אוכל לוודא שכל הפעולות מתבצעות אחת אחרי השנייה, בסדר ובלי חפיפה.

שגיאות פעולה מוחזרות למבצע הקריאה, בעוד ששגיאות קריטיות (לא צפויות) מסמנות את כל השרשרת כהתחייבות שנדחתה, ומוודאות שלא תתוזמן פעולה חדשה לאחר מכן.

שמירת ההקשר של המודול במשתנה פרטי (לא מיוצא) מפחיתה את הסיכונים לגישה ל-context בטעות במקום אחר באפליקציה, בלי לעבור דרך הקריאה ל-schedule().

כדי לסכם, עכשיו כל גישה להקשר של המכשיר צריכה להיות עטופה בקריאה ל-schedule() באופן הבא:

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

וגם

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

לאחר מכן, כל הפעולות בוצעו בהצלחה ללא התנגשויות.

סיכום

מומלץ לעיין בcodebase ב-GitHub כדי לקבל תובנות נוספות לגבי ההטמעה. אני רוצה גם להודות ל-Marcus Meissner על התחזוקה של gPhoto2 ועל הבדיקות שלו של בקשות העריכה שלי ב-upstream.

כפי שמוצג בפוסט הזה, ממשקי ה-API של WebAssembly, ‏ Asyncify ו-Fugu מספקים יעד הידור מתאים גם לאפליקציות המורכבות ביותר. הם מאפשרים להעביר ספרייה או אפליקציה שנוצרו בעבר לפלטפורמה אחת, ולהעביר אותן לאינטרנט, כך שהן יהיו זמינות למספר גדול בהרבה של משתמשים במחשבים ובמכשירים ניידים.