[Android] MediaMetadataRetriever: 미디어 정보와 썸네일 추출하기

이주형·2025년 4월 15일
post-thumbnail

❗ 문제 상황

다음과 같은 기능을 구현하려 했습니다:

  1. 미디어 배경: 비디오 파일을 화면의 배경으로 재생.

  2. 5초 카운트다운: 앱 실행 시 5초 카운트다운을 표시.

  3. 25분 타이머: 카운트다운 종료 후 25분 타이머를 시작.

  4. 블러 처리: 카운트다운 동안 미디어 배경을 블러 처리하여 "시작" 시점을 강조하고, 카운트다운 종료 시 블러를 제거.

이를 구현하기 위해 MediaMetadataRetriever로 비디오의 첫 프레임을 썸네일로 추출한 뒤, Cloudy 라이브러리(com.github.skydoves:cloudy:0.2.6)로 블러 처리하여 카운트다운 동안 표시하고, 5초 후 블러를 제거하려 했습니다. 하지만 다음과 같은 문제가 발생했습니다:

  • ExoPlayer로 재생되는 비디오와 블러된 썸네일의 동기화가 어려웠습니다.

  • Cloudy가 ExoPlayer의 동적 콘텐츠를 직접 블러 처리하지 못했습니다.

  • 카운트다운과 타이머 전환 시 블러 효과를 부드럽게 적용/제거해야 했습니다.

❓ 원인 파악

1. Cloudy의 한계:

  • Cloudy는 Jetpack Compose의 정적 UI 요소에 블러를 적용하도록 설계되었습니다.

  • ExoPlayer는 TextureView나 SurfaceView를 사용해 하드웨어 가속으로 비디오를 렌더링하므로, Cloudy가 이를 직접 캡처하거나 블러링하기 어렵습니다.

2. 정적 vs 실시간 블러:

  • MediaMetadataRetriever로 첫 프레임을 추출해 블러링하는 방식은 정적이며, 비디오의 진행과 어긋날 수 있습니다.

  • 실시간 블러는 프레임을 주기적으로 캡처하고 블러링해야 하지만, 이는 성능 부담이 큽니다.

3. 타이밍 동기화 문제:

  • 5초 카운트다운과 블러 해제, 25분 타이머 시작을 정확히 동기화하려면 상태 관리가 필요했습니다.

  • 블러 오버레이의 표시/숨김 전환이 부드럽지 않으면 UX가 저하될 수 있었습니다.

4. MediaMetadataRetriever의 특성:

  • MediaMetadataRetriever는 메타데이터 추출과 정적 프레임 캡처에 최적화되어 있지만, 실시간 프레임 처리는 불가능합니다.

  • 첫 프레임만 사용하면 비디오의 동적 콘텐츠와 시각적 불일치가 발생할 가능성이 있습니다.

✅ 해결 방법

MediaMetadataRetriever란?

MediaMetadataRetriever는 Android의 android.media 패키지에 포함된 클래스로, 미디어 파일에서 메타데이터와 프레임 데이터를 추출하는 데 사용됩니다. FFmpeg 같은 미디어 프레임워크를 기반으로 동작하며, 복잡한 미디어 파싱을 직접 구현할 필요 없이 간단한 API로 필요한 정보를 가져올 수 있습니다.
주요 활용 사례는 다음과 같습니다:

  • 비디오의 썸네일 이미지 생성.

  • 미디어 파일의 속성(길이, 제목, 아티스트 등) 읽기.

  • 커스텀 UI에 활용할 미디어 데이터 추출.

주요 기능과 메서드

MediaMetadataRetriever는 크게 세 가지 작업을 수행할 수 있습니다: 데이터 소스 설정, 메타데이터 추출, 프레임 캡처. 각 기능에 맞는 메서드를 자세히 알아보겠습니다.

1. 데이터 소스 설정

