Web 平台 API / Live Photo · 把一动一静放上网页 / 双文件与配对 待审核 1 / 5
anatomy · 双资源 / XMP 封装

双文件与配对

相册里那张「按住会动」的照片,在文件系统里从来不是一个文件。iOS 的 Live Photo 由两份资源组成:一张静图与一段短视频,各自是结构完整、可被单独解析的文件(能否解得开是另一回事,见解码一页——HEIC 在 Chrome 里就打不开)。把它当成「一种会动的图片格式」去找解码器,是搬上网页时最先撞的墙——没有这种格式。

Google 在 Android 上给同一件事选了另一条路:仍然是一张图加一段视频,但封装进同一个文件。两种设计各自的字节结构,决定了它们在 web 上完全不同的处理方式。

1 · 双资源模型

Live Photo 的静图是一张普通 HEIC,视频是一个普通 QuickTime 容器(.mov),内容覆盖按下快门前后各约 1.5 秒,并带一条音轨。早期机型产出的是 JPEG 加 H.264;默认的「高效率」设置下现为 HEIC 加 HEVC,但相机里选「兼容性最优」、或导出时走自动转换,拿到的仍是 JPEG 加 H.264(核对于 2026-08)。

两份资源之间没有任何字节级的相互引用。把其中一份单独拷出来,它就是一张能看的照片或一段能播的视频;把另一份删掉,剩下的那份不会损坏,只是不再「会动」。iOS 侧对应的抽象是 PHLivePhoto,它接收的也正是这两个资源的 URL。

注 ·「双资源」是语义模型,不是存储细节。系统相册在数据库里成对管理它们,但导出、AirDrop、上传到第三方服务时,这层关系依赖导出方主动保留。一动一静在传输中被拆散,是这种设计最常见的失效方式。

2 · 配对元数据的落点

两份资源靠一个 asset identifier(一个 UUID 字符串)认亲,它在两边各有落点:

落点 作用
静图 Apple maker note 字典的 17 号键 声明本图属于哪一次拍摄
视频 QuickTime 元数据 com.apple.quicktime.content.identifier 声明本片属于哪一次拍摄
视频 timed metadata track com.apple.quicktime.still-image-time 标出静图对应视频里的哪一帧

前两项是同一个字符串出现在两处,配对逻辑就是字符串相等。第三项的性质不同:它不是一个标量,而是视频里一条独立的轨道,数据类型登记为 com.apple.metadata.datatype.int8。要留意的是这条轨道里的样本值恒为 -10xFF),只是占位符、不承载信息;静图对应的时刻由该样本在轨道上的呈现时间戳给出。

这条轨道的存在决定了播放语义。视频覆盖的是快门前后各约 1.5 秒,静图对应的那一帧默认落在中段附近——但用户可以在相册里改「主要照片」,剪辑过的 Live Photo 也会让锚点偏移,实现不应假设它等于时长的一半;忠实的播放应当以该帧为锚点,而不是从零播到尾。把它当成一段普通的三秒视频从头播放,观感上会先「倒退」一秒半再走回按下快门的瞬间。

3 · 单文件封装

Motion Photo 把视频直接追加在图片字节之后,整个文件的扩展名与 MIME 仍是图片。定位信息写在图片的 XMP 里,分三个命名空间:

Camera:MotionPhoto="1"                          <!-- 0 = 不是, 1 = 是 -->
Camera:MotionPhotoVersion="1"
Camera:MotionPhotoPresentationTimestampUs="…"   <!-- 静图对应帧, -1 = 未指定 -->

Container:Directory                             <!-- 有序数组, 描述文件布局 -->
  └ Container:Item Item:Semantic="Primary"    Item:Mime="image/jpeg"
  └ Container:Item Item:Semantic="MotionPhoto" Item:Mime="video/mp4" Item:Length="…"

Container:Directory 是一个有序序列,首项必须是 Primary(显示用的主图),视频项的 Item:SemanticMotionPhoto 且必须排在最后。Ultra HDR 的增益图以 GainMap 语义插在两者之间。

规范给出的视频定位算法是正向求和:视频起点等于主图长度加上主图项的 Item:Padding。但它另附了一条非规范提示,供读者取用:从文件末尾回退 Item:Length 个字节。

这两条在只有主图与视频的文件上等价,在带 GainMap 的文件上则不然——正向求和必须把中间那一项的长度也算进去,漏算就会切到增益图里。反向回退不受影响,因为规范强制视频项排在最后。实现取反向那条更省事,代价是完全依赖「视频必须最后」这条约束成立。

警示 · 早期 Pixel 用的是另一套键:Xmp.GCamera.MicroVideoOffset,语义是「从文件末尾到视频起点的字节偏移」。它与新格式的 Item:Length 数值上恰好相同,但语义来源不同,且已随格式 1.0 废弃。同时读两代文件的实现需要分别处理,不能只认一个键。

ISOBMFF 系的主图(HEIC、AVIF)有额外约束:视频字节必须装进一个 mpvd box,且主图项的 Item:Padding 变为必填,取值等于该 box 头部的大小(通常 8 字节)。JPEG 主图没有这层包装,是裸拼接。

图 3-1 · 现场拼一个 Motion Photo 再按规范拆回来。左格 canvas 逐帧出画面,中格把整个拼接文件交给 <img>,右格收下按 Item:Length 反向切出的尾部字节。可展开查看写进 APP1 的 XMP packet。

4 · 两种封装的取舍

单文件设计有一个不显眼的好处:对不认识这套约定的解码器,它自动降级成一张普通图片。JPEG 解码器读到 EOI 标记就停,尾部那段视频字节被当作文件末尾的冗余数据忽略。图 3-1 中格验证的正是这一点——同一个拼接文件交给 <img>,画面照常显示。

这条降级路径是双文件模型给不出的。反过来,单文件封装把两份资源的生命周期绑死:想只要静图,得重新编码一份;想让 CDN 只回传静图,做不到,除非在服务端按 Item:Length 切一刀。

双文件(Live Photo) 单文件(Motion Photo)
不识别时的表现 两个各自可用的文件 一张普通图片,尾部字节被忽略
传输中拆散 常见失效方式 结构上不可能
只取静图 直接用静图那份 需按 Item:Length 切分
静图对应帧的记法 独立的 metadata track XMP 里一个标量

最后一行的差异值得留意:Apple 把锚点做成了一条轨道,Google 做成了一个数。轨道能表达随时间变化的信息,而锚点要表达的只是一个时间点,用一条轨道承载显得过重。这个选择的来历没有找到公开说明,本页不作推测。

两种封装的字节都拆得开,但拆出来的静图与视频,浏览器未必解得开。这一层见 解得开哪一半

5 · 参考文献

  1. Google. (2024). Motion Photo format 1.0. Android media documentation. https://developer.android.com/media/platform/motion-photo-format
  2. Jyrinki, T. (2021). MotionPhoto / MicroVideo file formats on Pixel phones. https://timojyrinki.gitlab.io/hugo/post/2021-03-30-pixel-motionphoto-microvideo-file-formats/
  3. Apple. PHLivePhoto. PhotoKit documentation. https://developer.apple.com/documentation/photos/phlivephoto