You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Android合成MotionPhoto无法被Google Photos识别问题求助

解决方案:修复MotionPhoto无法被Google Photos识别的问题

你的代码存在几个关键问题,导致生成的文件无法被Google Photos正确识别,以下是针对性修复:

核心问题分析

  1. 视频流被提前耗尽:日志中调用videoInputStream?.readBytes()会把输入流的内容全部读取,导致后续写入视频时没有数据。
  2. XMP元数据缺失关键属性:缺少视频的偏移量Item:Offset,Google Photos需要这个值定位视频数据的位置;同时无效的xmpNote:HasExtendedXMP属性会干扰识别。
  3. EXIF写入时机错误:先合并图片和视频再写入EXIF,可能破坏文件结构,应该先给图片添加元数据,再追加视频。
  4. 文件保存位置错误:应用私有目录filesDir无法被Google Photos扫描到,需要保存到公共媒体目录。
  5. MediaScanner MIME类型错误:多传的image/heic会干扰媒体类型识别。

修复后的代码

import android.media.MediaScannerConnection
import android.net.Uri
import android.os.Environment
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowForward
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.exifinterface.media.ExifInterface
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import coil3.compose.AsyncImage
import java.io.File
import java.io.InputStream

@Composable
fun ImageItem(
    getInputStream: (Uri) -> InputStream?
) {

    var imageUri by remember { mutableStateOf<Uri?>(null) }
    var videoUri by remember { mutableStateOf<Uri?>(null) }

    var imageInputStream by remember { mutableStateOf<InputStream?>(null) }
    var videoInputStream by remember { mutableStateOf<InputStream?>(null) }

    val imagePicker =
        rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
            if (uri != null) {
                imageUri = uri
                imageInputStream = getInputStream(uri)
            } else {
                Log.d("PhotoPicker", "No media selected")
            }
        }

    val videoPicker =
        rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
            if (uri != null) {
                videoUri = uri
                videoInputStream = getInputStream(uri)
            } else {
                Log.d("PhotoPicker", "No media selected")
            }
        }

    Card {
        Row(
            modifier = Modifier.padding(8.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Box(
                modifier = Modifier
                    .clip(
                        RoundedCornerShape(10.dp)
                    )
                    .border(
                        1.dp,
                        MaterialTheme.colorScheme.surfaceTint,
                        RoundedCornerShape(10.dp)
                    )
                    .clickable {
                        imagePicker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
                    }
                    .size(75.dp),
                contentAlignment = Alignment.Center
            ) {
                when (imageUri) {
                    null -> Image(
                        painter = painterResource(R.drawable.ic_image),
                        contentDescription = null,
                        colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.surfaceTint)
                    )

                    else -> AsyncImage(
                        model = imageUri,
                        contentDescription = null,
                        contentScale = ContentScale.Crop
                    )
                }
            }

            Spacer(modifier = Modifier.width(8.dp))
            Box(
                modifier = Modifier
                    .clip(
                        RoundedCornerShape(10.dp)
                    )
                    .border(
                        1.dp,
                        MaterialTheme.colorScheme.surfaceTint,
                        RoundedCornerShape(10.dp)
                    )
                    .clickable {
                        videoPicker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly))
                    }
                    .size(75.dp),
                contentAlignment = Alignment.Center
            ) {
                when (videoUri) {
                    null -> Image(
                        painter = painterResource(R.drawable.ic_video),
                        contentDescription = null,
                        colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.surfaceTint)
                    )

                    else -> {
                        val context = LocalContext.current
                        val exoPlayer = remember {
                            ExoPlayer.Builder(context).build().apply {
                                setMediaItem(MediaItem.fromUri(videoUri!!))
                                prepare()
                                playWhenReady = true
                                repeatMode = Player.REPEAT_MODE_ONE
                            }
                        }
                        PlayerSurface(
                            player = exoPlayer,
                            surfaceType = SURFACE_TYPE_SURFACE_VIEW
                        )
                    }
                }
            }
            Spacer(modifier = Modifier.weight(1f))
            val context = LocalContext.current
            Icon(
                Icons.AutoMirrored.Rounded.ArrowForward,
                contentDescription = null,
                modifier = Modifier.clickable {
                    if (imageInputStream != null && videoInputStream != null) {
                        // 创建临时文件保存带元数据的图片
                        val tempImageFile = File.createTempFile("temp_image", ".jpg", context.cacheDir)
                        // 保存到公共DCIM目录,确保Google Photos能扫描到
                        val dcimDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM)
                        val motionPhotoDir = File(dcimDir, "MotionPhotos")
                        if (!motionPhotoDir.exists()) motionPhotoDir.mkdirs()
                        val motionPhotoFile = File(
                            motionPhotoDir,
                            "dynamic_photo_${System.currentTimeMillis()}.MP.jpg"
                        )

                        try {
                            // 1. 先给图片添加占位XMP元数据
                            imageInputStream?.use { input ->
                                tempImageFile.outputStream().use { output ->
                                    input.copyTo(output)
                                }
                            }

                            // 获取图片文件大小作为视频偏移量
                            val imageFileSize = tempImageFile.length()
                            val exifInterface = ExifInterface(tempImageFile)
                            exifInterface.setAttribute(ExifInterface.TAG_XMP, getXmpMetadata(0, imageFileSize.toInt()))
                            exifInterface.saveAttributes()

                            // 2. 合并带元数据的图片和视频到最终文件
                            var videoLength: Long
                            motionPhotoFile.outputStream().use { outputStream ->
                                tempImageFile.inputStream().use { it.copyTo(outputStream) }
                                val videoStartOffset = outputStream.channel.position()
                                videoInputStream?.use { it.copyTo(outputStream) }
                                videoLength = outputStream.channel.size() - videoStartOffset
                            }

                            // 3. 更新最终文件的XMP元数据,填入正确视频长度
                            val finalExif = ExifInterface(motionPhotoFile)
                            finalExif.setAttribute(ExifInterface.TAG_XMP, getXmpMetadata(videoLength.toInt(), imageFileSize.toInt()))
                            finalExif.saveAttributes()

                            Log.d("MotionPhoto", "视频偏移量: $imageFileSize, 视频长度: $videoLength")

                            // 通知媒体扫描器,仅传递image/jpeg类型
                            MediaScannerConnection.scanFile(
                                context,
                                arrayOf(motionPhotoFile.absolutePath),
                                arrayOf("image/jpeg")
                            ) { _, uri ->
                                Log.d("MotionPhoto", "保存成功,URI: $uri")
                            }

                        } catch (e: Exception) {
                            Log.e("MotionPhoto", "保存失败: ${e.message}")
                            e.printStackTrace()
                        } finally {
                            tempImageFile.delete()
                        }
                    } else {
                        Log.e("MotionPhoto", "图片或视频流为空")
                    }
                }
            }

            Spacer(modifier = Modifier.weight(1f))

            Box(
                modifier = Modifier
                    .border(
                        1.dp,
                        MaterialTheme.colorScheme.surfaceTint,
                        RoundedCornerShape(10.dp)
                    )
                    .size(75.dp),
                contentAlignment = Alignment.Center
            ) {
                when (videoUri) {
                    else -> Image(
                        painter = painterResource(R.drawable.ic_image),
                        contentDescription = null,
                        colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.surfaceTint)
                    )
                }
            }
        }
    }
}

