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

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

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

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

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

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

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

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

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

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

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

lt_dlinit ();
lt_dladdsearchdir (iolibs);
result = lt_dlforeachfile (iolibs, foreach_func, list);
lt_dlexit ();

יש כמה בעיות עם הגישה הזו באינטרנט:

  • אין תמיכה סטנדרטית בקישור דינמי של מודולים של WebAssembly. ל-Emscripten יש הטמעה מותאמת אישית שיכולה לדמות את ממשק ה-API של dlopen() שמשמש את libtool, אבל נדרשת פיתוח מודולים 'ראשיים' ו'צדדיים' עם דגלים שונים, ובמיוחד ב-dlopen(), גם לטעון מראש את המודולים הצדדיים למערכת הקבצים המודמה במהלך ההפעלה של האפליקציה. יכול להיות שתתקשו לשלב את הסימונים והשינויים האלה במערכת build קיימת של קונפיגור אוטומטי שיש בה הרבה ספריות דינמיות.
  • גם אם ה-dlopen() עצמו מיושם, אין דרך לספור את כל הספריות הדינמיות בתיקייה מסוימת באינטרנט, כי רוב שרתי ה-HTTP לא חושפים רישומים של ספריות מטעמי אבטחה.
  • גם קישור ספריות דינמיות בשורת הפקודה במקום ספירה בזמן ריצה עלול להוביל לבעיות, כמו הבעיה של סמלים כפולים, שנגרמות עקב הבדלים בין הייצוג של ספריות משותפות ב-Emscripten ובפלטפורמות אחרות.

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

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

“Libtool מספקת תמיכה מיוחדת עבור פתיחה של קובצי אובייקט 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 fork'.

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

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

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

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

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

יצירת פיד 'וידאו' פעיל

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