توسيع المتصفح باستخدام WebAssembly

تتيح لنا WebAssembly توسيع المتصفح بميزات جديدة. توضّح هذه المقالة كيفية نقل برنامج فك ترميز الفيديو AV1 وتشغيل فيديو AV1 في أي متصفّح حديث.

Alex Danilo

من أفضل الميزات المتعلقة بـ WebAssembly إجراء تجربة للقدرة باستخدام إمكانات جديدة وتنفيذ أفكار جديدة قبل شحن المتصفح لهذه الميزات بشكل طبيعي (إن كان ذلك منطبقًا). يمكنك التفكير في استخدام WebAssembly بهذه الطريقة كآلية polyfill عالية الأداء، حيث تكتب الميزة بلغة C/C++ أو Rust بدلاً من JavaScript.

مع توفر عدد كبير من الرموز الحالية للنقل، فمن الممكن القيام بأشياء في المتصفح لم تكن قابلة للتطبيق إلى أن تم إنشاء WebAssembly.

ستستعرض هذه المقالة مثالاً حول كيفية أخذ رمز المصدر الحالي لبرنامج ترميز الفيديو AV1، وإنشاء برنامج تضمين له، وتجربته داخل المتصفح، كما ستقدّم نصائح لمساعدتك في إنشاء مفعِّل اختبار لتصحيح أخطاء برنامج تضمين. يتوفّر رمز المصدر الكامل للمثال هنا على الرابط github.com/GoogleChromeLabs/wam-av1 كمرجع.

يمكنك تنزيل أحد هذين الملفَين التجريبيَين لفيديو فيديو 24 لقطة في الثانية واختبارهما في العرض التوضيحي الذي تم إنشاؤه.

اختيار قاعدة تعليمات برمجية مثيرة للاهتمام

على مدار سنوات عدّة، رأينا أنّ نسبة كبيرة من الزيارات على الويب تتألّف من بيانات فيديو، وتقدّرها شركة Cisco بنسبة تصل إلى 80% في الواقع. بالطبع، يدرك مورّدو المتصفحات ومواقع الفيديو الإلكترونية بشكل كبير الرغبة في تقليل البيانات التي يستهلكها كل محتوى الفيديو هذا. أفضل أسلوب لذلك هو الضغط بشكل أفضل. وكما تتوقع، يتم إجراء الكثير من الأبحاث حول الجيل التالي من أدوات ضغط الفيديو التي تهدف إلى تخفيف أعباء شحن الفيديوهات على الإنترنت.

في الواقع، يعمل Alliance for Open Media على تطوير مخطّط لضغط الفيديو من الجيل التالي يسمى AV1، ويعِد بتقليص حجم بيانات الفيديو إلى حد كبير. في المستقبل، نتوقع أن توفّر المتصفحات الدعم الأصلي لبرنامج AV1، ولكن لحسن الحظّ رمز المصدر الخاص بالضاغط وفك الضغط مفتوح المصدر، ما يجعله مرشحًا مثاليًا لمحاولة تجميعه في WebAssembly حتى نتمكن من تجربته في المتصفح.

صورة فيلم Bunny

التكيف للاستخدام في المتصفح

أول ما يجب علينا فعله لإدخال هذه الرمز في المتصفح هو التعرُّف على الرمز الحالي لفهم طبيعة واجهة برمجة التطبيقات. عند النظر إلى هذه التعليمة البرمجية لأول مرة، يبرز شيئان:

  1. تم إنشاء شجرة المصدر باستخدام أداة تُسمى cmake.
  2. هناك عدد من الأمثلة التي تفترض جميعها نوعًا ما من الواجهة القائمة على الملف.

يمكن تشغيل جميع الأمثلة التي يتم إنشاؤها تلقائيًا على سطر الأوامر، ومن المحتمل أن يكون ذلك صحيحًا في العديد من قواعد التعليمات البرمجية الأخرى المتاحة في المجتمع. لذا، قد تكون الواجهة التي سننشئها لتشغيلها في المتصفح مفيدة للعديد من أدوات سطر الأوامر الأخرى.

استخدام cmake لإنشاء رمز المصدر

لحسن الحظ، كان مؤلفو AV1 يختبرون حزمة Emscripten التي سنستخدمها لإنشاء إصدار WebAssembly. في جذر مستودع AV1، يحتوي الملف CMakeLists.txtعلى قواعد الإصدار التالية:

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 لأنه ينتج مخرجات أصغر ويمكن أن يعمل بشكل أسرع. تهدف قواعد الإصدار الحالية إلى تجميع نسخة 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 بدلاً من المحول البرمجي الافتراضي للمضيف. ويمكن تحقيق ذلك باستخدام السمة Emscripten.cmake التي تشكّل جزءًا من حزمة تطوير برامج Emscripten وتمرير مسارها كمَعلمة إلى 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 لهذا المشروع، يمكنك معرفة كيفية استخدام هذا النص البرمجي لإعداد الإصدار.

