“瞬影”技术报告(一)

我开发了一款动态照片查看、转换与分享 APP

问题背景:
不同品牌的实况照片格式不同,在不支持动态照片的 APP 中(比如 QQ)无法正常分享。即使发送原图,保存到本地后也只能看静态图片。

软件功能:

  1. 自动扫描出手机里所有品牌的动态照片,通过 QQ 等不支持动态照片的APP保存对方原图后,可以直接播放
  2. 一键转换成自己手机支持的格式,使得在相册中可以直接播放
  3. 一键提取视频,把内嵌视频当成普通视频用
  4. 生成动态照片分享链接,让对方直接在网页中播放和下载

目前功能 2 仍在开发,已完成对 OPPO、vivo(新格式)、小米、华为(非纯血鸿蒙)/荣耀的适配,功能 1 3 4 所有品牌可正常使用。现邀请感兴趣的同学进行内测,想体验的可以联系我,要求手机发布时间不大于两年
(目前 APP 不支持纯血鸿蒙和苹果,后续是否适配看心情)

网页分享链接示例:http://animphoto.ylin314.cn/s/06d977f09b56

下面是技术讲解

动态照片到底是什么

手机里的动态照片(也叫 Live Photo)并不是一种统一格式

小米、OPPO、vivo(新格式)、华为(非纯血鸿蒙)和荣耀都采用了“静态照片 + 视频”的组合思路,也就是在照片后面直接嵌入视频,但它们在元数据、视频定位方式和厂商私有扩展上各不相同

vivo(旧格式)、华为(纯血鸿蒙)和苹果则采用了照片和视频分开存储的策略

两种技术最大的区别就是,在 QQ 等没有适配动态照片的软件中,如果勾选“发送原图”,前者会连带视频一起发送,而后者只会发送静态照片,接受者想尽一切手段都无法获取视频,简直是活全家了

本文基于四家相册 APK 的逆向结果、四家 Live Photo 原文件解析以及瞬影 APP 的开发过程,总结这前者四种格式的文件布局与读取方法

嵌入式动态照片结构

从文件结构看,这四家安卓动态照片都可以抽象成:

1
2
3
4
5
6
7
8
9
10
11
JPEG 主图
├── EXIF
├── XMP 元数据
└── 可选的 GainMap / 辅助图

厂商边界标记(部分品牌存在)

MP4 视频
└── 可选的厂商私有扩展

厂商尾标(荣耀)

判断一张 JPG 是否为动态照片的关键是XMP 元数据,它告诉软件照片是否有视频,视频在什么位置,而视频本身则是标准 MP4。

小米

动态照片的 XMP 元数据(动态照片相关字段)如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<rdf:Description rdf:about=""
xmlns:GCamera="http://ns.google.com/photos/1.0/camera/" ← ① 命名空间
xmlns:Container="http://ns.google.com/photos/1.0/container/"
xmlns:Item="http://ns.google.com/photos/1.0/container/item/"

GCamera:MotionPhoto="1" ← ② 身份标记
GCamera:MotionPhotoVersion="1"
GCamera:MotionPhotoPresentationTimestampUs="233333"封面时间戳(微秒)

<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="image/jpeg" Item:Length="651275"
Item:Semantic="GainMap"/>
</rdf:li>
<rdf:li rdf:parseType="Resource">
<Container:Item Item:Mime="video/mp4" Item:Semantic="MotionPhoto" ← ★视频项
Item:Length="2954367"/>
</rdf:li>
</rdf:Seq>
</Container:Directory>
</rdf:Description>

命名空间

小米的命名空间没有自己的私有字段,全部是 Google 官方字段

身份标记

GCamera:MotionPhoto="1":Google 规范下的动态照片开关,1 表示是动态照片

GCamera:MotionPhotoPresentationTimestampUs:封面帧的时间戳(微秒),用来标记哪一帧是封面

小米有两种版本动态照片,新机型走 MotionPhoto 容器,老机型走 MicroVideo

资源清单

资源清单是最核心的部分,标记了主图、HDR 增益图、视频

Item:Length 表示从视频起点到文件末尾的反向长度

小米的动态照片格式是最接近 Google 官方格式的,其读取和转换都是最便捷的

读取与解析

直接看 GCamera:MotionPhotoGCamera:MicroVideo 是否为 1 即可,非常简单

定位视频

  1. 新版:Container 里按 Semantic 找视频项,videoOffset = 文件大小 − Item:Length,之后校验 videoOffset + 4 处是否为 ftyp
  2. 旧版:videoOffset = 文件大小 − MicroVideoOffset,同样校验 ftyp
  3. 兜底策略:JPEG EOI(FF D9) 之后扫描 ftyp,videoOffset = ftyp 位置 − 4

转换

转换也是十分的便捷,直接保留图片和视频原字节,然后按照上述格式重写 XMP 即可

OPPO

vivo 和 OPPO 这两家步步高厂商格式就比较乱套了

