Android合成MotionPhoto无法被Google Photos识别问题求助
解决方案:修复MotionPhoto无法被Google Photos识别的问题
你的代码存在几个关键问题,导致生成的文件无法被Google Photos正确识别,以下是针对性修复:
核心问题分析
- 视频流被提前耗尽:日志中调用
videoInputStream?.readBytes()会把输入流的内容全部读取,导致后续写入视频时没有数据。 - XMP元数据缺失关键属性:缺少视频的偏移量
Item:Offset,Google Photos需要这个值定位视频数据的位置;同时无效的xmpNote:HasExtendedXMP属性会干扰识别。 - EXIF写入时机错误:先合并图片和视频再写入EXIF,可能破坏文件结构,应该先给图片添加元数据,再追加视频。
- 文件保存位置错误:应用私有目录
filesDir无法被Google Photos扫描到,需要保存到公共媒体目录。 - 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() }
关键修改说明
- 修复视频流耗尽问题:移除了
videoInputStream?.readBytes()的日志代码,改用合并后计算视频长度,避免提前消耗输入流。 - 补充XMP关键属性:在
getXmpMetadata中添加了Item:Offset参数,传入视频相对于文件开头的字节偏移量,同时删除无效的xmpNote:HasExtendedXMP属性。 - 调整EXIF写入流程:先给图片添加占位XMP,合并图片和视频后,再更新最终文件的XMP元数据,确保元数据写入正确的文件结构。
- 更改保存目录:将文件保存到公共DCIM目录下的
MotionPhotos子目录,确保Google Photos能扫描到该文件。 - 修正MediaScanner类型:只传递
image/jpeg作为MIME类型,避免类型混淆。 - 添加目录创建逻辑:确保保存目录存在,避免文件写入失败。
另外,需要在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
相关产品推荐
相关产品推荐

