close

公司最近有項需求是錄制第三方 360度全景相機的 preview 串流

說到影音,大家第一個想到的應該是 FFMpeg 吧?

不過由於他的門檻較高,需要懂 C 、NDK、JNI

因此坊間也有許多不同的 Library 來支持影音處理

比如說用 FFMpeg 當基底的 AndroidFFMpeg

或是以 Java 為接口,輸入 FFMpeg 指令的 android-ffmpeg-java

還有 Jcodec、JavaCV…等。

這裡有一個比較網站,可以看到各 lib 的狀況

點我進入

 

最後我們選擇使用 Jcodec 是因為他的評價不錯

而且更重要的是到最近還有在維護

其它很多都是好幾年沒變動了

在這個 Android OS 不斷更新的時代

跟著進步才是唯一解啊

 

那就開始來使用吧

首先 app gradle 加入

// 影片剪輯 & JCodec with Android images (Bitmap)
implementation 'org.jcodec:jcodec:0.2.3'
implementation 'org.jcodec:jcodec-android:0.2.3'            

 

然後來做一層影音的使用接口 RecordUtils 吧

盡量的將相關邏輯寫在裡面,code 會比較簡潔易讀

class RecordUtils {

    companion object {

        // For Singleton instantiation
        @Volatile
        private var instance: RecordUtils? = null
        private var encoder: AndroidSequenceEncoder? = null

        fun getInstance() =
            instance ?: synchronized(this) {
                instance ?: RecordUtils().also { 
                    instance = it 
                }
            }
    }

    /**
     * 最後產出的 video 位置
     */
    fun setResultUrl(url: String): RecordUtils {
        // 結束錄制,存檔
        finish()
        // 建立新 encoder
        encoder = AndroidSequenceEncoder
                  .create30Fps(File(url))
        Log.d("final video = $url")
        return this
    }

    /**
     * 塞進每一幀 Bitmap
     */
    fun recordBitmap(bitmap: Bitmap) {
        encoder?.encodeImage(bitmap)
    }

    /**
     * 結束錄制,存檔
     */
    fun finish() {
        // 等待剩餘 bitmap 傳完
        Thread.sleep(1000)
        Log.d("Jcodec record finish")
        encoder?.let {
            it.finish()
            encoder = null
        }
    }
}

由於我是用 SurfaceView 去 show 相機 preview 的

所以我在 SurfaceHolder 中不斷的塞顯示的 bitmap 給 RecordUtils

最後結束 preview 的時候再 invoke finish() 即可

 

===========================================

到這一切好像都很順利

實際跑跑看發現 preview 的畫面變得很卡

然後錄出來的影片是快轉的,而且比實際時間短

 

為什麼會這樣呢?

由於還沒調用到 finish() 前就可以看到 mp4 生成了

因此推測可能是 

encoder?.encodeImage(bitmap)

這個 function 是一邊存 bitmap 一邊 flush file 的關係

造成大吃效能,所以我們做一點變動

我們先存 bitmap 下來,等到結束的時候再一起制成影片

變動如下

 

先取得要暫存 bitmap 的空間

由於我們需要快速存取,所以不使用 External Storage

改用 Internal Storage,且於存成影片後

刪光暫存 bitmap,以釋放空間

// 建立錄影 bitmap 暫存區資料夾
val internalFolder = ContextWrapper(context)
    .getDir("imageDir", Context.MODE_PRIVATE)
    .absolutePath +"/LiveTours/$name/"
val file = File(internalFolder)
file.mkdirs()


然後在 FileUtils class 多寫一個 function 來執行讀 bitmap 的動作

這是我架構中的 File 處理層

大家若要直接調用 BitmapFactory.decodeFile() 也可以

/**
 * 讀取檔案轉成 Bitmap
 */
fun getBitmapFromFile(url: String): Bitmap? {
    return try {
        BitmapFactory.decodeFile(url)
    } catch (e: IOException) {
        Log.e(e, "assetsToBitmap")
        e.printStackTrace()
        null
    }
}

 

最後在 RecordUtils 修改一下 (只貼改動部份)

class RecordUtils {

    companion object {
        private var bitmapFilePathList = 
                    ArrayList<String>()
    }

    /**
     * 存下每一幀 Bitmap
     */
    fun recordBitmap(bitmap: Bitmap) {
        val file =
            File(ProjectFileHelper.mFolderInternalProject 
                 + bitmapFilePathList.size + ".jpeg")
        Log.d("Save bitmap to =>" + file.absolutePath)
        FileUtils.createFile(file.absolutePath)
        bitmapFilePathList.add(file.absolutePath)
        try {
            val outputStream = 
                BufferedOutputStream(FileOutputStream(file))
            // 先 resize 至指定大小
            val resizedBitmap = 
                Bitmap.createScaledBitmap(
                       bitmap, 
                       512, 
                       256, 
                       false)
            // 再以原圖 50% 解析度存檔
            resizedBitmap.compress(
                Bitmap.CompressFormat.JPEG, 
                50, 
                outputStream)
            // 寫入並釋放資源
            outputStream.flush()
            outputStream.close()
            resizedBitmap.recycle()
        } catch (e: IOException) {
            Log.e(e, "recordBitmap")
            e.printStackTrace()
        }
    }


    var isEncoding = false
    private fun encodeAllBitmaps() {
        // 已在處理中,別重復 encode
        if (isEncoding) return

        bitmapFilePathList.forEach { url ->
            isEncoding = true
            encoder?.encodeImage(
                FileUtils.getBitmapFromFile(url))
            Log.d("Encode Bitmap from => $url")
        }
        // 轉完刪除暫存
        FileUtils.deleteFolder(
            ProjectFileHelper.mFolderInternalProject)
        bitmapFilePathList = ArrayList()
        isEncoding = false
    }

    /**
     * 結束錄制,存檔
     */
    fun finish() {
        // 慢一點啟動 encode 怕 recordBitmap 還會被調用
        Thread.sleep(100)
        // 將所存 Bitmap 轉成 mp4
        encodeAllBitmaps()
        // encode 完成,關閉 encode
        if (encoder != null && !isEncoding) {
            encoder?.finish()
            encoder = null
            Log.d("Jcodec record finish")
        } 
    }
}

 

從上面可以看到

我多了一個 array 存下檔案的 path

在 finish 的時候會 invoke encodeAllBitmaps() 來轉檔影片

 

這邊要注意一點

在存檔的時候我們多建立了一個 resized 的 bitmap

這是因為專案不需要用到高畫質影片

所以為了縮小佔用空間所做的處理

這個 bitmap 在存檔之後記得要 recycle 喔

bitmap 要及時釋放才不會造成 OOM 的問題

 

做完調整,連快轉的問題也跟著解決了

由於在 preview 時,同時 flush 進檔案太吃效能

導致我們在 encode 的當下

不只 preview 卡,連 flush 也卡

所以產出的影片其實很多幀都沒有存到

幀若跳著存,當然產出就變成快轉囉

而且存的幀數也小於實際幀數

影片長度自然就比較短了

 

 

資料來源:

http://jcodec.org/

https://developer.android.com/training/data-storage/app-specific#kotlin

https://android.libhunt.com/categories/1476-media

https://github.com/FFmpeg/FFmpeg

https://github.com/guardianproject/android-ffmpeg-java

 

 


arrow
arrow

    顏澤偉 發表在 痞客邦 留言(0) 人氣()