Mở rộng trình duyệt bằng WebAssembly

WebAssembly cho phép chúng tôi mở rộng trình duyệt với các tính năng mới. Bài viết này cho biết cách chuyển bộ giải mã video AV1 và phát video AV1 trong bất kỳ trình duyệt hiện đại nào.

Alex Danilo

Một trong những điều tuyệt vời nhất về WebAssembly là thử nghiệm khả năng với các tính năng mới và triển khai các ý tưởng mới trước khi trình duyệt phân phối các tính năng đó một cách tự nhiên (nếu có). Bạn có thể coi việc sử dụng WebAssembly theo cách này là một cơ chế polyfill hiệu suất cao, trong đó bạn sẽ viết tính năng của mình bằng C/C++ hoặc Rust thay vì JavaScript.

Với rất nhiều mã hiện có để chuyển, bạn có thể làm những việc không hoạt động được trên trình duyệt cho đến khi WebAssembly ra mắt.

Bài viết này sẽ trình bày một ví dụ về cách lấy mã nguồn bộ mã hoá và giải mã video AV1 hiện có, tạo một trình bao bọc cho mã đó và dùng thử bên trong trình duyệt cùng các mẹo giúp xây dựng một khai thác kiểm thử để gỡ lỗi trình bao bọc. Để tham khảo, bạn có thể tham khảo mã nguồn đầy đủ của ví dụ này tại github.com/GoogleChromeLabs/wasm-av1.

Tải một trong hai tệp kiểm tra 24 khung hình/giây này video tệp và thử trên bản minh hoạ mà chúng tôi tạo sẵn.

Chọn cơ sở mã thú vị

Trong vài năm nay, chúng tôi đã nhận thấy rằng một tỷ lệ lớn lưu lượng truy cập trên web bao gồm dữ liệu video, trên thực tế, Cisco ước tính điều đó là 80%! Tất nhiên, các nhà cung cấp trình duyệt và trang web video đều ý thức được rất nhiều trong việc giảm lượng dữ liệu mà toàn bộ nội dung video này sử dụng. Tất nhiên, điểm mấu chốt của điều đó là khả năng nén tốt hơn và như bạn kỳ vọng sẽ có nhiều nghiên cứu về quá trình nén video thế hệ tiếp theo nhằm giảm gánh nặng dữ liệu khi truyền video qua Internet.

Như đã xảy ra, Alliance for Open Media đã và đang nghiên cứu một lược đồ nén video thế hệ mới có tên AV1. Lược đồ này hứa hẹn sẽ giảm đáng kể kích thước dữ liệu video. Trong tương lai, chúng tôi dự kiến các trình duyệt sẽ cung cấp dịch vụ hỗ trợ gốc cho AV1, nhưng thật may là mã nguồn cho bộ nén và trình giải nén là nguồn mở. Đây là phương án lý tưởng để biên dịch mã thành WebAssembly, nhờ đó chúng tôi có thể thử nghiệm mã nguồn trong trình duyệt.

Hình ảnh phim về thỏ.

Điều chỉnh để sử dụng trong trình duyệt

Một trong những điều đầu tiên chúng ta cần làm để đưa mã này vào trình duyệt là tìm hiểu mã hiện có để hiểu API là như thế nào. Khi mới xem xét mã này, có 2 điều nổi bật:

  1. Cây nguồn được tạo bằng một công cụ có tên là cmake; và
  2. Có một số ví dụ cho rằng tất cả đều giả định một loại giao diện dựa trên tệp.

Tất cả ví dụ được tạo theo mặc định đều có thể chạy trên dòng lệnh và điều này có thể đúng với nhiều cơ sở mã khác có sẵn trong cộng đồng. Vì vậy, giao diện chúng ta sẽ xây dựng để chạy trong trình duyệt có thể hữu ích cho nhiều công cụ dòng lệnh khác.

Sử dụng cmake để tạo mã nguồn

May mắn là các tác giả AV1 đã và đang thử nghiệm với Emscripten, SDK mà chúng tôi sắp sử dụng để xây dựng phiên bản WebAssembly. Trong thư mục gốc của kho lưu trữ AV1, tệp CMakeLists.txtchứa các quy tắc xây dựng sau:

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()