@Preview(showBackground = true)
@Composable
fun ImageItemPreview() {
    ImageItem { null }
}

fun getXmpMetadata(videoLengthInBytes: Int, videoOffsetInBytes: Int): String {
    return """
        <x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core 5.1.0-jc003">
            <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
                <rdf:Description rdf:about=""
                    xmlns:Container="http://ns.google.com/photos/1.0/container/"
                    xmlns:Item="http://ns.google.com/photos/1.0/container/item/"
                    xmlns:GCamera="http://ns.google.com/photos/1.0/camera/"
                    GCamera:MotionPhoto="1"
                    GCamera:MotionPhotoVersion="1">
                        <Container:Directory>
                            <rdf:Seq>
                                <rdf:li rdf:parseType="Resource">
                                    <Container:Item
                                        Item:Mime="image/jpeg"
                                        Item:Semantic="Primary" />
                                </rdf:li>
                                <rdf:li rdf:parseType="Resource">
                                    <Container:Item
                                        Item:Mime="video/mp4"
                                        Item:Semantic="MotionPhoto"
                                        Item:Length="$videoLengthInBytes"
                                        Item:Offset="$videoOffsetInBytes"
                                        Item:Padding="0" />
                                </rdf:li>
                            </rdf:Seq>
                        </Container:Directory>
                </rdf:Description>
            </rdf:RDF>
        </x:xmpmeta>
    """.trimIndent()
}

关键修改说明

  1. 修复视频流耗尽问题:移除了videoInputStream?.readBytes()的日志代码,改用合并后计算视频长度,避免提前消耗输入流。
  2. 补充XMP关键属性:在getXmpMetadata中添加了Item:Offset参数,传入视频相对于文件开头的字节偏移量,同时删除无效的xmpNote:HasExtendedXMP属性。
  3. 调整EXIF写入流程:先给图片添加占位XMP,合并图片和视频后,再更新最终文件的XMP元数据,确保元数据写入正确的文件结构。
  4. 更改保存目录:将文件保存到公共DCIM目录下的MotionPhotos子目录,确保Google Photos能扫描到该文件。
  5. 修正MediaScanner类型:只传递image/jpeg作为MIME类型,避免类型混淆。
  6. 添加目录创建逻辑:确保保存目录存在,避免文件写入失败。

另外,需要在AndroidManifest.xml中添加对应存储权限:

<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />

Android 13及以上需动态申请READ_MEDIA_IMAGES和READ_MEDIA_VIDEO权限;Android 12及以下需申请WRITE_EXTERNAL_STORAGE权限。

内容的提问来源于stack exchange,提问作者shawshuai

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.15 15:24:50