WebAssembly로 브라우저 확장

WebAssembly를 통해 새로운 기능으로 브라우저를 확장할 수 있습니다. 이 도움말에서는 최신 브라우저에서 AV1 동영상 디코더를 포팅하고 AV1 동영상을 재생하는 방법을 설명합니다.

Alex Danilo

WebAssembly의 가장 좋은 점 중 하나는 새로운 기능을 실험하고 새로운 아이디어를 브라우저가 이러한 기능을 기본으로 제공하기 전에 구현할 수 있다는 점입니다. 이런 식으로 WebAssembly를 자바스크립트가 아닌 C/C++ 또는 Rust로 기능을 작성하는 고성능 폴리필 메커니즘이라고 생각하면 됩니다.

포팅에 사용할 수 있는 기존 코드가 너무 많기 때문에 WebAssembly가 나오기 전에는 브라우저에서 실행할 수 없었던 작업을 할 수 있습니다.

이 도움말에서는 기존 AV1 동영상 코덱 소스 코드를 가져와서 래퍼를 빌드하고 브라우저 내에서 사용해 보는 방법의 예와 래퍼를 디버그하기 위한 테스트 하네스 빌드에 도움이 되는 팁을 안내합니다. 이 예의 전체 소스 코드는 github.com/GoogleChromeLabs/wasm-av1에서 참고용으로 제공됩니다.

이 24fps 테스트 동영상 파일 2개 중 하나를 다운로드하여 빌드된 데모에서 사용해 보세요.

흥미로운 코드베이스 선택

수년 동안 웹상의 트래픽의 상당 부분이 동영상 데이터로 구성되었음을 확인했습니다. Cisco는 이를 추정하는 비율을 80% 로 추산합니다. 물론 브라우저 공급업체와 동영상 사이트는 이 모든 동영상 콘텐츠에서 소비되는 데이터를 줄이고자 하는 바람을 잘 알고 있습니다. 물론 압축이 개선되는 것이 핵심입니다. 아시다시피 인터넷 전반에서 동영상 전송 시 데이터 부담을 줄이기 위한 차세대 동영상 압축에 관한 많은 연구가 진행되고 있습니다.

이에 따라 Alliance for Open MediaAV1이라는 차세대 동영상 압축 체계를 개발하여 동영상 데이터 크기를 상당히 축소할 것을 약속했습니다. 향후 브라우저에서 AV1의 기본 지원을 제공할 것으로 예상되지만 다행히 압축 프로그램 및 압축 해제 프로그램의 소스 코드는 오픈소스이므로 브라우저에서 이를 실험할 수 있도록 WebAssembly로 컴파일하는 데 이상적입니다.

Bunny 영화 이미지입니다.

브라우저에서 사용할 수 있도록 조정

이 코드를 브라우저에 가져오기 위해 가장 먼저 해야 할 일은 API가 어떤 것인지 파악하기 위해 기존 코드를 파악하는 것입니다. 이 코드를 처음 보면 다음 두 가지가 눈에 띕니다.

  1. 소스 트리는 cmake라는 도구를 사용하여 빌드됩니다.
  2. 모두 일종의 파일 기반 인터페이스를 가정하는 많은 예가 있습니다.

기본적으로 빌드된 모든 예는 명령줄에서 실행할 수 있으며, 커뮤니티에서 제공하는 다른 많은 코드베이스에도 해당될 가능성이 높습니다. 따라서 브라우저에서 실행되도록 빌드할 인터페이스는 다른 여러 명령줄 도구에도 유용할 수 있습니다.

cmake를 사용하여 소스 코드 빌드

다행히 AV1 작성자는 WebAssembly 버전을 빌드하는 데 사용할 SDK인 Emscripten을 실험했습니다. 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를 사용하여 빌드하면 먼저 cmake 자체를 실행하여 Makefiles를 생성한 다음 컴파일 단계를 실행할 make 명령어를 실행합니다. Emscripten을 사용하고 있으므로 기본 호스트 컴파일러가 아닌 Emscripten 컴파일러 도구 모음을 사용해야 합니다. 이는 Emscripten SDK의 일부인 Emscripten.cmake를 사용하고 그 경로를 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)에 구현합니다.

브라우저는 파일 시스템에서 파일을 읽을 수 없으므로 I/O를 추상화할 수 있는 인터페이스 형식을 설계해야 합니다. 그러면 예시 디코더와 유사한 것을 빌드하여 데이터를 AV1 라이브러리에 가져올 수 있습니다.

명령줄에서 파일 I/O는 스트림 인터페이스라고 알려져 있으므로 스트림 I/O처럼 보이는 인터페이스를 정의하고 기본 구현에서 원하는 대로 빌드할 수 있습니다.

