הרחבת הדפדפן באמצעות WebAssembly

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

Alex Danilo

אחד מהיתרונות הגדולים של WebAssembly הוא היכולת להתנסות ביכולות חדשות ולהטמיע רעיונות חדשים לפני שהדפדפן ישיק את התכונות האלה באופן מקורי (אם בכלל). אפשר להתייחס לשימוש ב-WebAssembly באופן הזה כאל מנגנון פוליפילי ביצועים גבוהים, שבו כותבים את התכונה ב-C/C++ או ב-Rust במקום ב-JavaScript.

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

במאמר הזה נסביר איך לבחור את קוד המקור הקיים של קודק הווידאו AV1, ליצור לו מעטפת ולנסות אותו בדפדפן. בנוסף, נספק טיפים שיעזרו לכם ליצור ערכת בדיקה לניפוי באגים במעטפת. קוד המקור המלא של הדוגמה כאן זמין בכתובת github.com/GoogleChromeLabs/wasm-av1.

אפשר להוריד אחד משני הקבצים של סרטון הבדיקה ב-24fps ולנסות אותם בהדגמה שלנו.

בחירת קוד בסיס מעניין

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

במקרה, Alliance for Open Media עבדה על תוכנית דחיסת וידאו מדור הבא שנקראת AV1, שמבטיחה לצמצם את גודל נתוני הווידאו באופן משמעותי. בעתיד, אנחנו מצפים שדפדפנים יספקו תמיכה מקורית ב-AV1, אבל למזלנו קוד המקור של המדחס ושל מפריד הקידוד הם קוד פתוח, כך שאפשר לנסות להכין מהם קובץ WebAssembly כדי שנוכל להתנסות בהם בדפדפן.

תמונה של הסרט Bunny.

הסתגלות לשימוש בדפדפן

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

  1. עץ המקור נוצר באמצעות כלי שנקרא cmake.
  2. יש כמה דוגמאות שכולן מבוססות על ממשק מסוים של קובץ.

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

שימוש ב-cmake כדי ליצור את קוד המקור

למרבה המזל, מחברי AV1 ערכו ניסויים עם Emscripten, ערכת ה-SDK שבה אנחנו מתכוונים להשתמש כדי ליצור את הגרסה שלנו ל-WebAssembly. בספריית הבסיס של מאגר AV1, הקובץ CMakeLists.txt מכיל את כללי ה-build הבאים:

if(EMSCRIPTEN)
add_preproc_definition(_POSIX_SOURCE)
append_link_flag_to_target("inspect" "-s TOTAL_MEMORY=402653184")
append_link_flag_to_target("inspect" "-s MODULARIZE=1")
append_link_flag_to_target("inspect"
                            "-s EXPORT_NAME=\"\'DecoderModule\'\"")
append_link_flag_to_target("inspect" "--memory-init-file 0")

if("${CMAKE_BUILD_TYPE}" STREQUAL "")
    # Default to -O3 when no build type is specified.
    append_compiler_flag("-O3")
endif()
em_link_post_js(inspect "${AOM_ROOT}/tools/inspect-post.js")
endif()

כלי השרשרת של Emscripten יכולים ליצור פלט בשני פורמטים, אחד שנקרא asm.js והשני WebAssembly. אנחנו נתמקד ב-WebAssembly כי הוא יוצר פלט קטן יותר ויכול לפעול מהר יותר. כללי ה-build הקיימים האלה נועדו לקמפל גרסה asm.js של הספרייה לשימוש באפליקציית הבדיקה שמשמשת לבדיקה של תוכן קובץ וידאו. לצורך השימוש שלנו, אנחנו צריכים פלט של WebAssembly, ולכן מוסיפים את השורות האלה לפני משפט ה-endif() הסגור בכללים שלמעלה.

# Force generation of Wasm instead of asm.js
append_link_flag_to_target("inspect" "-s WASM=1")
append_compiler_flag("-s WASM=1")

כדי ליצור build באמצעות cmake, קודם צריך ליצור קובצי Makefiles מסוימים על ידי הפעלת cmake עצמו, ולאחר מכן להריץ את הפקודה make שתבצע את שלב הידור הקוד. הערה: מכיוון שאנחנו משתמשים ב-Emscripten, אנחנו צריכים להשתמש בכלי הפיתוח של המהדר של Emscripten במקום במהדר המארח שמוגדר כברירת מחדל. כדי לעשות זאת, משתמשים ב-Emscripten.cmake שהוא חלק מ-Emscripten SDK ומעבירים את הנתיב שלו כפרמטר ל-cmake עצמו. שורת הפקודה הבאה משמשת אותנו ליצירת קובצי ה-Makefile:

