MediaCodec を利用した動画作成

明けましておめでとうございます。 大掃除で綺麗になったオフィスは仕事を始められるのはすばらしいことです。 (詳…

明けましておめでとうございます。
大掃除で綺麗になったオフィスは仕事を始められるのはすばらしいことです。 (詳細はこちら)
 
さて、今回は Android で動画を作成してみようと思います。
スマホで動画をどうにかする需要は意外とあります。
基本的に外部ライブラリに頼ることが多いので (mp4parser など) 、勉強がてら Android SDK のみでやってみます。
Android SDK ですと、 MediaRecorder や MediaCodec などが挙げられます。
 
MediaRecorder は内蔵カメラ・マイクからの入力をそのままキャプチャして保存したりするのに向いています。
MediaCodec は低レベルなマルチメディアサポートインフラということもあり、様々なことができるようです。
今回は、 MediaCodec を使って動画を作成してみます。
最初なのでまずは映像のみの動画から作ってみましょう。
 

仕組み


MediaCodec は API level 16 (Android 4.1) から追加された API で、結構な頻度でアップデートが入っています。
基本的な使い方は MediaCodec の Class OverView に書いてあります。
といっても膨大な量なので理解するのはなかなかに骨が折れます。
 
media_codec_flow
 
簡単にいってしまえば、 MediaCodec はあくまでコーデックでエンコードとデコードを担っています。
入力データを MediaCodec を介してエンコードし、 MediaMuxer を使ってファイルを作るといった形になります。
MediaMuxer は音声データと映像データをコンテナに入れて動画ファイルを作成する役割を持ちます。
 

入力データの作成


今回は単純に SDK に入っているカメラアイコン (ic_menu_camera) を 5 秒の動画にします。
映像の入力データはバッファを作成してそこに流し込む方法 (API level 16 ~) と
Surface に描画してそれを入力データとする方法 (API level 18 ~) があります。
バッファを使う方法は機種依存などの妨害に遭うことが多いらしいので、 Surface を使った手法でいきます。
MediaCodec#createInputSurface() で入力用の Surface が生成でき、これに描画して入力データを作成します。
 
ここで 1 つの問題が出てきます。
入力用 Surface に描画する際に、 lockCanvas や unlockCanvas で描画していくのは NG ということです。
OpenGL などの描画ライブラリを使う必要があるようで、 OpenGL を使うための下準備が一番手間になるかと思います。
 

実装


上記を踏まえた上で実装していきましょう。
今回は Kotlin で書いてみます。
DataBinding ライブラリを使用しています。
そして、コードが少し多めです。
 
MainActivity.kt

class MainActivity : BaseActivity() {

    /**
     * バインディング
     */
    lateinit private var mBinding: ActivityMainBinding
    /**
     * 動画エンコード
     */
    private val mVideoEditor by lazy {
        val path = File(getExternalFilesDir(null), "test.mp4").absolutePath
        VideoEditor(path)
    }
    /**
     * 描画スレッド
     */
    private val mGlThread by lazy {
        OpenGLThread(this, mVideoEditor.surface)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        mBinding = DataBindingUtil.setContentView(this, R.layout.activity_main)
        mBinding.handler = mMainEventHandler
    }

    /**
     * イベントハンドラー
     */
    private val mMainEventHandler = object : MainEventHandler {
        override fun onEncodeButton(v: View?) {
            mGlThread.start()
            mGlThread.setViewPort(512, 512)
            mVideoEditor.encode()
            Toast.makeText(this@MainActivity, "エンコードを開始しました", Toast.LENGTH_LONG).show()
            Handler().postDelayed({
                mVideoEditor.finishEncode()
                Handler().postDelayed({
                    mGlThread.requestExitAndWait()
                    mVideoEditor.release()
                    Toast.makeText(this@MainActivity, "エンコードが終了しました", Toast.LENGTH_LONG).show()
                }, 1000)
            }, 5000)
        }
    }

}

 
GLES20Util.kt

class GLES20Util {

    companion object {

        private const val INVALID = 0
        private const val DEFAULT_OFFSET = 0
        private const val FIRST_INDEX = 0
        private const val FLOAT_SIZE_BYTES = 4

        /**
         * プリミティブ配列データを FloatBuffer に変換して返します。
         *
         * @param array バッファデータ
         * @return 変換されたバッファデータ
         */
        fun createBuffer(array: FloatArray): FloatBuffer {
            val buffer = with(ByteBuffer.allocateDirect(array.size * FLOAT_SIZE_BYTES)) {
                order(ByteOrder.nativeOrder())
                asFloatBuffer()
            }
            buffer.apply {
                put(array)
                position(FIRST_INDEX)
            }
            return buffer
        }

        /**
         * プログラムを生成します。
         *
         * @param vertexSource 頂点シェーダ
         * @param fragmentSource フラグメントシェーダ
         * @return 生成したプログラム
         */
        fun createProgram(vertexSource: String, fragmentSource: String): Int {
            // バーテックスシェーダのコンパイル
            val vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, vertexSource)
            when (vertexShader) {
                INVALID -> {
                    Log.w("OpenGL", "バーテックスシェーダのコンパイルに失敗しました")
                    return INVALID
                }
            }
            // フラグメントシェーダのコンパイル
            val fragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource)
            when (fragmentShader) {
                INVALID -> {
                    Log.w("OpenGL", "フラグメントシェーダのコンパイルに失敗しました")
                    return INVALID
                }
            }
            // プログラム生成
            var program = GLES20.glCreateProgram()
            when (program != INVALID) {
                true -> {
                    // バーテックスシェーダの関連付け
                    GLES20.glAttachShader(program, vertexShader)
                    // フラグメントシェーダの関連付け
                    GLES20.glAttachShader(program, fragmentShader)

                    GLES20.glLinkProgram(program)
                    val linkStatus = IntArray(1)
                    GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linkStatus, DEFAULT_OFFSET)
                    when (linkStatus[FIRST_INDEX] != GLES20.GL_TRUE) {
                        true -> {
                            Log.w("OpenGL", "リンクに失敗しました")
                            Log.w("OpenGL", GLES20.glGetProgramInfoLog(program))
                            GLES20.glDeleteProgram(program)
                            program = INVALID
                        }
                    }
                }
            }
            return program
        }

        /**
         * シェーダをコンパイルします。
         *
         * @param shaderType シェーダの種類
         * @param source シェーダのソースコード
         * @return シェーダハンドラまたはINVALID
         */
        fun loadShader(shaderType: Int, source: String): Int {
            var shader = GLES20.glCreateShader(shaderType)
            when {
                shader != INVALID && shader != GLES20.GL_INVALID_ENUM -> {
                    GLES20.glShaderSource(shader, source)
                    GLES20.glCompileShader(shader)
                    val compiled = IntArray(1)
                    GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compiled, DEFAULT_OFFSET)
                    when (compiled[FIRST_INDEX]) {
                        INVALID -> {
                            Log.w("OpenGL", "シェーダのコンパイルに失敗しました")
                            Log.w("OpenGL", GLES20.glGetShaderInfoLog(shader))
                            GLES20.glDeleteShader(shader)
                            shader = INVALID
                        }
                    }
                }
                else -> {
                    shader = INVALID
                }
            }
            return shader
        }

        fun loadTexture(bitmap: Bitmap, min: Int = GLES20.GL_NEAREST, mag: Int = GLES20.GL_LINEAR): Int {
            val textures = IntArray(1)
            GLES20.glGenTextures(1, textures, DEFAULT_OFFSET)

            val texture = textures[FIRST_INDEX]
            GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, texture)
            GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0)

            GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, min)
            GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, mag)

            return texture
        }

        fun checkGlError(op: String) {
            var error = GLES20.glGetError()
            while (error != GLES20.GL_NO_ERROR) {
                Log.w("OpenGL", "$op: glError $error")
                error = GLES20.glGetError()
            }
        }

    }

}

 
Renderer.kt

