TAE

[Android/Kotlin] 비디오에서 썸네일 이미지 지정하기 - 비디오 썸네일 지정(2) 본문

android/코드

[Android/Kotlin] 비디오에서 썸네일 이미지 지정하기 - 비디오 썸네일 지정(2)

tg-world 2023. 3. 20. 16:49
반응형

앨범에서 영상을 선택하여 이미지를 보여주는 포스팅에 이어 해당 영상 초에 맞는 썸네일 이미지를 보여주는 기능을 포스팅하려 합니다.

앨범에서 영상 선택은 아래 포스팅 되어 있습니다.

https://tg-world.tistory.com/10

 

[Android/Kotlin] registerForActivityResult사용하여 앨범에서 사진 선택하기- 비디오 썸네일 지정(1)

전에 만들었던 커스텀 앨범에서 비디오를 선택하여 비디오에서 썸네일을 추출하는 작업을 해보려고 합니다. 비디오 업로드 기능을 사용하는 앱이라면 썸네일을 지정하는 기능이 있습니다. 예

tg-world.tistory.com


실행동작

위 영상과 같이 커스텀 앨범에서 영상을 선택하고 썸네일 선택하기를 누르면 하단에 seekbar로 해당 썸네일을 보여주며, 선택 클릭 시 선택했던 초에 맞는 썸네일 이미지를 보여주는 기능입니다. (인스타그램 썸네일 추출과 비슷한 기능입니다.)


코드

thumbanil 이라는 이름으로 packge를 만든 후 총 5개의 파일을 만들어 주었습니다.

CneterCropVideoView, SeekListener, ThumbnailTimeline, ThumbnailView, ThumbyActivity

위 소스는

https://github.com/bufferapp/Thumby

여기를 참고하여 변경 하여 작업하였습니다.

CneterCropVideoView.kt

class CenterCropVideoView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null
) : TextureView(context, attrs), TextureView.SurfaceTextureListener {

    private var mediaPlayer: MediaPlayer? = null

    private var videoHeight = 0f
    private var videoWidth = 0f
    private var videoSizeDivisor = 1
    private var mScalableType = ScalableType.CENTER_CROP

    init {
        surfaceTextureListener = this
    }

    override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, width: Int, height: Int) {}

    override fun onSurfaceTextureUpdated(surface: SurfaceTexture) {}

    override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean = false

    override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) {
        mediaPlayer?.setSurface(Surface(surfaceTexture))
    }

    fun setDataSource(context: Context, uri: Uri, videoSizeDivisor: Int = 1) {
        this.videoSizeDivisor = videoSizeDivisor
        initPlayer()
        mediaPlayer?.setDataSource(context, uri)
        prepare()
    }

    fun seekTo(milliseconds: Int) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            mediaPlayer?.seekTo(milliseconds.toLong(), MediaPlayer.SEEK_CLOSEST)
        }
    }

    fun getDuration(): Int {
        return mediaPlayer?.duration ?: 0
    }

    private fun prepare() {
        mediaPlayer?.setOnVideoSizeChangedListener { _, width, height ->
            videoWidth = width.toFloat() / videoSizeDivisor
            videoHeight = height.toFloat() / videoSizeDivisor
            updateTextureViewSize()
            seekTo(0)
        }
        mediaPlayer?.prepareAsync()
    }

    private fun updateTextureViewSize() {
        val viewSize = Size(width, height)
        val videoSize = Size(videoWidth.toInt(), videoHeight.toInt())
        val scaleManager = ScaleManager(viewSize, videoSize)
        val matrix = scaleManager.getScaleMatrix(mScalableType)

        setTransform(matrix)

    }

     fun initPlayer() {
        if (mediaPlayer == null) {
            mediaPlayer = MediaPlayer()
        } else {
            mediaPlayer?.reset()
        }
    }
}

썸네일 뷰 이미지를 표시할 커스텀 뷰입니다.

TexttureView를 상속받아 이미지를 그려줍니다.

TextTureView에 대한 자세한 사항은 공식 디벨롭 사이트를 확인하시면 됩니다.

https://source.android.com/docs/core/graphics/arch-tv?hl=ko

 

TextureView  |  Android 오픈소스 프로젝트  |  Android Open Source Project

TextureView 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. TextureView 클래스는 뷰를 SurfaceTexture와 결합하는 뷰 객체입니다. OpenGL ES를 통한 테더링 TextureView 객체

source.android.com

ThumbnailTimeline.kt

