영상 편집 및 인코딩 - boostcampwm2023/and01-SnapPoint GitHub Wiki

영상 편집 및 인코딩

구현 이유

게시글은 여행 기록이기 때문에, 사진뿐만 아니라 영상 또한 업로드 할 수 있어야 한다고 생각했습니다.

여행하며 찍은 영상은 재생시간이 길 가능성이 높아, 원하는 구간만 선택해 업로드를 할 수 있으면 좋을 것 같다 생각했고, 컷 편집을 포함한 영상 업로드를 구현하게 되었습니다.

구현 방식

영상과 관련한 부분은 Jetpack의 Media3 라이브러리를 사용했습니다.

image

편집할 영상을 Exoplayer를 통해 로드하고,

영상의 구간을 선택할 수 있는 타임라인은 커스텀뷰로 구현하였습니다.

image 영상의 구간을 설정한 후, 확인 버튼을 누르면 transformer를 통해 인코딩을 수행합니다.

private fun initTransFormer() {

        trans = Transformer.Builder(this@VideoEditActivity)
            .setVideoMimeType(MimeTypes.VIDEO_H265)
            .setAudioMimeType(MimeTypes.AUDIO_AAC)
            .build()

        trans.addListener(object : Listener {
            override fun onCompleted(composition: Composition, exportResult: ExportResult) {
                super.onCompleted(composition, exportResult)
                viewModel.finishLoading()
                intent.putExtra("path", file.path)
                intent.putExtra("original", viewModel.uri.value)
                setResult(RESULT_OK, intent)
                finish()

            }
            override fun onError(
                composition: Composition,
                exportResult: ExportResult,
                exportException: ExportException
            ) {
                super.onError(composition, exportResult, exportException)
                viewModel.finishLoading()
                showToastMessage(exportException.message?:"error")
            }
        })
    }

영상은 VIDEO_H265, 음성은 AUDIO_AAC형식으로 인코딩을 수행했습니다.

object CacheManager {
    private var names: MutableSet<String> = mutableSetOf()

    private fun getRandomName(): String{
        val random = Random
        var temp = ""
        while(true){
            temp = ""
            repeat(10){
                temp += random.nextInt(0, 10)
            }
            if(names.contains(temp).not()) break
        }
        names.add(temp)
        return temp
    }

    fun createExternalCacheFile(context: Context): File {

        Log.d("TAG", "createExternalCacheFile: ${context.cacheDir.path}/video_cache/${getRandomName()}.mp4}")
        val cacheDir = File(context.cacheDir,"video_cache")
        cacheDir.mkdir()
        val file = File(cacheDir, "${getRandomName()}.mp4")

        try{
            if (file.exists() && !file.delete()) {
                throw IllegalStateException("Could not delete the previous export output file")
            }
            if (!file.createNewFile()) {
                throw IllegalStateException("Could not create the export output file")
            }
        } catch (e:Exception){
            Log.d("TAG", "createExternalCacheFile: ${e.message}")
        }
        return file
    }

    fun clearVideoCache(context: Context){
        val cacheDir = File(context.cacheDir,"video_cache")
        val caches = cacheDir.list() ?: return
        caches.forEach {
            File(cacheDir, it).delete()
        }
        names.clear()
        cacheDir.delete()
    }
}

결과물은 업로드를 위해 캐시 디렉토리 내부에 임시로 저장되며, 게시물 생성 후 바로 삭제되도록 구현했습니다.

image

private fun getBitMap() {
        if (videoLengthInMs == 0F || viewWidth == 0F || viewHeight == 0F) return
        if(thumbnails.isNotEmpty()) thumbnails.clear()

        val mediaMetadataRetriever: MediaMetadataRetriever = MediaMetadataRetriever()
        mediaMetadataRetriever.setDataSource(context, videoUri)
        val initialBitmap = mediaMetadataRetriever.getFrameAtTime(0, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)
        val frameHeight = viewHeight.toInt()
        val frameWidth = ((initialBitmap?.width?.toFloat()!! / initialBitmap.height.toFloat()) * frameHeight).toInt()
        val numThumbs = ceil(viewWidth / frameWidth).toInt()
        val interval = videoLengthInMs.toLong() / numThumbs
        for(i in 0 until numThumbs){
            var bitmap = mediaMetadataRetriever.getFrameAtTime(i * interval * 1000, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)
            if(bitmap != null){
                bitmap = Bitmap.createScaledBitmap(bitmap, frameWidth, frameHeight, false)
                bitmap = Bitmap.createBitmap(bitmap,0, 0, bitmap.width, bitmap.height)
                thumbnails.add(bitmap)
            }
        }
        mediaMetadataRetriever.release()
        invalidate()
    }

타임라인의 썸네일의 갯수는 커스텀뷰의 높이와 / 사진의 세로 비율에 맞는 가로길이를 구해, / 전체 가로에서 나누어 구했습니다.

썸네일 사진은, 해당 사진의 위치의 /  X 좌표에 해당하는 시간과 / 가장 가까운 Frame을 가져와 / 썸네일로 표현했습니다.

선택 구간은 영상 전체의 시간과 타임라인의 가로 길이의 비율을 계산해, 0.1초를 단위로 하는 x값을 구해 설정했습니다.

image 미디어 파일을 다루다 보니까, 업로드하는 영상의 크기 때문에 서버에 부하가 발생하지 않을까 생각했습니다.

이에 대해 백엔드 분들과 의견을 나눴고,

Prisigned Url 방식으로 원격 미디어 저장소에 직접 업로드를 하는 방법을 선택했습니다.

미디어 파일을 업로드하는 api 요청을 시작할 때

편집된 영상을 bytearray로 읽어들이고,

영상을 5mb 단위로 나눠 병렬로 멀티파트 형태로 전송 했습니다.

이를 통해 서버를 거치지 않게 되어 서버의 부하를 줄이고,

사용자 입장에서도 로딩 시간이 줄어들게 되는 효과를 얻었습니다.

https://github.com/androidx/media/issues/810 변환 문제. 제작자가 23+ 버전에서 문제가 있다고 판단 후 수정 .현재 버전은 1.2.0이고 1.2.1이나 1.3.0 버전에서 업데이트가 될 예정이라고 밝힘

⚠️ **GitHub.com Fallback** ⚠️