WebAssembly로 브라우저 확장

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

Alex Danilo

WebAssembly의 가장 큰 장점 중 하나는 브라우저가 이러한 기능을 기본적으로 제공하기 전에 (제공하는 경우) 새로운 기능을 실험하고 새로운 아이디어를 구현할 수 있다는 점입니다. 이러한 방식으로 WebAssembly를 사용하는 것은 JavaScript가 아닌 C/C++ 또는 Rust로 기능을 작성하는 고성능 폴리필 메커니즘으로 생각할 수 있습니다.

이전할 수 있는 기존 코드가 많으므로 WebAssembly가 등장하기 전에는 불가능했던 작업을 브라우저에서 실행할 수 있습니다.

이 도움말에서는 기존 AV1 동영상 코덱 소스 코드를 가져와 래퍼를 빌드하고 브라우저 내에서 사용해 보는 방법의 예와 래퍼를 디버그하는 테스트 하네스를 빌드하는 데 도움이 되는 도움말을 설명합니다. 이 예시의 전체 소스 코드는 참고용으로 github.com/GoogleChromeLabs/wasm-av1에서 확인할 수 있습니다.

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

흥미로운 코드베이스 선택

지난 몇 년 동안 웹 트래픽의 상당 부분이 동영상 데이터로 구성되어 있는 것으로 확인되었습니다. 실제로 Cisco는 80% 에 이른다고 추정합니다. 물론 브라우저 공급업체와 동영상 사이트는 이러한 동영상 콘텐츠에서 소비되는 데이터를 줄이고자 하는 욕구를 잘 알고 있습니다. 이를 위한 핵심은 물론 더 나은 압축입니다. 예상대로 인터넷을 통해 동영상을 전송할 때의 데이터 부담을 줄이기 위한 차세대 동영상 압축에 관한 많은 연구가 진행되고 있습니다.

Alliance for Open Media는 동영상 데이터 크기를 크게 줄일 수 있는 차세대 동영상 압축 스킴인 AV1을 개발하고 있습니다. 향후 브라우저에서 AV1에 대한 네이티브 지원을 제공할 것으로 예상되지만 다행히 압축기와 압축 해제기의 소스 코드는 오픈소스이므로 브라우저에서 실험할 수 있도록 WebAssembly로 컴파일하기에 이상적인 후보입니다.

토끼 영화 이미지

브라우저에서 사용하도록 조정

이 코드를 브라우저에 가져오기 위해 가장 먼저 해야 할 일 중 하나는 기존 코드를 숙지하여 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)에서 인터페이스를 구현합니다.

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

명령줄에서 파일 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 모듈을 빌드하고 명령줄에서 테스트할 코드를 빌드할 때 실제 파일과 상호작용할 수 있습니다. 테스트 하네스 코드는 예시 소스 파일 test.c에서 확인할 수 있습니다.

여러 동영상 프레임의 버퍼링 메커니즘 구현

동영상을 재생할 때는 더 원활한 재생을 위해 몇 프레임을 버퍼링하는 것이 일반적입니다. 여기서는 동영상 프레임 10개로 구성된 버퍼를 구현하기만 하므로 재생을 시작하기 전에 10개 프레임을 버퍼링합니다. 그런 다음 프레임이 표시될 때마다 버퍼를 가득 채우기 위해 다른 프레임을 디코딩하려고 시도합니다. 이 접근 방식을 사용하면 프레임을 미리 가져와 동영상 끊김을 방지할 수 있습니다.

이 간단한 예에서는 압축된 전체 동영상을 읽을 수 있으므로 버퍼링이 실제로 필요하지 않습니다. 그러나 서버의 스트리밍 입력을 지원하도록 소스 데이터 인터페이스를 확장하려면 버퍼링 메커니즘을 마련해야 합니다.

AV1 라이브러리에서 동영상 데이터 프레임을 읽고 버퍼에 저장하는 decode-av1.c의 코드는 다음과 같습니다.

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에서 확인할 수 있습니다.

향후 작업 및 주요 내용

테스트 동영상 파일(24fps 동영상으로 녹화됨) 두 개에 데모를 사용해 보면 다음과 같은 몇 가지 사항을 알 수 있습니다.

  1. WebAssembly를 사용하여 브라우저에서 성능이 우수하게 실행되는 복잡한 코드베이스를 빌드하는 것은 완전히 실현 가능합니다.
  2. 고급 동영상 디코딩과 같이 CPU 집약적인 작업도 WebAssembly를 통해 실행할 수 있습니다.

하지만 몇 가지 제한사항이 있습니다. 구현은 모두 기본 스레드에서 실행되며 단일 스레드에서 페인팅과 동영상 디코딩을 교차로 실행합니다. 디코딩을 웹 작업자로 오프로드하면 프레임을 디코딩하는 시간이 프레임의 콘텐츠에 크게 좌우되고 예산보다 시간이 더 걸릴 수 있으므로 더 원활한 재생을 제공할 수 있습니다.

WebAssembly로 컴파일하면 일반 CPU 유형에 AV1 구성이 사용됩니다. 일반 CPU용 명령줄에서 네이티브로 컴파일하면 WebAssembly 버전과 마찬가지로 동영상을 디코딩하는 데 비슷한 CPU 부하가 발생하지만, AV1 디코더 라이브러리에는 최대 5배 더 빠르게 실행되는 SIMD 구현도 포함되어 있습니다. WebAssembly 커뮤니티 그룹은 현재 SIMD 프리미티브를 포함하도록 표준을 확장하는 작업을 진행하고 있으며, 이 작업이 완료되면 디코딩 속도가 크게 빨라질 것으로 기대됩니다. 그러면 WebAssembly 동영상 디코더에서 실시간으로 4K HD 동영상을 디코딩하는 것이 완전히 가능해집니다.

어쨌든 예시 코드는 기존 명령줄 유틸리티를 WebAssembly 모듈로 실행하도록 포팅하는 데 도움이 되는 가이드로 유용하며 현재 웹에서 가능한 작업을 보여줍니다.

크레딧

Jeff Posnick, Eric Bidelman, Thomas Steiner님, 소중한 리뷰와 의견을 제공해 주셔서 감사합니다.