Android動(dòng)態(tài)圖片技術(shù)深度解析
1.概述
動(dòng)態(tài)照片是一種融合靜態(tài)圖片與動(dòng)態(tài)視頻的多媒體格式,其核心設(shè)計(jì)采用"主靜態(tài)文件 + 附加視頻 + 元數(shù)據(jù)"的組合模式,既能實(shí)現(xiàn)靜態(tài)展示,又可支持動(dòng)態(tài)播放,為用戶帶來(lái)更豐富的視覺體驗(yàn),同時(shí)保持了較好的兼容性。
不過(guò),由于不同手機(jī)廠商對(duì)動(dòng)態(tài)照片的技術(shù)實(shí)現(xiàn)存在差異(如封裝格式、元數(shù)據(jù)標(biāo)簽定義等),導(dǎo)致顯示和提取方式不同。目前測(cè)試了三個(gè)主流廠商,其特點(diǎn)如下:
- 小米: 使用 Micro Video 格式,通過(guò)自定義 EXIF 字段存儲(chǔ)元數(shù)據(jù)
- Google: 采用標(biāo)準(zhǔn) Motion Photo 格式,基于 XMP 元數(shù)據(jù)系統(tǒng)
- OPPO: 實(shí)現(xiàn) O Live Photo 格式,支持 HDR GainMap 等高級(jí)特性
本文將深入解析 Android 動(dòng)態(tài)照片的技術(shù)原理,重點(diǎn)分析 XMP 元數(shù)據(jù)系統(tǒng),并通過(guò)小米 Micro Video、Google Motion Photo 和 OPPO O Live Photo 三個(gè)真實(shí)案例,展示不同廠商的技術(shù)實(shí)現(xiàn)細(xì)節(jié),探尋一套統(tǒng)一的檢測(cè)和處理解決方案。
2.動(dòng)態(tài)照片的核心結(jié)構(gòu)
2.1 三層架構(gòu)設(shè)計(jì)
動(dòng)態(tài)照片的基礎(chǔ)框架由三部分構(gòu)成:
主要靜態(tài)圖片文件
- 作用:作為視覺主體,是用戶直觀看到的 "照片" 部分
- 格式支持:JPEG、HEIC(高效圖像格式)、AVIF(新一代開放格式)
- 內(nèi)容特點(diǎn):通常為拍攝瞬間的靜態(tài)畫面,可能包含增益圖以支持 HDR 效果
次要視頻文件
- 作用:作為動(dòng)態(tài)補(bǔ)充,提供動(dòng)態(tài)效果
- 內(nèi)容特點(diǎn):通常為拍攝前后 1-3 秒的短視頻片段
- 存儲(chǔ)方式:附加在靜態(tài)文件中,用于呈現(xiàn)微小動(dòng)作、聲音等動(dòng)態(tài)元素
元數(shù)據(jù)系統(tǒng)
- Camera XMP:定義靜態(tài)圖片與視頻的顯示規(guī)則,例如
Camera:MotionPhoto屬性定義了是否是動(dòng)態(tài)照片,0 是非動(dòng)態(tài)照片;1 是動(dòng)態(tài)照片。 - Container XMP:指引設(shè)備定位并讀取附加的視頻文件,例如
Length字段定義了次要媒體內(nèi)容的字節(jié)長(zhǎng)度。
2.2 增益圖特性(可選)
動(dòng)態(tài)照片的主要靜態(tài)文件可能包含 "增益圖"(Gain Map),這一設(shè)計(jì)與 Ultra HDR JPEG 的增益圖邏輯一致:
- 基礎(chǔ)原理:通過(guò) "基礎(chǔ)圖片 + 增益圖" 的組合實(shí)現(xiàn)高動(dòng)態(tài)范圍(HDR)效果
- 兼容性設(shè)計(jì):支持設(shè)備渲染 HDR 效果,不支持的設(shè)備顯示基礎(chǔ)圖片
3.Android 官方動(dòng)態(tài)照片 XMP 元數(shù)據(jù)系統(tǒng)詳解
3.1 Camera XMP 元數(shù)據(jù)
命名空間 URI:http://ns.google.com/photos/1.0/camera/默認(rèn)前綴:Camera
核心屬性
屬性名 | 類型 | 說(shuō)明 |
| Integer | 0:非動(dòng)態(tài)照片;1:動(dòng)態(tài)照片;其他值視為 0 |
| Integer | 動(dòng)態(tài)照片格式版本,當(dāng)前規(guī)范為"1" |
| Long | 與靜態(tài)圖片對(duì)應(yīng)的視頻幀時(shí)間戳(微秒),-1 表示未設(shè)置 |
3.2 Container XMP 元數(shù)據(jù)
命名空間 URI:http://ns.google.com/photos/1.0/container/默認(rèn)前綴:Container
目錄結(jié)構(gòu)
Directory: 有序結(jié)構(gòu)數(shù)組
├── Container:Item (主圖片 - 必須是第一項(xiàng))
├── Container:Item (增益圖 - Ultra HDR時(shí))
└── Container:Item (視頻文件 - 必須是最后一項(xiàng))必需屬性
屬性名 | 類型 | 說(shuō)明 |
| String | 媒體內(nèi)容的 MIME 類型 |
| String | 媒體內(nèi)容的語(yǔ)義含義 |
| Integer | 次要媒體內(nèi)容的字節(jié)長(zhǎng)度 |
可選屬性
屬性名 | 類型 | 說(shuō)明 |
| Integer | 主圖片結(jié)尾到下一媒體項(xiàng)的間隔字節(jié)數(shù) |
Semantic 的可能的值
語(yǔ)義值 | 說(shuō)明 |
| 主顯示圖片(必須有且僅有一個(gè)) |
| 視頻容器(必須有且僅有一個(gè),位于文件末尾) |
| 增益圖(Ultra HDR 時(shí)必需,位于視頻項(xiàng)之前) |
3.3 元數(shù)據(jù)格式的解釋
命名空間的概念可以結(jié)合 Android XML 文件來(lái)理解。例如,xmlns:app="http://schemas.android.com/apk/res-auto" 定義了 app 命名空間,有了這個(gè)定義,像 MotionPhotoView 這樣的自定義視圖才能通過(guò) app:layout_constraintLeft_toLeftOf 這類帶 app 前綴的屬性來(lái)設(shè)置值。
由于不同手機(jī)廠商可能會(huì)定義同名的元數(shù)據(jù)屬性,命名空間的核心作用就是通過(guò)前綴區(qū)分這些屬性,避免因名稱重復(fù)導(dǎo)致的沖突。
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns: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>以下是一個(gè)根據(jù)官方文檔生成的 XMP 數(shù)據(jù)格式,可能不準(zhǔn)確,大概結(jié)構(gòu)是這樣的,通過(guò)下面的示例,我們可以更好的理解元數(shù)據(jù)是怎么存儲(chǔ)的。
<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf: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:li rdf: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>4.Android 官方動(dòng)態(tài)照片文件名模式規(guī)范
4.1 正則表達(dá)式規(guī)范
動(dòng)態(tài)照片的文件名需遵循特定正則表達(dá)式命名規(guī)則,這是官方推薦的命名規(guī)范,目的是通過(guò)文件名快速識(shí)別動(dòng)態(tài)照片。但實(shí)際情況中,各手機(jī)廠商并未統(tǒng)一遵循這一規(guī)則,導(dǎo)致該方法的識(shí)別效果大打折扣。
^([^\\s\\/\\\\][^\\/\\\\]*MP)\\.(JPG|jpg|JPEG|jpeg|HEIC|heic|AVIF|avif)4.2 命名規(guī)則解析
前綴部分:[^\\s\\/\\\\][^\\/\\\\]*
- 第一個(gè)字符不能是空格、斜杠或反斜杠
- 后續(xù)字符可以是除斜杠和反斜杠之外的任意字符
標(biāo)識(shí)部分:MP
- 作用:作為動(dòng)態(tài)照片文件的標(biāo)志性標(biāo)識(shí)
- 位置:必須位于文件名末尾(擴(kuò)展名之前)
- 示例:
IMG_1234MP.jpg、DSC0056MP.heic
后綴部分:支持的擴(kuò)展名
- JPEG:
.jpg、.jpeg - HEIC:
.heic - AVIF:
.avif
5.Android 官方動(dòng)態(tài)照片視頻容器內(nèi)容規(guī)范
以下是 android 官方定義的動(dòng)態(tài)照片中視頻部分的編碼方式、軌道結(jié)構(gòu)、同步機(jī)制和播放行為,這些規(guī)范保證了用戶在查看動(dòng)態(tài)照片時(shí)能夠獲得流暢、一致的體驗(yàn),但不同手機(jī)廠商實(shí)現(xiàn)方式可能會(huì)不同。
5.1 軌道結(jié)構(gòu)
主視頻軌道(必需)
- 編碼格式:AVC(H.264)、HEVC(H.265)或 AV1
- 分辨率:無(wú)強(qiáng)制限制
- 色彩支持:
SDR:8 位,BT.709 色彩空間,sRGB 轉(zhuǎn)換
HDR:10 位,BT.2100 色彩空間,HLG/PQ 轉(zhuǎn)換
次要視頻軌道(可選)
- 作用:高分辨率縮略圖或替代畫面
- 編碼格式:同主視頻軌道
- 幀率:通常較低(1-5fps)
- 幀關(guān)聯(lián):與主軌道幀一一對(duì)應(yīng),時(shí)間戳完全相同
音頻軌道(可選)
- 編碼格式:AAC
- 參數(shù):16 位單聲道或立體聲
- 采樣率:44kHz、48kHz 或 96kHz
- 播放規(guī)則:與主視頻軌道同步
5.2 軌道排序規(guī)則
- 主視頻軌道:索引最小,必須是第一個(gè)視頻軌道
- 次要視頻軌道:索引大于主軌道,位于主軌道之后
- 音頻軌道:無(wú)強(qiáng)制順序,但需時(shí)間同步
6.不同動(dòng)態(tài)照片實(shí)現(xiàn)案例分析
6.1 動(dòng)態(tài)照片技術(shù)特點(diǎn)與檢測(cè)方法
Android 的ExifInterface類主要支持標(biāo)準(zhǔn) EXIF 標(biāo)簽,對(duì) XMP 命名空間的支持非常有限,例如小米的 MicroVideo 標(biāo)簽屬于自定義 XMP 命名空間,因此 ExifInterface 無(wú)法解析。實(shí)際開發(fā)中需要使用二進(jìn)制解析或 Adobe XMP SDK 進(jìn)行處理。
小米官方提供了專用 SDK,可以展示、判斷和制作動(dòng)態(tài)照片,為開發(fā)者提供了完整的解決方案。
6.2 小米 Micro Video 案例分析
小米手機(jī)使用自有的 Micro Video 格式,通過(guò)自定義 EXIF 字段存儲(chǔ)動(dòng)態(tài)照片元數(shù)據(jù):
exiftool /Users/allenzhang/Downloads/1752549853110.jpg關(guān)鍵元數(shù)據(jù)信息:
File Name : 1752549853110.jpg
File Size : 5.5 MB
Make : (小米手機(jī)型號(hào))
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 : 4096XMP SDK 解析結(jié)果:
通過(guò) Adobe XMP SDK 的 xmpMeta.dumpObject() 方法可以獲取小米動(dòng)態(tài)照片的完整 XMP 結(jié)構(gòu):
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 = ""技術(shù)特點(diǎn)分析:
- 小米復(fù)用了 Google 的 Camera 命名空間,并在其下擴(kuò)展了 MicroVideo 等自定義屬性(非標(biāo)準(zhǔn)屬性),這可能導(dǎo)致與其他廠商的標(biāo)準(zhǔn)實(shí)現(xiàn)產(chǎn)生沖突
- 通過(guò) MicroVideoOffset 字段指示視頻數(shù)據(jù)位置
- 視頻大小可能通過(guò)
文件總大小 - MicroVideoOffset計(jì)算得出 - 相比 Google 和 OPPO,小米的 XMP 結(jié)構(gòu)相對(duì)簡(jiǎn)單
6.3 Google Pixel 4 動(dòng)態(tài)照片案例分析
Google Pixel 手機(jī)使用標(biāo)準(zhǔn)的 Motion Photo 格式,以下是 Pixel 4 拍攝的動(dòng)態(tài)照片元數(shù)據(jù):
exiftool /Users/allenzhang/Downloads/PXL_20250722_065151464.MP.jpg關(guān)鍵元數(shù)據(jù)信息:
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 解析結(jié)果:
通過(guò) Adobe XMP SDK 的 xmpMeta.dumpObject() 方法可以獲取 Google Motion Photo 的完整 XMP 結(jié)構(gòu):
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"技術(shù)特點(diǎn)分析:
- Google 使用標(biāo)準(zhǔn) Container 命名空間定義復(fù)雜的容器結(jié)構(gòu)
- 通過(guò) Directory 數(shù)組清晰區(qū)分 Primary 圖片和 MotionPhoto 視頻
- Item:Length 字段直接提供視頻大小信息(870,399 字節(jié))
- HasExtendedXMP 表示使用了擴(kuò)展 XMP 存儲(chǔ)大型元數(shù)據(jù)
- 完全符合 Adobe XMP 和 Google Motion Photo 標(biāo)準(zhǔn)
6.4 OPPO Find X8 動(dòng)態(tài)照片案例分析
OPPO 手機(jī)實(shí)現(xiàn)了自己的動(dòng)態(tài)照片格式,稱為 "O Live Photo",同時(shí)也支持標(biāo)準(zhǔn) Motion Photo:
exiftool /Users/allenzhang/Downloads/IMG20250722163505.jpg關(guān)鍵元數(shù)據(jù)信息:
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 解析結(jié)果:
通過(guò) Adobe XMP SDK 的 xmpMeta.dumpObject() 方法可以獲取 OPPO O Live Photo 的完整 XMP 結(jié)構(gòu):
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"技術(shù)特點(diǎn)分析:
- OPPO 中的這個(gè)例子包含三層容器結(jié)構(gòu):Primary + GainMap + MotionPhoto
- 使用專有命名空間
http://ns.oplus.com/photos/1.0/camera/存儲(chǔ) O Live Photo 擴(kuò)展信息 - 支持 HDR 增益圖(GainMap),文件大小 474,937 字節(jié)
- 兩個(gè)視頻大小字段:Container 中的 3,334,834 字節(jié)和 OPPO 專有的 3,334,498 字節(jié)(OPPO 視頻項(xiàng)未顯式定義 Padding 字段,推測(cè)其 Length 包含的額外字節(jié)(336 字節(jié))為隱式 Padding,可能與廠商編碼邏輯有關(guān))
- 集成 Adobe HDR-Gain-Map 標(biāo)準(zhǔn),版本 1.0
- 雙時(shí)間戳機(jī)制:標(biāo)準(zhǔn)時(shí)間戳和 Primary 專用時(shí)間戳
- 向后兼容 Google Motion Photo 標(biāo)準(zhǔn)的同時(shí)提供 OPPO 增強(qiáng)功能
6.5 三大廠商動(dòng)態(tài)照片格式對(duì)比分析
特征項(xiàng) | 小米 Micro Video | Google Motion Photo | OPPO O Live Photo |
格式標(biāo)識(shí) |
|
|
+ |
存儲(chǔ)位置 | EXIF 自定義字段 | 標(biāo)準(zhǔn) XMP 元數(shù)據(jù) | 標(biāo)準(zhǔn) XMP 元數(shù)據(jù) + OPPO 擴(kuò)展 |
視頻標(biāo)識(shí) |
|
|
+ |
時(shí)間戳字段 |
|
| 雙時(shí)間戳支持 |
容器結(jié)構(gòu) | 簡(jiǎn)單二進(jìn)制附加 | 符合 Google 容器規(guī)范 | 支持 GainMap (HDR) |
文件大小占比 | 視頻約 70% | 視頻約 20% | 視頻約 47% |
兼容性 | 小米專有 | 標(biāo)準(zhǔn)格式 | OPPO 擴(kuò)展 + 標(biāo)準(zhǔn)兼容 |
特性 | 不支持增益圖、HDR | 支持增益圖、HDR | 支持增益圖、HDR |
音頻支持 | 不支持 | 部分支持 | 完全支持 |
XMP 命名空間 |
|
|
|
詳細(xì)分析:
1)小米 Micro Video(傳統(tǒng)格式)
文件總大小: 5.5 MB
├── 靜態(tài)圖片: 1.74 MB (約 31%)
└── 視頻數(shù)據(jù): 4.03 MB (約 69%)- 使用自定義 EXIF 字段存儲(chǔ)元數(shù)據(jù)
- 視頻直接附加在靜態(tài)圖片后
- 格式簡(jiǎn)單但兼容性有限
2)Google Motion Photo(標(biāo)準(zhǔn)格式)
文件總大小: 4.2 MB
├── 靜態(tài)圖片: 約 3.3 MB (約 79%)
├── 視頻數(shù)據(jù): 870 KB (約 20%)
└── Extended XMP: 少量元數(shù)據(jù)- 完全符合 Google Motion Photo 標(biāo)準(zhǔn)
- 使用 Extended XMP 處理大型元數(shù)據(jù)
- 容器結(jié)構(gòu)清晰,支持多種媒體類型
3)OPPO O Live Photo(混合格式)
文件總大小: 7.0 MB
├── 靜態(tài)圖片: 約 3.2 MB (約 46%)
├── GainMap (HDR): 475 KB (約 7%)
└── 視頻數(shù)據(jù): 3.3 MB (約 47%)- 支持 HDR 增益圖 (GainMap)
- 雙時(shí)間戳機(jī)制提供更精確的同步
- 向后兼容標(biāo)準(zhǔn) Motion Photo 格式
7.處理動(dòng)態(tài)照片的 Kotlin 實(shí)現(xiàn)
7.1 依賴配置
首先在 build.gradle 中添加必要的依賴:
dependencies {
// Adobe XMP SDK
implementation 'com.adobe.xmp:xmpcore:6.1.11'
// 協(xié)程支持
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
// 文件處理
implementation 'androidx.core:core-ktx:1.12.0'
}7.2 動(dòng)態(tài)照片檢測(cè)方法和解析方法
基于以上對(duì)三大廠商動(dòng)態(tài)照片格式的分析,我嘗試設(shè)計(jì)了一套統(tǒng)一的動(dòng)態(tài)照片檢測(cè)解決方案。該方案采用雙重檢測(cè)策略:首先通過(guò) extractXMPFromJPEG 方法解析 XMP 元數(shù)據(jù)來(lái)識(shí)別動(dòng)態(tài)照片類型和視頻信息;當(dāng) XMP 元數(shù)據(jù)不完整或缺失時(shí),則使用 findMp4HeaderOffset 方法直接從文件二進(jìn)制數(shù)據(jù)中定位并提取視頻信息。這種可以提供更好的兼容性和可靠性。
下面以 OPPO 動(dòng)態(tài)照片為例,展示完整的元數(shù)據(jù)解析和視頻提取實(shí)現(xiàn)。小米和 Google 動(dòng)態(tài)照片的處理邏輯與此類似,遵循相同的接口設(shè)計(jì)模式。
class UnifiedMotionPhotoExtractor {
companionobject {
constval TAG = "MotionPhotoExtractor"
privateconstval BUFFER_SIZE = 8192
// MP4文件頭標(biāo)識(shí)
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())
// 元數(shù)據(jù)標(biāo)簽
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"
// 進(jìn)度回調(diào)間隔(字節(jié)數(shù))
privateconstval PROGRESS_INTERVAL = 100000
// JPEG 段標(biāo)記
privateconstval JPEG_SOI = 0xFFD8
privateconstval JPEG_APP1 = 0xFFE1
privateconstval JPEG_SOS = 0xFFDA
// XMP 頭標(biāo)識(shí)常量
privateval XMP_HEADER = "http://ns.adobe.com/xap/1.0/".toByteArray()
}
/**
* 從 JPEG 文件中提取 XMP 元數(shù)據(jù)
*/
privatefun extractXMPFromJPEG(filePath: String): XMPMeta? {
returntry {
RandomAccessFile(filePath, "r").use { raf ->
// 驗(yàn)證 JPEG 文件頭
if (raf.readUnsignedShort() != JPEG_SOI) {
returnnull
}
while (true) {
// 讀取段標(biāo)記
val marker = raf.readUnsignedShort()
// 如果到達(dá)圖像數(shù)據(jù)段,停止解析
if (marker == JPEG_SOS) {
break
}
// 只處理 APP1 段
if (marker == JPEG_APP1) {
val segmentLength = raf.readUnsignedShort()
val segmentData = ByteArray(segmentLength - 2)
raf.readFully(segmentData)
// 檢查是否為 XMP 數(shù)據(jù)
if (isXMPSegment(segmentData)) {
Log.d("chao"," 發(fā)現(xiàn) XMP 數(shù)據(jù)段,文件: ${filePath}")
return parseXMPFromSegment(segmentData)
}
} else {
// 跳過(guò)其他段
val segmentLength = raf.readUnsignedShort()
raf.skipBytes(segmentLength - 2)
}
}
}
null
} catch (e: Exception) {
Log.e(TAG, "XMP 提取失敗: ${e.message}", e)
null
}
}
/**
* 檢查是否為 XMP 段
*/
privatefun isXMPSegment(data: ByteArray): Boolean {
if (data.size < XMP_HEADER.size) returnfalse
for (i in XMP_HEADER.indices) {
if (data[i] != XMP_HEADER[i]) returnfalse
}
returntrue
}
/**
* 從段數(shù)據(jù)中解析 XMP
*/
privatefun parseXMPFromSegment(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手機(jī)提取xmp方法,其它手機(jī)的類似
privatefun checkOppoLivePhotoInXMP(xmpMeta: XMPMeta): MotionPhotoResult? {
returntry {
val cameraNamespace = "http://ns.google.com/photos/1.0/camera/"
val oppoNamespace = "http://ns.oplus.com/photos/1.0/camera/"http:// 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 }
// 檢查標(biāo)準(zhǔn)命名空間中的Motion Photo標(biāo)識(shí)
val motionPhoto = try {
xmpMeta.getPropertyInteger(cameraNamespace, "MotionPhoto")
} catch (e: Exception) { null }
// 如果是OPPO格式 (有OPPO專有標(biāo)識(shí)或VideoLength字段)
val isOppoFormat = oppoMotionPhotoOwner == "oplus" ||
oppoOLivePhotoVersion != null ||
oppoVideoLength != null
if (motionPhoto == 1 && isOppoFormat) {
Log.d("XMP", "檢測(cè)到OPPO O Live Photo格式")
val version = try {
// 優(yōu)先使用OPPO版本,回退到標(biāo)準(zhǔn)版本
oppoOLivePhotoVersion?.toIntOrNull()
?: xmpMeta.getPropertyInteger(cameraNamespace, "MotionPhotoVersion")
} catch (e: Exception) { 1 }
val timestamp = try {
// 優(yōu)先使用OPPO的Primary時(shí)間戳
xmpMeta.getPropertyLong(oppoNamespace, "MotionPhotoPrimaryPresentationTimestampUs")
} catch (e: Exception) {
try {
// 回退到標(biāo)準(zhǔn)時(shí)間戳
xmpMeta.getPropertyLong(cameraNamespace, "MotionPhotoPresentationTimestampUs")
} catch (e2: Exception) { -1L }
}
// 解析Container結(jié)構(gòu)獲取視頻大小和GainMap信息
val containerInfo = parseOppoContainerInfo(xmpMeta, containerNamespace)
// 優(yōu)先使用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檢測(cè)結(jié)果 - 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檢測(cè)失敗: ${e.message}", e)
null
}
}
/**
* 提取OPPO動(dòng)態(tài)照片中的視頻
*/
privatefun extractVideo(
inputFile: File,
outputFile: File,
videoSize: Long = 0L,
videoOffset: Long = 0L,
progressCallback: ((Long, Long) -> Unit)?
): Boolean {
// OPPO動(dòng)態(tài)照片可能包含增益圖,需要特殊處理
// 這里使用簡(jiǎn)化方法,直接搜索MP4文件頭
val offset = findMp4HeaderOffset(inputFile.path)?:0
Log.d(TAG,"找到視頻數(shù)據(jù),偏移量: $offset")
// 嘗試多個(gè)可能的偏移量
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文件頭的偏移量
*/
privatefun findMp4HeaderOffset(filePath: String): Long? {
val file = File(filePath)
if (!file.exists() || file.length() < 8) {
returnnull
}
// 只搜索文件的后半部分,因?yàn)橐曨l通常在文件末尾
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) // 滑動(dòng)窗口,用于查找簽名
var bytesRead: Int
var currentOffset = startOffset
while (raf.read(buffer).also { bytesRead = it } > 0) {
for (i in0 until bytesRead) {
// 更新滑動(dòng)窗口
System.arraycopy(window, 1, window, 0, window.size - 1)
window[window.size - 1] = buffer[i]
// 檢查當(dāng)前窗口是否包含任何一種MP4文件頭標(biāo)識(shí)
val signatureOffset = containsSignature(window)
if (signatureOffset != -1) {
// 找到了MP4文件頭,返回文件中的實(shí)際偏移量
// 減去4是因?yàn)镸P4的box大小字段在標(biāo)識(shí)符之前
return currentOffset + i - (window.size - signatureOffset) + 1 - 4
}
}
currentOffset += bytesRead
}
}
returnnull
}
/**
* 檢查給定的字節(jié)數(shù)組是否包含任何一種MP4文件頭標(biāo)識(shí)
*
* @return 如果包含,返回標(biāo)識(shí)符在數(shù)組中的起始位置;否則返回-1
*/
privatefun containsSignature(data: ByteArray): Int {
// 按優(yōu)先級(jí)順序檢查各種MP4文件頭標(biāo)識(shí)
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
}
/**
* 從輸入文件的指定偏移量開始提取視頻數(shù)據(jù)到輸出文件
*/
privatefun extractVideoData(
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 ->
// 跳過(guò)偏移量之前的數(shù)據(jù)
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
// 更新進(jìn)度
if (progressCallback != null && totalBytesRead - lastProgressUpdate > PROGRESS_INTERVAL) {
progressCallback(totalBytesRead, videoSize)
lastProgressUpdate = totalBytesRead
}
}
// 最終進(jìn)度更新
progressCallback?.invoke(totalBytesRead, videoSize)
}
}
}
/**
* 檢查文件是否為有效的MP4格式
*/
privatefun isValidMp4(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標(biāo)識(shí)
val headerStr = String(header, StandardCharsets.UTF_8)
return headerStr.contains("ftyp")
}
}
}7.3 動(dòng)態(tài)照片播放組件
為了更好地展示動(dòng)態(tài)照片,我們可以創(chuàng)建一個(gè)專用的 MotionPhotoView 組件,ImageView用來(lái)顯示靜態(tài)圖片,VideoView用來(lái)顯示視頻,主要方法如下:
/**
* 動(dòng)態(tài)照片播放組件
* 支持靜態(tài)圖片顯示和視頻播放切換
*/
class MotionPhotoView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
privateval imageView: ImageView
privateval videoView: VideoView
privatevar motionPhotoResult: MotionPhotoResult? = null
/**
* 設(shè)置動(dòng)態(tài)照片數(shù)據(jù)
*/
fun setMotionPhoto(imagePath: String, result: MotionPhotoResult) {
this.originalImagePath = imagePath
this.motionPhotoResult = result
// 顯示靜態(tài)圖片
loadStaticImage(imagePath)
}
/**
* 播放視頻
*/
fun playVideo() {
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()
}
}
/**
* 停止視頻播放
*/
fun stopVideo() {
if (videoView.isPlaying) {
videoView.stopPlayback()
}
showStaticImage()
}
/**
* 暫停視頻
*/
fun pauseVideo() {
if (videoView.isPlaying) {
videoView.pause()
}
}
/**
* 設(shè)置VideoView回調(diào)
*/
privatefun setupVideoCallbacks() {
videoView.setOnPreparedListener { mediaPlayer ->
Log.d("MotionPhotoView", "視頻準(zhǔn)備完成")
// 設(shè)置循環(huán)播放
if (autoLoop) {
mediaPlayer.isLooping = true
}
// 根據(jù)時(shí)間戳定位播放位置
motionPhotoResult?.presentationTimestamp?.let { timestamp ->
if (timestamp > 0) {
val seekPosition = (timestamp / 1000).toInt() // 轉(zhuǎn)換為毫秒
Log.d("MotionPhotoView", "定位到時(shí)間戳: ${seekPosition}ms (原始: ${timestamp}μs)")
mediaPlayer.seekTo(seekPosition)
}
}
}
videoView.setOnCompletionListener {
Log.d("MotionPhotoView", "視頻播放完成")
if (!autoLoop) {
showStaticImage()
}
}
videoView.setOnErrorListener { _, what, extra ->
showStaticImage()
true
}
}
}08總結(jié)與展望
Android 動(dòng)態(tài)照片技術(shù)借助精心設(shè)計(jì)的文件結(jié)構(gòu)、元數(shù)據(jù)系統(tǒng)及容器規(guī)范,實(shí)現(xiàn)了靜態(tài)與動(dòng)態(tài)內(nèi)容的無(wú)縫融合。然而,不同廠商的技術(shù)實(shí)現(xiàn)存在差異,這不可避免地導(dǎo)致了兼容性問(wèn)題 —— 各廠商的動(dòng)態(tài)照片格式往往難以互通。
本文通過(guò)深入解析 XMP 元數(shù)據(jù)系統(tǒng),并結(jié)合小米 Micro Video、Google Motion Photo 和 OPPO O Live Photo 的真實(shí)案例,詳細(xì)展示了動(dòng)態(tài)照片的實(shí)現(xiàn)原理。對(duì)于開發(fā)者而言,理解這些技術(shù)細(xì)節(jié)不僅有助于構(gòu)建更好的應(yīng)用體驗(yàn),也為跨平臺(tái)兼容性處理提供了重要參考。
























