הרחבת הדפדפן באמצעות 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 שלנו.

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

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

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

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

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

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

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

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

זיכויים

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