Расширение браузера с помощью WebAssembly

WebAssembly позволяет расширить браузер новыми функциями. В этой статье показано, как портировать видеодекодер AV1 и воспроизводить видео AV1 в любом современном браузере.

Alex Danilo

Одна из лучших особенностей WebAssembly — это возможность экспериментировать с новыми возможностями и реализовывать новые идеи до того, как браузер отправит эти функции в исходное состояние (если вообще будет). Вы можете думать об использовании WebAssembly как о высокопроизводительном механизме полифилла, в котором вы пишете свою функцию на C/C++ или Rust, а не на JavaScript.

Благодаря множеству существующего кода, доступного для портирования, в браузере можно делать вещи, которые были невозможны до появления WebAssembly.

В этой статье будет рассмотрен пример того, как взять существующий исходный код видеокодека AV1, создать для него оболочку и опробовать ее в браузере, а также даны советы, которые помогут создать тестовую программу для отладки оболочки. Полный исходный код приведенного здесь примера доступен для справки по адресу github.com/GoogleChromeLabs/wasm-av1 .

Загрузите один из этих двух тестовых видеофайлов со скоростью 24 кадра в секунду и опробуйте их на нашей встроенной демо-версии .

Выбор интересной кодовой базы

Вот уже несколько лет мы видим, что большой процент веб-трафика состоит из видеоданных. По оценкам Cisco, на самом деле этот показатель составляет целых 80 %! Конечно, производители браузеров и видеосайты прекрасно осознают желание сократить объем данных, потребляемых всем этим видеоконтентом. Ключом к этому, конечно же, является лучшее сжатие, и, как и следовало ожидать, проводится множество исследований в области сжатия видео следующего поколения, направленных на снижение нагрузки на данные при доставке видео через Интернет.

Так получилось, что Альянс открытых медиа работает над схемой сжатия видео следующего поколения под названием AV1 , которая обещает значительно сократить размер видеоданных. В будущем мы ожидаем, что браузеры предоставят встроенную поддержку AV1, но, к счастью, исходный код компрессора и декомпрессора открыт , что делает его идеальным кандидатом для попытки скомпилировать его в WebAssembly, чтобы мы могли поэкспериментировать с ним в браузер.

Изображение из фильма «Зайчик».

Адаптация для использования в браузере

Первое, что нам нужно сделать, чтобы разместить этот код в браузере, — это познакомиться с существующим кодом, чтобы понять, что представляет собой API. При первом взгляде на этот код можно выделить две вещи:

  1. Дерево исходного кода создается с помощью инструмента cmake ; и
  2. Существует ряд примеров, каждый из которых предполагает наличие некоторого файлового интерфейса.

Все примеры, которые собираются по умолчанию, можно запускать из командной строки, и это, вероятно, верно для многих других баз кода, доступных в сообществе. Итак, интерфейс, который мы собираемся создать для запуска его в браузере, может быть полезен для многих других инструментов командной строки.

Использование cmake для сборки исходного кода

К счастью, авторы AV1 экспериментировали с Emscripten , SDK, который мы собираемся использовать для создания нашей версии 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 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 верхнего уровня для этого проекта, вы увидите, как этот сценарий используется для настройки сборки.

Теперь, когда все настройки выполнены, мы просто вызываем 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) .

Поскольку наш браузер не может читать файлы из файловой системы, нам необходимо разработать некую форму интерфейса, который позволит нам абстрагировать ввод-вывод, чтобы мы могли создать что-то похожее на пример декодера для получения данных в нашу библиотеку 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-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, который будет использовать двоичные данные в браузере и взаимодействовать с реальными файлами, когда мы создаем код для тестирования из командной строки. Код нашей тестовой программы можно найти в примере исходного файла 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. Что-то столь же ресурсоемкое, как расширенное декодирование видео, возможно с помощью WebAssembly.

Однако есть некоторые ограничения: вся реализация выполняется в основном потоке, и мы чередуем рисование и декодирование видео в этом единственном потоке. Выгрузка декодирования в веб-воркер может обеспечить более плавное воспроизведение, поскольку время декодирования кадров сильно зависит от содержимого этого кадра и иногда может занять больше времени, чем мы запланировали.

При компиляции в WebAssembly используется конфигурация AV1 для общего типа ЦП. Если мы компилируем в командной строке для обычного ЦП, мы видим аналогичную загрузку ЦП для декодирования видео, как и в версии WebAssembly, однако библиотека декодера AV1 также включает реализации SIMD , которые работают до 5 раз быстрее. Группа сообщества WebAssembly в настоящее время работает над расширением стандарта, включив в него примитивы SIMD , и когда это произойдет, обещают значительно ускорить декодирование. Когда это произойдет, станет вполне возможным декодировать HD-видео 4K в режиме реального времени с помощью видеодекодера WebAssembly.

В любом случае пример кода полезен в качестве руководства, помогающего портировать любую существующую утилиту командной строки для запуска в качестве модуля WebAssembly и показывает, что возможно в Интернете уже сегодня.

Кредиты

Благодарим Джеффа Посника, Эрика Бидельмана и Томаса Штайнера за ценные обзоры и отзывы.