class Renderer(context: Context) {

    /**
     * 頂点バッファ
     */
    private val mVertexBuffer: FloatBuffer
    /**
     * テクスチャバッファ
     */
    private val mTexcoordBuffer: FloatBuffer

    /**
     * コンテキスト
     */
    private val mContext: Context
    //
    private var mProgram: Int
    // シェーダ変数
    private var mPosition: Int
    private var mTexcoord: Int
    private var mTexture: Int
    private var mTextureId: Int

    companion object {

        /**
         * 頂点シェーダ
         */
        private const val VERTEX_SHADER = "" +
                "attribute vec4 position;\n" +
                "attribute vec2 texcoord;\n" +
                "varying vec2 texcoordVarying;\n" +
                "void main() {\n" +
                "    gl_Position = position;\n" +
                "    texcoordVarying = texcoord;\n" +
                "}\n"
        /**
         * フラグメントシェーダ
         */
        private const val FRAGMENT_SHADER = "" +
                "precision mediump float;\n" +
                "varying vec2 texcoordVarying;\n" +
                "uniform sampler2D texture;\n" +
                "void main() {\n" +
                "    gl_FragColor = texture2D(texture, texcoordVarying);\n" +
                "}\n"

    }

    init {
        // 頂点情報
        val vertices = floatArrayOf(
                -1.0f, 1.0f, 0.0f,
                -1.0f, -1.0f, 0.0f,
                1.0f, 1.0f, 0.0f,
                1.0f, -1.0f, 0.0f
        )
        mVertexBuffer = GLES20Util.createBuffer(vertices)
        // テクスチャ座標情報
        val texcoords = floatArrayOf(
                0.0f, 0.0f,
                0.0f, 1.0f,
                1.0f, 0.0f,
                1.0f, 1.0f
        )
        mTexcoordBuffer = GLES20Util.createBuffer(texcoords)

        mContext = context
        mProgram = 0
        mPosition = 0
        mTexcoord = 0
        mTexture = 0
        mTextureId = 0
    }