class ThumbnailTimeline @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyle: Int = 0
) : FrameLayout(context, attrs) {

    private var frameDimensionW: Int = 0
    private var frameDimensionH: Int = 0
    var currentProgress = 0.0
    var currentSeekPosition = 0f
    var seekListener: SeekListener? = null
    var uri: Uri? = null
        set(value) {
            field = value
            field?.let {
                loadThumbnails(it)
                invalidate()
                view_seek_bar.setDataSource(context, it, 4)
                view_seek_bar.seekTo(currentSeekPosition.toInt())
            }
        }

    init {
        View.inflate(getContext(), R.layout.view_timeline, this)
        frameDimensionW = context.resources.getDimensionPixelOffset(R.dimen.frames_video_width)
        frameDimensionH = context.resources.getDimensionPixelOffset(R.dimen.frames_video_height)
        isFocusable = true
        isFocusableInTouchMode = true
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) elevation = 8f
        val margin = 50
        val params = RelativeLayout.LayoutParams(
            RelativeLayout.LayoutParams.WRAP_CONTENT,
            RelativeLayout.LayoutParams.WRAP_CONTENT
        )
        params.setMargins(margin, 0, margin, 0)
        layoutParams = params
    }

    override fun onTouchEvent(event: MotionEvent?): Boolean {
        when (event!!.action) {
            MotionEvent.ACTION_MOVE -> handleTouchEvent(event)
        }
        return true
    }

    private fun handleTouchEvent(event: MotionEvent) {
        val seekViewWidth = context.resources.getDimensionPixelSize(R.dimen.frames_video_width)
        currentSeekPosition = (Math.round(event.x) - (seekViewWidth / 2)).toFloat()

        val availableWidth = container_thumbnails.width -
                (layoutParams as RelativeLayout.LayoutParams).marginEnd -
                (layoutParams as RelativeLayout.LayoutParams).marginStart
        if (currentSeekPosition + seekViewWidth > container_thumbnails.right) {
            currentSeekPosition = (container_thumbnails.right - seekViewWidth).toFloat()
        } else if (currentSeekPosition < container_thumbnails.left) {
            currentSeekPosition = paddingStart.toFloat()
        }

        currentProgress = (currentSeekPosition.toDouble() / availableWidth.toDouble()) * 100
        container_seek_bar.translationX = currentSeekPosition
        view_seek_bar.seekTo(((currentProgress * view_seek_bar.getDuration()) / 100).toInt())

        seekListener?.onVideoSeeked(currentProgress)
    }

    private fun loadThumbnails(uri: Uri) {
        val metaDataSource = MediaMetadataRetriever()
        metaDataSource.setDataSource(context, uri)

        val videoLength = (metaDataSource.extractMetadata(
            MediaMetadataRetriever.METADATA_KEY_DURATION
        )?.toInt()?.times(1000))?.toLong()

        val thumbnailCount = 10

        val interval = videoLength?.div(thumbnailCount)

        for (i in 0 until thumbnailCount) {
            val frameTime = i * interval!!
            var bitmap =
                metaDataSource.getFrameAtTime(frameTime, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)
            try {
                var targetWidth: Int = 0
                var targetHeight: Int = 0
                if (bitmap != null) {
                    if (bitmap.height > bitmap.width) {
                        targetHeight = frameDimensionH
                        val percentage = frameDimensionH.toFloat() / bitmap.height
                        targetWidth = (bitmap.width * percentage).toInt()
                    } else {
                        targetWidth = frameDimensionW
                        val percentage = frameDimensionW.toFloat() / bitmap.width
                        targetHeight = (bitmap.height * percentage).toInt()
                    }
                }
                bitmap =
                    bitmap?.let { Bitmap.createScaledBitmap(it, targetWidth, targetHeight, false) }
            } catch (e: Exception) {
                e.printStackTrace()
            }


            container_thumbnails.addView(ThumbnailView(context).apply { setImageBitmap(bitmap) })
        }
        metaDataSource.release()
    }
}

하단 타임라인 뷰를 그려주는 커스텀 뷰입니다.

ThumbnailView.kt

class ThumbnailView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null
) : androidx.appcompat.widget.AppCompatImageView(context, attrs) {

    init {
        scaleType = ScaleType.CENTER_CROP
        setColorFilter(ContextCompat.getColor(context, R.color.video_dimmed))
        val dimension = resources.getDimensionPixelSize(R.dimen.frames_video_width)
        val dimension1 = resources.getDimensionPixelSize(R.dimen.frames_video_height)
        layoutParams = LinearLayout.LayoutParams(dimension, dimension1).apply { weight = 1f }
    }
}

