B612에서의 동영상 코덱 선정과 옵션 최적화

안녕하세요. 라인플러스에서 B612 Android 개발을 담당하고 있는 조현태라고 합니다. B612는 소설 ‘어린왕자’의 별에서 이름을 따온 앱으로, 셀피 앱 최초로 선 필터를 적용하였으며 3~6초 분할 동영상 촬영도 가능합니다. 이 글에서는 B612앱을 개발하면서 MediaCodec을 활용하여 비디오를 콜라주 형태로 만들고 최종 결과물인 MP4 파일을 생성하기까지의 과정에 대해서 이야기해보고자 합니다.

동영상 크기의 결정

B612 앱에서는 동영상을 촬영할 수 있는데, 이러한 실시간 동영상 인코딩 작업은 많은 양의 데이터를 처리해야 하기 때문에 구현하는 데 어려움이 있습니다. 화면에 그려지는 데이터를 GPU 메모리로부터 시스템 메모리로 가져와 이를 다시 하드웨어 인코더에 전달하여 처리하거나, 또는 CPU에서 각 픽셀에 대해 개별적인 연산을 수행하여 동영상 데이터로 만들어야 하기 때문입니다.

많은 양의 데이터를 실시간으로 꾸준히 처리해야 하기 때문에 최대한 데이터의 크기가 작으면 작을수록 좋습니다. 적은 양의 데이터로 동영상 정보를 만들려면 화면을 읽는 크기부터 최소화하여 필요한 만큼의 정보만을 가져오도록 해야 합니다. 그러기 위해서는 결과 영상의 크기에 대해 먼저 결정한 다음 결과 영상에서 필요로 하는 개별 영상의 크기를 계산합니다. 이를 바탕으로 원본 동영상의 크기를 결정하고, 그 크기만큼의 영상만 인코딩하는 구조로 되어 있습니다.

결과 영상의 크기를 결정하는 것은 사용자가 결정한 콜라주에 따라 결정됩니다. 카메라로부터 오는 이미지 정보를 콜라주의 모양대로 가로, 세로 개수에 맞게 나열합니다. 이때의 크기가 일차적인 결과 영상의 최대 크기가 됩니다. 예를 들어 카메라로부터 전달되는 이미지의 크기가 320×480 픽셀이라면 1×2 모양의 콜라주를 선택한 경우 가로로는 1개의 이미지를 배치하고 세로로는 2개의 이미지를 배치하여 320×960 픽셀의 모양으로 배치하게 됩니다. 이렇게 결정된 결과 영상의 사이즈를 디바이스가 처리할 수 있는 최대 사이즈에 맞추어 동일한 비율로 줄입니다. 이 프로세스가 GL(graphic library)내에서 처리된다는 점을 감안하면 장치가 처리할 수 있는 최대 영상의 크기는 GL이 처리할 수 있는 텍스처 크기보다 작아야 하며, 단말의 성능에 따라서도 너무 큰 해상도는 실시간으로 처리할 수 없기 때문에 최대 해상도를 줄여야 합니다.

결과 영상의 크기가 결정되면 이를 바탕으로 원본 개별 동영상의 크기를 결정합니다. 결과 영상의 크기에서 가로, 세로의 콜라주 개수로 나눠주고 나눈 결과 영상의 크기를 16의 배수가 되도록 조절합니다. 일반적으로 동영상에서 사용되는 매크로 블럭의 크기가 16×16 픽셀이어서 16의 배수의 해상도인 동영상이 대다수의 기기에서 잘 재생되기 때문입니다.
1_ko그림1. 단일 영상 리코딩 해상도 결정 과정

MediaCodec의 초기화 및 디코더 종류 결정

MediaCodec은 다음과 같은 코드로 초기화합니다.

mediaCodec = MediaCodec.createEncoderByType("video/avc");
MediaFormat mediaFormat = MediaFormat.createVideoFormat("video/avc", width, height);
mediaFormat.setInteger(MediaFormat.KEY_BIT_RATE, bitRate);
mediaFormat.setInteger(MediaFormat.KEY_FRAME_RATE, frameRate);
mediaFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
mediaFormat.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, frameInterval);
mediaCodec.configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);

