Android动态图片技术深度解析
01
概述
01
概述
动态照片是一种融合静态图片与动态视频的多媒体格式,其核心设计采用"主静态文件 + 附加视频 + 元数据"的组合模式,既能实现静态展示,又可支持动态播放,为用户带来更丰富的视觉体验,同时保持了较好的兼容性。
不过,由于不同手机厂商对动态照片的技术实现存在差异(如封装格式、元数据标签定义等),导致显示和提取方式不同。目前测试了三个主流厂商,其特点如下:
小米: 使用 Micro Video 格式,通过自定义 EXIF 字段存储元数据 Google: 采用标准 Motion Photo 格式,基于 XMP 元数据系统 OPPO: 实现 O Live Photo 格式,支持 HDR GainMap 等高级特性
本文将深入解析 Android 动态照片的技术原理,重点分析 XMP 元数据系统,并通过小米 Micro Video、Google Motion Photo 和 OPPO O Live Photo 三个真实案例,展示不同厂商的技术实现细节,探寻一套统一的检测和处理解决方案。
02
动态照片的核心结构
2.1 三层架构设计
动态照片的基础框架由三部分构成:
主要静态图片文件
作用:作为视觉主体,是用户直观看到的 "照片" 部分 格式支持:JPEG、HEIC(高效图像格式)、AVIF(新一代开放格式) 内容特点:通常为拍摄瞬间的静态画面,可能包含增益图以支持 HDR 效果
次要视频文件
作用:作为动态补充,提供动态效果 内容特点:通常为拍摄前后 1-3 秒的短视频片段 存储方式:附加在静态文件中,用于呈现微小动作、声音等动态元素
元数据系统
Camera XMP:定义静态图片与视频的显示规则,例如 Camera:MotionPhoto属性定义了是否是动态照片,0 是非动态照片;1 是动态照片。Container XMP:指引设备定位并读取附加的视频文件,例如 Length字段定义了次要媒体内容的字节长度。
2.2 增益图特性(可选)
动态照片的主要静态文件可能包含 "增益图"(Gain Map),这一设计与 Ultra HDR JPEG 的增益图逻辑一致:
基础原理:通过 "基础图片 + 增益图" 的组合实现高动态范围(HDR)效果 兼容性设计:支持设备渲染 HDR 效果,不支持的设备显示基础图片
03
Android 官方动态照片 XMP 元数据系统详解
03
Android 官方动态照片 XMP 元数据系统详解
3.1 Camera XMP 元数据
命名空间 URI:http://ns.google.com/photos/1.0/camera/默认前缀:Camera
核心属性
| 属性名 | 类型 | 说明 |
|---|---|---|
Camera:MotionPhoto | ||
Camera:MotionPhotoVersion | ||
Camera:MotionPhotoPresentationTimestampUs |
3.2 Container XMP 元数据
命名空间 URI:http://ns.google.com/photos/1.0/container/默认前缀:Container
目录结构
Directory: 有序结构数组
├── Container:Item (主图片 - 必须是第一项)
├── Container:Item (增益图 - Ultra HDR时)
└── Container:Item (视频文件 - 必须是最后一项)
必需属性
| 属性名 | 类型 | 说明 |
|---|---|---|
Mime | ||
Semantic | ||
Length |
可选属性
| 属性名 | 类型 | 说明 |
|---|---|---|
Padding |
Semantic 的可能的值
| 语义值 | 说明 |
|---|---|
Primary | |
MotionPhoto | |
GainMap |
3.3 元数据格式的解释
命名空间的概念可以结合 Android XML 文件来理解。例如,xmlns:app="http://schemas.android.com/apk/res-auto" 定义了 app 命名空间,有了这个定义,像 MotionPhotoView 这样的自定义视图才能通过 app:layout_constraintLeft_toLeftOf 这类带 app 前缀的属性来设置值。
由于不同手机厂商可能会定义同名的元数据属性,命名空间的核心作用就是通过前缀区分这些属性,避免因名称重复导致的冲突。
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayoutxmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/Blk_12"
xmlns:app="http://schemas.android.com/apk/res-auto">
<hy.sohu.com.app.ugc.share.view.widget.MotionPhotoView
android:id="@+id/motion_photo_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
以下是一个根据官方文档生成的 XMP 数据格式,可能不准确,大概结构是这样的,通过下面的示例,我们可以更好的理解元数据是怎么存储的。
<x:xmpmetaxmlns:x="adobe:ns:meta/">
<rdf:RDFxmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Descriptionrdf:about=""
xmlns:Camera="http://ns.google.com/photos/1.0/camera/"
xmlns:Container="http://ns.google.com/photos/1.0/container/">
<Camera:MotionPhoto>1</Camera:MotionPhoto>
<Camera:MotionPhotoVersion>1</Camera:MotionPhotoVersion>
<Camera:MotionPhotoPresentationTimestampUs>1500000</Camera:MotionPhotoPresentationTimestampUs>
<Container:Directory>
<rdf:Seq>
<rdf:lirdf:parseType="Resource">
<Container:Item:Mime>image/jpeg</Container:Item:Mime>
<Container:Item:Semantic>Primary</Container:Item:Semantic>
</rdf:li>
</rdf:Seq>
</Container:Directory>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
04
Android 官方动态照片文件名模式规范
04
Android 官方动态照片文件名模式规范
4.1 正则表达式规范
动态照片的文件名需遵循特定正则表达式命名规则,这是官方推荐的命名规范,目的是通过文件名快速识别动态照片。但实际情况中,各手机厂商并未统一遵循这一规则,导致该方法的识别效果大打折扣。
^([^\\s\\/\\\\][^\\/\\\\]*MP)\\.(JPG|jpg|JPEG|jpeg|HEIC|heic|AVIF|avif)4.2 命名规则解析
前缀部分:[^\\s\\/\\\\][^\\/\\\\]*
第一个字符不能是空格、斜杠或反斜杠 后续字符可以是除斜杠和反斜杠之外的任意字符
标识部分:MP
作用:作为动态照片文件的标志性标识 位置:必须位于文件名末尾(扩展名之前) 示例: IMG_1234MP.jpg、DSC0056MP.heic
后缀部分:支持的扩展名
JPEG: .jpg、.jpegHEIC: .heicAVIF: .avif
05
Android 官方动态照片视频容器内容规范
05
Android 官方动态照片视频容器内容规范
以下是 android 官方定义的动态照片中视频部分的编码方式、轨道结构、同步机制和播放行为,这些规范保证了用户在查看动态照片时能够获得流畅、一致的体验,但不同手机厂商实现方式可能会不同。
5.1 轨道结构
主视频轨道(必需)
编码格式:AVC(H.264)、HEVC(H.265)或 AV1 分辨率:无强制限制 色彩支持:
SDR:8 位,BT.709 色彩空间,sRGB 转换 HDR:10 位,BT.2100 色彩空间,HLG/PQ 转换
次要视频轨道(可选)
作用:高分辨率缩略图或替代画面 编码格式:同主视频轨道 帧率:通常较低(1-5fps) 帧关联:与主轨道帧一一对应,时间戳完全相同
音频轨道(可选)
编码格式:AAC 参数:16 位单声道或立体声 采样率:44kHz、48kHz 或 96kHz 播放规则:与主视频轨道同步
5.2 轨道排序规则
主视频轨道:索引最小,必须是第一个视频轨道 次要视频轨道:索引大于主轨道,位于主轨道之后 音频轨道:无强制顺序,但需时间同步
06
不同动态照片实现案例分析
06
不同动态照片实现案例分析
6.1 动态照片技术特点与检测方法
Android 的ExifInterface类主要支持标准 EXIF 标签,对 XMP 命名空间的支持非常有限,例如小米的 MicroVideo 标签属于自定义 XMP 命名空间,因此 ExifInterface 无法解析。实际开发中需要使用二进制解析或 Adobe XMP SDK 进行处理。
小米官方提供了专用 SDK,可以展示、判断和制作动态照片,为开发者提供了完整的解决方案。
6.2 小米 Micro Video 案例分析
小米手机使用自有的 Micro Video 格式,通过自定义 EXIF 字段存储动态照片元数据:
exiftool /Users/allenzhang/Downloads/1752549853110.jpg
关键元数据信息:
File Name : 1752549853110.jpg
File Size : 5.5 MB
Make : (小米手机型号)
XMP Toolkit : Adobe XMP Core 5.1.0-jc003
Micro Video Version : 1
Micro Video : 1
Micro Video Offset : 1735850
Micro Video Presentation Timestamp Us: 761955
Image Width : 3072
Image Height : 4096
XMP SDK 解析结果:
通过 Adobe XMP SDK 的 xmpMeta.dumpObject() 方法可以获取小米动态照片的完整 XMP 结构:
ROOT NODE
http://ns.google.com/photos/1.0/camera/ = "GCamera:" (0x80000000 : SCHEMA_NODE)
GCamera:MicroVideo = "1"
GCamera:MicroVideoVersion = "1"
GCamera:MicroVideoOffset = "1757635"
GCamera:MicroVideoPresentationTimestampUs = "333227"
http://ns.adobe.com/exif/1.0/ = "exif:" (0x80000000 : SCHEMA_NODE)
exif:ImageWidth = "4096"
exif:ImageLength = "3072"
exif:Make = "Xiaomi"
exif:Model = "XIAOMI Device"
http://ns.adobe.com/xmp/note/ = "xmpNote:" (0x80000000 : SCHEMA_NODE)
xmpNote:HasExtendedXMP = ""
技术特点分析:
小米复用了 Google 的 Camera 命名空间,并在其下扩展了 MicroVideo 等自定义属性(非标准属性),这可能导致与其他厂商的标准实现产生冲突 通过 MicroVideoOffset 字段指示视频数据位置 视频大小可能通过 文件总大小 - MicroVideoOffset计算得出相比 Google 和 OPPO,小米的 XMP 结构相对简单
6.3 Google Pixel 4 动态照片案例分析
Google Pixel 手机使用标准的 Motion Photo 格式,以下是 Pixel 4 拍摄的动态照片元数据:
exiftool /Users/allenzhang/Downloads/PXL_20250722_065151464.MP.jpg
关键元数据信息:
File Name : PXL_20250722_065151464.MP.jpg
File Size : 4.2 MB
Make : Google
Camera Model Name : Pixel 4
XMP Toolkit : Adobe XMP Core 5.1.0-jc003
Motion Photo : 1
Motion Photo Version : 1
Motion Photo Presentation Timestamp Us: 411003
Has Extended XMP : 4938039014578563D928899A05F0B30F
Directory Item Mime : image/jpeg, video/mp4
Directory Item Semantic : Primary, MotionPhoto
Directory Item Length : 0, 870399
Directory Item Padding : 0, 0
Motion Photo Video : (Binary data 870399 bytes, use -b option to extract)
XMP SDK 解析结果:
通过 Adobe XMP SDK 的 xmpMeta.dumpObject() 方法可以获取 Google Motion Photo 的完整 XMP 结构:
ROOT NODE
http://ns.google.com/photos/1.0/container/ = "Container:" (0x80000000 : SCHEMA_NODE)
Container:Directory (0x600 : ARRAY | ARRAY_ORDERED)
[1] (0x100 : STRUCT)
Container:Item (0x100 : STRUCT)
Item:Length = "0"
Item:Mime = "image/jpeg"
Item:Padding = "0"
Item:Semantic = "Primary"
[2] (0x100 : STRUCT)
Container:Item (0x100 : STRUCT)
Item:Length = "870399"
Item:Mime = "video/mp4"
Item:Padding = "0"
Item:Semantic = "MotionPhoto"
http://ns.google.com/photos/1.0/camera/ = "GCamera:" (0x80000000 : SCHEMA_NODE)
GCamera:MotionPhoto = "1"
GCamera:MotionPhotoPresentationTimestampUs = "411003"
GCamera:MotionPhotoVersion = "1"
http://ns.adobe.com/xmp/note/ = "xmpNote:" (0x80000000 : SCHEMA_NODE)
xmpNote:HasExtendedXMP = "4938039014578563D928899A05F0B30F"
技术特点分析:
Google 使用标准 Container 命名空间定义复杂的容器结构 通过 Directory 数组清晰区分 Primary 图片和 MotionPhoto 视频 Item:Length 字段直接提供视频大小信息(870,399 字节) HasExtendedXMP 表示使用了扩展 XMP 存储大型元数据 完全符合 Adobe XMP 和 Google Motion Photo 标准
6.4 OPPO Find X8 动态照片案例分析
OPPO 手机实现了自己的动态照片格式,称为 "O Live Photo",同时也支持标准 Motion Photo:
exiftool /Users/allenzhang/Downloads/IMG20250722163505.jpg
关键元数据信息:
File Name : IMG20250722163505.jpg
File Size : 7.0 MB
Make : OPPO
Camera Model Name : OPPO Find X8
XMP Toolkit : Adobe XMP Core 5.1.0-jc003
Version : 1.0
Motion Photo : 1
Motion Photo Version : 1
Motion Photo Presentation Timestamp Us: 266704
Motion Photo Primary Presentation Timestamp Us: 266704
Motion Photo Owner : oplus
O Live Photo Version : 2
Video Length : 3334498
Directory Item Mime : image/jpeg, image/jpeg, video/mp4
Directory Item Semantic : Primary, GainMap, MotionPhoto
Directory Item Length : 0, 474937, 3334834
Directory Item Padding : 0, 0
Gain Map Image : (Binary data 3334834 bytes, use -b option to extract)
Motion Photo Video : (Binary data 3334834 bytes, use -b option to extract)
XMP SDK 解析结果:
通过 Adobe XMP SDK 的 xmpMeta.dumpObject() 方法可以获取 OPPO O Live Photo 的完整 XMP 结构:
ROOT NODE
http://ns.google.com/photos/1.0/container/ = "Container:" (0x80000000 : SCHEMA_NODE)
Container:Directory (0x600 : ARRAY | ARRAY_ORDERED)
[1] (0x100 : STRUCT)
Container:Item (0x100 : STRUCT)
Item:Length = "0"
Item:Mime = "image/jpeg"
Item:Padding = "0"
Item:Semantic = "Primary"
[2] (0x100 : STRUCT)
Container:Item (0x100 : STRUCT)
Item:Length = "474937"
Item:Mime = "image/jpeg"
Item:Padding = "0"
Item:Semantic = "GainMap"
[3] (0x100 : STRUCT)
Container:Item (0x100 : STRUCT)
Item:Length = "3334834"
Item:Mime = "video/mp4"
Item:Semantic = "MotionPhoto"
http://ns.google.com/photos/1.0/camera/ = "GCamera:" (0x80000000 : SCHEMA_NODE)
GCamera:MotionPhoto = "1"
GCamera:MotionPhotoPresentationTimestampUs = "266704"
GCamera:MotionPhotoVersion = "1"
http://ns.oplus.com/photos/1.0/camera/ = "OpCamera:" (0x80000000 : SCHEMA_NODE)
OpCamera:MotionPhotoOwner = "oplus"
OpCamera:MotionPhotoPrimaryPresentationTimestampUs = "266704"
OpCamera:OLivePhotoVersion = "2"
OpCamera:VideoLength = "3334498"
http://ns.adobe.com/hdr-gain-map/1.0/ = "hdrgm:" (0x80000000 : SCHEMA_NODE)
hdrgm:Version = "1.0"
技术特点分析:
OPPO 中的这个例子包含三层容器结构:Primary + GainMap + MotionPhoto 使用专有命名空间 http://ns.oplus.com/photos/1.0/camera/存储 O Live Photo 扩展信息支持 HDR 增益图(GainMap),文件大小 474,937 字节 两个视频大小字段:Container 中的 3,334,834 字节和 OPPO 专有的 3,334,498 字节(OPPO 视频项未显式定义 Padding 字段,推测其 Length 包含的额外字节(336 字节)为隐式 Padding,可能与厂商编码逻辑有关) 集成 Adobe HDR-Gain-Map 标准,版本 1.0 双时间戳机制:标准时间戳和 Primary 专用时间戳 向后兼容 Google Motion Photo 标准的同时提供 OPPO 增强功能
6.5 三大厂商动态照片格式对比分析
| 特征项 | 小米 Micro Video | Google Motion Photo | OPPO O Live Photo |
|---|---|---|---|
| 格式标识 | Micro Video: 1 | Motion Photo: 1 | Motion Photo: 1O Live Photo Version: 2 |
| 存储位置 | |||
| 视频标识 | Micro Video Offset | Directory Item Length | Video LengthDirectory Item Length |
| 时间戳字段 | Micro Video Presentation Timestamp Us | Motion Photo Presentation Timestamp Us | |
| 容器结构 | |||
| 文件大小占比 | |||
| 兼容性 | |||
| 特性 | |||
| 音频支持 | |||
| XMP 命名空间 | com.mi | com.google | com.oppo |
详细分析:
1. 小米 Micro Video(传统格式)
文件总大小: 5.5 MB
├── 静态图片: 1.74 MB (约 31%)
└── 视频数据: 4.03 MB (约 69%)
使用自定义 EXIF 字段存储元数据 视频直接附加在静态图片后 格式简单但兼容性有限
2. Google Motion Photo(标准格式)
文件总大小: 4.2 MB
├── 静态图片: 约 3.3 MB (约 79%)
├── 视频数据: 870 KB (约 20%)
└── Extended XMP: 少量元数据
完全符合 Google Motion Photo 标准 使用 Extended XMP 处理大型元数据 容器结构清晰,支持多种媒体类型
3. OPPO O Live Photo(混合格式)
文件总大小: 7.0 MB
├── 静态图片: 约 3.2 MB (约 46%)
├── GainMap (HDR): 475 KB (约 7%)
└── 视频数据: 3.3 MB (约 47%)
支持 HDR 增益图 (GainMap) 双时间戳机制提供更精确的同步 向后兼容标准 Motion Photo 格式
07
处理动态照片的 Kotlin 实现
07
处理动态照片的 Kotlin 实现
7.1 依赖配置
首先在 build.gradle 中添加必要的依赖:
dependencies {
// Adobe XMP SDK
implementation 'com.adobe.xmp:xmpcore:6.1.11'
// 协程支持
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
// 文件处理
implementation 'androidx.core:core-ktx:1.12.0'
}
7.2 动态照片检测方法和解析方法
基于以上对三大厂商动态照片格式的分析,我尝试设计了一套统一的动态照片检测解决方案。该方案采用双重检测策略:首先通过 extractXMPFromJPEG 方法解析 XMP 元数据来识别动态照片类型和视频信息;当 XMP 元数据不完整或缺失时,则使用 findMp4HeaderOffset 方法直接从文件二进制数据中定位并提取视频信息。这种可以提供更好的兼容性和可靠性。
下面以 OPPO 动态照片为例,展示完整的元数据解析和视频提取实现。小米和 Google 动态照片的处理逻辑与此类似,遵循相同的接口设计模式。
classUnifiedMotionPhotoExtractor{
companionobject {
constval TAG = "MotionPhotoExtractor"
privateconstval BUFFER_SIZE = 8192
// MP4文件头标识
privateval FTYP_SIGNATURE = byteArrayOf('f'.toByte(), 't'.toByte(), 'y'.toByte(), 'p'.toByte())
privateval FTYPMP4_SIGNATURE = byteArrayOf('f'.toByte(), 't'.toByte(), 'y'.toByte(), 'p'.toByte(),
'm'.toByte(), 'p'.toByte(), '4'.toByte())
privateval FTYPMP42_SIGNATURE = byteArrayOf('f'.toByte(), 't'.toByte(), 'y'.toByte(), 'p'.toByte(),
'm'.toByte(), 'p'.toByte(), '4'.toByte(), '2'.toByte())
privateval FTYPISOM_SIGNATURE = byteArrayOf('f'.toByte(), 't'.toByte(), 'y'.toByte(), 'p'.toByte(),
'i'.toByte(), 's'.toByte(), 'o'.toByte(), 'm'.toByte())
// 元数据标签
privateconstval XIAOMI_MICRO_VIDEO = "MicroVideo"
privateconstval XIAOMI_MICRO_VIDEO_OFFSET = "MicroVideoOffset"
privateconstval GCAMERA_MICRO_VIDEO = "GCamera:MicroVideo"
privateconstval GCAMERA_MICRO_VIDEO_OFFSET = "GCamera:MicroVideoOffset"
privateconstval GOOGLE_MOTION_PHOTO = "GCamera:MotionPhoto"
privateconstval OPPO_LIVE_PHOTO = "OpCamera:OLivePhotoVersion"
// 进度回调间隔(字节数)
privateconstval PROGRESS_INTERVAL = 100000
// JPEG 段标记
privateconstval JPEG_SOI = 0xFFD8
privateconstval JPEG_APP1 = 0xFFE1
privateconstval JPEG_SOS = 0xFFDA
// XMP 头标识常量
privateval XMP_HEADER = "http://ns.adobe.com/xap/1.0/".toByteArray()
}
/**
* 从 JPEG 文件中提取 XMP 元数据
*/
privatefunextractXMPFromJPEG(filePath: String): XMPMeta? {
returntry {
RandomAccessFile(filePath, "r").use { raf ->
// 验证 JPEG 文件头
if (raf.readUnsignedShort() != JPEG_SOI) {
returnnull
}
while (true) {
// 读取段标记
val marker = raf.readUnsignedShort()
// 如果到达图像数据段,停止解析
if (marker == JPEG_SOS) {
break
}
// 只处理 APP1 段
if (marker == JPEG_APP1) {
val segmentLength = raf.readUnsignedShort()
val segmentData = ByteArray(segmentLength - 2)
raf.readFully(segmentData)
// 检查是否为 XMP 数据
if (isXMPSegment(segmentData)) {
Log.d("chao"," 发现 XMP 数据段,文件: ${filePath}")
return parseXMPFromSegment(segmentData)
}
} else {
// 跳过其他段
val segmentLength = raf.readUnsignedShort()
raf.skipBytes(segmentLength - 2)
}
}
}
null
} catch (e: Exception) {
Log.e(TAG, "XMP 提取失败: ${e.message}", e)
null
}
}
/**
* 检查是否为 XMP 段
*/
privatefunisXMPSegment(data: ByteArray): Boolean {
if (data.size < XMP_HEADER.size) returnfalse
for (i in XMP_HEADER.indices) {
if (data[i] != XMP_HEADER[i]) returnfalse
}
returntrue
}
/**
* 从段数据中解析 XMP
*/
privatefunparseXMPFromSegment(segmentData: ByteArray): XMPMeta? {
returntry {
val xmpDataStart = XMP_HEADER.size + 1// +1 for null terminator
val xmpData = segmentData.copyOfRange(xmpDataStart, segmentData.size)
XMPMetaFactory.parseFromBuffer(xmpData)
} catch (e: XMPException) {
Log.e(TAG, "XMP 解析失败: ${e.message}", e)
null
}
}
// 这里是oppo手机提取xmp方法,其它手机的类似
privatefuncheckOppoLivePhotoInXMP(xmpMeta: XMPMeta): MotionPhotoResult? {
returntry {
val cameraNamespace = "http://ns.google.com/photos/1.0/camera/"
val oppoNamespace = "http://ns.oplus.com/photos/1.0/camera/"// OPPO专有命名空间
val containerNamespace = "http://ns.google.com/photos/1.0/container/"
val hdrNamespace = "http://ns.adobe.com/hdr-gain-map/1.0/"
// 首先检查OPPO专有命名空间
val oppoMotionPhotoOwner = try {
xmpMeta.getPropertyString(oppoNamespace, "MotionPhotoOwner")
} catch (e: Exception) { null }
val oppoOLivePhotoVersion = try {
xmpMeta.getPropertyString(oppoNamespace, "OLivePhotoVersion")
} catch (e: Exception) { null }
val oppoVideoLength = try {
xmpMeta.getPropertyString(oppoNamespace, "VideoLength")?.toLongOrNull()
} catch (e: Exception) { null }
// 检查标准命名空间中的Motion Photo标识
val motionPhoto = try {
xmpMeta.getPropertyInteger(cameraNamespace, "MotionPhoto")
} catch (e: Exception) { null }
// 如果是OPPO格式 (有OPPO专有标识或VideoLength字段)
val isOppoFormat = oppoMotionPhotoOwner == "oplus" ||
oppoOLivePhotoVersion != null ||
oppoVideoLength != null
if (motionPhoto == 1 && isOppoFormat) {
Log.d("XMP", "检测到OPPO O Live Photo格式")
val version = try {
// 优先使用OPPO版本,回退到标准版本
oppoOLivePhotoVersion?.toIntOrNull()
?: xmpMeta.getPropertyInteger(cameraNamespace, "MotionPhotoVersion")
} catch (e: Exception) { 1 }
val timestamp = try {
// 优先使用OPPO的Primary时间戳
xmpMeta.getPropertyLong(oppoNamespace, "MotionPhotoPrimaryPresentationTimestampUs")
} catch (e: Exception) {
try {
// 回退到标准时间戳
xmpMeta.getPropertyLong(cameraNamespace, "MotionPhotoPresentationTimestampUs")
} catch (e2: Exception) { -1L }
}
// 解析Container结构获取视频大小和GainMap信息
val containerInfo = parseOppoContainerInfo(xmpMeta, containerNamespace)
// 优先使用Container中的视频大小,回退到OPPO的VideoLength字段
val videoSize = containerInfo.videoSize.takeIf { it > 0 }
?: oppoVideoLength ?: -1L
// 检查是否有HDR GainMap
val hasHdrGainMap = try {
val hdrVersion = xmpMeta.getPropertyString(hdrNamespace, "Version")
hdrVersion != null && containerInfo.hasGainMap
} catch (e: Exception) {
containerInfo.hasGainMap
}
Log.d("XMP", "OPPO检测结果 - VideoSize: $videoSize, HasGainMap: $hasHdrGainMap, Version: $version")
return MotionPhotoResult(
type = MotionPhotoType.OPPO_LIVE_PHOTO,
vendor = "OPPO",
version = version,
videoSize = videoSize,
presentationTimestamp = timestamp,
detectionMethod = "XMP SDK",
hasGainMap = hasHdrGainMap
)
}
null
} catch (e: Exception) {
Log.e("XMP", "OPPO检测失败: ${e.message}", e)
null
}
}
/**
* 提取OPPO动态照片中的视频
*/
privatefunextractVideo(
inputFile: File,
outputFile: File,
videoSize: Long = 0L,
videoOffset: Long = 0L,
progressCallback: ((Long, Long) -> Unit)?
): Boolean {
// OPPO动态照片可能包含增益图,需要特殊处理
// 这里使用简化方法,直接搜索MP4文件头
val offset = findMp4HeaderOffset(inputFile.path)?:0
Log.d(TAG,"找到视频数据,偏移量: $offset")
// 尝试多个可能的偏移量
val possibleOffsets = listOf(
videoOffset,
offset
)
for (tryOffset in possibleOffsets) {
if (tryOffset < 0) continue
val tempFile = File.createTempFile("motion_video_", ".mp4")
extractVideoData(inputFile, tempFile, tryOffset, progressCallback)
if (isValidMp4(tempFile)) {
Log.d(TAG,"✅ 偏移量 $tryOffset 提取的文件是有效的MP4格式")
tempFile.copyTo(outputFile, overwrite = true)
tempFile.delete()
returntrue
}
tempFile.delete()
}
return isValidMp4(outputFile)
}
/**
* 查找MP4文件头的偏移量
*/
privatefunfindMp4HeaderOffset(filePath: String): Long? {
val file = File(filePath)
if (!file.exists() || file.length() < 8) {
returnnull
}
// 只搜索文件的后半部分,因为视频通常在文件末尾
val fileSize = file.length()
val startOffset = maxOf(0, fileSize / 2)
RandomAccessFile(filePath, "r").use { raf ->
raf.seek(startOffset)
val buffer = ByteArray(BUFFER_SIZE)
val window = ByteArray(8) // 滑动窗口,用于查找签名
var bytesRead: Int
var currentOffset = startOffset
while (raf.read(buffer).also { bytesRead = it } > 0) {
for (i in0 until bytesRead) {
// 更新滑动窗口
System.arraycopy(window, 1, window, 0, window.size - 1)
window[window.size - 1] = buffer[i]
// 检查当前窗口是否包含任何一种MP4文件头标识
val signatureOffset = containsSignature(window)
if (signatureOffset != -1) {
// 找到了MP4文件头,返回文件中的实际偏移量
// 减去4是因为MP4的box大小字段在标识符之前
return currentOffset + i - (window.size - signatureOffset) + 1 - 4
}
}
currentOffset += bytesRead
}
}
returnnull
}
/**
* 检查给定的字节数组是否包含任何一种MP4文件头标识
*
* @return 如果包含,返回标识符在数组中的起始位置;否则返回-1
*/
privatefuncontainsSignature(data: ByteArray): Int {
// 按优先级顺序检查各种MP4文件头标识
val signatures = listOf(FTYPMP42_SIGNATURE, FTYPMP4_SIGNATURE, FTYPISOM_SIGNATURE, FTYP_SIGNATURE)
for (signature in signatures) {
for (i in0..data.size - signature.size) {
var found = true
for (j in signature.indices) {
if (data[i + j] != signature[j]) {
found = false
break
}
}
if (found) return i
}
}
return -1
}
/**
* 从输入文件的指定偏移量开始提取视频数据到输出文件
*/
privatefunextractVideoData(
inputFile: File,
outputFile: File,
offset: Long,
progressCallback: ((Long, Long) -> Unit)?
) {
val fileSize = inputFile.length()
val videoSize = fileSize - offset
FileInputStream(inputFile).use { input ->
FileOutputStream(outputFile).use { output ->
// 跳过偏移量之前的数据
input.skip(offset)
val buffer = ByteArray(BUFFER_SIZE)
var bytesRead: Int
var totalBytesRead: Long = 0
var lastProgressUpdate: Long = 0
while (input.read(buffer).also { bytesRead = it } > 0) {
output.write(buffer, 0, bytesRead)
totalBytesRead += bytesRead
// 更新进度
if (progressCallback != null && totalBytesRead - lastProgressUpdate > PROGRESS_INTERVAL) {
progressCallback(totalBytesRead, videoSize)
lastProgressUpdate = totalBytesRead
}
}
// 最终进度更新
progressCallback?.invoke(totalBytesRead, videoSize)
}
}
}
/**
* 检查文件是否为有效的MP4格式
*/
privatefunisValidMp4(file: File): Boolean {
if (!file.exists() || file.length() < 8) returnfalse
FileInputStream(file).use { input ->
val header = ByteArray(8)
if (input.read(header) != header.size) returnfalse
// 检查文件头是否包含ftyp标识
val headerStr = String(header, StandardCharsets.UTF_8)
return headerStr.contains("ftyp")
}
}
}
7.3 动态照片播放组件
为了更好地展示动态照片,我们可以创建一个专用的 MotionPhotoView 组件,ImageView用来显示静态图片,VideoView用来显示视频,主要方法如下:
/**
* 动态照片播放组件
* 支持静态图片显示和视频播放切换
*/
classMotionPhotoView@JvmOverloadsconstructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
privateval imageView: ImageView
privateval videoView: VideoView
privatevar motionPhotoResult: MotionPhotoResult? = null
/**
* 设置动态照片数据
*/
funsetMotionPhoto(imagePath: String, result: MotionPhotoResult) {
this.originalImagePath = imagePath
this.motionPhotoResult = result
// 显示静态图片
loadStaticImage(imagePath)
}
/**
* 播放视频
*/
funplayVideo() {
try {
Log.d("MotionPhotoView", "开始播放视频: $videoPath")
videoView.setVideoPath(videoPath)
videoView.isVisible = true
imageView.isVisible = false
videoView.start()
isVideoPlaying = true
} catch (e: Exception) {
Log.e("MotionPhotoView", "播放视频异常: ${e.message}", e)
onError?.invoke("视频播放失败: ${e.message}")
showStaticImage()
}
}
/**
* 停止视频播放
*/
funstopVideo() {
if (videoView.isPlaying) {
videoView.stopPlayback()
}
showStaticImage()
}
/**
* 暂停视频
*/
funpauseVideo() {
if (videoView.isPlaying) {
videoView.pause()
}
}
/**
* 设置VideoView回调
*/
privatefunsetupVideoCallbacks() {
videoView.setOnPreparedListener { mediaPlayer ->
Log.d("MotionPhotoView", "视频准备完成")
// 设置循环播放
if (autoLoop) {
mediaPlayer.isLooping = true
}
// 根据时间戳定位播放位置
motionPhotoResult?.presentationTimestamp?.let { timestamp ->
if (timestamp > 0) {
val seekPosition = (timestamp / 1000).toInt() // 转换为毫秒
Log.d("MotionPhotoView", "定位到时间戳: ${seekPosition}ms (原始: ${timestamp}μs)")
mediaPlayer.seekTo(seekPosition)
}
}
}
videoView.setOnCompletionListener {
Log.d("MotionPhotoView", "视频播放完成")
if (!autoLoop) {
showStaticImage()
}
}
videoView.setOnErrorListener { _, what, extra ->
showStaticImage()
true
}
}
}
08
总结与展望
08
总结与展望
Android 动态照片技术借助精心设计的文件结构、元数据系统及容器规范,实现了静态与动态内容的无缝融合。然而,不同厂商的技术实现存在差异,这不可避免地导致了兼容性问题 —— 各厂商的动态照片格式往往难以互通。
本文通过深入解析 XMP 元数据系统,并结合小米 Micro Video、Google Motion Photo 和 OPPO O Live Photo 的真实案例,详细展示了动态照片的实现原理。对于开发者而言,理解这些技术细节不仅有助于构建更好的应用体验,也为跨平台兼容性处理提供了重要参考。
希望本文能为对动态照片技术感兴趣的开发者提供一些帮助。