먼저 MediaMetadataRetriever가 어떤 미디어 파일을 분석할지 지정해야 합니다. 이를 위해 setDataSource 메서드를 사용합니다. 다양한 오버로드가 제공되므로 상황에 맞게 선택하면 됩니다.

// 예시: 로컬 파일 설정

val retriever = MediaMetadataRetriever()
retriever.setDataSource("/sdcard/video.mp4")
// 예시: URL 설정

val retriever = MediaMetadataRetriever()
val context = applicationContext
retriever.setDataSource(context, Uri.parse("https://example.com/video.mp4"))

주요 오버로드

  • setDataSource(String path): 로컬 파일 경로.

  • setDataSource(Context context, Uri uri): Uri로 로컬 파일이나 원격 URL 지정.

  • setDataSource(FileDescriptor fd): 파일 디스크립터 사용.

  • setDataSource(String path, Map<String, String> headers): HTTP 요청에 커스텀 헤더 추가.

팁: 원격 URL을 사용할 때는 INTERNET 권한(uses-permission android:name="android.permission.INTERNET")을 AndroidManifest.xml에 추가해야 합니다.

2. 메타데이터 추출
미디어 파일에는 제목, 아티스트, 재생 시간 같은 메타데이터가 포함되어 있습니다. extractMetadata 메서드를 사용하면 이런 정보를 문자열로 가져올 수 있습니다. keyCode는 MediaMetadataRetriever에 정의된 상수를 사용합니다.

지원되는 주요 메타데이터 키

  • METADATA_KEY_DURATION: 재생 시간(밀리초).

  • METADATA_KEY_VIDEO_WIDTH: 비디오 너비(px).

  • METADATA_KEY_VIDEO_HEIGHT: 비디오 높이(px).

  • METADATA_KEY_TITLE: 제목.

  • METADATA_KEY_ARTIST: 아티스트 이름.

// 예시: 비디오 정보 가져오기

fun getVideoInfo(context: Context, videoUri: String): Map<String, String?> {
    return MediaMetadataRetriever().use { retriever ->
        retriever.setDataSource(context, Uri.parse(videoUri))
        mapOf(
            "Duration" to retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION),
            "Width" to retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH),
            "Height" to retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT),
            "Title" to retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE)
        )
    }
}
// 사용 예시

val videoInfo = getVideoInfo(context, "https://example.com/video.mp4")
println("Duration: ${videoInfo["Duration"]}ms, Resolution: ${videoInfo["Width"]}x${videoInfo["Height"]}")

3. 프레임 캡처
비디오에서 특정 시간의 프레임을 Bitmap으로 가져오는 것은 MediaMetadataRetriever의 강력한 기능 중 하나입니다. getFrameAtTime 메서드를 사용하며, 두 가지 파라미터가 중요합니다:

  • timeUs: 프레임을 가져올 시간(마이크로초 단위).

  • option: 어떤 프레임을 반환할지 결정.

프레임 캡처 옵션

  • OPTION_PREVIOUS_SYNC: 요청 시간 이전의 가장 가까운 키프레임.

  • OPTION_NEXT_SYNC: 요청 시간 이후의 가장 가까운 키프레임.

  • OPTION_CLOSEST_SYNC: 요청 시간과 가장 가까운 키프레임(앞뒤 상관없이).

  • OPTION_CLOSEST: 요청 시간과 가장 가까운 프레임(키프레임 여부 상관없음).

// 예시: 첫 프레임 캡처

fun getVideoThumbnail(context: Context, videoUri: String): Bitmap? {
    return MediaMetadataRetriever().use { retriever ->
        retriever.setDataSource(context, Uri.parse(videoUri))
        retriever.getFrameAtTime(0, MediaMetadataRetriever.OPTION_CLOSEST)
    }
}

// Jetpack Compose에서 사용
@Composable
fun VideoThumbnailView(videoUri: String) {
    val context = LocalContext.current
    var thumbnail by remember { mutableStateOf<Bitmap?>(null) }

    LaunchedEffect(Unit) {
        thumbnail = getVideoThumbnail(context, videoUri)
    }

    thumbnail?.let {
        Image(
            bitmap = it.asImageBitmap(),
            contentDescription = "Video Thumbnail",
            modifier = Modifier.size(200.dp)
        )
    }
}