인터페이스를 다음과 같이 정의합니다.

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 함수는 일반적인 파일 I/O 작업과 매우 유사하므로 명령줄 애플리케이션의 파일 I/O에 쉽게 매핑하거나 브라우저 내에서 실행할 때 다른 방법으로 구현할 수 있습니다. DATA_Source 유형은 JavaScript 측에서 불투명하며 인터페이스를 캡슐화하는 역할만 합니다. 파일 의미 체계를 밀접하게 준수하는 API를 빌드하면 명령줄에서 사용할 다른 여러 코드베이스(예: diff, sed 등)에서 쉽게 재사용할 수 있습니다.

원시 바이너리 데이터를 스트림 I/O 함수에 바인딩하는 DS_set_blob라는 도우미 함수도 정의해야 합니다. 이렇게 하면 blob을 마치 스트림인 것처럼 '읽을' 수 있습니다 (순차적으로 읽은 파일처럼 보임).

구현 예에서는 전달된 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 라이브러리의 인터페이스로 에뮬레이션했습니다. 따라서 DATA_Source API 아래에 파일 I/O 자체를 구현하여 명령줄에서 실행되고 실제 파일 I/O를 실행하는 API 버전을 빌드하는 데 사용할 수 있는 테스트 하네스를 빌드하는 것이 논리적으로 타당합니다.

테스트 하네스의 스트림 I/O 코드는 간단하며 다음과 같습니다.

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 모듈을 빌드하여 브라우저에서 바이너리 데이터 blob을 사용하고 명령줄에서 테스트할 코드를 빌드할 때는 실제 파일에 연결할 수 있습니다. 테스트 하네스 코드는 예시 소스 파일 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 텍스처는 색상 채널당 1바이트인 RGB 이미지여야 합니다. AV1 디코더의 출력은 소위 YUV 형식의 이미지입니다. 여기서 기본 출력에는 채널당 16비트가 있고 각각의 U 또는 V 값은 실제 출력 이미지에서 4픽셀에 해당합니다. 즉, 이미지를 WebGL에 전달하여 표시하려면 먼저 이미지를 색상을 변환해야 합니다.

이를 위해 소스 파일 yuv-to-rgb.c에서 찾을 수 있는 AVX_YUV_to_RGB() 함수를 구현합니다. 이 함수는 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);
    }
}

WebGL 페인팅을 구현하는 drawImageToCanvas() 함수는 참고용으로 소스 파일 draw-image.js에서 찾을 수 있습니다.

향후 작업 및 핵심 내용

두 개의 테스트 동영상 파일(24f.p.s. 동영상으로 녹화됨)에서 데모를 사용해보면 다음과 같은 정보를 얻을 수 있습니다.

  1. WebAssembly를 사용하여 브라우저에서 효율적으로 실행되도록 복잡한 코드베이스를 빌드하는 것이 전적으로 가능합니다.
  2. WebAssembly를 통해 고급 동영상 디코딩처럼 CPU 집약적인 작업을 할 수 있습니다.

하지만 몇 가지 제한사항이 있습니다. 구현은 모두 기본 스레드에서 실행되며 이 단일 스레드에서 페인팅과 동영상 디코딩을 인터리브 처리합니다. 웹 작업자로 디코딩을 오프로드하면 프레임 디코딩 시간은 해당 프레임의 콘텐츠에 따라 크게 달라지며 때로 예산보다 더 많은 시간이 걸릴 수 있기 때문에 더 원활한 재생을 제공할 수 있습니다.

WebAssembly로 컴파일할 때는 일반 CPU 유형에 AV1 구성을 사용합니다. 일반 CPU의 명령줄에서 기본적으로 컴파일하는 경우 WebAssembly 버전과 마찬가지로 동영상을 디코딩하기 위한 CPU 로드가 유사합니다. 하지만 AV1 디코더 라이브러리에는 최대 5배 더 빠르게 실행되는 SIMD 구현도 포함됩니다. WebAssembly Community Group은 현재 SIMD 프리미티브를 포함하도록 표준을 확장하기 위해 노력하고 있으며, 이를 통해 디코딩 속도를 크게 높일 수 있을 것으로 기대합니다. 이 경우 WebAssembly 동영상 디코더에서 실시간으로 4K HD 동영상을 디코딩할 수 있습니다.

어떤 경우든 예시 코드는 기존 명령줄 유틸리티가 WebAssembly 모듈로 실행되도록 포팅하고 현재 웹에서 가능한 작업을 보여주는 가이드로 유용합니다.

크레딧

소중한 리뷰와 의견을 제공해 주신 Jeff Posnick, Eric Bidelman, Thomas Steiner에게 감사드립니다.