Skip to main content

motion-photo-bridge

vivo / iQOO 手机拍的动态照片,在 Immich 里会变成「一张照片 + 一个小视频」两个文件。这个工具把它们补成标准的单文件动态照片 —— 包括已经进库的照片。

Live photos from vivo / iQOO phones don't work in Immich. This fixes that — including the photos already in your library.

Motion Photo demo

One .jpg. Click it and the 2.9-second clip inside plays — recorded from a real vivo X100 Ultra live photo.


30 秒最快路径(只做离线转换)

pip install motion-photo-bridge
mpb doctor                                  # 只查 Python + ffmpeg + 内置引擎,不碰 Immich
mpb scan   ~/Pictures/vivo                  # 看看能配出多少对
mpb convert ~/Pictures/vivo -o ~/Pictures/fixed --transcode

三条命令就够。不用装 Immich、不用 Python 环境以外的任何东西(ffmpeg 除外)。 照片来自网盘同步盘、或从手机相册手工拷贝出来,也可以直接跑 —— 见下面「不同来源都能用」。


中文说明(先看这个)

问题出在哪

vivo / iQOO 的动态照片落盘是两个独立文件:同名的 IMG_xxx.jpg + IMG_xxx.mp4。两者之间 没有标准元数据,只有各自身尾部一段 vivo 私有 JSON 里的同一个 ID (com.android.camera.livephoto)—— Immich 只认 Apple 的 ContentIdentifier 和 Google 的 Camera:MotionPhoto,所以这段私有 ID 它根本不看。结果就是时间线里多出一个孤立小视频,下载给两个 文件;Windows 上那小段视频还常常黑屏只剩声音(vivo 录的是全范围 yuvj420p,硬件解码器拒播)。

它做什么

JPG + MP4 ──► 可选转码 ──► 合并 ──► 一个标准 Motion Photo JPEG
                                    (像素与 EXIF 逐字节原样)
  1. 配对 —— 优先用 vivo 私有 ID(相册另存/改名也不怕),没有 ID 时退回「同名 + 同目录 + 片段 ≤ 10 秒」。
  2. 转码(可选)—— 全范围片段重编码成 limited-range H.264/BT.709,修掉 Windows 黑屏。
  3. 合并 —— 把片段追加到 JPEG 尾部并写入 Container:Directory XMP,成为标准动态照片。
  4. 关联 —— Immich 里给图片写 livePhotoVideoId、隐藏那个小视频,和 Immich 自己处理 iPhone 动态照片的做法一致。

不同来源都能用

来源 会遇到什么 工具怎么处理
手机 DCIM 原样拷出 正常双文件 直接配对合并
网盘同步目录(OneDrive/坚果云/NAS) Thumbs.db、._xxx.jpg、@eaDir/、.crdownload 半成品、IMG_1 (1).jpg 副本 自动跳过垃圾与半成品;副本与原图同组时优先原始文件名;可递归子目录并保持目录结构
从相册「另存/导出」再拷出来 文件名被改(DSC_0001 copy.jpg)、剪辑丢了拍摄时间(报「缺少可比较的拍摄时间」) 先按 vivo 私有 ID 认人(改名也能配上);ID 没了就读 JPEG 的 EXIF,把拍摄时间补到剪辑上再合并
只想要 .MOV(Apple 形态) 默认不处理 --include-mov

三种用法

场景 命令
照片还在电脑上,入库前处理 mpb convert ~/Pictures/phone -o ~/Pictures/fixed --transcode
已经进了 Immich,不想重传 mpb apply-pair --image-id <uuid>(单张)/ mpb apply-batch --owner <邮箱>(全库,可续跑)
想全自动 上传即关联(Immich workflow + mpb serve),定时再自动合并(mpb auto-merge,每 6 小时)

已入库的照片不用重传:原地替换图片文件,资产 ID、相册、标签、收藏、拍摄时间全部保留;每对 先备份到独立目录,可中断、可续跑,回滚就是把备份拷回去。

哪些 vivo 机型真的需要它

同是 vivo,出片格式按机型分两代:

机型 落盘形态 需要本工具吗
X300 / X300 Pro(2025-10 起)、X300 Ultra、X300s、X500 系列 标准单文件 Motion Photo(JPEG + 追加 MP4 + 标准 XMP),Immich 原生识别 不需要
X100 / X200 系列(含 X100 Ultra、X200 Pro Mini)及更早 同名 JPG + MP4 两个文件,靠私有 ID 配对 需要

