Home [Android] 동영상에 그림 그리기 (7) - MediaCodec으로 인코딩
Post
Cancel

[Android] 동영상에 그림 그리기 (7) - MediaCodec으로 인코딩

이번 포스트에서는 MediaCodec과 MediaMuxer를 사용하여 렌더링된 그래픽들을 동영상으로 합성하는 내용에 대해 작성하도록 하겠습니다.

MediaCodec

MediaCodec은 인코딩, 디코딩 기능을 하는 클래스입니다.

MediaCodec 클래스에서 사용하는 데이터는 ByteBuffer로도 가능하지만, 비디오 데이터는 지난 포스트들에서 그래픽 버퍼 전달에 쓰여왔던 Surface를 사용하는 것이 코덱 성능에 이롭습니다. 지난 포스트들에서 겪었던 과정들을 돌아보면 Surface 클래스를 사용하면서 그래픽 버퍼를 이동시키기 위해 복사를 하거나 매핑하는 과정을 거칠 필요가 없었기 때문에 효율적이라고 할 수 있습니다.

또한 Surface를 사용하는 것이 구현에 있어서도 더 편합니다. MediaCodec의 입력으로 Surface를 사용하면, 자동으로 버퍼를 코덱에 연결하기 때문에 입력 버퍼와 관련된 함수를 사용할 필요가 없을 뿐만 아니라, 색 영역에 대한 포맷 설정이 매우 간단해집니다.

H.264(AVC) 포맷으로 인코딩시에는 YUV420 값이 필요하고, 이 포맷은 기기의 색상값에 따라 다르기 때문에 확인을 해야 하는 등 복잡한 과정이 필요합니다. 하지만 Surface를 사용한다면, 아래와 같이 색 영역 포맷 설정을 끝낼 수 있습니다.

1
2
3
4
format.setInteger(
    MediaFormat.KEY_COLOR_FORMAT,
    MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
)

Surface로 전달받는 비디오 버퍼의 색 영역 포맷을 그대로 사용할 수 있습니다. 그렇다면 색 영역 포맷은 어디서 설정했을까요? 이전 포스트에서 EGLConfig를 설정할 때 RGBA_8888 로 설정했습니다. 이 값이 그대로 적용될 것입니다.

또한 Surface 사용시 동영상이 끝나는 지점을 signalEndOfInputStream() 함수로 알 수 있기 때문에 종료 시점을 아는 것에 있어 유용합니다.

정리하자면, Surface 클래스를 사용하는 것이 ByteBuffer를 사용하는 것 보다 인코딩/디코딩에 있어 편리합니다.

MediaCodec 클래스는 createInputSurface() 함수로 Surface 인스턴스를 생성하여 인코더의 입력으로 사용할 수 있습니다. 이는 지난 포스트에서 SurfaceTexture가 생성한 Surface 인스턴스를 MediaPlayer에게 주어 동영상의 이미지 버퍼를 받던 것과 같은 방식이라고 생각할 수 있습니다.

하지만 createInputSurface() 전에 인코더의 포맷을 설정해야 합니다.

1
val encoder = MediaCodec.createEncoderByType("video/avc")

우선 인코더를 희망하는 MIME 타입에 맞게 생성하고, 포맷 설정을 살펴보도록 하겠습니다.

MediaFormat

필수적으로 설정하여야 하는 포맷은 MediaFormat 레퍼런스 페이지를 참조하면 됩니다.

1
val format = MediaFormat.createVideoFormat("video/avc", width, height)

우선 최소한의 설정을 담은 비디오 포맷을 생성합니다. MIME, 너비, 높이 값(픽셀 단위)이 파라미터로 필요합니다. 이후부터는 format.setInteger(...)로 포맷을 더해가면 됩니다.

1
2
3
4
5
6
7
format.setInteger(
    MediaFormat.KEY_COLOR_FORMAT,
    MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
)
format.setInteger(MediaFormat.KEY_BIT_RATE, bitrate)
format.setInteger(MediaFormat.KEY_FRAME_RATE, frameRate)
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1)

KEY_COLOR_FORMAT은 위에서 작성한대로 Surface의 포맷을 따르도록 하였습니다.

KEY_BIT_RATE는 비트레이트 값을 설정합니다. 안드로이드에서 H.264(AVC) 인코딩 시에 권장하는 스펙은 공식 문서 에서 확인할 수 있습니다.

KEY_FRAME_RATE는 목표로 하는 초당 프레임 수를 설정합니다. 하지만 실제 값은 timestamp에 의해 각 프레임이 자리하는 시간이 바뀌기 때문에, 여기서 설정한 FPS 값대로 동영상이 출력된다는 보장은 없습니다.

KEY_I_FRAME_INTERVAL은 Intra Frame이 몇 초에 하나씩 위치할 것인지를 설정합니다. 인코딩 시에는 모든 프레임이 완전한 그림의 형태를 가지지 않고, 이러한 Intra Frame와의 변화값만을 가져 크기를 압축합니다. 하지만 Intra Frame이 너무 적다면, 영상을 재생하다가 다른 부분을 보고 싶어서 중간으로 이동할 때, 해당 프레임을 알기 위해서 Intra Frame을 찾을 때 목표 위치와 크게 떨어질 수 있고, 그 결과 재생에 오랜 시간이 걸리게 될 것입니다. 따라서 적절한 값을 설정하여야 합니다.

