동기
네이버 부스트캠프에서 진행 중인 그룹 프로젝트가 드디어 개발 막바지 단계에 이르렀습니다.
영상 업로드 기능을 구현하면서, 영상의 썸네일을 추출하고 함께 전송하는 기능도 구현하였습니다.
해당 과정에 대해서는 다음 포스트에 자세히 기록되어 있습니다.
Third-Party-Library 없이 비디오 컷 편집 기능 구현하기
동기 네이버 부스트캠프의 꽃, 그룹 프로젝트를 개발하고 있습니다. 실력있는 동료들과 함께 비디오와 관련된 앱 서비스를 기획, 디자인, 개발하고 있는데요. 제가 낸 주제이기도 하고, 비디오
blothhundr.tistory.com
위 포스트에는 썸네일을 얻어 오기 위해 MediaCodec 과 MediaMetadataRetriever 를 두고 고민했던 과정이 담겨있는데요. 당시에는 MediaCodec 을 활용하는 방향으로 먼저 구현을 했으나, 속도가 느린 감이 있어 구현도 깔끔하고 속도도 비슷한 MediaMetadataRetriever 를 최종 선택하였습니다.
하지만 분명히 느린 감이 있었는데요. 카카오톡에서 영상을 보내거나 프로필 이미지로 영상을 선택할 때, 정말 빠르게 프레임 비트맵을 얻어 오는 것을 보곤, '뭔가 다른 방법이 있을텐데..' 라는 생각이 들었습니다.
그래서 제가 세운 가설은 두 가지였습니다. 첫 째는 FFmpeg 과 같은 Third-Party-Library 를 이용했을 것이다. 이고, 둘 째는 MediaCodec 을 활용하였을 것이다. 였습니다.
애초에 영상 컷 편집에 Third-Party-Library 를 사용하지 않는 것이 저와의 약속이었기에, 이슈를 해결하기 위해 FFmpeg 의존성을 추가하는 것은 선택지에서 제외 되었습니다. 그럼 남은 선택지는(적어도 제가 아는) MediaCodec 인데, 위 포스트에서는 분명히 MediaCodec 이 더 느리다고 기술하고 있습니다.
곰곰히 돌이켜 생각해봤는데, 당시엔 특정 프레임에 인덱스로 접근하지 않고 모든 프레임에 대해 버퍼를 넣었다 뺐다 하고 있었다는 걸 깨달았습니다. 그 부분을 수정하면 훨씬 빠르게 되지 않을까? 라는 생각이 들었고, 관련하여 리팩토링을 진행하였습니다. 오늘은 그 과정에서 알게 된 지식과 관련 코드를 공유하고자 합니다.
왜 느렸는가?
언급하였듯, MediaCodec 으로 썸네일을 추출하는 것에 이미 도전했던 기록이 남아 있습니다. 지금 생각해 보면 아주 많이 부끄러운데, MediaMetadataRetriever.getFrameAtTime() 메서드는 특정 TimeUs 에 대해서만 접근하는데, 당시에 작성했던 MediaCodec 관련 코드는 전체 프레임을 순회하는 방식의 코드였습니다.
그렇게 생각할 수밖에 없었던 것은, 샘플링을 위해서는 꼭 MediaExtractor.advance() 메서드를 호출해야 하는 줄로만 알았기 때문입니다.
개선해 보기
개선의 단서가 되었던 것은 MediaExtractor 의 seekTo() 메서드입니다.
/**
* All selected tracks seek near the requested time according to the
* specified mode.
*/
public native void seekTo(long timeUs, @SeekMode int mode);
native 키워드가 붙은 것으로 보아, IDE 차원에서 접근할 수 없는 메서드로 보입니다. 해당 메서드는 작성된 주석과 같이, MediaExtractor 를 파라미터로 넘겨준 PresentationTimeUs 와 대응되는 프레임을 반환할 수 있도록 설정합니다.
객체 초기화
먼저, MediaCodec 객체 및 MediaExtractor 객체를 초기화합니다. 이에 관한 코드는 언급한 포스트에도 있는 내용이긴 합니다.
val mediaExtractor = MediaExtractor().apply {
setDataSource(path)
}
먼저, MediaExtractor 를 선언합니다. 선언과 동시에 DataSource 를 설정해 주는데, 이때 넘겨주는 path 는 영상 파일의 절대 경로입니다.
var mimeType: String? = null
var format: MediaFormat? = null
for (i in 0 until mediaExtractor.trackCount) {
format = mediaExtractor.getTrackFormat(i)
mimeType = format.getString(MediaFormat.KEY_MIME)
if (mimeType?.startsWith(MIME_VIDEO) == true) {
mediaExtractor.selectTrack(i)
break
}
}
이후, 영상 파일의 트랙을 순회하면서 영상 트랙을 찾습니다. 영상 트랙을 찾으면 MediaExtractor 가 해당 트랙의 정보를 추출할 수 있도록 설정한 후 for 문을 탈출합니다. format 및 mimeType 에 할당되는 값 역시 영상 트랙에 해당합니다.
val decoder = MediaCodec.createDecoderByType(mimeType).apply {
configure(format, null, null, 0)
start()
}
format 과 mimeType 을 활용하여 Decoder 를 생성합니다. null 두 개는 각각 순서대로 렌더링을 위한 Surface 및 MediaCrypto 객체입니다. 저는 둘 다 딱히 필요 없어서 null 로 설정했습니다만, Android View System 에서 제공하는 Surface 로 프레임을 렌더링 하기 위해서는 별도의 설정이 필요할 것입니다.
이후로는 사실 간단합니다. MediaCodec 의 기본 사용법과 같이, MediaExtractor 로부터 추출한 데이터를 InputBuffer 에 쓰고, 이를 MediaCodec 에 전달한 뒤, OutputBuffer 를 참조하여 처리된 데이터를 활용하고 OutputBuffer 를 릴리즈해주면 됩니다.
다만, 이 때 MediaExtractor 의 seekTo() 메서드를 활용하여 추출하고자 하는 프레임을 설정해주어야 합니다.
val unit = duration * 1000L / THUMBNAIL_COUNT
for (i in 1..THUMBNAIL_COUNT) {
mediaExtractor.seekTo(unit * i, MediaExtractor.SEEK_TO_CLOSEST_SYNC)
...
}
seekTo() 의 mode 파라미터로 넘겨줄 수 있는 모드는 총 세 가지가 있는데요.
SEEK_TO_PREVIOUS_SYNC,
SEEK_TO_NEXT_SYNC,
SEEK_TO_CLOSEST_SYNC
이렇게 세 가지입니다. 저는 편중되지 않은 썸네일을 제공하여 영상 편집에 편의를 더하기 위해, 마지막 모드인 SEEK_TO_CLOSEST_SYNC 로 설정하였습니다. 사실 큰 차이는 없는 것 같습니다.
OutputBuffer 에서 프레임 추출하기
OutputBuffer 에는 MediaCodec 에 의해 처리된 데이터가 담겨 있으니, 이를 비트맵으로 변환하면 끝일 거라고 생각했습니다만, 그리 녹록지는 않았습니다. 단순히 BitmapFactory.decodeByteArray() 메서드를 사용하니, 변환이 안 되더군요.
이와 관련하여 조금 찾아보니, 프레임은 Y'UV(이하 YUV) 라는 방식으로 저장됨을 알 수 있었습니다.
YUV 는 1개의 휘도(Y), 2개의 색차(청색 색차 정보를 의미하는 U, 적색 색차 정보를 의미하는 V) 정보를 갖는 색의 구성 방식입니다. 컬러 TV 가 탄생함에 따라, 이를 송출하기 위해 고안됐습니다. 기존에 존재하던 RGB 를 통해 송출하자니 인프라도 모두 뜯어고쳐야 하고, 애초에 RGB를 통한 송출 자체가 힘들었다고 합니다. 그래서 나온 것이 기존의 흑백 신호에 색차 신호만 추가한 YUV 입니다.
압축률과 성능면에서는 YUV 가 RGB 에 비해 나으므로, 영상물의 경우에는 YUV 를 사용하는 게 일반적입니다. 인코딩, 디코딩, 전송 등에 준수한 속도를 보장해야 하며, 스트리밍의 경우에도 영상이 멈추는 등의 문제가 발생하면 안 되니, 조금이라도 속도가 빠른 쪽을 선택할 수밖에 없습니다. 이러한 이유로 안드로이드 영상의 프레임이 YUV 로 저장되어 있는 것입니다. YUV 역시 RGB 와 같이 크로마 서브샘플링이 가능하며, 안드로이드 환경에서도 이에 관한 여러 클래스와 메서드를 제공하고 있습니다.
안드로이드 기기로 촬영하는 영상의 프레임은 대개 NV21 포맷으로 저장됩니다. OutputBuffer 에 담긴 프레임 정보를 YUV 형태로 획득할 수 있습니다.
val yuvImage = YuvImage(byteBuffer.moveToByteArray(), ImageFormat.NV21, width, height, null)
그러나, 이 역시 그냥 확인할 수는 없습니다. YUV 와 RGB 는 색을 표현하는 방식 자체가 다르고, 비트맵은 YUV 가 아닌 RGB 방식을 지원합니다. 그러므로, YUV 를 RGB 에 맞게 변환해주어야 합니다.
Y = 0.257 * R + 0.504 * G + 0.098 * B + 16;
U = -0.148 * R - 0.291 * G + 0.439 * B + 128;
V = 0.439 * R - 0.368 * G - 0.071 * B + 128;
수식은 위와 같은데, 모든 픽셀에 접근하여 이러한 계산을 수행하면 정말 긴 시간이 걸리게 됩니다. 다행인 것은, YuvImage 클래스가 compressToJpeg() 메서드를 제공한다는 것입니다. 간편하고 빠르게 이를 JPG 로 저장할 수 있습니다. 그러나, 추가적인 문제가 발생했습니다.
분명히 살구색 손이 키보드를 타건하고 있는 영상인데, 어이없게도 푸르게 나옵니다. compressToJpeg() 메서드 내부에는 분명히 모든 처리가 다 되어있을 텐데, 결과물은 그렇지 못합니다. 원인은 사실 간단한데, NV21 의 경우, 다른 YUV 형식과는 달리 청색 색차 정보 U와 적색 색차 정보 V가 반대로 되어있습니다. 청색 색차 정보와 적색 색차 정보를 서로 바꿔주면 됩니다.
private fun swapRAndB(originalBitmap: Bitmap): Bitmap {
val width = originalBitmap.width
val height = originalBitmap.height
val swappedBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
for (x in 0 until width) {
for (y in 0 until height) {
val pixel = originalBitmap.getPixel(x, y)
val red = Color.blue(pixel)
val green = Color.green(pixel)
val blue = Color.red(pixel)
val newPixel = Color.rgb(red, green, blue)
swappedBitmap.setPixel(x, y, newPixel)
}
}
return swappedBitmap
}
처음에는 꽤 단순하게 접근했습니다. 비트맵에 직접 접근하여 모든 픽셀의 청색과 적색의 값을 서로 스왑 해주는 방식을 구상했죠. 이렇게 하니, 다음과 같이 정상적으로 프레임을 얻어 올 수 있었습니다.
다만 문제가 있었는데, 말도 안 되게 오래 걸립니다. 8개의 비트맵을 처리하는 데에만 3분 이상 걸렸습니다. 여기서 꽤 깊게 고민했는데, 시중의 수많은 카메라 및 이미지 앱에서는 대부분 필터 기능을 제공하는 것을 떠올렸고, 이에 대한 구현을 찾아보았습니다. 여기서 ColorMatrix 라는 힌트를 얻게 됩니다.
청색과 적색의 위치만 바꿔주면 처리가 끝나므로, 이를 다음과 같은 ColorMatrix 로 선언할 수 있습니다.
private val filteredPaint by lazy {
val colorMatrix = ColorMatrix(
floatArrayOf(
0f, 0f, 1f, 0f, 0f, // R값과 B값 교환
0f, 1f, 0f, 0f, 0f, // G값 그대로 유지
1f, 0f, 0f, 0f, 0f, // B값과 R값 교환
0f, 0f, 0f, 1f, 0f // A값 그대로 유지
)
)
val colorFilter = ColorMatrixColorFilter(colorMatrix)
Paint().apply { setColorFilter(colorFilter) }
}
이를 활용하여 Canvas 객체에 그려내면 되므로, 위를 참조하는 코드는 다음과 같습니다.
var originalBitmap = BitmapFactory.decodeByteArray(stream.toByteArray(), 0, stream.size())
val canvas = Canvas(filteredBitmap)
canvas.drawBitmap(originalBitmap, 0f, 0f, filteredPaint)
디테일 챙기기
이렇게 모든 구현이 완료되었다면 좋았을 텐데, 아쉽게도 몇 가지 문제가 남아 있었습니다.
위 이미지는 두 가지 문제점을 포함하고 있는데요.
첫 째는 프레임의 방향 및 표시가 이상하다는 것입니다. 영상은 분명 세로로 촬영되어 있고, 표시되기도 세로로 표시되는데 프레임은 가로 상태의 이미지가 세로로 회전되어 있습니다.
둘 째는, 예시에 사용된 카운팅 영상은 42초를 마지막으로 끝이 납니다. 그러나 마지막 프레임은 44초로 표시되고 있습니다. 그리고 저는 프레임 수를 15장으로 설정하였으나, 총 13장만 확인됩니다.
위 이미지에 나타나진 않지만 셋 째는, 프레임을 얻어 와 화면에 표시했을 때, 화면이 심하게 버벅거린다는 점입니다. 즉, 큰 프레임 드랍이 있는 것이지요.
첫 번째 이슈를 해결하기 위해서는 MediaCodec 에 지정되는 MediaFormat 의 Rotation 값을 얻어오면 해결할 수 있습니다.
val rotation = outputFormat.getInteger(KEY_ROTATION)
MediaFormat 의 가로와 세로 길이를 얻는 방식처럼, Rotation 값도 획득할 수 있습니다. 이를 활용하여 새롭게 메서드를 작성할 수 있었습니다.
private fun createScaledBitmap(
width: Int,
height: Int,
rotation: Int,
stream: ByteArrayOutputStream,
): Bitmap {
var originalBitmap = BitmapFactory.decodeByteArray(stream.toByteArray(), 0, stream.size())
if (rotation != 0) {
originalBitmap = Bitmap.createBitmap(originalBitmap, 0, 0, width, height, Matrix().apply { postRotate(rotation.toFloat()) }, true)
}
val filteredBitmap = Bitmap.createBitmap(
if (rotation == 0) width else height,
if (rotation == 0) height else width,
Bitmap.Config.ARGB_8888
)
val canvas = Canvas(filteredBitmap)
canvas.drawBitmap(originalBitmap, 0f, 0f, filteredPaint)
return Bitmap.createScaledBitmap(
filteredBitmap,
filteredBitmap.width / 4,
filteredBitmap.height / 4,
false
)
}
처음에는 MediaFormat 에서 획득한 가로, 세로 길이를 활용하여 originalBitmap 변수를 선언하고 프레임을 비트맵에 씁니다.
이후, 회전값이 없는 경우에는 그대로 사용하고, 그렇지 않은 경우에는 가로, 세로 길이를 서로 바꿔주고 회전시킵니다. 이후 NV21 에 대한 처리(청, 적색 스왑)를 수행하면 되겠습니다.
저는 이때 세 번째 이슈도 함께 해결했는데요. 다량의 고화질 이미지가 화면에 렌더링 되는 것이 부하를 일으키는 것이라 예상했고, 맞았습니다. 이를 Bimtap.createScaledBitmap() 메서드를 통해 리스케일링 하여 해결합니다.
이제 두 번째 이슈만 해결하면 되겠지요. 두 번째 이슈가 발생한 원인은 간단했는데, MediaCodec 의 dequeueOutputBuffer() 메서드가 음수를 반환하고 있기 때문이었습니다. 즉, OutputBuffer 를 정상적으로 획득하지 못하는 경우가 있습니다. 이러한 경우가 두 번 고정적으로 발생하여, 15장 중 13장만 획득하게 된 것입니다.
해당 메서드가 음수를 반환하는 경우는 세 가지가 있는데요. 다음과 같습니다.
/**
* If a non-negative timeout had been specified in the call
* to {@link #dequeueOutputBuffer}, indicates that the call timed out.
*/
public static final int INFO_TRY_AGAIN_LATER = -1;
/**
* The output format has changed, subsequent data will follow the new
* format. {@link #getOutputFormat()} returns the new format. Note, that
* you can also use the new {@link #getOutputFormat(int)} method to
* get the format for a specific output buffer. This frees you from
* having to track output format changes.
*/
public static final int INFO_OUTPUT_FORMAT_CHANGED = -2;
/**
* The output buffers have changed, the client must refer to the new
* set of output buffers returned by {@link #getOutputBuffers} from
* this point on.
*
* <p>Additionally, this event signals that the video scaling mode
* may have been reset to the default.</p>
*
* @deprecated This return value can be ignored as {@link
* #getOutputBuffers} has been deprecated. Client should
* request a current buffer using on of the get-buffer or
* get-image methods each time one has been dequeued.
*/
public static final int INFO_OUTPUT_BUFFERS_CHANGED = -3;
INFO_TRY_AGAIN_LATER 의 경우, 음수 시간이 아닌 양수 시간을 타임 아웃으로 지정한 상황에서 타임 아웃이 발생한 경우입니다.
INFO_OUTPUT_FORMAT_CHANGED 는 MediaCodec 의 프로퍼티인 outputFormat 이 변경된 경우입니다. 저의 경우에도 outputFormat 을 초기에 설정하고 있으므로, 당연히 발생할 수 있는 에러입니다.
INFO_OUTPUT_BUFFERS_CHANGED 는 현재 Deprecated 되어 있는 에러입니다. OutputBuffer Set 이 변경된 경우인데, API 21 부터 사용되지 않는 에러이므로 해당 값을 리턴으로 받는 경우에는, 이를 무시하고 양의 정수를 반환받을 때까지 여러 번 시도하면 됩니다. 다음과 같은 코드로 간단히 해결할 수 있었습니다.
var outputBufferIndex = dequeueOutputBuffer(bufferInfo, OUTPUT_BUFFER_TIMEOUT)
while (outputBufferIndex < 0) {
outputBufferIndex = dequeueOutputBuffer(bufferInfo, OUTPUT_BUFFER_TIMEOUT)
}
이와 같은 처리들을 통해 세로가 더 긴 영상, 가로가 더 긴 영상, 회전된 영상 등 모든 경우에 대한 대응이 완료되었습니다.
성능 비교
좌측부터 순서대로 MediaMetadataRetriever 의 getFrameAtTime() 메서드 사용(기존 방식),
Android RenderScript Toolkit 사용(Toolkit.yuvToRgbBitmap(), Toolkit.colorMatrix)
별도의 의존성 추가 없이 진행(본문 내용)
입니다.
약 10분 길이의 영상으로 각각 1.11초, 0.46초, 0.42초 걸렸으며, 최대 약 62%의 속도 개선이 있었습니다. 퍼센테이지로 보면 유의미한 차이이긴 합니다만, 절대 시간으로 보면 그렇지만은 않다는 생각도 드네요.
꽤 긴 시간이 걸렸던 것 같은데, 해결하고 나니 굉장히 뿌듯합니다. 라이브러리를 쓰면 금방 풀어낼 수 있었겠지만, 직접 이런 방식, 저런 방식으로 시도해 보면서 문제를 바라보는 시각도 넓어지고, 견문과 배경 지식도 쌓을 수 있다고 생각해요. 하나 씩 해결했을 땐 정말 너무나 짜릿했던 것 같습니다.
'Android > Trouble Shoot' 카테고리의 다른 글
AccessToken 재발급과 예외 처리 (feat.Ktor) (0) | 2023.12.02 |
---|---|
[Jetpack Compose] List 아이템의 필드 변경이 Recomposition 을 트리거 해야 할 때 (0) | 2023.05.16 |
[Jetpack Compose] LazyColumn, LazyRow 의 항목 삭제가 정상적으로 이루어지지 않는 이슈 (0) | 2023.04.25 |
[Jetpack Compose] Column 속 LazyColumn 의 OverScroll 을 막는 방법 (0) | 2023.02.23 |
[Jetpack Compose] LazyColumn/LazyRow/Pager OverScrollEffect 없애기. (0) | 2022.11.19 |