Chuỗi công cụ Emscripten có thể tạo dữ liệu đầu ra ở hai định dạng, một định dạng có tên là asm.js và định dạng còn lại là WebAssembly. Chúng ta sẽ nhắm đến WebAssembly vì WebAssembly tạo ra đầu ra nhỏ hơn và có thể chạy nhanh hơn. Mục đích của các quy tắc xây dựng hiện có này là biên dịch phiên bản asm.js của thư viện để dùng trong ứng dụng công cụ kiểm tra được tận dụng để xem nội dung của tệp video. Để sử dụng, chúng ta cần đầu ra WebAssembly. Vì vậy, chúng ta thêm các dòng này ngay trước câu lệnh đóng endif() trong các quy tắc ở trên.

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

Việc xây dựng bằng cmake có nghĩa là trước tiên, bạn sẽ tạo một số Makefiles bằng cách chạy chính cmake, sau đó chạy lệnh make để thực hiện bước biên dịch. Xin lưu ý rằng vì đang dùng Emscripten nên chúng ta cần dùng chuỗi công cụ trình biên dịch Emscripten thay vì trình biên dịch máy chủ lưu trữ mặc định. Bạn có thể thực hiện việc này bằng cách sử dụng Emscripten.cmake thuộc SDK mô phỏng và truyền đường dẫn của mã này dưới dạng tham số đến chính cmake. Dòng lệnh bên dưới là dòng lệnh chúng tôi sử dụng để tạo các tệp 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

Bạn phải đặt tham số path/to/aom thành đường dẫn đầy đủ đến vị trí của các tệp nguồn thư viện AV1. Bạn cần đặt tham số path/to/emsdk-portable/…/Emscripten.cmake thành đường dẫn cho tệp mô tả chuỗi công cụ Emscripten.cmake.

Để thuận tiện, chúng tôi sử dụng tập lệnh shell để định vị tệp đó:

#!/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

Nếu xem Makefile cấp cao nhất của dự án này, bạn có thể thấy cách tập lệnh đó được sử dụng để định cấu hình bản dựng.

Bây giờ, sau khi hoàn tất việc thiết lập, chúng ta chỉ cần gọi make để tạo toàn bộ cây nguồn, bao gồm cả các mẫu, nhưng quan trọng nhất là tạo libaom.a chứa bộ giải mã video đã biên dịch và sẵn sàng để chúng ta đưa vào dự án.

Thiết kế API cho giao diện cho thư viện

Sau khi xây dựng thư viện, chúng ta cần tìm hiểu cách giao tiếp với thư viện để gửi dữ liệu video nén tới thư viện rồi đọc lại các khung hình video có thể hiển thị trong trình duyệt.

Hãy xem xét bên trong cây mã AV1, bạn có thể bắt đầu bằng bộ giải mã video mẫu có thể tìm thấy trong tệp [simple_decoder.c](https://aomedia.googlesource.com/aom/+/master/examples/simple_decoder.c). Bộ giải mã đó sẽ đọc trong một tệp IVF và giải mã thành một loạt hình ảnh đại diện cho các khung hình trong video.

Chúng ta triển khai giao diện trong tệp nguồn [decode-av1.c](https://github.com/GoogleChromeLabs/wasm-av1/blob/master/decode-av1.c).

Vì trình duyệt của chúng ta không thể đọc các tệp từ hệ thống tệp, nên chúng ta cần thiết kế một số dạng giao diện cho phép rút gọn I/O để có thể tạo một nội dung tương tự như bộ giải mã mẫu nhằm lấy dữ liệu vào thư viện AV1.

Trên dòng lệnh, tệp I/O được gọi là giao diện luồng, vì vậy, chúng ta có thể chỉ cần xác định giao diện riêng của mình trông giống như I/O luồng và tạo bất kỳ thứ gì chúng ta muốn trong quá trình triển khai cơ bản.

Chúng ta xác định giao diện như sau:

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);

Các hàm open/read/empty/close rất giống với các hoạt động I/O thông thường đối với tệp, cho phép chúng ta dễ dàng ánh xạ các hàm đó vào tệp I/O cho một ứng dụng dòng lệnh hoặc triển khai các hàm đó theo cách khác khi chạy trong trình duyệt. Loại DATA_Source không rõ ràng ở phía JavaScript và chỉ đóng gói giao diện. Lưu ý: việc tạo API theo sát ngữ nghĩa của tệp giúp bạn dễ dàng sử dụng lại trong nhiều cơ sở mã khác nhằm mục đích sử dụng qua một dòng lệnh (ví dụ: diff, sed, v.v.).

Chúng ta cũng cần xác định một hàm trợ giúp có tên là DS_set_blob. Hàm này liên kết dữ liệu nhị phân thô với các hàm I/O trong luồng. Thao tác này cho phép blob được "đọc" như thể đó là một luồng (tức là trông giống như một tệp được đọc tuần tự).

Cách triển khai mẫu của chúng tôi cho phép đọc blob đã truyền như thể đó là một nguồn dữ liệu được đọc tuần tự. Bạn có thể tìm thấy mã tham chiếu trong tệp blob-api.c và toàn bộ quá trình triển khai chỉ như sau:

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;
}