    fun init() {
        mProgram = GLES20Util.createProgram(VERTEX_SHADER, FRAGMENT_SHADER)
        when (mProgram) {
            0 -> throw IllegalStateException()
        }
        GLES20.glUseProgram(mProgram)

        mPosition = GLES20.glGetAttribLocation(mProgram, "position")
        when (mPosition) {
            -1 -> throw IllegalStateException()
        }
        GLES20.glEnableVertexAttribArray(mPosition)

        mTexcoord = GLES20.glGetAttribLocation(mProgram, "texcoord")
        when (mTexcoord) {
            -1 -> throw IllegalStateException()
        }
        GLES20.glEnableVertexAttribArray(mTexcoord)

        mTexture = GLES20.glGetUniformLocation(mProgram, "texture")
        when (mTexture) {
            -1 -> throw IllegalStateException()
        }

        // とりあえずカメラアイコンを描画してみる
        val bitmap = BitmapFactory.decodeResource(mContext.resources, android.R.drawable.ic_menu_camera)
        mTextureId = GLES20Util.loadTexture(bitmap)
        bitmap.recycle()
    }

    fun draw() {
        // 背景クリア
        GLES20.glClearColor(1.0f, 1.0f, 1.0f, 1.0f)
        GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)

        // テクスチャ有効化
        GLES20.glEnable(GLES20.GL_TEXTURE_2D)
        // ブレンド有効化
        GLES20.glEnable(GLES20.GL_BLEND)
        // ブレンド設定
        GLES20.glBlendFunc(GLES20.GL_SRC_ALPHA, GLES20.GL_ONE_MINUS_SRC_ALPHA)

        // 頂点描画
        GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, mTextureId)
        GLES20.glUniform1i(mTexture, 0)
        GLES20.glVertexAttribPointer(mTexcoord, 2, GLES20.GL_FLOAT, false, 0, mTexcoordBuffer)
        GLES20.glVertexAttribPointer(mPosition, 3, GLES20.GL_FLOAT, false, 0, mVertexBuffer)
        GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)

        // ブレンド無効化
        GLES20.glDisable(GLES20.GL_BLEND)
        // テクスチャ無効化
        GLES20.glDisable(GLES20.GL_TEXTURE_2D)
    }

}

 
OpenGLThread

class OpenGLThread(context: Context, surface: Surface) : Thread() {

    /**
     * 描画Surface
     */
    private val mSurface: Surface
    /**
     * スレッド終了フラグ
     */
    private var mDone: Boolean
    /**
     * ディスプレイコネクション
     */
    private var mEglDisplay: EGLDisplay? = null
    /**
     * コンフィグ
     */
    lateinit private var mEglConfig: EGLConfig
    /**
     * レンダリングコンテキスト
     */
    private var mEglContext: EGLContext? = null
    /**
     * サーフェース
     */
    private var mEglSurface: EGLSurface? = null
    /**
     * 描画ヘルパー
     */
    private var mRenderer: Renderer

