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

WebAssembly cho phép chúng ta mở rộng trình duyệt bằng các tính năng mới. Bài viết này hướng dẫn cách chuyển bộ giải mã video AV1 và phát video AV1 trong mọi trình duyệt hiện đại.

Alex Danilo

Một trong những điểm tốt nhất của WebAssembly là khả năng thử nghiệm 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 cung cấp các tính năng đó theo cách gốc (nếu có). Bạn có thể xem 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 viết tính năng của mình bằng C/C++ hoặc Rust thay vì JavaScript.

Với vô số mã hiện có để chuyển đổi, bạn có thể làm những việc trong trình duyệt mà trước đây không thể thực hiện được cho đến khi WebAssembly ra đời.

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

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

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

Trong nhiều năm qua, 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 con số này lên tới 80%! Tất nhiên, các nhà cung cấp trình duyệt và trang web video đều nhận thấy rõ nhu cầu giảm mức sử dụng dữ liệu của tất cả nội dung video này. Tất nhiên, yếu tố chính để làm được điều đó là khả năng nén tốt hơn. Như bạn mong đợi, có rất nhiều nghiên cứu về việc nén video thế hệ mới nhằm giảm gánh nặng dữ liệu khi truyền video qua Internet.

Tình cờ, Liên minh vì nội dung nghe nhìn mở (Alliance for Open Media) đang nghiên cứu một giao thức nén video thế hệ mới có tên là AV1. Giao thứ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 trình duyệt sẽ hỗ trợ gốc cho AV1, nhưng may mắn là mã nguồn cho trình nén và trình giải nén là mã nguồn mở, điều này giúp trình nén và trình giải nén trở thành ứng cử viên lý tưởng để cố gắng biên dịch mã nguồn đó thành WebAssembly để chúng ta có thể thử nghiệm trong trình duyệt.

Hình ảnh phim Bunny.

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

Một trong những việc đầ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 trông như thế nào. Khi xem xét mã này đầu tiên, 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ụ đều giả định một số loại giao diện dựa trên tệp.

Tất cả cá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 trong nhiều cơ sở mã khác có trong cộng đồng. Vì vậy, giao diện mà 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

Rất may là các tác giả AV1 đã và đang thử nghiệm với Emscripten, SDK mà chúng tôi sẽ sử dụng để tạo phiên bản WebAssembly. Trong thư mục gốc của kho lưu trữ AV1, tệp CMakeLists.txt chứa các quy tắc bản 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 đầu ra ở hai định dạng, một định dạng được gọi là asm.js và định dạng còn lại là WebAssembly. Chúng ta sẽ nhắm đến WebAssembly vì công cụ này tạo ra đầu ra nhỏ hơn và có thể chạy nhanh hơn. Các quy tắc bản dựng hiện có này dùng để biên dịch phiên bản asm.js của thư viện để sử dụng trong ứng dụng trình 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 sẽ thêm các dòng này ngay trước câu lệnh endif() đóng 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 tạo bằng cmake có nghĩa là trước tiên, bạn phải 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. Lưu ý: 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ủ mặc định. Bạn có thể thực hiện việc này bằng cách sử dụng Emscripten.cmake, một phần của SDK Emscripten và truyền đường dẫn của SDK 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 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 nên đặt tham số path/to/aom thành đường dẫn đầy đủ của vị trí 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 ta sử dụng tập lệnh shell để tìm 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 cho dự án này, bạn có thể thấy cách tập lệnh đó được dùng để định cấu hình bản dựng.

Giờ đây, khi mọi thiết lập đã hoàn tất, 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 được biên dịch và sẵn sàng để chúng ta đưa vào dự án.

Thiết kế API để giao diện với thư viện

Sau khi tạo 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 đến thư viện, sau đó đọc lại các khung hình video mà chúng ta có thể hiển thị trong trình duyệt.

Khi xem xét bên trong cây mã AV1, một điểm khởi đầu phù hợp là bộ giải mã video mẫu có trong tệp [simple_decoder.c](https://aomedia.googlesource.com/aom/+/master/examples/simple_decoder.c). Bộ giải mã đó đọc tệp IVF và giải mã tệp đó 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 không thể đọ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 chúng ta trừu tượng hoá I/O để có thể tạo một giao diện tương tự như bộ giải mã mẫu nhằm đưa dữ liệu vào thư viện AV1.

Trên dòng lệnh, I/O tệp được gọi là giao diện luồng, vì vậy, chúng ta chỉ cần xác định giao diện của riêng mình trông giống như I/O luồng và tạo bất kỳ nội dung nào 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 trông giống như các thao tác I/O tệp thông thường, cho phép chúng ta dễ dàng liên kết các hàm này với I/O tệp cho một ứng dụng dòng lệnh hoặc triển khai các hàm này theo cách khác khi chạy bên trong trình duyệt. Loại DATA_Source không rõ ràng từ phía JavaScript và chỉ dùng để đóng gói giao diện. Xin lưu ý rằng việc tạo một API tuân theo chặt chẽ ngữ nghĩa của tệp sẽ giúp bạn dễ dàng sử dụng lại trong nhiều cơ sở mã khác được dùng 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 luồng của chúng ta. 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 tuần tự).