동작 원리

MediaMetadataRetriever는 미디어 파일을 파싱하고 디코딩하는 과정을 내부적으로 처리합니다:

  1. 파일 파싱: FFmpeg 기반으로 파일 구조를 분석해 메타데이터를 읽습니다.

  2. 프레임 디코딩: 비디오의 경우, 요청한 시간에 맞는 프레임을 찾아 디코딩 후 Bitmap으로 변환합니다.

  3. 캐싱: 메타데이터는 캐싱해서 빠르게 반환하지만, 프레임 캡처는 실시간으로 수행됩니다.

키프레임이란?
비디오 압축에서 I-frame(키프레임)은 독립적으로 디코딩 가능한 프레임입니다. P-frame, B-frame은 이전/이후 프레임을 참조해서 디코딩됩니다. OPTION_CLOSEST는 이런 프레임도 계산해서 반환할 수 있습니다.

🎈 결과

val context = LocalContext.current
var applyBlur by remember { mutableStateOf(true) }
var thumbnail by remember { mutableStateOf<Bitmap?>(null) }

val uiState by timerViewModel.uiState.collectAsStateWithLifecycle()

val exoPlayer = remember {
    ExoPlayer.Builder(context).build().apply {
        val videoUri = Uri.parse("android.resource://${context.packageName}/${uiState.bgResource}")
        val mediaItem = MediaItem.fromUri(videoUri)
        setMediaItem(mediaItem)
        repeatMode = Player.REPEAT_MODE_ONE
        volume = 0f
        prepare()
        playWhenReady = true

        MediaMetadataRetriever().use { retriever ->
            retriever.setDataSource(context, videoUri)
            thumbnail = retriever.getFrameAtTime(0, MediaMetadataRetriever.OPTION_CLOSEST)
        }
    }
}

Surface(
        modifier = Modifier.fillMaxSize(),
        color = MaterialTheme.colorScheme.background
    ) {
        Box(modifier = Modifier.fillMaxSize()) {
            VideoPlayer(
                exoPlayer = exoPlayer,
                modifier = Modifier
                    .fillMaxSize()
            )

            if(applyBlur) {
                thumbnail?.let {
                    Image(
                        bitmap = it.asImageBitmap(),
                        contentDescription = null,
                        contentScale = ContentScale.Crop,
                        modifier = Modifier
                            .fillMaxSize()
                            .cloudy(radius = 20)
                    )
                }
            }

            Box(
                modifier = Modifier.fillMaxSize(),
                contentAlignment = Alignment.Center
            ) {
                CountdownScreen(onTimerStart = { applyBlur = false })
            }
        }
    }
    
@Composable
fun VideoPlayer(exoPlayer: ExoPlayer, modifier: Modifier = Modifier) {
    AndroidView(
        factory = { context ->
            PlayerView(context).apply {
                player = exoPlayer
                useController = false
                resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM
            }
        },
        modifier = modifier
    )
}

장점과 한계

장점

  • 사용 간편: 복잡한 미디어 처리를 간단한 API로 해결합니다.

  • 유연성: 로컬 파일, URL, 파일 디스크립터 등 다양한 소스를 지원합니다.

  • Compose 통합: Kotlin의 use와 상태 관리로 깔끔하게 사용할 수 있습니다.

한계

  • 성능: 프레임 캡처는 디코딩이 필요해서 느릴 수 있습니다. 특히 OPTION_CLOSEST는 추가 계산이 필요합니다.

  • 메모리: 큰 해상도의 Bitmap을 반환하므로 OOM에 주의해야 합니다.

  • 실시간 불가: 실시간 프레임 처리는 지원하지 않습니다(정적인 데이터만).

0개의 댓글