ThumbyActivity.kt

class ThumbyActivity : ComponentActivity() {

    companion object {
        const val THUMBNAIL_POSITION = "thumbnail_position"
        const val VIDEO_URI = "video_url"
        const val VIDEO_THUMBNAIL_RESULT_OK = 1001
        fun getStartIntent(context: Context, uri: Uri, thumbnailPosition: Long = 0): Intent {
            val intent = Intent(context, ThumbyActivity::class.java)
            intent.putExtra(VIDEO_URI, uri)
            intent.putExtra(THUMBNAIL_POSITION, thumbnailPosition)
            return intent
        }
    }

    private lateinit var videoUri: Uri
    private var location: Int = 0
    private lateinit var binding: ActivityThumbyBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = DataBindingUtil.setContentView(this, R.layout.activity_thumby)
        binding.uploadCV.setOnClickListener { finishWithData() }
        binding.closeIV.setOnClickListener { finish() }

        videoUri = intent.getParcelableExtra(VIDEO_URI)!!

        setupVideoContent(videoUri)

    }

    private fun setupVideoContent(videoUri: Uri) {
        binding.thumbnailCCV.setDataSource(this, videoUri)

        binding.thumbsTTL.seekListener = seekListener
        binding.thumbsTTL.currentSeekPosition = intent.getLongExtra(THUMBNAIL_POSITION, 0).toFloat()
        binding.thumbsTTL.viewTreeObserver.addOnGlobalLayoutListener(object :
            ViewTreeObserver.OnGlobalLayoutListener {
            override fun onGlobalLayout() {
                binding.thumbsTTL.viewTreeObserver.removeOnGlobalLayoutListener(this)
                binding.thumbsTTL.uri = videoUri
            }
        })
    }

    private fun finishWithData() {
        val intent = Intent()
        intent.putExtra(
            THUMBNAIL_POSITION,
            location
        )
        intent.putExtra(VIDEO_URI, videoUri)
        setResult(VIDEO_THUMBNAIL_RESULT_OK, intent)
        finish()
    }

    private val seekListener = object : SeekListener {
        override fun onVideoSeeked(percentage: Double) {
            location = (percentage.toInt() * binding.thumbnailCCV.getDuration()) / 100
            binding.thumbnailCCV.seekTo((percentage.toInt() * binding.thumbnailCCV.getDuration()) / 100)
        }
    }
}

썸네일 지정하는 Activity입니다.

filepath를 url로 사용하여 해당 파일을 가지고 와 미디어 파일에 연결 후 해당 초에 맞는 이미지를 랜더링 해줍니다.

 

MainActivity.kt

  private var filePath = ""
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
//        setContentView(R.layout.activity_main)
        binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
        binding.thumbnailCCV.initPlayer()

커스텀뷰를 init 시켜줍니다

binding.selectThumbnailBT.setOnClickListener {
    activityForResult.launch(
        ThumbyActivity.getStartIntent(
            this@MainActivity,
            Uri.parse(filePath),
            0
        )
    )
}

썸네일 선택하기 버튼을 클릭할 경우

ActivityResultLauncher으로 ThumbyActivity를 호출하여 줍니다.

이때 파라미터는 context와 파일 url을 넘겨주시면 됩니다.

 

결괏값

private fun activityForResult(){
    activityForResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()){
        if (it.resultCode == RESULT_OK){
            binding.selectThumbnailBT.visibility = View.VISIBLE
            filePath = it.data?.getStringExtra("videoData")!!
            binding.thumbnailCCV.setDataSource(this, Uri.parse(filePath), 1)

        } else if (it.resultCode == VIDEO_THUMBNAIL_RESULT_OK){
            val location: Int = it.data?.getIntExtra(THUMBNAIL_POSITION, 0)!!
            binding.thumbnailCCV.seekTo(location)
        }
    }
}

 

cutsomAlbumActivity에서 setResult으로 받은 데이터인 파일 url로 커스텀 뷰에 setData를 해줍니다.

ThumbyActivity에서 setResult으로 받은 데이터인 초 값으로 seekTo를 설정하여 줍니다.

 


layout

activity_thumby.xml

