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 tệp video kiểm thử ở tốc độ 24 khung hình/giây xuống và thử trên bản minh hoạ mà chúng tôi đã tạo.

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 rất hiểu rõ mong muốn 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 rõ API. Khi lần đầu tiên nhìn vào mã này, có hai đ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

May mắn thay, các tác giả AV1 đã thử nghiệm với Emscripten, SDK mà chúng ta 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 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. Xin lưu ý rằng vì chúng ta đang sử dụng Emscripten nên cần sử 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. Điều đó đạt được bằng cách sử dụng Emscripten.cmake thuộc SDK Emscripten và truyền đường dẫn của Emscripten.cmake 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 ta sử dụng để tạo 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

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 thông 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 tiếp 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 đó 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 đó 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. Điều này cho phép "đọc" blob như thể đó là một luồng (tức là trông giống như một tệp được đọc tuần tự).

Ví dụ về cách triển khai của chúng ta cho phép đọc blob được truyền vào 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, bạn nên tạo một bộ kiểm thử mà chúng ta có thể sử dụng để tạo 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 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 trừu tượng hoá giao diện luồng, chúng ta có thể tạo mô-đun WebAssembly để sử dụng các 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ã bộ 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 video, thông thường, bạn nên 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 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ộ nhớ đệm của 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 việc đó, chúng ta chuyển sang WebGL.

WebGL cho phép chúng ta lấy một hình ảnh, chẳng hạn như một khung hình video, và sử dụng hình ảnh đó làm hoạ tiết được vẽ lên một số hình học. Trong thế giới WebGL, mọi thứ đều bao gồm tam giác. Vì vậy, trong trường hợp của chúng ta, chúng ta có thể sử dụng một tính năng tích hợp 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 của bộ giải mã AV1 là hình ảnh ở định dạng YUV, trong đó đầu ra mặc định có 16 bit trên 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 đó đến 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 một nội dung mà chúng ta có thể truyền vào 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, hàm 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à vẽ 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. Bạn có thể thực hiện các tác vụ tốn nhiều CPU như giải mã video nâng cao 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 quá trình giải mã vào một worker web có thể giúp chúng ta phát video 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 hình đó và đôi khi có thể mất nhiều thời gian hơn 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ác nguyên hàm SIMD. Khi tiêu chuẩn này ra mắt, tốc độ giải mã sẽ tăng lên đá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ụ này đều hữu ích dưới dạng 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 và cho thấy những gì 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ị.