新机型只是把私有 JSON 保留成「双尾」(MP4 ftyp 前一份、文件末尾一份),正文已经是标准结构。

固件升级不会回填:X200 系列在 OriginOS 6 上仍复现双文件。所以老机型想要「拍照即单文件」,只能靠 入库后自动合并 —— 效果等同于新机型出片,区别是手机上那两份原件还在。

一个必须知道的副作用

合并会改写文件字节,但工具故意保留原始的 checksum——因为 Immich 手机端是靠「本地文件 SHA1 == 服务器 checksum」判断"是否已备份"的(backup.repository.dart),改了它,手机就会认为这些照片从没上传过, 每次同步都把原图重传一遍。

代价是:Immich 后台的 完整性检查 → checksum files 会把这些资产列成 ChecksumFail。那只是一份 报告,不会删文件、不会改资产、不影响播放和下载。可以忽略,或者只关掉这一项检查。

验证过什么

  • ~1000 对真实 vivo X100 Ultra 动态照片(跨两个账号)走完整流程。
  • 合并后的 JPEG:像素解码哈希与原件完全一致,内嵌片段可被 ffprobe 正常读出(H.264 + AAC)。
  • 配对:把其中一半 mp4 改名成 gallery-renamed-13.mp4 后,仍按私有 ID 全部正确配上。
  • Immich 端:图片 livePhotoVideoId 已写、视频 hidden、视频挪进 encoded-video/,下载只给一个文件。

The problem

Immich recognises two kinds of live photo:

Kind How it is detected
Apple Live Photo HEIC + MOV paired by ContentIdentifier metadata
Android Motion Photo a single JPEG with the video embedded (Camera:MotionPhoto XMP or MicroVideo)
vivo / iQOO live photo neither — two plain files joined only by a private vivo Live Photo ID that Immich never reads

So a vivo live photo lands as two assets: the picture, plus a 2-second clip sitting next to it in the timeline. Downloading gives you two files instead of one moving picture. And on Windows the clip often plays as a black frame with audio, because vivo records it full-range (yuvj420p) and the hardware decoder refuses it.

The same shape (a JPG and a same-stem MP4) shows up on some OPPO / realme builds.

What this does

JPG + MP4 ──► optional transcode ──► merge ──► one Motion Photo JPEG
                                               (pixels + EXIF copied byte-for-byte)
  1. Pair — by vivo's private livephoto id when both files carry one, otherwise by same folder + same filename stem with a clip ≤ 10 s. The id survives a gallery re-save; the filename does not.
  2. Transcode (optional) — re-encode full-range clips to limited-range 4:2:0 BT.709 so Windows browsers and the Photos app play them instead of showing black. Clean clips are skipped.
  3. Merge — append the clip to the JPEG and write the Container:Directory XMP block.
  4. Link — in Immich the image gets livePhotoVideoId and the clip is hidden, which is exactly what Immich does for a phone it recognises.

Install

pip install motion-photo-bridge
# 或者从源码:
# git clone https://github.com/duruonanni/motion-photo-bridge && cd motion-photo-bridge && pip install .

The merger is vendored under motion_photo_bridge/_vendor (MIT, see NOTICE.md) — there is no second package to install, and the package itself has zero runtime dependencies:

pip install motion-photo-bridge          # PyPI, 0.2.2+

ffmpeg and ffprobe must be on PATH — mpb doctor checks that (and only checks Immich when you pass --immich).

Inspect a file, offline

Open docs/viewer.html in a browser and drop a JPEG on it. It tells you whether the file is a Motion Photo, which spec it uses and how big the embedded clip is — and lets you play it. No server, no upload, works straight from file://.

Convert files before uploading

mpb scan    ~/Pictures/phone                       # what would be paired
mpb convert ~/Pictures/phone -o ~/Pictures/fixed --transcode

convert writes <stem>.jpg containing the clip. The originals are never modified.

Fix a library you already have

This is the part most tools skip. If the split pairs are already imported you do not have to delete and re-upload — asset IDs, albums, tags, favourites and dates all survive.

# one pair
mpb apply-pair --image-id <asset-uuid> --dry-run
mpb apply-pair --image-id <asset-uuid>