<?xml version="1.0" encoding="utf-8"?>
<layout>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/white"
    android:orientation="vertical">

    <!--타이틀-->
    <RelativeLayout
        android:id="@+id/headerRL"
        android:layout_width="match_parent"
        android:layout_height="48dp">

        <ImageView
            android:id="@+id/closeIV"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_alignParentLeft="true"
            android:layout_centerVertical="true"
            android:src="@drawable/ic_baseline_arrow_back_24" />

        <androidx.cardview.widget.CardView
            android:id="@+id/uploadCV"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_alignParentRight="true"
            android:layout_centerVertical="true"
            android:layout_marginRight="6dp"
            app:cardBackgroundColor="#BABABA"
            app:cardCornerRadius="4dp"
            app:cardElevation="0dp"
            app:contentPaddingBottom="6dp"
            app:contentPaddingLeft="12dp"
            app:contentPaddingRight="12dp"
            app:contentPaddingTop="6dp">

            <TextView
                android:id="@+id/btn_upload"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="선택"
                android:textColor="#FFFF"
                android:textSize="14sp"
                android:textStyle="normal" />

        </androidx.cardview.widget.CardView>


    </RelativeLayout>



    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_above="@+id/thumbsTTL"
        android:layout_below="@+id/headerRL"
        android:orientation="vertical"
        android:gravity="center">

        <androidx.cardview.widget.CardView
            android:id="@+id/btn_profile"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:cardCornerRadius="4dp"
            app:cardElevation="0dp">

            <com.example.selectvideothumbnail.thumbnail.CenterCropVideoView
                android:id="@+id/thumbnailCCV"
                android:layout_width="@dimen/frames_thumbnail_width"
                android:layout_height="@dimen/frames_thumbnail_height"
                android:layout_gravity="center" />
        </androidx.cardview.widget.CardView>

        <ImageView
            android:id="@+id/test"
            android:layout_width="40dp"
            android:layout_height="40dp"
            />
    </LinearLayout>

    <com.example.selectvideothumbnail.thumbnail.ThumbnailTimeline
        android:id="@+id/thumbsTTL"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentBottom="true"
        android:layout_centerHorizontal="true"
        android:layout_gravity="center_horizontal"
        android:layout_marginBottom="28dp" />
</RelativeLayout>
</layout>

썸네일 선택하는 ThumbyActivity의 레이아웃입니다.

썸네일 선택하는 화면 커스텀하여 사용 가능합니다.

view_tiemline.xml

<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent">

    <LinearLayout
        android:id="@+id/container_thumbnails"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_vertical"
        android:orientation="horizontal" />

    <FrameLayout
        android:id="@+id/container_seek_bar"
        android:layout_width="wrap_content"
        android:layout_height="@dimen/frames_timeline_height"
        android:layout_gravity="center_vertical">

        <com.google.android.material.card.MaterialCardView
            android:id="@+id/thumbsMCV"
            android:layout_width="wrap_content"
            android:layout_height="@dimen/frames_timeline_height"
            android:layout_alignParentBottom="true"
            android:layout_centerHorizontal="true"
            android:layout_marginBottom="28dp"
            android:theme="@style/Theme.MaterialComponents.Light"
            app:cardCornerRadius="3dp"
            app:rippleColor="@android:color/transparent"
            app:strokeColor="#FD2678"
            app:strokeWidth="4dp">

            <com.example.selectvideothumbnail.thumbnail.CenterCropVideoView
                android:id="@+id/view_seek_bar"
                android:layout_width="@dimen/frames_timeline_width"
                android:layout_height="@dimen/frames_timeline_height" />

        </com.google.android.material.card.MaterialCardView>
    </FrameLayout>

</merge>

썸네일 선택 시 하단 seekbar 커스텀하는 view입니다.

 

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<layout>

    <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".MainActivity">

            <com.example.selectvideothumbnail.thumbnail.CenterCropVideoView
                android:id="@+id/thumbnailCCV"
                android:layout_width="200dp"
                android:layout_height="300dp"
                app:layout_constraintBottom_toTopOf="@id/goAlbumTV"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent"/>

        <Button
            android:id="@+id/goAlbumTV"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="앨범 불러오기"
            app:layout_constraintBottom_toTopOf="@id/selectThumbnailBT"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
         />

        <Button
            android:id="@+id/selectThumbnailBT"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="썸네일 선택하기"
            android:visibility="gone"
            app:layout_constraintVertical_bias="0.8"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

    </androidx.constraintlayout.widget.ConstraintLayout>

</layout>

 

앨범 불러오기, 썸네일 선택하기 버튼 및 썸네일 이미지가 표시되는 mainActivity의 레이아웃입니다.

 


이상 비디오에서 썸네일 추출하기 포스팅을 마치겠습니다.

궁금 사항이나 풀 소스를 원하시면 따로 요청 주시면 답변드리겠습니다.

감사합니다.

반응형
Comments