Coroutine과 MediaCodec만으로 영상 재생하기

goddoro
10 min readAug 31, 2022

--

안녕하세요 개발자 doro입니다. 안드로이드에서 동영상 편집을 한다거나 워터마크 이미지를 넣는 등 미디어 관련 작업을 할 때, 미디어 프레임워크를 이용해서 인코딩, 트랜스코딩을 할 수 있는데요.

실제로 ExoPlayer도 내부적으로 미디어 프레임워크를 사용하고 있는만큼 동영상 재생의 원리가 되기도 합니다. 이번 포스팅에서는 코루틴과 미디어 프레임워크를 이용해 영상을 재생하는 작업을 소개해보려고 합니다.

영상은 이미지의 연속

그 전에 일반적인 동영상의 원리를 알아보겠습니다. 동영상의 원리는 여러 장의 사진을 빠르게 보여주면서 마치 사진들이 움직이는 것처럼 보이게 되는 겁니다.

플립북 — 동영상의 원리

얼마나 많은 사진을 보여주길래 우리는 사진이라고 인식하지 못할까요?

에 대한 단위가 FPS(Frame Per Second)입니다. 예를 들어, 어떤 동영상이 60FPS라고 한다면 이 동영상은 1초에 60장의 이미지를 빠르게 보여주고 있는것입니다. 이미지의 갯수가 많을수록 영상이 매끄럽게 이어지게 됩니다.

초기 무성 영화는 18FPS에서 24FPS정도의 프레임률이었고, 이 정도가 이미지들이 모여 동영상처럼 느껴지게 하는 최소한의 FPS입니다.

하지만, 동영상의 FPS가 높다고해서 화질이 좋아지는 것은 아닙니다. 화질과 관련된 정보는 해상도인데요. 해상도는 한 장의 이미지가 얼마나 많은 픽셀을 담고 있냐를 나타낸 수치입니다.

해상도별 픽셀

예를 들어, Full HD+의 동영상의 경우는 1장의 이미지당 2160 * 1080개의 픽셀이 존재하게 됩니다. 이는 한 장의 약 6MB 정도의 용량을 가지게 되는데요. 그렇다면 24FPS의 1분짜리 동영상은 얼마큼의 용량을 가질까요?

6MB * 24* 60 = 8640MB = 8.64GB

너무 크지 않나요? 여기에 오디오 트랙과 자막 등등이 추가적으로 들어간다면 1분짜리 영상 하나에 10GB는 족히 넘을 것 같습니다. 저희가 알고 있는 동영상의 용량과는 차이가 있는데, 이는 동영상을 만들 때 단순히 이미지를 배열에 저장하는 것이 아니라 압축을 해주기 때문입니다. 이 작업을 인코딩이라고 합니다.

1초에 렌더링될 24장의 이미지를 쭉 아래와 같이 나열한다면, 비슷한 픽셀을 가지고 있는 경우가 대부분일겁니다. 이렇게 중복된 픽셀을 줄일 수 있다면 영상의 용량이 좀 더 줄어들지 않을까요?

연속된 이미지들

이전 이미지와 비교해서 같은 데이터를 가지고 있는 픽셀은 내부 알고리즘에 의해 이미지 자체를 변형해서 동영상에 담게 됩니다. 그 이후에 비슷한 방식으로 음성 데이터와 자막, 메타 데이터 등등을 동영상에 담아 하나의 컨테이너를 만듭니다. 이 컨테이너가 우리가 알고있는 동영상 파일입니다. 대표적인 컨테이너 형식으로는 AVI, MP4, MKV 등등이 있습니다.

Container Architecture

영상을 컨테이너 형식으로 저장할 때 위에 설명한 방식대로 압축을 하여 저장한 것과 비슷한 원리로, 이런 동영상 컨테이너를 재생할 때도 압축된 데이터를 압축전의 데이터로 만드는 작업이 필요합니다. 이 과정을 디코딩이라고 합니다.