    init {
        mSurface = surface
        mDone = false
        mRenderer = Renderer(context)
    }

    override fun run() {
        super.run()
        when (initGlEs()) {
            false -> {
                Log.w("OpenGL", "OpenGL ES初期化失敗")
                mDone = true
            }
        }
        mRenderer.init()
        while (!mDone) {
            drawFrame()
        }
        finishGlEs()
    }

    /**
     * 描画します。
     */
    private fun drawFrame() {
        mRenderer.draw()

        //画面に出力するバッファの切り替え
        val egl = EGLContext.getEGL() as EGL10
        egl.eglSwapBuffers(mEglDisplay, mEglSurface)
    }

    /**
     * OpenGL ESを初期化します。
     *
     * @return true:正常終了 false:エラー
     */
    private fun initGlEs(): Boolean {
        val egl = EGLContext.getEGL() as EGL10

        // ディスプレイコネクション作成
        mEglDisplay = egl.eglGetDisplay(EGL10.EGL_DEFAULT_DISPLAY)
        when (mEglDisplay) {
            EGL10.EGL_NO_DISPLAY -> {
                Log.w("OpenGL", "ディスプレイコネクション作成失敗")
                return false
            }
        }
        // ディスプレイコネクション初期化
        val version = IntArray(2)
        when (egl.eglInitialize(mEglDisplay, version)) {
            false -> {
                Log.w("OpenGL", "ディスプレイコネクション初期化失敗")
                return false
            }
        }

        // コンフィグ設定
        val configSpec = intArrayOf(
                EGL10.EGL_ALPHA_SIZE, 8,
                EGL10.EGL_DEPTH_SIZE, 16,
                EGL10.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT,
                EGL10.EGL_NONE
        )
        val configs = kotlin.arrayOfNulls<EGLConfig>(1)
        val numConfigs = IntArray(1)
        when (egl.eglChooseConfig(mEglDisplay, configSpec, configs, 1, numConfigs)) {
            false -> {
                Log.w("OpenGL", "コンフィグ設定失敗")
                return false
            }
        }
        mEglConfig = configs[0]!!

        // レンダリングコンテキスト作成
        val attrib_list = intArrayOf(
                0x3098, 2,
                EGL10.EGL_NONE
        )
        mEglContext = egl.eglCreateContext(mEglDisplay, mEglConfig, EGL10.EGL_NO_CONTEXT, attrib_list)
        when (mEglContext) {
            EGL10.EGL_NO_CONTEXT -> {
                Log.w("OpenGL", "レンダリングコンテキスト作成失敗")
                return false
            }
        }

        // サーフェース作成
        mEglSurface = egl.eglCreateWindowSurface(mEglDisplay, mEglConfig, mSurface, null)
        when (mEglSurface) {
            EGL10.EGL_NO_SURFACE -> {
                Log.w("OpenGL", "サーフェース作成失敗")
                return false
            }
        }
        // サーフェースとレンダリングコンテキスト結びつけ
        when (egl.eglMakeCurrent(mEglDisplay, mEglSurface, mEglSurface, mEglContext)) {
            false -> {
                Log.w("OpenGL", "レンダリングコンテキストとの結びつけ失敗")
                return false
            }
        }

        return true
    }

    /**
     * ビューポートを設定します。
     *
     * @param width 幅
     * @param height 高さ
     */
    fun setViewPort(width: Int, height: Int) {
        GLES20.glViewport(0, 0, width, height)
    }

    /**
     * スレッド終了をリクエストします。
     */
    fun requestExitAndWait() {
        synchronized(this) {
            mDone = true
        }
        try {
            // スレッド終了待ち
            join()
        } catch (e: InterruptedException) {
            Log.w("OpenGL", e.message, e)
            Thread.currentThread().interrupt()
        }
    }

    /**
     * OpenGL ESの終了します。
     */
    private fun finishGlEs() {
        val egl = EGLContext.getEGL() as EGL10

        // サーフェース破棄
        when (mEglSurface != null) {
            true -> {
                // レンダリングコンテキストとの結びつきを解除
                egl.eglMakeCurrent(mEglDisplay, EGL10.EGL_NO_SURFACE, EGL10.EGL_NO_SURFACE, EGL10.EGL_NO_CONTEXT)
                egl.eglDestroySurface(mEglDisplay, mEglSurface)
                mEglSurface = null
            }
        }
        // レンダリングコンテキスト破棄
        when (mEglContext != null) {
            true -> {
                egl.eglDestroyContext(mEglDisplay, mEglContext)
                mEglContext = null
            }
        }
        // ディスプレイコネクション破棄
        when (mEglDisplay != null) {
            true -> {
                egl.eglTerminate(mEglDisplay)
                mEglDisplay = null
            }
        }
    }

}

 
VideoEditor.kt