OPPO 将动态照片称为 OLive Photo。它的基础结构仍然是 JPEG 加 MP4,但比纯 Google Motion Photo 多了一组 OPPO 私有元数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core Test.SNAPSHOT">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:hdrgm="http://ns.adobe.com/hdr-gain-map/1.0/"
xmlns:GCamera="http://ns.google.com/photos/1.0/camera/"
xmlns:OpCamera="http://ns.oplus.com/photos/1.0/camera/"
xmlns:Container="http://ns.google.com/photos/1.0/container/"
xmlns:Item="http://ns.google.com/photos/1.0/container/item/"
hdrgm:Version="1.0"
GCamera:MotionPhoto="1"
GCamera:MotionPhotoVersion="1"
GCamera:MotionPhotoPresentationTimestampUs="1324628"
OpCamera:MotionPhotoPrimaryPresentationTimestampUs="1324628"
OpCamera:MotionPhotoOwner="oplus"
OpCamera:OLivePhotoVersion="2"
OpCamera:VideoLength="7546146">
<Container:Directory>
<rdf:Seq>
<rdf:li rdf:parseType="Resource">
<Container:Item Item:Mime="image/jpeg" Item:Semantic="Primary"
Item:Length="0" Item:Padding="0"/>
</rdf:li>
<rdf:li rdf:parseType="Resource">
<Container:Item Item:Mime="image/jpeg" Item:Semantic="GainMap"
Item:Length="412705" Item:Padding="0"/>
</rdf:li>
<rdf:li rdf:parseType="Resource">
<Container:Item Item:Mime="video/mp4" Item:Semantic="MotionPhoto"
Item:Length="9903570"/>
</rdf:li>
</rdf:Seq>
</Container:Directory>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>

OPPO 的 XMP 数据在 Google 官方的基础上添加了 xmlns:OpCamera 以及大量 OpCamera 相关字段,比较重要的如下:

  1. OpCamera:VideoLength:MP4 的长度
  2. Item:Length:MP4 + OLive 私有尾数据长度

读取与解析

XMP 里 GCamera:MotionPhoto=="1" 且出现任意 OpCamera: 标签

定位视频

  1. Container 里按 Semantic 找视频项,videoOffset = 文件大小 − Item:Length,之后校验 videoOffset + 4 处是否为 ftyp
  2. 兜底策略:JPEG EOI(FF D9) 之后扫描 ftyp,videoOffset = ftyp 位置 − 4

OPPO 没有旧版字段,只有新版

转换

保留主图 + GainMap + MP4,写 OPPO 的 XMP 和 EXIFOLive 私有尾数据可不写(OPPO 图库不要求,实测可播)且 Primary 项要带 Length="0" Padding="0",要求非常的多

对的,ColorOS 新图库会检测 EXIF 中的 UserComment 项,必须是 oplus_* 才给识别动态照片,神了

vivo(新格式)

vivo 与 OPPO 相同,也添加了自己的私有元数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<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:hdrgm="http://ns.adobe.com/hdr-gain-map/1.0/"
xmlns:GCamera="http://ns.google.com/photos/1.0/camera/"
xmlns:VCamera="http://ns.vivo.com/photos/1.0/camera/"
xmlns:Container="http://ns.google.com/photos/1.0/container/"
xmlns:Item="http://ns.google.com/photos/1.0/container/item/"
hdrgm:Version="1.0"
GCamera:MotionPhoto="1"
GCamera:MotionPhotoVersion="1"
GCamera:MotionPhotoPresentationTimestampUs="1524906"
VCamera:VMotionPhotoVersion="1"
VCamera:VMotionPhotoSource="1"
VCamera:VMediaKitVersion="1.0.0.5">
<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="image/jpeg" Item:Semantic="GainMap"
Item:Length="359031"/>
</rdf:li>
<rdf:li rdf:parseType="Resource">
<Container:Item Item:Mime="video/mp4" Item:Semantic="MotionPhoto"
Item:Length="4431209" Item:Padding="0"/>
</rdf:li>
</rdf:Seq>
</Container:Directory>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>

vivo 最大的特点有两个:

  1. 尾部有两个 UUID 盒

  2. 视频起点带有 cameralbum! 标记,且 Item:Length 覆盖的是 纯 MP4 + 两个 UUID 尾盒的长度,不含前面 10 字节的 cameralbum!

也就是 videoOffset = 文件大小 − Item:Length 公式仍成立

读取与解析

XMP 里 GCamera:MotionPhoto=="1" 且出现任意 VCamera: 标签

定位视频

  1. Container 里按 Semantic 找视频项,videoOffset = 文件大小 − Item:Length,之后校验 videoOffset + 4 处是否为 ftyp
  2. 兜底策略:JPEG EOI(FF D9) 之后扫描 ftyp,videoOffset = ftyp 位置 − 4

vivo 也没有旧版字段,只有新版

转换

不仅要保留原有图片 + GainMap + 视频,还要添加两个 UUID 盒,还有中间的 cameralbum! 标记,且每次都要生产新的 id

华为(非纯血鸿蒙)/荣耀

华为(非纯血鸿蒙)和荣耀均属于 OpenHarmony 旧式单文件动态照片格式,通常不依赖 MotionPhoto XMP

照片的尾部有一段数据:

1
2
3
v2_f40
333:1000
LIVE_5838362

每个字段都使用空格补齐到 20 字节

定位视频的方法如下:

1
2
3
4
liveSize = parseInt("LIVE_" 后面的数字)
imageSize = fileLength - liveSize - 40
videoOffset = imageSize
videoSize = liveSize - 20

四家格式总览

品牌 主要存储形态 动态标记 视频定位方式 主要私有数据
小米 单文件:JPEG + MP4 GCamera:MotionPhoto 或旧版 MicroVideo XMP 中的 Item:LengthMicroVideoOffset 反向计算
OPPO 单文件:JPEG + MP4 GCamera:MotionPhoto + OpCamera:* XMP 中的 Item:Length oplus_* EXIF、OLive 私有尾数据
vivo 单文件:JPEG + MP4 GCamera:MotionPhoto + VCamera:* XMP 中的 Item:LengthMicroVideoOffset cameralbum!、两个 vivo UUID 扩展盒
荣耀 单文件:JPEG + MP4 + 尾标 通常没有动态 XMP 文件尾 LIVE_ 尾标反推 OpenHarmony 旧格式的 20 字节固定尾标

我说真得买小米,最听 Google 话的厂商

非嵌入式动态照片结构

没适配呢,你先别急