위의 코드에서와 같이 인코더를 생성할 때 적절한 MIME 값인 “video/avc”를 설정해서 H.264 동영상으로 만들도록 선언하였습니다. 또한 인코더를 생성할 때 알맞은 bitrate와 frame rate를 전달하여 원하는 형식의 동영상이 생성되도록 하였으며, Color Format은 COLOR_FormatSurface를 전달하여 Surface를 통해서 인코딩이 가능하도록 하였습니다. 다른 Color Format 옵션을 제공하여 동영상을 만들수도 있으나 Surface를 사용하지 않는 경우에는 MediaCodec에서 제공하는 Color Format만 옵션으로 주어야 합니다.

하지만 기기마다 제공하는 컬러 포맷이 너무 다양하여 각각의 컬러 포맷을 지정해주는 경우에는 기기마다 다른 색상 포맷으로 입력 데이터를 변환해주어야 합니다. 또한 서로 다른 컬러포맷을 가진 이미지는 콜라주를 합치는 과정에서도 색상 포맷에 맞는 이미지 데이터의 배치를 해주어야 하기 때문에 안드로이드 단말이 지원하는 컬러 포맷만큼의 별도 코드가 필요하게 됩니다. 그렇기 때문에 보다 통일된 방법이면서 Surface를 사용하는 MediaCodec 인코더를 생성하였습니다. 이 방법은 구현 또한 수월하다는 장점이 있습니다.

MediaCodec 디코더의 경우에는 하드웨어 성능에 따라 디코딩 속도가 다릅니다. 개별 콜라주의 크기는 작지만, 여러 개의 영상을 병합해야 하는 경우에 소프트웨어를 통해 동영상을 디코딩하는 경우가 하드웨어 디코더를 통해 영상을 디코딩하는 것보다 더 효과적인 경우가 있습니다. 또한 하드웨어 디코더 코덱을 사용하는 경우에는 일정 개수 이상의 MediaCodec 인스턴스를 생성할 때 특정 단말에서는 기대와 다르게 동작하는 경우가 있습니다. 그렇기 때문에 보다 안정성이 필요한 것으로 추정되는 단말에서는 OMX.google.h264.decoder 옵션을 사용하여 구글에서 제공하는 소프트웨어 디코더를 사용하였습니다. 그러면 안정성이 더 높으면서도 빠르게 여러 장의 동영상을 디코딩할 수 있기 때문입니다.

멀티 스레드를 활용한 병렬 디코딩

MediaCodec은 단일 스레드에서도 인코딩 / 디코딩을 할 수 있도록 설계되어 dequeueOutputBuffer와 같은 함수에서 timeout 값을 제공받도록 되어 있습니다. 그래서 이 값을 매우 낮게 지정하는 경우에는 단일 스레드에서도 너무 오랜시간 스레드가 블록되는 일이 없이 여러 개의 디코더에 접근이 가능합니다. 하지만 이와 같이 구현하는 경우에도 약간의 대기시간이 생기는 것은 피할 수 없기 때문에 멀티 스레드를 활용하는 것이 동영상 처리에 더 효과적입니다. 각각의 동영상 디코딩을 위한 MediaCodec마다 스레드를 할당하여 MediaCodec에서 블록이 발생하더라도 동영상 처리는 빠르게 이루어지도록 처리하였습니다.

아래의 그림2에 각각의 디코딩 스레드가 생성되었을 때 MediaCodec 내부의 스레드와 상호작용하는 과정을 표시하였습니다. 이처럼 개별 스레드를 쓰면 성능에는 큰 이점이 있지만 모든 스레드가 개별적으로 진행되는 중에 인코더가 디코딩 속도를 따라가지 못하게 되는 경우에는 너무 많은 이미지가 메모리에 생성되는 역효과가 발생할 수 있습니다. 이런 일을 방지하기 위해서 각각의 디코더가 만드는 이미지를 담는 버퍼를 두고 그 크기에 제한을 두어 너무 많은 디코딩된 이미지를 두지 않도록 하였습니다. 이와 같은 방식을 적용하면 인코딩 속도가 디코딩 속도에 비해 느린 경우에는 버퍼가 가득찰 때까지만 디코딩하고, 인코딩이 진행되어 다시 버퍼가 비는 경우에는 다시 디코딩을 진행하여 불필요하게 메모리가 낭비되지 않게 됩니다.
2그림2. 애플리케이션에서 생성한 스레드와 MediaCodec 내부 스레드의 상호 작용

