본문 바로가기

Android/Tech

Third-Party-Library 없이 비디오 컷 편집 기능 구현하기

Unsplash, Wahid Khene.

동기

네이버 부스트캠프의 꽃, 그룹 프로젝트를 개발하고 있습니다. 실력있는 동료들과 함께 비디오와 관련된 앱 서비스를 기획, 디자인, 개발하고 있는데요. 제가 낸 주제이기도 하고, 비디오와 관련된 작업을 경험하고 싶었기도 해서 굉장히 즐겁게 개발하고 있습니다.

 

'앱 내에서 비디오 컷 편집을 할 수 있다면 편하지 않을까?' 라는 생각이 들었고, '라이브러리 없이 개발하면 분명 어렵겠지만, 그만큼 배우는 것도 많고 재미도 있지 않을까?' 라는 생각도 들었습니다. 그래서 스스로의 기술적 도전 과제로 설정하여 구현해보기로 마음 먹었습니다. 해당 포스트는 라이브러리 없이 직접 비디오 컷 편집을 개발하는 과정과, 그 과정 안에서 알게 된 것을 기반으로 작성됩니다.

 


Jetpack Compose 에서 비디오 불러오기

저희 팀은 UI 구현을 위해 Jetpack Compose 를 사용하고 있습니다. 너무나도 높은 생산성이 가장 큰 이유입니다. Jetpack Compose 에서 이미지나 영상을 불러오는 것은 생각보다 꽤 간단한 작업인데요.

 

rememberLauncherForActivityResult() 메서드에 필요한 contract 만 넘겨주면 거의 끝납니다.

 

@Composable
public fun <I, O> rememberLauncherForActivityResult(
    contract: ActivityResultContract<I, O>,
    onResult: (O) -> Unit
): ManagedActivityResultLauncher<I, O> {
    // Keep track of the current contract and onResult listener
    val currentContract = rememberUpdatedState(contract)
    val currentOnResult = rememberUpdatedState(onResult)

    // It doesn't really matter what the key is, just that it is unique
    // and consistent across configuration changes
    val key = rememberSaveable { UUID.randomUUID().toString() }

    val activityResultRegistry = checkNotNull(LocalActivityResultRegistryOwner.current) {
        "No ActivityResultRegistryOwner was provided via LocalActivityResultRegistryOwner"
    }.activityResultRegistry
    val realLauncher = remember { ActivityResultLauncherHolder<I>() }
    val returnedLauncher = remember {
        ManagedActivityResultLauncher(realLauncher, currentContract)
    }

    // DisposableEffect ensures that we only register once
    // and that we unregister when the composable is disposed
    DisposableEffect(activityResultRegistry, key, contract) {
        realLauncher.launcher = activityResultRegistry.register(key, contract) {
            currentOnResult.value(it)
        }
        onDispose {
            realLauncher.unregister()
        }
    }
    return returnedLauncher
}

 

별도의 자원 해제도 필요 없는 것이, 내부적으로 DisposableEffect() 를 통해 직접 자원을 해제해주고 있습니다. DisposableEffect() 에 관해서는 다음 포스트에 간략히 작성해두었으니, 참고하시면 좋을 것 같습니다.

 

 

[Jetpack Compose] Jetpack Compose 의 다양한 Side-Effect