동영상 컨테이너는 디먹싱(트랙 추출) — 디코딩 — 렌더링의 과정을 거쳐서 재생됩니다. 이 과정을 안드로이드에서 구현을 하려면 MediaExtractorMediaCodec 객체를 이용해야 되고, 이런 기능을 제공하는 프레임워크가 안드로이드 미디어 프레임워크입니다.

MediaExtractor & MediaCodec

전체적으로 미디어 프레임워크는 어떤 과정을 통해서 영상을 재생하는지 알아보도록 하겠습니다.

Media Rendering Pipeline

위의 파이프라인에서 볼 수 있듯이 Source를 입력받으면 각각 재생에 필요한 트랙(스트림)을 Demuxing하여 Compressed Data를 트랙별로 추출합니다. 이후에 각 트랙들을 Decoding하여 Uncompressed Data를 만들어내고 결과적으로 화면에 Rendering할 수 있습니다.

자 이제, 실제로 안드로이드 미디어 프레임워크를 이용해서 비디오를 재생하는 코드를 작성해볼텐데요. 먼저, 입력받은 소스의 메타데이터를 이용해 MediaCodecMediaExtractor 객체를 만들어줘야합니다.

videoExtractor 는 비디오 스트림이 컨테이너 내에서 몇 번째 인덱스에 있는지를 firstVideoTrack 확장함수를 통해 찾은 후에 selectTrack 함수로 초기화가 가능합니다.

videoExtractor 객체를 생성하고나면 디코딩하는데 필요한 하드웨어 디코더를 mime 타입을 통해 찾을 수 있고, videoDecoder 또한 초기화가 가능해집니다. 이 때 configure 메소드를 호출해야만 디코딩할 수 있는 상태로 진입이 가능해지는데요.

MediaCodec State Diagram

start 메소드를 호출해야만 Executing 상태로 넘어가서 디코딩을 진행할 수 있는데, 그러기 위해서는 configured 상태를 먼저 만들어줘야 하기 때문에 초기 객체 생성시에 configure 메소드를 같이 호출해주었습니다.

자 이제, videoExtractorvideoDecoder 를 사용할 준비가 되었습니다. 재생할 때 필요한 비즈니스 로직을 작성해보도록 하겠습니다.

영상(음성)을 디먹싱/디코딩하는 작업은 안드로이드 하드웨어 리소스를 할당받기 때문에 synchronized 로 선언이 되어야합니다. 혹여나 다른 객체에서 해당 리소스에 동시에 접근한다면 동기화 문제가 생길 수 있기 때문이죠.

재생하는 로직에는 먼저 해당 작업이 끝났는지 진행되고 있는지를 알려주는 플래그값들을 초기화해주고, 각 디코더의 상태값을 start 메소드를 호출함으로써 Executing 상태로 변경시켜줍니다.

그리고 extract하는 작업과 decode하는 작업은 각각 다른 코루틴에서 비동기적으로 동작하는데요. 이는 모든 샘플을 추출한 다음에 동기적으로 디코딩을 시작하게 되면 대기 시간이 오래 걸리기 때문입니다.

이미지가 추출 될 때마다 디코딩을 바로 하고 디코드 된 이미지는 바로 렌더링까지 할 수 있게 여러 코루틴이 동시에 동작을 한다면 좀 더 좋은 사용자 경험을 만들 수 있습니다.

Extract & Decode Video

비디오 트랙을 추출하는 로직을 살펴보겠습니다.

extract & decode input buffer

디코더와 클라이언트(extractor)의 데이터 통신은 버퍼를 통해 이뤄집니다. 디코더 내부에 존재하는 버퍼들은 클라이언트에게 전달하고, 클라이언트는 자신이 가지고 있는 데이터를 빈 버퍼 안에 채워서 다시 디코더에게 전달합니다.

디코더는 클라이언트로부터 데이터를 전달받으면 내부 알고리즘을 거쳐 압축 해제된 데이터를 저장하게 됩니다.

실제로 구현한 코드를 보도록 하겠습니다.