Xây dựng phần khai thác kiểm thử để kiểm thử bên ngoài trình duyệt

Một trong những phương pháp hay nhất trong kỹ thuật phần mềm là xây dựng chương trình kiểm thử đơn vị cho mã kết hợp với kiểm thử tích hợp.

Khi xây dựng bằng WebAssembly trong trình duyệt, bạn nên tạo một số dạng kiểm thử đơn vị cho giao diện đối với mã đang xử lý để chúng ta có thể gỡ lỗi bên ngoài trình duyệt và cũng có thể kiểm thử giao diện mình đã xây dựng.

Trong ví dụ này, chúng tôi đã mô phỏng một API dựa trên luồng làm giao diện cho thư viện AV1. Vì vậy, về mặt logic, hợp lý để xây dựng một khai thác kiểm thử mà chúng tôi có thể sử dụng để xây dựng một phiên bản API chạy trên dòng lệnh và thực hiện I/O tệp thực tế bằng cách triển khai chính tệp I/O bên dưới API DATA_Source của chúng tôi.

Mã I/O luồng cho khai thác kiểm thử của chúng tôi rất đơn giản và có dạng như sau:

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);
}

Bằng cách tóm tắt giao diện luồng, chúng ta có thể xây dựng mô-đun WebAssembly để sử dụng các blob dữ liệu nhị phân khi đang ở trong trình duyệt và giao diện với các tệp thực khi tạo mã để kiểm thử từ dòng lệnh. Bạn có thể tìm thấy mã khai thác kiểm thử của chúng tôi trong tệp nguồn mẫu test.c.

Triển khai cơ chế lưu vào bộ đệm cho nhiều khung hình video

Khi phát lại video, bạn nên lưu vào vùng đệm một vài khung hình để giúp phát mượt mà hơn. Chúng ta sẽ triển khai vùng đệm gồm 10 khung video, vì vậy, chúng ta sẽ lưu vào bộ đệm 10 khung hình trước khi bắt đầu phát. Sau đó, mỗi lần hiển thị một khung, chúng tôi sẽ cố gắng giải mã một khung khác để giữ cho bộ đệm đầy. Phương pháp này đảm bảo các khung hình có sẵn trước để giúp tránh tình trạng kết xuất video.

Với ví dụ đơn giản của chúng tôi, toàn bộ video nén có thể đọc được, do đó, việc lưu vào bộ đệm không thực sự cần thiết. Tuy nhiên, nếu mở rộng giao diện dữ liệu nguồn để hỗ trợ dữ liệu đầu vào truyền trực tuyến từ máy chủ, thì chúng ta cần phải có sẵn cơ chế lưu vào bộ đệm.

Mã trong decode-av1.c để đọc khung dữ liệu video từ thư viện AV1 và lưu trữ trong vùng đệm như sau:

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);
        }
    }


Chúng tôi đã chọn làm cho vùng đệm chứa 10 khung video, đây chỉ là một lựa chọn tuỳ ý. Việc lưu vào bộ đệm nhiều khung hình hơn đồng nghĩa với việc mất nhiều thời gian chờ hơn để video bắt đầu phát, trong khi việc lưu vào bộ đệm quá ít khung hình có thể gây ra tình trạng trì hoãn trong khi phát. Khi triển khai trình duyệt gốc, việc lưu khung hình vào bộ đệm phức tạp hơn nhiều so với cách triển khai này.

Đưa khung hình video lên trang bằng WebGL

Khung video mà chúng tôi đã lưu vào bộ đệm cần được hiển thị trên trang của chúng tôi. Vì đây là nội dung video động nên chúng tôi muốn có thể thực hiện việc đó nhanh nhất có thể. Để làm được điều đó, chúng tôi sử dụng WebGL.