class VideoEditor(path: String) {

    private var mCodec: MediaCodec
    private var mMuxer: MediaMuxer
    var surface: Surface
    private var mTrackIndex: Int
    private var mEndOfStream: Boolean
    private var mMuxerStarted: Boolean

    init {
        mMuxerStarted = false
        mEndOfStream = false
        mMuxer = MediaMuxer(path, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
        mTrackIndex = -1
        val format = MediaFormat.createVideoFormat("video/avc", 512, 512).apply {
            setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)
            setInteger(MediaFormat.KEY_BIT_RATE, 1 * 1000 * 1000)
            setInteger(MediaFormat.KEY_FRAME_RATE, 30)
            setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1)
        }
        mCodec = MediaCodec.createEncoderByType("video/avc")
        mCodec.setCallback(object : MediaCodec.Callback() {
            private val FPS = 30
            private var start = 0L
            private var now = 0L
            private var base = 0.0
            override fun onOutputBufferAvailable(codec: MediaCodec?, index: Int, info: MediaCodec.BufferInfo?) {
                when (start == 0L) {
                    true -> start = System.currentTimeMillis()
                }
                now = System.currentTimeMillis()

                val outputBuffer = codec!!.getOutputBuffer(index)
                when (info!!.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) {
                     true -> {
                        info.size = 0
                    }
                }
                when (info.size != 0) {
                    true -> {
                        when (mMuxerStarted) {
                            false -> throw RuntimeException()
                        }
                        outputBuffer.position(info.offset)
                        outputBuffer.limit(info.offset + info.size)
                        when (now - start >= base) {
                            true -> {
                                base += 1000 / FPS
                                mMuxer.writeSampleData(mTrackIndex, outputBuffer, info)
                            }
                        }
                    }
                }
                codec.releaseOutputBuffer(index, false)

                when (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
                    true -> {
                        when (mEndOfStream) {
                            true -> {
                                Log.d("MediaCodec", "出力終了")
                            }
                            else -> {
                                Log.w("MediaCodec", "予期しない出力終了")
                            }
                        }
                    }
                }
            }

            override fun onOutputFormatChanged(codec: MediaCodec?, format: MediaFormat?) {
                // MediaMuxer開始
                when (mMuxerStarted) {
                    true -> throw RuntimeException()
                }
                mTrackIndex = mMuxer.addTrack(format)
                mMuxer.start()
                mMuxerStarted = true
            }

            override fun onInputBufferAvailable(codec: MediaCodec?, index: Int) {
                // 入力がSurfaceからなので何もしない
            }

            override fun onError(codec: MediaCodec?, e: MediaCodec.CodecException?) {
                Log.w("MediaCodec", "codec=${codec?.toString() ?: "(null)"},e.message=${e?.message ?: "(null)"}", e)
            }
        })
        mCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
        surface = mCodec.createInputSurface()
    }

    fun encode() {
        mCodec.start()
    }

    fun finishEncode() {
        mEndOfStream = true
        mCodec.signalEndOfInputStream()
    }

    fun release() {
        mCodec.stop()
        mCodec.release()
        mMuxer.stop()
        mMuxer.release()
    }

}

 
以上が主要クラスです。
上記コードの MediaCodec の使い方は Class OverView を参照していただければ。
OpenGLThread で Surface に描画し、
VideoEditor で描画したデータを入力にして動画としてファイルに保存しています。
ちなみにこのコードは Marshmallow (API level 21) 以降に対応したコードとなっております。
たった 5 秒の静止画を動画にするだけで、これだけ書かないといけないことに驚きですね。
 

実行結果


生成された動画は次の通りです。
 
 
静止画を動画にしただけなので、動画としては特に面白みはありませんが
外部ライブラリに頼らずに動画が作れたことに感動を覚えます。
次回は、音声データも入れてみたいと思います。