KENTEM TechBlog

建設業のDXを実現するKENTEMの技術ブログです。

KotlinでGoogle Motion Photo撮影アプリを自作

Canvacapturenowが提供する素材

こんにちは、まつです。
最近スマホを別のメーカーに変えましたのですが、Google Motion Photo形式で写真撮影できないことに気付きました。
Google Motion Photo形式で撮影できるアプリが無いか調べたところ、スマホを変えた時点(2025年3月)ではそのようなアプリが見付かりませんでした。

ということで、無いならば作ればいいの精神で、Google Motion Photo形式で撮影できるアプリを自作しました。
今回は、AndroidのKotlin環境で「Google Motion Photo」を自作する方法をご紹介します。

モーションフォトとは?

Google Motion Photoは、JPEG画像に短い動画(シャッター前後合わせて1秒~3秒程度)を埋め込むことで、静止画としても動画としても楽しめる機能です。
ファイル形式としてはJPEGですが、以下の要素から構成されます。

  • JPEG本体(サムネイル用の静止画)
  • APP1セグメントにXMP拡張メタデータ(モーションフォトだよという印を含む)
  • 末尾に連結されたMP4動画(Googleフォトアプリで再生時に流れる動画)

この形式で撮影された写真はGoogleフォト上では以下のように表示されます。
※Googleフォトのようにモーションフォトの再生に対応したアプリで無ければただのJPEGとして扱われます。ブラウザは対応していないので、イメージとして二つの再生パターンを載せています。

静止画表示時:

静止画表示
モーションフォト再生時:
動画

シャッターのタイミングで静止画の前後を動画として保存することで、重要なシャッターチャンスを逃すことが無いというのが強みです。
iPhoneのLiveフォトと同じような機能です。
ちなみに、本家のPixelカメラアプリでは「AIがベストショットを提案」してくれるみたいです。

全体の構成

今回はAIを使わないので、シャッターしたタイミングを静止画として保存、リングバッファで録画している動画と繋ぎ合わせるという方式で行きます。
執筆時間の関係上ポイントだけのコード掲示になりますがあしからず。

  • MediaCodecで動画を常時リングバッファ録画
  • ImageReaderでJPEGを取得
  • JPEGにXMPを埋め込む
  • JPEG末尾にMP4動画を連結

MediaCodecで動画を常時リングバッファ録画

動画は常時録画し、最新の数秒間を保存できるようにします。MediaCodecでエンコードしつつ、自前でバッファリングします。

val handler = Handler(recordingThread.looper)
val format = getMediaFormat(_mediaCodec)
handler.post(object : Runnable {
    override fun run() {
        val file = File.createTempFile("clip_", ".mp4", _context.cacheDir)    //  一時領域に録画ファイルを保存
        videoBuffer.addLast(file)

        val mediaMuxer = setupMediaMuxer(file)
        startRecordingSegment(_mediaCodec, format, mediaMuxer) {
            if (videoBuffer.size > BUFFER_SIZE)
                videoBuffer.removeFirst().delete()    // バッファサイズよりも多ければ古い録画ファイルから削除
            handler.post(this)    // 次の録画ファイルを作成
        }
    }
})

ImageReaderでJPEGを取得

JPEGの撮影はシャッターを押したタイミングで実行します。
動画を常時録画している関係上、キャプチャから作成するには撮影のリクエストを受け取る形にします。

ImageReader = android.media.ImageReader.newInstance(captureWidth, captureHeight, ImageFormat.JPEG, 2)
ImageReader.setOnImageAvailableListener({ reader ->
    val image = reader.acquireLatestImage()
    image?.let {
        // 撮影のリクエストがあった時だけ保存する
        if (_takeRequest == null) {
            it.close()
            return@setOnImageAvailableListener
        }

        val buffer = it.planes[0].buffer
        val bytes = ByteArray(buffer.remaining())
        buffer.get(bytes)

        // ファイル保存処理
        val timestamp = System.currentTimeMillis()
        val photoFile = File(cacheDir, "capture_${timestamp}.jpg")
        FileOutputStream(photoFile).use { it.write(bytes) }
        it.close()
        _takeRequest?.complete(photoFile)
        _takeRequest = null
    }
}, Handler(Looper.getMainLooper()))

JPEGにXMPを埋め込む

Google Motion Photo仕様に準拠したXMPメタデータをAPP1セグメントに挿入します。 ※XMPメタタグを省略していますが、GCamera:MicroVideoOffsetに動画のバイナリ開始位置(JPEGのファイルサイズ)を指定する必要があるので忘れずに、、

val xmpDataBytes = xmpTemplate.toByteArray(Charsets.UTF_8)
val xmpHeader = "http://ns.adobe.com/xap/1.0/\u0000".toByteArray(Charsets.US_ASCII)
val app1Content = xmpHeader + xmpDataBytes

val app1Marker = byteArrayOf(0xFF.toByte(), 0xE1.toByte()) // MarkerPreffixとAPP1Markerを用意
val app1Length = (app1Content.size + 2).toShort() // フィールドの長さを計算

val app1Segment = ByteArrayOutputStream().apply {
    write(app1Marker)    // マーカーを書き込み
    write(byteArrayOf((app1Length.toInt() shr 8).toByte(), (app1Length.toInt() and 0xFF).toByte()))    // フィールド長をビッグエンディアンで書き込み
    write(app1Content)    // XMPコンテンツを書き込み
}.toByteArray()
val soi = byteArrayOf(0xFF.toByte(), 0xD8.toByte())
val xmpEmbeddedPhoto= File.createTempFile("xmpEmbeddedPhoto", ".jpg", _context.cacheDir)
val outputStream = FileOutputStream(xmpEmbeddedPhoto)
outputStream.use { out ->
    out.write(soi)    // SOIを書き込み
    out.write(app1Segment)    // APP1セグメントを書き込み
    out.write(originalJpegBytes.copyOfRange(2, originalJpegBytes.size))    // JPEGデータを書き込み
}

JPEG末尾にMP4動画を連結

最後に、XMPを埋め込んだJPEGの後にMP4バイナリを連結して1つのファイルにまとめます。

val xmpEmbeddedJepgBytes = xmpEmbeddedPhoto.readBytes()
val mp4Bytes = videoFile.readBytes()
val motionPhoto = File(outputPath)
FileOutputStream(motionPhoto ).use { out ->
    out.write(xmpEmbeddedJpegBytes)    // XMP付のJPEGを書き込み
    out.write(mp4Bytes)  // MP4を書き込み
}

ファイルの確認

最後に、作成した画像をGoogleフォトで確認して、モーションフォトとして認識・再生されれば完成です!

ハマりポイントと対処法

  • MAUIで対応しようとしたが、リングバッファにラグが発生するため断念しました。
  • MediaRecorderを使用してさっくりリングバッファ実装しようとしたが、こちらもラグが発生するため断念しました。並列処理でラグを埋めようとしたが難しかった。。

おわりに

このアプリは生成AIとタッグを組んで作成しました。
私はKotlinを初めて使用したのですが、Kotlinで作成を初めて2日ほどでアプリが完成しました。
恐らく無駄な処理もあるとは思いますが、多少の知識があれば複雑なアプリケーションも作成できてしまうところが非常に魅力的に感じました。
これで動き回る子供の写真が撮れる。。

KENTEMでは、様々な拠点でエンジニアを大募集しています! 建設×ITにご興味頂いた方は、是非下記のリンクからご応募ください。 recruit.kentem.jp career.kentem.jp