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

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

Alex Danilo

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

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

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

מורידים אחד משני הקבצים האלה של בדיקת 24fps video הקבצים ומנסים אותם עם ההדגמה שלנו.

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

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

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

תמונת סרט 'ארנב'.

התאמה לשימוש בדפדפן

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

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

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

שימוש ב-cmake לבניית קוד המקור

למרבה המזל, מחברי AV1 ערכו ניסויים עם Emscripten, ה-SDK שבו אנחנו משתמשים כדי לפתח את גרסת WebAssembly. ברמה הבסיסית (root) של מאגר 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()

ה-toolchain של 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")

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

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 שמצורף ל-toolchain.

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

#!/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, שייבנה את עץ המקור כולו, כולל דוגמאות, אבל חשוב ביותר, ייצור 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).

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

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

בניית רשת בדיקה לבדיקה מחוץ לדפדפן

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

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

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

קוד הקלט/פלט של רשת הבדיקה שלנו הוא פשוט, ונראה כך:

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 f.p.s.) מלמד אותנו כמה דברים:

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

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

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

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

זיכויים

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