Ví dụ về cách triển khai của chúng ta 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 một bộ 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 kiểm thử đơn vị cho mã cùng với kiểm thử tích hợp.

Khi tạo bằng WebAssembly trong trình duyệt, bạn nên tạo một số hình thức kiểm thử đơn vị cho giao diện với mã mà chúng ta đang sử dụng để 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à chúng ta đã tạo.

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

Mã I/O luồng cho bộ kiểm thử của chúng ta 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 blob dữ liệu nhị phân khi ở 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ã của bộ kiểm thử 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 video, thông thường bạn sẽ lưu một vài khung hình vào bộ đệm để giúp quá trình phát mượt mà hơn. Đối với mục đích của mình, chúng ta sẽ chỉ triển khai vùng đệm gồm 10 khung hình video, vì vậy, chúng ta sẽ lưu 10 khung hình vào vùng đệm trước khi bắt đầu phát. Sau đó, mỗi khi một khung hình hiển thị, chúng ta sẽ cố gắng giải mã một khung hình khác để giữ cho vùng đệm luôn đầy. Phương pháp này đảm bảo có sẵn các khung hình để giúp ngăn chặn hiện tượng giật video.

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

Mã trong decode-av1.c để đọc các khung hình của 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 hình video, đây chỉ là một lựa chọn tuỳ ý. Việc lưu nhiều khung hình vào bộ đệm đồng nghĩa với việc bạn phải chờ lâu hơn để video bắt đầu phát, trong khi việc lưu quá ít khung hình vào bộ đệm có thể gây ra tình trạng bị giật trong khi phát. Trong quá trình triển khai trình duyệt gốc, việc lưu vào bộ đệm khung hình phức tạp hơn nhiều so với cách triển khai này.

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

Các khung hình video mà chúng ta đã lưu vào bộ đệm cần hiển thị trên trang. Vì đây là nội dung video động, nên chúng ta muốn thực hiện việc này nhanh nhất có thể. Để làm được điều đó, chúng tôi chuyển sang WebGL.

WebGL cho phép chúng ta chụp ảnh, chẳng hạn như khung video và sử dụng hình ảnh đó làm hoạ tiết vẽ lên một hình học nào đó. Trong thế giới WebGL, mọi thứ đều bao gồm 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 tiện lợi 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 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ế. Tất cả đ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ể chuyển hình ảnh đó sang WebGL để hiển thị.

Để thực hiện việc này, chúng ta triển khai một 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 đầu ra từ bộ giải mã AV1 thành nội dung mà chúng ta có thể truyền tới WebGL. Xin 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 ghi hình ảnh đã chuyển đổi vào đã được phân bổ bên trong bộ nhớ của mô-đun WebAssembly – nếu không, mô-đun này sẽ không thể truy cập vào bộ nhớ đó. Hàm lấy hình ảnh từ mô-đun WebAssembly và dán hình ảnh lên màn hình như sau:

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 tính năng vẽ WebGL trong tệp nguồn draw-image.js để tham khảo.

Công việc trong tương lai và thông tin cần ghi nhớ

Việc thử bản minh hoạ trên hai tệp video kiểm thử (được ghi dưới dạng video 24 khung hình/giây) cho chúng ta biết một số điều:

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

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

Quá trình biên dịch thành WebAssembly sử dụng cấu hình AV1 cho một loại CPU chung. Nếu biên dịch gốc trên dòng lệnh cho một CPU chung, chúng ta sẽ thấy mức tải CPU tương tự để giải mã video như với 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 gấp 5 lần. Nhóm cộng đồng WebAssembly hiện đang nỗ lực mở rộng tiêu chuẩn này để bao gồm các dữ liệu gốc của SIMD và khi có được điều này, họ hứa hẹn sẽ tăng tốc độ giải mã đáng kể. Khi đó, 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ụ vẫn hữu ích khi đóng vai trò hướng dẫn giúp chuyển mọi tiện ích dòng lệnh hiện có để chạy dưới dạng mô-đun WebAssembly, đồng thời 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à ý kiến phản hồi có giá trị.