WebGL cho phép chúng tôi chụp ảnh, chẳng hạn như khung video và sử dụng hình ảnh đó làm hoạ tiết được vẽ lên hình học. Trong thế giới WebGL, mọi thứ đều bao gồm hình tam giác. Vì vậy, trong trường hợp này, chúng ta có thể sử dụng một tính năng tích hợp sẵn thuận tiện của WebGL, có tên là gl.TRIANGLE_Fan.

Tuy nhiên, có một vấn đề nhỏ. Hoạ tiết WebGL phải là hình ảnh RGB, một byte cho mỗi kênh màu. Đầu ra từ bộ giải mã AV1 của chúng tôi là các hình ảnh ở định dạng YUV, trong đó đầu ra mặc định có 16 bit cho mỗi kênh và mỗi giá trị U hoặc V tương ứng với 4 pixel trong hình ảnh đầu ra thực tế. Điều này có nghĩa là chúng ta cần chuyển đổi màu hình ảnh trước khi có thể truyền hình ảnh đó đến WebP để hiển thị.

Để thực hiện việc này, chúng tôi triển khai hàm AVX_YUV_to_RGB() mà bạn có thể tìm thấy trong tệp nguồn yuv-to-rgb.c. Hàm đó chuyển đổi dữ liệu đầu ra từ bộ giải mã AV1 thành nội dung chúng tôi có thể truyền sang WebGL. Lưu ý rằng khi gọi hàm này từ JavaScript, chúng ta cần đảm bảo rằng bộ nhớ mà chúng ta đang viết hình ảnh chuyển đổi đã được phân bổ bên trong bộ nhớ của mô-đun WebAssembly, nếu không thì sẽ không thể truy cập vào bộ nhớ đó. Hàm để lấy hình ảnh ra từ mô-đun WebAssembly và vẽ hình ảnh đó lên màn hình là:

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);
    }
}

Bạn có thể tìm thấy hàm drawImageToCanvas() triển khai việc vẽ WebGL trong tệp nguồn draw-image.js để tham khảo.

Công việc và những điểm cần ghi nhớ trong tương lai

Việc dùng thử bản minh hoạ trên hai tệp video kiểm thử (được quay dưới dạng video 24 f.p.s) sẽ dạy chúng ta một số điều:

  1. Việc xây dựng cơ sở mã phức tạp để chạy hiệu suất trong trình duyệt bằng cách sử dụng WebAssembly là hoàn toàn khả thi; và
  2. WebAssembly có thể sử dụng một giải pháp cần nhiều CPU để giải mã video nâng cao.

Tuy nhiên, có một số hạn chế như sau: tất cả quá trình triển khai đều đang chạy trên luồng chính, đồng thời chúng tôi xen kẽ giải mã tranh và video trên luồng đó. Việc giảm tải giải mã vào một trình chạy web có thể mang lại cho chúng tôi quá trình phát mượt mà hơn, vì thời gian để giải mã khung hình phụ thuộc rất nhiều vào nội dung của khung đó và đôi khi có thể mất nhiều thời gian hơn dự kiến.

Quá trình biên dịch vào WebAssembly sử dụng cấu hình AV1 cho một loại CPU chung. Nếu biên dịch nguyên gốc trên dòng lệnh cho một CPU chung, chúng tôi sẽ thấy có tải CPU tương tự để giải mã video như phiên bản WebAssembly, tuy nhiên, thư viện bộ giải mã AV1 cũng bao gồm các phương thức triển khai SIMD chạy nhanh hơn tới 5 lần. Nhóm cộng đồng WebAssembly hiện đang nỗ lực mở rộng tiêu chuẩn để bao gồm cả dữ liệu nguyên gốc SIMD, và khi đó, nhóm cộng đồng này hứa hẹn sẽ tăng tốc độ giải mã đáng kể. Khi điều đó xảy ra, bạn hoàn toàn có thể giải mã video HD 4k theo thời gian thực từ bộ giải mã video WebAssembly.

Trong mọi trường hợp, mã ví dụ đều hữu ích như một hướng dẫn giúp chuyển mọi tiện ích dòng lệnh hiện có sang chạy dưới dạng mô-đun WebAssembly và cho thấy những việc có thể thực hiện trên web hiện nay.

Ghi công

Cảm ơn Jeff Posnick, Eric Bidelman và Thomas Steiner đã cung cấp bài đánh giá và phản hồi có giá trị.