KEY_I_FRAME_INTERVAL 값을 음수로 설정하면 첫 번째 프레임을 제외한 어떠한 Intra Frame도 설정하지 않으며, 값을 0으로 설정시에는 모든 프레임이 Intra Frame이 됩니다.

포맷 설정을 마치고 나면, 이제 createInputSurface()로 Surface 인스턴스를 생성할 수 있을 것입니다.

MediaCodec 설정

1
2
3
encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
inputSurface = encoder.createInputSurface()
encoder.start()

createInputSurface()는 레퍼런스 문서에서 확인할 수 있듯이, configure(...) 이후, start() 이전에 위치하여야 합니다.

configure(…)MediaFormat, Surface, MediaCrypto, flags: Int 를 필요로 합니다. MediaFormat은 위에서 설정한 포맷이 그대로 들어가면 되고, Surface와 MediaCrypto는 인코더에서는 사용하지 않으므로 null, 인코더로 사용을 알리는 flag인 CONFIGURE_FLAG_ENCODE을 각각 인자로 넣었습니다.

모든 설정이 끝난 후, start() 함수를 호출하면 됩니다.

인코딩

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
val bufferInfo = MediaCodec.BufferInfo()

while (true) {
    when (val encoderStatus = encoder.dequeueOutputBuffer(bufferInfo, 0)) {
        MediaCodec.INFO_TRY_AGAIN_LATER -> break
        MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
            encodedFormat = encoder.outputFormat
            videoTrack = mediaMuxer.addTrack(encodedFormat)
            mediaMuxer.start()
            isMuxerStart = true
        }
        else -> {
            val encoderOutputBuffers = encoder.getOutputBuffer(encoderStatus)
                ?: throw Exception("MediaCodec.getOutputBuffer is null")
            if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) bufferInfo.size = 0
            if (bufferInfo.size != 0) {
                if (isMuxerStart) mediaMuxer.writeSampleData(
                    videoTrack,
                    encoderOutputBuffers,
                    bufferInfo
                )
            }
            encoder.releaseOutputBuffer(encoderStatus, false)
            if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) break
        }
    }
}

dequeueOutputBuffer는 출력 버퍼의 인덱스를 반환합니다. 두 개의 파라미터가 필요한데, 첫 번째는 MediaCodec.BufferInfo로, 버퍼의 메타데이터가 채워지게 됩니다. 두 번째로는 타임아웃입니다. 타임아웃 값을 0으로 설정하였는데 이 경우 버퍼를 확인하고 없으면 기다리지 않고 바로 INFO_TRY_AGAIN_LATER 값을 반환합니다.

이 과정이 성공적으로 끝났다면 이후로는 getOutputBuffer로 출력 버퍼의 데이터를 ByteBuffer로 가져옵니다. 이제 dequeueOutputBuffer(...)에서 가져온 BufferInfo의 메타데이터를 확인하여 유효한 값인지 확인하여야 합니다.

우선 bufferInfo.flagsMediaCodec.BUFFER_FLAG_CODEC_CONFIG == 2의 비트연산입니다. 비트연산에서 0이 나오지 않으면 size를 0으로 만들어 다음에서 MediaMuxer에 버퍼를 넘기지 않도록 구현이 되어있습니다. 다른 일반적인 flags는 2와의 비트연산 결과에서 0이 나올 수 있도록 값을 가지고 있습니다. MediaCodec.INFO_TRY_AGAIN_LATER == -1MediaCodec.INFO_OUTPUT_FORMAT_CHANGED == -2 둘은 연산결과에서 0이 아닌 값을 가지게 되는데, dequeueOutputBuffer의 반환값을 구분했던 것과 같습니다.

다음으로 MediaMuxer에 출력 버퍼의 데이터를 전달하고, 출력 버퍼를 release 합니다. 이 작업을 매 프레임마다 반복합니다.

MediaMuxer

MediaCodec의 결과물이 바로 .MP4 등의 파일로 출력되는 것은 아닙니다. 포맷에 맞게 인코딩은 완료되었지만, 이제 파일로 만드는 부분을 MediaMuxer가 담당하게 됩니다.

인코딩 데이터는 writeSampleData 함수에 ByteBuffer 데이터와 MediaCodec.BufferInfo 메타데이터를 파라미터로 전달하게 됩니다.

종료

인코딩을 마치고 나면, MediaCodec과 MediaMuxer 각각을 종료해야 합니다.

MediaCodec에서의 stop은 인코딩/디코딩 후 호출하면 됩니다. stop() 호출 뒤에도 다시 start() 함수를 호출하여 다시 시작할 수 있습니다.

MediaCodec에서 사용하였던 모든 리소스들을 정리하려면 release를 호출하여야 합니다. 인코딩/디코딩 작업이 끝났을 때 메모리 정리를 가비지 컬렉터에 의존하지 말고, 직접 release() 함수를 호출하여야 한다고 공식 문서에 명시되어 있습니다.

MediaMuxer에서의 stop은 약간 다릅니다. 호출 후에는 다시 시작될 수 없습니다.

MediaMuxer에서의 releaseMediaCodecrelease처럼 사용한 리소스들을 정리합니다.

두 클래스의 인스턴스에 대하여 각각 stop(), release() 순서로 호출하면 됩니다.

This post is licensed under CC BY 4.0 by the author.

[Android] 동영상에 그림 그리기 (6) - Framebuffer 전달 및 복사

[Android] 동영상에 그림 그리기 (8) - BufferQueue, WindowManager, SurfaceFlinger