ניוד אפליקציות 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 בפלטפורמות סטטיות. לשם כך, המערכת מקשרת אובייקטים לתוכנה בזמן הידור (compile) ויוצרת מבני נתונים שמייצגים את טבלת הסמלים של התוכנה. כדי להשתמש בתכונה הזו, צריך להצהיר על האובייקטים שרוצים שהאפליקציה תפעיל באמצעות הדגלים -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 אין מנגנון לאחזור רק הגדרות שהשתנו, אלא רק את כל העץ או ווידג'טים ספציפיים. כדי לשמור על ממשק המשתמש מעודכן בלי הבהובים ואובדן מיקוד הקלט או מיקום הגלילה, ביקשתי דרך להבחין בין עצי הווידג'ט בין ההפעלות ולעדכן רק את מאפייני ממשק המשתמש שהשתנו. למרבה המזל, זו בעיה באינטרנט שכבר נפתרה, וזו הפונקציונליות העיקרית של frameworks כמו 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 הם יעד הידור היטב גם באפליקציות המורכבות ביותר. הם מאפשרים להעביר ספרייה או אפליקציה שנוצרו בעבר לפלטפורמה אחת, ולהעביר אותן לאינטרנט, כך שהן יהיו זמינות למספר גדול בהרבה של משתמשים במחשבים ובמכשירים ניידים.