다음과 같은 기능을 구현하려 했습니다:
미디어 배경: 비디오 파일을 화면의 배경으로 재생.
5초 카운트다운: 앱 실행 시 5초 카운트다운을 표시.
25분 타이머: 카운트다운 종료 후 25분 타이머를 시작.
블러 처리: 카운트다운 동안 미디어 배경을 블러 처리하여 "시작" 시점을 강조하고, 카운트다운 종료 시 블러를 제거.
이를 구현하기 위해 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는 Android의 android.media 패키지에 포함된 클래스로, 미디어 파일에서 메타데이터와 프레임 데이터를 추출하는 데 사용됩니다. FFmpeg 같은 미디어 프레임워크를 기반으로 동작하며, 복잡한 미디어 파싱을 직접 구현할 필요 없이 간단한 API로 필요한 정보를 가져올 수 있습니다.
주요 활용 사례는 다음과 같습니다:
비디오의 썸네일 이미지 생성.
미디어 파일의 속성(길이, 제목, 아티스트 등) 읽기.
커스텀 UI에 활용할 미디어 데이터 추출.
MediaMetadataRetriever는 크게 세 가지 작업을 수행할 수 있습니다: 데이터 소스 설정, 메타데이터 추출, 프레임 캡처. 각 기능에 맞는 메서드를 자세히 알아보겠습니다.
먼저 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는 미디어 파일을 파싱하고 디코딩하는 과정을 내부적으로 처리합니다:
파일 파싱: FFmpeg 기반으로 파일 구조를 분석해 메타데이터를 읽습니다.
프레임 디코딩: 비디오의 경우, 요청한 시간에 맞는 프레임을 찾아 디코딩 후 Bitmap으로 변환합니다.
캐싱: 메타데이터는 캐싱해서 빠르게 반환하지만, 프레임 캡처는 실시간으로 수행됩니다.
키프레임이란?
비디오 압축에서 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에 주의해야 합니다.
실시간 불가: 실시간 프레임 처리는 지원하지 않습니다(정적인 데이터만).