وبعد الانتهاء من كل عملية الإعداد، نسمي ببساطة السمة make التي ستنشئ شجرة المصدر بالكامل، بما في ذلك النماذج، والأهم من ذلك إنشاء libaom.a التي تحتوي على برنامج فك ترميز الفيديوهات الذي تم تجميعه ويكون جاهزًا لنا لدمجه في مشروعنا.

تصميم واجهة برمجة تطبيقات لواجهة المكتبة

بعد إنشاء مكتبتنا، نحتاج إلى إيجاد طريقة للتفاعل معها لإرسال بيانات الفيديو المضغوطة إليها، ثم قراءة إطارات الفيديو التي يمكننا عرضها في المتصفح.

من خلال إلقاء نظرة داخل شجرة رموز 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، ويعمل فقط على تغليف الواجهة. تجدر الإشارة إلى أنّ إنشاء واجهة برمجة تطبيقات تتّبع دلالات الملفات بدقة يسهّل إعادة استخدامها في العديد من قواعد التعليمات البرمجية الأخرى المُعَدّة للاستخدام من سطر الأوامر (على سبيل المثال، diff وsed وما إلى ذلك).

نحتاج أيضًا إلى تعريف دالة مساعِدة تُسمى DS_set_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 في المتصفح، من المنطقي إنشاء شكل من أشكال اختبار الوحدات لواجهة الرمز الذي نعمل عليه، حتى نتمكن من تصحيح الأخطاء خارج المتصفح وكذلك اختبار الواجهة التي أنشأناها.

في هذا المثال، نستخدم واجهة برمجة تطبيقات مستندة إلى البث كواجهة لمكتبة AV1. إذًا، من المنطقي إنشاء أداة اختبار يمكننا استخدامها لإنشاء إصدار من واجهة برمجة التطبيقات التي تعمل على سطر الأوامر وتنفّذ عمليات الإدخال والإخراج الفعلية للملف من خلال تنفيذ الملف I/O نفسه ضمن واجهة برمجة تطبيقات 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 لاستخدام فقاعات البيانات الثنائية عند التواجد في المتصفح، وواجهة للملفات الحقيقية عندما ننشئ الرمز للاختبار من سطر الأوامر. يمكن العثور على رمز مفعِّل الاختبار الخاص بنا في مثال الملف المصدر 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 صور نموذج أحمر أخضر أزرق، بنسبة بايت واحد لكل قناة لون. الناتج من برنامج فك ترميز 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. يمكن استخدام WebAssembly (كوحدة معالجة مركزية) مثل فك ترميز الفيديو المتقدِّم.

على الرغم من ذلك، هناك بعض القيود: يتم تنفيذ جميع المهام على سلسلة التعليمات الرئيسية، ونحن نتداخل الرسم وفك ترميز الفيديو على سلسلة التعليمات هذه. يمكن أن يوفر لنا تفريغ الترميز إلى أحد مشغّلي الويب إمكانية تشغيل أكثر سلاسة، حيث يعتمد الوقت اللازم لفك ترميز الإطارات بشكل كبير على محتوى هذا الإطار وقد يستغرق أحيانًا وقتًا أطول من الميزانية المحدّدة.

يستخدم التجميع في WebAssembly إعدادات AV1 لنوع عام من وحدة المعالجة المركزية. إذا عملنا على تجميع البيانات في سطر الأوامر في وحدة معالجة مركزية عامة، نرى أنّ الحِمل نفسه على وحدة المعالجة المركزية (CPU) لفك ترميز الفيديو كما في إصدار WebAssembly، إلا أنّ مكتبة فك ترميز AV1 تتضمّن أيضًا عمليات تنفيذ SIMD تعمل بسرعة أكبر بخمس مرات. تعمل مجموعة WebAssembly Community Group حاليًا على توسيع نطاق المعايير ليشمل أساسيات SIMD، وعندما تصدر عن ذلك وعدك بتسريع عملية فك الترميز إلى حد كبير. وعند حدوث ذلك، سيكون من الممكن تمامًا فك ترميز فيديو بدقة عالية 4K في الوقت الفعلي باستخدام برنامج فك ترميز الفيديوهات من WebAssembly.

على أي حال، يكون الرمز البرمجي المثالي مفيدًا كدليل للمساعدة في نقل أي أداة سطر أوامر حالية لتشغيلها كوحدة WebAssembly وتعرض ما هو ممكن على الويب حاليًا.

المساهمون

نشكر "جيف بوسنيك" و"إريك بيدلمان" و"توماس شتاينر" على تقديمهم مراجعات وملاحظات قيّمة.