# the whole library, resumable
mpb apply-batch --owner you@example.com --dry-run
mpb apply-batch --owner you@example.com

Per pair it:

  1. backs the original jpg + mp4 up;
  2. transcodes + merges;
  3. replaces the image's file in place — the path and the asset ID never change;
  4. moves the clip into Immich's encoded-video/ folder, the folder Immich treats as "embedded video", so downloads skip it;
  5. updates checksum, originalPath, duration and fileSizeInByte to match the new file.

It refuses to merge when both files carry a private id and the ids disagree — that pair was matched on filenames alone, and merging it would attach the wrong clip.

It is idempotent and resumable: a pair whose clip already lives under encoded-video/ is skipped, so you can stop and restart freely. Every pair is backed up first, and rolling back means copying the backup over the file again.

These commands drive Immich through docker compose exec … psql, so they expect a local Compose deployment and three environment variables (no machine-specific defaults are baked in):

export IMMICH_COMPOSE_DIR=~/immich            # folder with docker-compose.yml / compose.yaml
export IMMICH_MEDIA_DIR=~/immich/library      # host path of the upload folder
export MPB_BACKUP_DIR=~/immich-backup/vivo    # where originals are copied before merging
mpb doctor --immich                            # verifies the three paths + the database

apply-pair is safe to re-run: it refuses to touch a file that is already a single Motion Photo.

Verify a pair afterwards:

python3 tools/verify_apply.py <image-uuid> <video-uuid>

It reports the asset state and the contents of the download archive — you want to see one file there.

Keep it automatic

Two layers, both optional and independent:

1. Link on upload (seconds). Immich has no third-party plugin loader yet, but since v3.0 it ships workflows with a webhook action, and that is the supported extension point:

phone uploads JPG + MP4
        │
        ▼
Immich ── Workflow (AssetCreate, filename matches \.mp4$) ──► POST
        │                                                     │
        ▼                                                     ▼
   asset in library                       motion-photo-bridge serve
                                                 (finds the same-stem image,
                                                  links it through the Immich
                                                  API; Immich then hides the clip)
mpb serve --port 9100

It needs an Immich API key for the account that owns the photos (asset.read + asset.update) in API_KEY, and IMMICH_URL pointing at your instance. Then, from that same account, paste docs/immich-workflow.json into Utilities → Workflows and point its url somewhere Immich can reach — on a Docker bridge network that is usually http://172.17.0.1:9100/….

Workflows are scoped to their owner: create it with the account that uploads the photos, not as an admin acting on someone else's behalf.

2. Link + merge on a timer (minutes, every 6 h). The webhook can only link: merging rewrites the stored file, which is not something to do inside an upload event. tools/auto_merge.py does both stages, is idempotent, and prints nothing when there is no work:

python3 tools/auto_merge.py --dry-run          # what is pending
python3 tools/auto_merge.py                    # link (id-first) + merge, all owners with vivo photos
python3 tools/auto_merge.py --owner you@example.com --limit 10

Run it from cron:

0 */6 * * *  cd ~/motion-photo-bridge && .venv/bin/python tools/auto_merge.py --quiet

Apple-style JPG + MOV pairs (still-image-time, com.apple.quicktime.content.identifier) are linked but not merged — they are already a coherent live photo, and merging gains nothing. Pass --include-mov if you want them turned into single files anyway.

Test the whole chain:

IMMICH_VIVO_PASSWORD=... python3 tools/test_e2e_immich.sh

It uploads a real pair, waits for the link and the hidden flag, then trashes the test assets. The clip content is randomised on purpose: identical bytes get deduplicated by Immich, no AssetCreate event fires, and the test passes for the wrong reason.

Troubleshooting

Ambiguous — missing comparable capture time. The merger pairs a JPEG with its clip by comparing capture times, and some JPEGs carry no date at all (hand-exported frames, some gallery re-exports). Write one in first — this refuses to touch a file that already has EXIF:

python3 tools/stamp_capture_time.py photo.jpg "2026:01:01 12:00:00"

Times come out 8 hours apart. The merger compares the JPEG's local DateTimeOriginal against the clip's creation_time normalised to local time. Re-encoding a clip drops its original stamp, so pass the photo's capture time when transcoding — tools/apply_pair.py takes it from Immich's dateTimeOriginal.