Surface를 활용한 영상 합성

앞의 “MediaCodec의 초기화 및 디코더 종류 결정”에서 살펴본 것과 같이 인코딩 과정에서는 Surface를 사용하여 데이터를 전달받도록 인코더가 설정되어 있습니다. 이 인코더에 데이터를 전달하기 전에 이미 원하는 모양으로 구성된 결과 이미지가 있어야 하기 때문에, 결과 이미지를 그리기 위해서는 FrameBuffer를 할당해 원하는 모양으로 영상을 랜더링해야 했습니다. 위에서의 멀티 스레드를 통한 디코딩 결과를 updateTexImage 함수를 통해서 GL 스레드에서 각각의 Texture로 업데이트합니다. 이후 FrameBuffer에 우리가 원하는 모양으로 배치가 될 수 있도록 Texture들의 Vertex 좌표를 결정합니다. 결정된 Vertex를 기준으로 glDrawArrays 함수를 통해서 그리게 되면 결과 영상은 우리가 원하는 이미지가 되며, 이때의 각각의 영상은 약간의 시간 차이가 있을 수 있으나 프레임을 합치는 과정에서 원하는 FPS(frame per second)에 맞추어 하나의 프레임으로 만들어 줍니다.
3그림3. 여러 장의 텍스처를 한 장의 이미지로 합성하여 미디어 코덱에 전달하는 과정

MediaMuxer를 활용한 MP4 파일 생성

인코딩한 H.264 raw 데이터로만 구성된 결과물은 오디오와 같은 정보가 들어있지 않고 재생에 필요한 추가적인 정보를 넣을 수 없기 때문에 일반적으로 사용하는 플레이어에서는 재생할 수 없습니다. 완료된 결과물을 결과 화면이나 다른 애플리케이션에서 재생하기 위해서는 MP4와 같은 컨테이너 포맷에 인코딩된 결과물을 넣어야 할 필요가 있습니다. 이를 위해서 Android에서 제공하는 MediaMuxer를 통해서 MP4 파일로 만들어 냅니다. 이 과정은 다음과 같은 소스 코드로 처리할 수 있습니다.

muxer = new MediaMuxer(path, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
trackIndex = muxer.addTrack(newFormat); muxer.start();
// Repeatedly call the code below to record data on the MP4 file.
muxer.writeSampleData(trackIndex, encodedData, bufferInfo);
muxer.stop();
muxer.release();

위의 코드와 같이 MediaMuxer를 생성하면 원하는 영상과 음성을 각각의 트랙으로 넣어주어야 합니다. 하지만 B612에서 영상을 합성하는 과정에는 오디오 데이터를 필요로 하지 않기 때문에 오디오 데이터는 별도로 추가해주지 않고 나중에 사용자가 오디오를 선택하여 저장하는 경우에만 오디오 트랙을 추가해 결과 파일을 생성합니다.

결론

위의 블로그 내용 전반에서 MediaCodec은 어떻게 사용되었는지와 Thread를 사용한 속도 개선, 콜라주 합성 과정 등을 살펴봤습니다. 위의 과정을 통해서 하드웨어 인코더를 이용할 수 있고 이를 통해 소프트웨어 코덱을 사용하는 것에 비해 더 빠른 인코딩 속도를 사용자에게 제공할 수 있습니다. 최대한 리사이즈가 필요 없는 처리과정을 만들고 여러 스레드에서 동시에 처리함으로써 메모리도 최소한으로 쓰면서 고성능을 낼 수 있는 애플리케이션을 만들도록 노력했습니다.