cmake path/to/aom \
  -DENABLE_CCACHE=1 -DAOM_TARGET_CPU=generic -DENABLE_DOCS=0 \
  -DCONFIG_ACCOUNTING=1 -DCONFIG_INSPECTION=1 -DCONFIG_MULTITHREAD=0 \
  -DCONFIG_RUNTIME_CPU_DETECT=0 -DCONFIG_UNIT_TESTS=0
  -DCONFIG_WEBM_IO=0 \
  -DCMAKE_TOOLCHAIN_FILE=path/to/emsdk-portable/.../Emscripten.cmake

צריך להגדיר את הפרמטר path/to/aom לנתיב המלא של המיקום של קובצי המקור של ספריית AV1. צריך להגדיר את הפרמטר path/to/emsdk-portable/…/Emscripten.cmake לנתיב של קובץ התיאור של כלי הפיתוח Emscripten.cmake.

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

#!/bin/sh
EMCC_LOC=`which emcc`
EMSDK_LOC=`echo $EMCC_LOC | sed 's?/emscripten/[0-9.]*/emcc??'`
EMCMAKE_LOC=`find $EMSDK_LOC -name Emscripten.cmake -print`
echo $EMCMAKE_LOC

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

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

תכנון ממשק API לספרייה

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

כשבודקים את עץ הקוד של AV1, נקודת התחלה טובה היא מפענח וידאו לדוגמה שנמצא בקובץ [simple_decoder.c](https://aomedia.googlesource.com/aom/+/master/examples/simple_decoder.c). המפענח קורא קובץ IVF ומפענח אותו לסדרה של תמונות שמייצגות את הפריימים בסרטון.

אנחנו מטמיעים את הממשק בקובץ המקור [decode-av1.c](https://github.com/GoogleChromeLabs/wasm-av1/blob/master/decode-av1.c).

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

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

אנחנו מגדירים את הממשק שלנו כך:

DATA_Source *DS_open(const char *what);
size_t      DS_read(DATA_Source *ds,
                    unsigned char *buf, size_t bytes);
int         DS_empty(DATA_Source *ds);
void        DS_close(DATA_Source *ds);
// Helper function for blob support
void        DS_set_blob(DATA_Source *ds, void *buf, size_t len);

הפונקציות open/read/empty/close דומות מאוד לפעולות רגילות של קלט/פלט של קבצים, מה שמאפשר לנו למפות אותן בקלות לקלט/פלט של קבצים באפליקציה של שורת הפקודה, או להטמיע אותן בדרך אחרת כשהן פועלות בדפדפן. הסוג DATA_Source הוא אטום מצד JavaScript, והוא משמש רק כדי להכיל את הממשק. הערה: כשמפתחים ממשק API בהתאם לסמנטיקה של קבצים, קל לעשות שימוש חוזר במסדי קוד רבים אחרים שמיועדים לשימוש משורת הפקודה (למשל diff, sed וכו').

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

ההטמעה לדוגמה מאפשרת לקרוא את ה-blob שהוענק כאילו הוא מקור נתונים לקריאה רציפה. קוד העזר נמצא בקובץ blob-api.c, וההטמעה כולה היא רק זה:

struct DATA_Source {
    void        *ds_Buf;
    size_t      ds_Len;
    size_t      ds_Pos;
};

DATA_Source *
DS_open(const char *what) {
    DATA_Source     *ds;

    ds = malloc(sizeof *ds);
    if (ds != NULL) {
        memset(ds, 0, sizeof *ds);
    }
    return ds;
}

size_t
DS_read(DATA_Source *ds, unsigned char *buf, size_t bytes) {
    if (DS_empty(ds) || buf == NULL) {
        return 0;
    }
    if (bytes > (ds->ds_Len - ds->ds_Pos)) {
        bytes = ds->ds_Len - ds->ds_Pos;
    }
    memcpy(buf, &ds->ds_Buf[ds->ds_Pos], bytes);
    ds->ds_Pos += bytes;

    return bytes;
}

int
DS_empty(DATA_Source *ds) {
    return ds->ds_Pos >= ds->ds_Len;
}

void
DS_close(DATA_Source *ds) {
    free(ds);
}

void
DS_set_blob(DATA_Source *ds, void *buf, size_t len) {
    ds->ds_Buf = buf;
    ds->ds_Len = len;
    ds->ds_Pos = 0;
}

פיתוח ערכת בדיקה לבדיקה מחוץ לדפדפן

אחת השיטות המומלצות בתכנות היא ליצור בדיקות יחידה לקוד בשילוב עם בדיקות שילוב.

כשמפתחים באמצעות WebAssembly בדפדפן, כדאי ליצור שיטה כלשהי של בדיקת יחידה (unit testing) של הממשק עם הקוד שאיתו אנחנו עובדים, כדי שנוכל לנפות באגים מחוץ לדפדפן ולבדוק את הממשק שבנינו.

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

קוד הקלט/פלט של הסטרימינג (I/O) של מסגרת הבדיקה שלנו הוא פשוט, ונראה כך:

DATA_Source *
DS_open(const char *what) {
    return (DATA_Source *)fopen(what, "rb");
}

size_t
DS_read(DATA_Source *ds, unsigned char *buf, size_t bytes) {
    return fread(buf, 1, bytes, (FILE *)ds);
}

int
DS_empty(DATA_Source *ds) {
    return feof((FILE *)ds);
}

void
DS_close(DATA_Source *ds) {
    fclose((FILE *)ds);
}

על ידי הפשטה של ממשק הסטרימינג, אנחנו יכולים ליצור את מודול WebAssembly כך שישתמש ב-blobs של נתונים בינאריים בדפדפן, וישתמש בממשק לקובצי נתונים אמיתיים כשאנחנו יוצרים את הקוד לבדיקה משורת הפקודה. הקוד של ערכת הבדיקה שלנו נמצא בקובץ המקור לדוגמה test.c.

הטמעה של מנגנון אגירת נתונים לכמה פריימים של וידאו

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

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

הקוד ב-decode-av1.c לקריאת פריימים של נתוני וידאו מספריית AV1 ואחסון במאגר הנתונים הזמני, באופן הבא:

void
AVX_Decoder_run(AVX_Decoder *ad) {
    ...
    // Try to decode an image from the compressed stream, and buffer
    while (ad->ad_NumBuffered < NUM_FRAMES_BUFFERED) {
        ad->ad_Image = aom_codec_get_frame(&ad->ad_Codec,
                                           &ad->ad_Iterator);
        if (ad->ad_Image == NULL) {
            break;
        }
        else {
            buffer_frame(ad);
        }
    }


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

הצגת הפריימים של הסרטון בדף באמצעות WebGL

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

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

עם זאת, יש בעיה קטנה. טקסטורות של WebGL אמורות להיות תמונות RGB, עם בית אחד לכל ערוץ צבע. הפלט ממקודד ה-AV1 שלנו הוא תמונות בפורמט שנקרא YUV, שבו הפלט שמוגדר כברירת מחדל מכיל 16 ביט לכל ערוץ, וגם כל ערך U או V תואם ל-4 פיקסלים בתמונה בפועל של הפלט. כלומר, אנחנו צריכים לבצע המרה של צבעי התמונה לפני שאנחנו יכולים להעביר אותה ל-WebGL להצגה.

כדי לעשות זאת, אנחנו מטמיעים את הפונקציה AVX_YUV_to_RGB(), שאפשר למצוא בקובץ המקור yuv-to-rgb.c. הפונקציה הזו ממירה את הפלט ממקודד ה-AV1 למשהו שאפשר להעביר ל-WebGL. חשוב לזכור שכאשר קוראים לפונקציה הזו מ-JavaScript, צריך לוודא שהזיכרון שאליו אנחנו כותבים את התמונה המומרת הוקצה בתוך הזיכרון של מודול WebAssembly – אחרת לא תהיה לו גישה אליו. הפונקציה לשליפת תמונה ממודול WebAssembly והצגה שלה למסך היא:

function show_frame(af) {
    if (rgb_image != 0) {
        // Convert The 16-bit YUV to 8-bit RGB
        let buf = Module._AVX_Video_Frame_get_buffer(af);
        Module._AVX_YUV_to_RGB(rgb_image, buf, WIDTH, HEIGHT);
        // Paint the image onto the canvas
        drawImageToCanvas(new Uint8Array(Module.HEAPU8.buffer,
                rgb_image, 3 * WIDTH * HEIGHT), WIDTH, HEIGHT);
    }
}

הפונקציה drawImageToCanvas() שמטמיעה את הציור ב-WebGL מופיעה בקובץ המקור draw-image.js.

עבודות עתידיות ותובנות

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

  1. אפשר בהחלט ליצור בסיס קוד מורכב שיפעל בצורה יעילה בדפדפן באמצעות WebAssembly.
  2. אפשר לבצע פעולות שמתבצעות במעבד (CPU) באופן אינטנסיבי, כמו פענוח וידאו מתקדם, באמצעות WebAssembly.

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

בהידור ל-WebAssembly נעשה שימוש בהגדרות ה-AV1 לסוג מעבד (CPU) גנרי. אם אנחנו מבצעים הידור מקורי משורת הפקודה עבור מעבד (CPU) גנרי רואים עומס מעבד (CPU) דומה לעומס על המעבד (CPU) שבו נעשה שימוש בגרסת WebAssembly, אבל ספריית המפענח AV1 כוללת גם הטמעות SIMD שעובדות עד פי 5 מהר יותר. קבוצת הקהילה של WebAssembly עובדת כרגע על הרחבת התקן כך שיכלול פרימיטיבים של SIMD, וכשהדבר יקרה, הוא צפוי לזרז את פענוח הנתונים באופן משמעותי. כשזה יקרה, יהיה אפשר לפענח סרטון HD באיכות 4K בזמן אמת ממקודד וידאו של WebAssembly.

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

זיכויים

תודה ל-Jeff Posnick, ל-Eric Bidelman ול-Thomas Steiner על הביקורת והמשוב החשובים.