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

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

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

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

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

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

בנוסף, libgphoto2 משתמש ב-libtool לטעינת יישומי פלאגין דינמיים, ולמרות שלא נאלצתי לחבר את 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, אבל כדי להשתמש בו צריך ליצור 'main' ו'בצד' מודולים עם דגלים שונים, ובמיוחד ב-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, שמיוצג על ידי ה-ptp2 Camlib ונתמך כמעט על ידי כל מצלמה בשוק.

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

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

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

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

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

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

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

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

בצד C++ היה צורך עכשיו לאחזר וללכת באופן רקורסיבי את עץ ההגדרות דרך ממשק ה-API של C+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);
  // …

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

בצד C++ נחשפתי שיטה בשם 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 המודרניים מבטיח שכל עבודת הפענוח תתבצע ברקע, והאזור יעודכן רק כאשר גם התמונה וגם הדפדפן מוכנים לשרטוט. כך קיבלתי במחשב הנייד שלי מהירות יציבה של יותר מ-30 FPS, שתאמה לביצועים של הגרסה המקורית של 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 כדי לקבל תובנות נוספות לגבי ההטמעה. אני גם רוצה להודות למרקוס מייסנר על התחזוקה של gPhoto2 ועל הביקורות שלו על יחסי הציבור שלי ב-upstream.

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