State Management In Composables Composable 은 기본적으로 Stateless 입니다. '기본적으로 Stateless' 라는 문장의 의미는, 때로는 Stateless 하지 않을 수 있다는 것을 말합니다. @Composable fun StatelessText() { Text(text = "

blothhundr.tistory.com

 

val videoLauncher = rememberLauncherForActivityResult(
    contract = ActivityResultContracts.PickVisualMedia(),
    onResult = { uri ->
        uri?.let {

        } ?: run {

        }
    }
)
Box(
    modifier = Modifier
        .fillMaxWidth()
        .weight(1f)
        .clickableWithoutRipple {
            videoLauncher.launch(PickVisualMediaRequest(VideoOnly))
        }
)

 

파일 Uri 를 반환 받았을 때의 처리를 위한 코드가 Box clicakble { } 내에 포함되는 것이 꺼려져서 외부로 뺐습니다. PickVisualMediaRequest() 객체를 launch() 의 파라미터로 넘겨주면 되는데, 현재 넘겨주는 VideoOnly 외에도 ImageOnly, SingleMimeType 등이 있습니다. 아무 것도 넘겨주지 않으면 이미지, 비디오관계 없이 모두 받아 옵니다.

 

internal fun getVisualMimeType(input: VisualMediaType): String? {
    return when (input) {
        is ImageOnly -> "image/*"
        is VideoOnly -> "video/*"
        is SingleMimeType -> input.mimeType
        is ImageAndVideo -> null
    }
}

 


ExoPlayer

ExoPlayer 는 음악은 물론, 영상까지 커버되는 안드로이드 미디어 라이브러리입니다. 아마 시중에 나와있는 대부분의 미디어 앱 서비스들도 ExoPlayer 를 메인 기술로 채택하여 사용하고 있을텐데요. 저도 ExoPlayer 를 사용하기로 계획하고 적용하였습니다.

 

ExoPlayer 는 아직 Jetpack Compose 와 호환되지 않습니다. 그러므로, AndroidView 를 이용해서 구현해야 합니다.

 

val context = LocalContext.current
val exoPlayer = remember {
    ExoPlayer.Builder(context).build().apply {
    val dataSourceFactory = DefaultDataSource.Factory(context, DefaultDataSource.Factory(context))
    setMediaSource(ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(MediaItem.fromUri(uri)))
    videoScalingMode = C.VIDEO_SCALING_MODE_DEFAULT
    repeatMode = Player.REPEAT_MODE_ONE
    prepare()
}
    
AndroidView(
    modifier = Modifier.fillMaxSize(),
        factory = {
        PlayerView(context).apply {
            useController = false
            player = exoPlayer
        }
    }
)
    
DisposableEffect(Unit) {
    onDispose {
        exoPlayer.release()
    }
}

 

ExoPlayer 의 경우, 영상을 계속해서 물고 있기 때문에 AndroidView 가 Composition 을 벗어나더라도 해제되지 않을 수 있습니다. 그러므로, DisposableEffect { } 등을 통해서 리소스를 해제해줘야 합니다. 

 

ExoPlayer 는 Uri 만 넘겨주면 영상을 실행할 수 있기 때문에, 이전에 획득한 Uri 를 넘겨주어 재생할 수 있습니다.

 


편집기 UI 구현

여러 앱에서 자주 볼 수 있었던 형식의 편집기를 만들기로 하였습니다.

화면 가로 길이를 픽셀로 구하고, 해당 값을 바탕으로 좌측 및 우측 끝에 바운더를 배치합니다.

각 바운더는 pointerInput() 메서드를 통해 양 방향으로 이동할 수 있고, 대응하는 바운더의 위치 이상으로 넘어 갈 수 없도록 코드를 작성합니다.

 

var lowerBoundDraggingState by remember { mutableStateOf(false) }
var lowerBoundOffsetState by remember { mutableFloatStateOf(0f) }

var upperBoundDraggingState by remember { mutableStateOf(false) }
var upperBoundOffsetState by remember { mutableFloatStateOf(0f) }

val boundWidthDp = 8.dp

Box(
    modifier = Modifier
        .absoluteOffset(x = lowerBoundOffsetState.pxToDp())
        .width(boundWidthDp)
        .fillMaxHeight()
        .background(color = if (lowerBoundDraggingState) Point else Color.White)
        .pointerInput(Unit) {
            detectHorizontalDragGestures(
                onDragStart = {
                    lowerBoundDraggingState = true
                },
                onDragEnd = {
                    lowerBoundDraggingState = false
                },
                onDragCancel = {
                    lowerBoundDraggingState = false
                },
                onHorizontalDrag = { _, dragAmount ->
                    val sum = lowerBoundOffsetState + dragAmount
                    if (sum >= 0 && sum < timeLineWidthState + upperBoundOffsetState - (boundWidthDp.toPx() * 2)) {
                        lowerBoundOffsetState = sum
                    }
                }
            )
        }
)

 

pointerInput() 의 내부에서 사용할 수 있는 detectHorizontalDragGestures() 메서드를 통해 드래그 중일 때의 UI 변화도 줄 수 있겠습니다.

 

위와 같이 코드를 작성하여 다음과 같은 결과를 얻을 수 있었습니다.

 

 

이제 영상의 전체 길이와 화면 가로 길이를 수식으로 연결하여 각 바운더에 맞게 보여 줄 영상의 길이를 바인딩하면 되겠습니다.

 

detectHorizontalDragGestures(
    onDragStart = { lowerBoundDraggingState = true },
    onDragEnd = {
        lowerBoundDraggingState = false
        videoStartMsState = (videoTimelineUnit * (lowerBoundOffsetState / timelineUnitWidthState)).toLong()
    },
    onDragCancel = { lowerBoundDraggingState = false },
    onHorizontalDrag = { _, dragAmount ->
        val sum = lowerBoundOffsetState + dragAmount
        if (sum >= 0 && sum < timelineWidthState + upperBoundOffsetState - (boundWidthDp.toPx() * 2)) {
            lowerBoundOffsetState = sum
        }
    }
)

 

수많은 변수들을 활용하여 위치에 맞게 영상이 재생될 수 있도록 바인딩합니다. 다만, 이는 ExoPlayer 의 currentPosition 이 로워 바운더 위치보다 아래에 있는 경우 (즉, 유저가 지정하는 최소보다 더 앞쪽을 플레이하려는 경우)를 막기 위한 것이므로, 현재 재생 중인 위치보다 뒤로 밀려야만 seekTo() 메서드를 호출합니다.

 

 

ExoPlayer 의 currentPosition 이 어퍼 바운드를 초과하여 재생되는 경우도 처리를 해줘야 합니다. 위 코드 내 수식의 변화만 조금 주면 되겠습니다. 영상의 현재 위치를 표시하는 인디케이터까지 함께 구현한 결과는 다음과 같습니다.

 

 

바운더의 내부만을 반복 재생하는 비디오 플레이어가 준비되었으며, 지속적으로 현재 실행 위치도 표시하고 있습니다. 

 

저는 여기에 추가로, 편집 시 편의를 위해 타임라인에 썸네일을 띄워주기로 했습니다. 각 프레임을 비트맵으로 가져오려면 FFmpeg 라이브러리를 써야 한다는 이야기가 많은데, 다행히도 android.media 패키지의 MediaMetadataRetriever 로 해결이 가능했습니다.

 

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
    mediaMetadataRetriever.getScaledFrameAtTime(
        ((exoPlayer.duration / 15) * it + 1) * 1000L,
        MediaMetadataRetriever.OPTION_CLOSEST,
        thumbnailImageSizeState.first,
        thumbnailImageSizeState.second
    )
} else {
    mediaMetadataRetriever.getFrameAtTime(((exoPlayer.duration / 15) * it + 1) * 1000L)
}

 

메서드명도 굉장히 정직한데요. 생각보다 간단하게 구현할 수 있었습니다. 

getScaledFrameAtTime() 메서드의 경우, 프레임을 비트맵으로 가져 올 때 나름의 최적화도 가능합니다.

구현 결과는 다음과 같습니다.

 

사실 다른 방법으로 먼저 구현을 했었는데요...

처음부터 MediaMetadata 를 통해 프레임 데이터에 접근할 수 있다는 사실을 알았으면 좋았겠지만, 얄팍한 배경 지식에 매몰되어, 꽤 긴 시간 동안 직접 MediaCodec 을 통해 영상을 디코딩하는 코드를 작성했습니다.

 

 

MediaCodec 을 통해 프레임 정보를 버퍼로 얻어 와 화면에 표시해주었습니다. 사실 이대로도 사용할만 했지만, 결정적으로 다른 방식을 강구하게 된 이유는 속도 때문이었습니다. 당연히 제가 뭔가를 잘못한 것이라 사료되는 부분이긴 한데, 프레임을 추출해내는데에 시간이 조금 오래 걸리는 느낌이 있었습니다. 같은 양의 이미지를 추출해내는 데에 5초 이상 차이가 났습니다. 그럼에도 불구하고, 꽤 좋은 경험이었어서 MediaCodec 에 관해서도 기록하고자 합니다.

 

 

Third-Party-Library 없이 영상으로부터 빠르게 프레임을 추출하기 (feat.YUV)

동기 네이버 부스트캠프에서 진행 중인 그룹 프로젝트가 드디어 개발 막바지 단계에 이르렀습니다. 영상 업로드 기능을 구현하면서, 영상의 썸네일을 추출하고 함께 전송하는 기능도 구현하였

blothhundr.tistory.com

 

(2023.12.08+) 위 포스트에서는 MediaCodec 을 이용하여 영상의 썸네일을 빠르게 얻어오는 과정에 대해 설명하고 있습니다.

MediaCodec

코덱은 인코더와 디코더를 통틀어 이르는 말(COder and DECoder)입니다. 인코더는 음성이나 영상의 신호를 디지털 신호로, 디코더는 그 반대입니다. 안드로이드는 안드로이드 5계층 중, 리눅스 커널 바로 위의 네이티브 라이브러리 계층에 있는 코덱에 접근할 수 있도록 MediaCodec 이라는 클래스를 제공합니다.

 

 

코덱은 위 그림과 같이 동작합니다. 코덱에서 InputBuffer 를 가져와 작업이 필요한 데이터를 채워 다시 코덱으로 반환합니다. 코덱이 파일에 대한 적절한 처리를 수행하고, 이를 OutputBuffer 에 저장합니다. 클라이언트는 작업이 끝나 반환될 수 있는 OutputBuffer 를 얻어 와 사용합니다. 안드로이드 View 클래스인 Surface 가 코덱에 설정되어 있다면 별도로 렌더링이 가능합니다. 그렇게 사용된 OutputBuffer 는 다시 코덱으로 릴리즈 해야 합니다.

 

이정도만 알아도 아주 간단(?)하게 영상을 인코딩, 디코딩할 수 있습니다.

 

저의 경우, 영상 파일의 프레임 정보를 비트맵으로 받아오고 싶었기 때문에 디코딩을 진행했습니다. 위 그림을 바탕으로 간단하게 투두 리스트를 작성한다면 다음과 같겠죠.

  1. 디코더로부터 InputBuffer 를 얻어 온다.
  2. 얻어 온 InputBuffer 에 영상 파일을 쓴다.
  3. 쓴 InputBuffer 를 코덱에 넣는다.
  4. OutputBuffer Dequeue 하여 활용한다.
  5. OutputBuffer  release() 한다.

먼저, 디코더 초기 설정을 수행해야 합니다. 그렇지 않으면 illegalStateException 을 던지기 때문입니다. 초기에 설정해줘야 할 것들은 다음과 같습니다.

 

public void configure(
        @Nullable MediaFormat format, @Nullable Surface surface,
        @ConfigureFlag int flags, @Nullable MediaDescrambler descrambler) {
    configure(format, surface, null,
            descrambler != null ? descrambler.getBinder() : null, flags);
}

 

먼저, MediaFormat 은 해당 인코더가 제공하는 버퍼에 쓸 미디어 파일의 형식입니다. 

 

대부분의 모바일 기기에서 사용되는 비디오 포맷은 .mp4 입니다. .avi 는 이제 잘 보이지 않고, .mkv 는 너무나 고용량이기에 모바일에서 사용되는 일이 극히 드뭅니다.

 

그래도 혹시 모르니, 정확한 타입을 코덱에 넘겨주는 것이 맞겠지요. 이를 구하려면 MediaExtractor 를 활용하여야 합니다. MediaExtractor 는 영상의 Uri 를 통해 얻어 온 경로를 넘겨주어, 해당 파일의 트랙 수와 트랙의 포맷을 얻어 올 수 있습니다. 

 

 

0번과 1번을 차례대로 출력하도록 코드를 작성했고, 0번이 영상, 1번이 오디오 트랙인 것을 확인할 수 있습니다. 영상을 먼저 디코딩합니다. 즉, 0번을 넘겨줍니다.

 

Surface 는 android.view 패키지에 있는 클래스인데요. 화면에 바로 표시할 것이 아니라면, 즉 버퍼를 반환받으려 하는 경우에는 null 을 넘겨주면 됩니다. 저는 별도로 이미지를 띄워 주어야 했어서 null 을 넘겨줬습니다.

 

crypto DRM(Digital Right Management) 에 관한 속성입니다만, 제 상황에 딱히 적용할만한 부분은 아니라고 생각해서 null 을 넘겨주었습니다.

 

val mediaExtractor = MediaExtractor()
getVideoPathFileFromUri(uri)?.let {
    mediaExtractor.setDataSource(it)
}

decoder.configure(
    mediaExtractor.getTrackFormat(0),
    null,
    null,
    0
)

 

영상 파일을 읽어와야 하기 때문에, MediaExtractor 도 추가로 초기화해줍니다. 저는 Uri 를 넘겨줬는데, Uri 말고도 여러 타입의 파라미터를 넘겨 줄 수 있습니다. 이렇게 모든 컴포넌트가 준비되면 decoderstart() 메서드로 시작해주면 됩니다. 

 

val inputBufferIndex = mediaCodec.dequeueInputBuffer(-1)
if (inputBufferIndex >= 0) {
    val inputBuffer = mediaCodec.getInputBuffer(inputBufferIndex)
    val sampleSize = mediaExtractor.readSampleData(inputBuffer!!, 0)
    if (sampleSize < 0) {
        // End of input stream
        isInputDone = true
        mediaCodec.queueInputBuffer(inputBufferIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
    } else {
        mediaCodec.queueInputBuffer(inputBufferIndex, 0, sampleSize, mediaExtractor.sampleTime, 0)
        mediaExtractor.advance()
    }
}

 

가장 먼저, 디코더의 가용 InputBuffer 의 인덱스를 반환 받습니다. 넘겨주는 파라미터 -1 은 타임아웃인데, -1로 지정하면 무기한 대기하므로, 가용 InputBuffer 가 생길 때까지 대기합니다. 즉, 예외 발생 없이 확정적으로 가용 InputBuffer 를 반환 받을 수 있습니다. 

 

MediaExtractor 의 readSampleData() 메서드는 넘겨준 Buffer 에 각 프레임을 작성합니다. 반환 타입은 Int 인데, 샘플의 크기를 반환합니다. 즉, 작성할 샘플이 없는 경우에는 -1을 반환하므로 이에 맞게 큐잉 작업을 멈춰주면 되도록 설계되어 있습니다.

 

그러므로, 변수 sampleSize 가 0보다 작은 경우에는 InputBuffer 에 스트림의 마지막을 알리는 플래그를 추가하여 큐잉합니다. 그렇지 않은 경우에는 샘플의 사이즈와 샘플 타임을 넘겨주고, advance() 메서드를 사용하여, 다음 루프에 다음 프레임이 샘플링되도록 설정해줍니다. 커서를 연상하면 이해하기 쉽습니다.

 

val outputBufferIndex = mediaCodec.dequeueOutputBuffer(inputBufferInfo, -1)
val outputBuffer = mediaCodec.getOutputBuffer(outputBufferIndex) ?: continue

// Bitmap 활용 코드

mediaCodec.releaseOutputBuffer(outputBufferIndex, false)

 

이후에는 간단하게, OutputBuffer 를 코덱에서 디큐하고, ByteBuffer 에서 비트맵 정보를 얻어 와 변환하여 필요에 따라 사용한 후, OutputBuffer 를 다시 코덱으로 release 해주면 끝이 납니다.

 

해당 부분에만 시간을 무기한 쏟을 수는 없어서 깊게 학습하지 못한 탓에 효율이 안 나왔던 건지, 아니면 원래 그런 건지 모르겠지만, 어쨌든 MediaMetadataRetriever 를 사용하는 편이 훨씬 빠르게 이미지가 로드되어 프로젝트에는 MediaMetadataRetriever 로 프레임을 불러 와 넣고 있습니다.


컷 편집 기능 구현하기

UI 를 모두 구현했으니 기능을 구현 해야겠죠. 사실 이 부분에서 디코딩, 인코딩이 필요하리라 생각했는데, 프레임을 얻어 오는 데에 크게 데여서(들인 시간에 비해 프로젝트에 적용하기는 적절치 않은 코드인 것 같아서), 다른 방법을 모색하였습니다. 저는 FFmpeg 와 같은 외부 라이브러리를 사용하지 않기로 했기 때문에, 안드로이드 내부에 정의된 API 중 적절한 것을 찾아서 사용해야 했습니다. 찾았던 모든 방법들 중 가장 간편하고 적합했던 방법은 MediaMuxer 였습니다.

 

Muxer 는 다중화 장치를 의미하는 MultiPlexer 의 준말인데요. 안드로이드의 MediaMuxer 는 미디어 파일을 구성하는 여러 트랙(Video, Audio) 을 하나의 미디어 파일로 다중화할 수 있습니다. 자세한 설명은 안드로이드 공식 문서를 참고하시면 좋겠습니다.

 

 

Muxer  |  Android Developers

androidx.core.content.res

developer.android.com

어떻게 구현하였는가?

MediaMuxer 를 이용하는 방법 역시 코덱을 이용하는 것과 유사합니다. MediaExtractor, MediaMetadataRetriever 등 친숙한 컴포넌트들이 필요합니다. Muxer 를 제외한 두 컴포넌트들은 원본 영상을 DataSource 로 지정해주면 됩니다.

 

private fun initMediaExtractor() {
    mediaExtractor.setDataSource(originalPath)
}

private fun initMediaMetadataRetriever() {
    mediaMetadataRetriever.setDataSource(originalPath)
}

 

MultiPlexer 이니 만큼 여러 트랙이 추가되는데요. 영상은 대체로 비디오 및 오디오 트랙을 하나 씩 갖습니다. 해당 트랙들을 더해주면 되는데, 몇 번째 인덱스는 무조건 Video 트랙이다, Audid 트랙이다 단언할 수 없기 때문에, 반복문을 돌면서 해당하는 값이 존재하는 경우에만 더해줍니다.

 

private fun addTracksToMuxer() {
    for (i in 0 until TRACK_COUNT) {
        val format = mediaExtractor.getTrackFormat(i)
        val mime = format.getString(MediaFormat.KEY_MIME)

        mime?.let {
            if (mime.startsWith("audio/") || mime.startsWith("video/")) {
                mediaExtractor.selectTrack(i)
                val newTrackIndex = mediaMuxer.addTrack(format)
                indexMap[i] = newTrackIndex
                if (format.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) {
                    bufferSize = maxOf(bufferSize, format.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE))
                }
            }
        } ?: run {
            releaseComponents()
        }
    }
}

 

또한, Buffer 의 크기는 미리 지정되어야 합니다. 여러 트랙의 샘플 사이즈에 대응하기 위해 bufferSize 변수는 외부에 두어 필요시 변경할 수 있도록 합니다. indexMap 이라는 HashMap<Int, Int> 를 사용하는 이유는, 해당 트랙이 실제로 Muxer 에 추가 되었는지, 추가 되었다면 index 가 몇인지 판별하기 위함입니다.

 

@SuppressLint("WrongConstant")
fun trim() {
    val outputBuffer = ByteBuffer.allocate(bufferSize)
    val bufferInfo = MediaCodec.BufferInfo()

    if (bufferSize < 0) {
        bufferSize = DEFAULT_BUFFER_SIZE
    }

    if (startMs > 0) {
        mediaExtractor.seekTo((startMs * 1000), MediaExtractor.SEEK_TO_CLOSEST_SYNC)
    }

    try {
        mediaMuxer.start()

        while (true) {
            bufferInfo.offset = 0
            bufferInfo.size = mediaExtractor.readSampleData(outputBuffer, 0)

            if (bufferInfo.size == -1) {
                break
            } else {
                bufferInfo.presentationTimeUs = mediaExtractor.sampleTime
                if (endMs > 0 && bufferInfo.presentationTimeUs > endMs * 1000) {
                    break
                } else {
                    bufferInfo.flags = mediaExtractor.sampleFlags
                    indexMap[mediaExtractor.sampleTrackIndex]?.let { trackIndex ->
                        mediaMuxer.writeSampleData(
                            trackIndex,
                            outputBuffer,
                            bufferInfo
                        )

                        mediaExtractor.advance()
                    }
                }
            }
        }

        mediaMuxer.stop()
    } catch (e: IllegalStateException) {
        releaseComponents()
    }
}

 

이후에는 사실 간단합니다. outputBuffer 의 경우 트랙에 따라 사이즈가 다를 수 있으므로 생성할 때에 사이즈를 지정해줍니다. 이 때, 사이즈가 지정되지 않은 경우 (그럴 일은 없어 보이지만) 를 대비하여 기본값에 대한 처리도 해줍니다.

 

이후 Muxer 를 시작해주고, 지정한 위치까지 버퍼 쓰기 및 Muxer 에 작성이 끝나면 무한 반복문을 탈출하고 muxer  는 stop() release(), 그 외의 컴포넌트들은 release() 해줍니다. 

 


실행

 

컷 편집 실행

 

컷 편집 후

 

생각보다 시간이 꽤 걸렸습니다. 비디오에 대한 기본적인 지식 자체가 없어서 더 그랬던 것이겠지요. 그래도 차근 차근 기본 지식부터 채우고 나니, 난해했던 여러 레퍼런스들이 자연스럽게 눈에 들어왔습니다. 이후의 구현은 그다지 어려운 일은 아니었고요.

 

비디오와 관련된 작업을 해본 적이 없는 건 아니었습니다. 그냥 파일을 가져와서 서버에 올려주는 정도는 해봤거든요. 다만 이게 비디오에 대한 이해가 필요했던 건 아니고, 그저 서버에 올려주는 것에 대한 이해만 필요했기에 그 당시에는 이러한 지식을 얻지 못했던 것 같습니다.

 

이번 기회에 안드로이드의 비디오에 대한 지식도 얕게나마 학습하면서 성장할 수 있어 좋았습니다.