videoDecoder가 가지고 있는 inputBufferdequeInputBuffer()를 호출해 videoExtractor에게 넘겨줍니다.

이 때 videoDecoderFlushed상태에서 Running상태로 바뀌게 됩니다.

videoExtractor 가 버퍼를 받았으니, readSampleData()를 호출해 본인이 가지고 있는 비디오 스트림을 추출하게 되는데요. 추출한 데이터를 inputBuffer 안에 담아줍니다.

그 이후에 videoDecoderqueueInputBuffer() 메소드를 호출해 전달해준 inputBuffer를 다시 디코더 내부로 가져오게 됩니다. videoDecoder가 정상적으로 데이터를 가져왔다면 이제 다음 데이터를 추출해서 전달받아야겠죠.

다음 데이터를 추출하기 위해서 videoExtractoradvance() 메소드를 호출하고, 위 과정을 계속 반복하게 됩니다. 몇 번의 반복 끝에 데이터 추출이 완료되면 MediaCodec.BUFFER_FLAG_END_OF_STREAM이라는 플래그를 디코더에게 전달하고 디코더는 다시 Flushed 상태로 바뀌게 됩니다. 비즈니스 로직도 videoInEos 플래그의 값을 바꿔줌으로써 비디오 스트림의 추출을 종료하게 됩니다.

extractCoroutineScope은 연산량이 많은 작업이고 UI쓰레드를 방해하면 안되므로 CoroutineScope(Dispatchers.Default)으로 초기화해주었습니다. 재귀방식으로 Job을 계속 생성하기 때문에, 너무 많은 요청을 방지하기 위해서 delay로 job을 생성하는 주기를 조절해주어야 합니다.

Render Video

render output buffer

디코더는 데이터를 전달받고, 내부 알고리즘을 통해서 Uncompressed Data를 저정한다고 했는데요. 이렇게 저장한 데이터를 다시 클라이언트(Renderer)에게 버퍼에 담아서 전달하면 클라이언트는 화면에 렌더링을 하게 됩니다.

렌더링을 마치고 남은 버퍼는 다시 디코더에게 회수됩니다.

videoDecoderdequeueOutputBuffer() 메소드를 호출해 디코더 내부에 존재하는 Uncomporessed Data가 채워진 버퍼를 꺼냅니다.

이 때 정상적으로 outputBuffer 를 꺼내왔다면 releaseOutputBuffer() 메소르를 호출하여 화면에 렌더링할 수 있게 되는데요.

이 때 화면은 처음 디코더를 생성했을 때 configure 해주었던 surface를 말합니다.

public void releaseOutputBuffer(int index, boolean render)

render라는 파라미터에 true값을 넘겨주게 되면 화면에 이미지가 렌더링 되는 것을 확인할 수 있습니다. 디코더 내부에 있는 모든 데이터를 렌더링하게 된다면 render 파라미터에 false를 할당해 렌더링 작업을 마칠 수 있습니다.

Video Play

이 때, 비디오 렌더링은 Main Thread에서 동작해야하므로 videoRenderCoroutineScopeCoroutineScope(Dispatchers.Main)으로 초기화해주었습니다.

TODO

비디오 스트림을 추출해서 디코딩한 후에 렌더링하는 작업까지 해봤는데요. 그런데 위의 영상이 원래 영상보다 빠르게 재생되는 것을 확인할 수 있습니다. 이는 영상을 디먹싱 — 디코딩 — 렌더링 하는 속도가 너무 빨라서 발생하는 문제인데요.

또한 오디오 스트림과의 싱크도 맞지 않습니다. 영상을 제대로 재생하기 위해서는 속도도 맞춰야 하고 여러 다른 스트림들과의 동기화 작업도 추가적으로 필요할 것 같습니다.

감사합니다.

참조

https://deview.kr/2020/sessions/355

https://developer.android.com/reference/android/media/MediaCodec

--

--

goddoro
goddoro

Written by goddoro

TVING에서 동영상 플레이어를 개발하고 있습니다

No responses yet