A photo edited in the phone's gallery pairs with the wrong time. vivo's gallery re-saves an edited live photo and rewrites the file name, the mtime and the clip's creation_time to the export moment, while keeping the original DateTimeOriginal. Trust EXIF, not the file name: one such photo is named IMG_20261005_000310 but its EXIF says 2026-10-04 14:09:23 — three seconds after IMG_20261004_140920, while the minute its name claims contains no photos at all. This is also why pairing prefers the private id over the filename.

After merging, the phone re-uploads the original JPG + MP4. Immich's mobile app decides "already backed up" by comparing the local file's SHA1 against asset.checksum on the server (backup.repository.dart: remoteAssetEntity.checksum.equalsExp(localAsset.checksum)). Merging rewrites the file, so this tool keeps the original checksum on purpose — otherwise every sync sees the originals as new and uploads them again.

Consequence, by design: Immich's Integrity → checksum files report will list those assets as ChecksumFail (byte hash no longer matches the recorded one). It is only a report — nothing is deleted, moved or blocked. Ignore it, or turn that one check off if the noise bothers you.

Windows plays a black frame (audio only). The clip is full-range yuvj420p and Windows hardware decoding chokes on it. mpb convert --transcode re-encodes it:

ffmpeg -i in.mp4 -vf "scale=in_range=full:out_range=limited,format=yuv420p" \
  -c:v libx264 -profile:v high -crf 20 -color_range tv \
  -colorspace bt709 -color_primaries bt709 -color_trc bt709 \
  -c:a aac -b:a 96k -movflags +faststart out.mp4

Before blaming the merge, check the same file in another player: Linux/Chromium software decoding shows these clips fine, which is what made this look like an Immich bug at first.

mpb convert prints FAIL and a reason. The upstream merger exits 0 even when it declines to pair a file, so the CLI verifies the artefact and reports the merger's own reason (Ambiguous, Rejected, …) instead of trusting the exit code.

Scope and limits

  • Pairs are only touched when the clip is ≤ 10 s (or carries the same private id as the photo). Anything else is reported and skipped.
  • Original files are never deleted — convert writes new files, and the library mode keeps a backup.
  • JPEG pixels and the entire EXIF segment are copied unchanged (checked by hashing the decoded pixels before and after: identical).
  • Verified against ~1000 pairs from a vivo X100 Ultra, across two Immich accounts.
  • No Immich source changes. This talks to the official API and to workflows, so your instance stays a stock Immich — including across upgrades.

Credits

  • Merging is delegated to oneone1995/android-motion-photo-merger (MIT) — it does the JPEG/XMP work correctly.
  • Thanks to the Immich maintainers for shipping workflows with a webhook action instead of forcing people to fork.

License

MIT

Metadata

Release files for motion-photo-bridge 0.2.4

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for motion-photo-bridge 0.2.4
File Size Uploaded Provenance Actions
motion_photo_bridge-0.2.4.tar.gz 108.6 kB With provenance Details for motion_photo_bridge-0.2.4.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for motion-photo-bridge 0.2.4
File Interpreter ABI Platform Actions
motion_photo_bridge-0.2.4-py3-none-any.whl Python 3 none any Details for motion_photo_bridge-0.2.4-py3-none-any.whl

Total release size: 218.6 kB

Release files / motion_photo_bridge-0.2.4.tar.gz

Download URL motion_photo_bridge-0.2.4.tar.gz
Size 108.6 kB
Tags Source
SHA-256 checksum
How to use checksums
2091bcd4cb27cc094bd753e137d3b005787f97c8e40a381657e328e715d3ff60
BLAKE2b-256 checksum
How to use checksums
a78f29e6fd5842d10f63b2b81a1f805cfda7e30ed6f5b5f4d55f0365b1dae211
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 21, 2026.

Transparency log

Release files / motion_photo_bridge-0.2.4-py3-none-any.whl

Download URL motion_photo_bridge-0.2.4-py3-none-any.whl
Size 110.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
b73d650f5c1f7b2bd3743a0567478774545b139572eedaa840609f3e2b00f177
BLAKE2b-256 checksum
How to use checksums
44bee3074b467e2ca4454fb553d2e619a7676fe20f539f08356cb44a67197b02
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 21, 2026.

Transparency log

Security for motion-photo-bridge 0.2.4

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for motion_photo_bridge-0.2.4.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 21, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page