motion-photo-bridge 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. motion_photo_bridge-0.2.2/LICENSE +21 -0
  2. motion_photo_bridge-0.2.2/PKG-INFO +353 -0
  3. motion_photo_bridge-0.2.2/README.md +336 -0
  4. motion_photo_bridge-0.2.2/motion_photo_bridge/__init__.py +0 -0
  5. motion_photo_bridge-0.2.2/motion_photo_bridge/_vendor/NOTICE.md +9 -0
  6. motion_photo_bridge-0.2.2/motion_photo_bridge/_vendor/__init__.py +1 -0
  7. motion_photo_bridge-0.2.2/motion_photo_bridge/_vendor/vivo_motion_photo/LICENSE +21 -0
  8. motion_photo_bridge-0.2.2/motion_photo_bridge/_vendor/vivo_motion_photo/__init__.py +6 -0
  9. motion_photo_bridge-0.2.2/motion_photo_bridge/_vendor/vivo_motion_photo/__main__.py +6 -0
  10. motion_photo_bridge-0.2.2/motion_photo_bridge/_vendor/vivo_motion_photo/cli.py +302 -0
  11. motion_photo_bridge-0.2.2/motion_photo_bridge/_vendor/vivo_motion_photo/cli_entry.py +17 -0
  12. motion_photo_bridge-0.2.2/motion_photo_bridge/_vendor/vivo_motion_photo/gui.py +1343 -0
  13. motion_photo_bridge-0.2.2/motion_photo_bridge/_vendor/vivo_motion_photo/gui_entry.py +17 -0
  14. motion_photo_bridge-0.2.2/motion_photo_bridge/_vendor/vivo_motion_photo/jpeg.py +1557 -0
  15. motion_photo_bridge-0.2.2/motion_photo_bridge/_vendor/vivo_motion_photo/metadata.py +203 -0
  16. motion_photo_bridge-0.2.2/motion_photo_bridge/_vendor/vivo_motion_photo/models.py +195 -0
  17. motion_photo_bridge-0.2.2/motion_photo_bridge/_vendor/vivo_motion_photo/mp4.py +1012 -0
  18. motion_photo_bridge-0.2.2/motion_photo_bridge/_vendor/vivo_motion_photo/paths.py +242 -0
  19. motion_photo_bridge-0.2.2/motion_photo_bridge/_vendor/vivo_motion_photo/source_actions.py +1073 -0
  20. motion_photo_bridge-0.2.2/motion_photo_bridge/_vendor/vivo_motion_photo/workflow.py +1043 -0
  21. motion_photo_bridge-0.2.2/motion_photo_bridge/auto_merge.py +251 -0
  22. motion_photo_bridge-0.2.2/motion_photo_bridge/cli.py +296 -0
  23. motion_photo_bridge-0.2.2/motion_photo_bridge/doctor.py +111 -0
  24. motion_photo_bridge-0.2.2/motion_photo_bridge/exif.py +113 -0
  25. motion_photo_bridge-0.2.2/motion_photo_bridge/immich_apply.py +217 -0
  26. motion_photo_bridge-0.2.2/motion_photo_bridge/immich_batch.py +91 -0
  27. motion_photo_bridge-0.2.2/motion_photo_bridge/pairing.py +175 -0
  28. motion_photo_bridge-0.2.2/motion_photo_bridge/sql.py +26 -0
  29. motion_photo_bridge-0.2.2/motion_photo_bridge/transcode.py +94 -0
  30. motion_photo_bridge-0.2.2/motion_photo_bridge/vivo.py +114 -0
  31. motion_photo_bridge-0.2.2/motion_photo_bridge/webhook.py +195 -0
  32. motion_photo_bridge-0.2.2/motion_photo_bridge.egg-info/PKG-INFO +353 -0
  33. motion_photo_bridge-0.2.2/motion_photo_bridge.egg-info/SOURCES.txt +41 -0
  34. motion_photo_bridge-0.2.2/motion_photo_bridge.egg-info/dependency_links.txt +1 -0
  35. motion_photo_bridge-0.2.2/motion_photo_bridge.egg-info/entry_points.txt +2 -0
  36. motion_photo_bridge-0.2.2/motion_photo_bridge.egg-info/requires.txt +3 -0
  37. motion_photo_bridge-0.2.2/motion_photo_bridge.egg-info/top_level.txt +1 -0
  38. motion_photo_bridge-0.2.2/pyproject.toml +34 -0
  39. motion_photo_bridge-0.2.2/setup.cfg +4 -0
  40. motion_photo_bridge-0.2.2/tests/test_backup_paths.py +64 -0
  41. motion_photo_bridge-0.2.2/tests/test_exif_time.py +28 -0
  42. motion_photo_bridge-0.2.2/tests/test_pairing_messy.py +114 -0
  43. motion_photo_bridge-0.2.2/tests/test_vivo_pairing.py +94 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 motion-photo-bridge contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,353 @@
1
+ Metadata-Version: 2.4
2
+ Name: motion-photo-bridge
3
+ Version: 0.2.2
4
+ Summary: Turn dual-file live photos (vivo/OPPO style JPG + MP4) into standard Android Motion Photos and wire them into Immich.
5
+ Author: duruonanni
6
+ License: MIT
7
+ Keywords: motion-photo,live-photo,immich,vivo,android,jpeg
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.11
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest>=8; extra == "dev"
16
+ Dynamic: license-file
17
+
18
+ # motion-photo-bridge
19
+
20
+ **vivo / iQOO 手机拍的动态照片,在 Immich 里会变成「一张照片 + 一个小视频」两个文件。这个工具把它们补成标准的单文件动态照片 —— 包括已经进库的照片。**
21
+
22
+ **Live photos from vivo / iQOO phones don't work in Immich. This fixes that — including the photos already in your library.**
23
+
24
+ ![Motion Photo demo](docs/demo.gif)
25
+
26
+ *One `.jpg`. Click it and the 2.9-second clip inside plays — recorded from a real vivo X100 Ultra live photo.*
27
+
28
+ ---
29
+
30
+ ## 30 秒最快路径(只做离线转换)
31
+
32
+ ```bash
33
+ pip install "git+https://github.com/duruonanni/motion-photo-bridge" # PyPI 上架前用这条
34
+ mpb doctor # 只查 Python + ffmpeg + 内置引擎,不碰 Immich
35
+ mpb scan ~/Pictures/vivo # 看看能配出多少对
36
+ mpb convert ~/Pictures/vivo -o ~/Pictures/fixed --transcode
37
+ ```
38
+
39
+ 三条命令就够。不用装 Immich、不用 Python 环境以外的任何东西(ffmpeg 除外)。
40
+ **照片来自网盘同步盘、或从手机相册手工拷贝出来,也可以直接跑** —— 见下面「不同来源都能用」。
41
+
42
+ ---
43
+
44
+ ## 中文说明(先看这个)
45
+
46
+ ### 问题出在哪
47
+
48
+ vivo / iQOO 的动态照片落盘是**两个独立文件**:同名的 `IMG_xxx.jpg` + `IMG_xxx.mp4`。两者之间
49
+ 没有标准元数据,只有各自身尾部一段 vivo 私有 JSON 里的同一个 ID
50
+ (`com.android.camera.livephoto`)—— Immich 只认 Apple 的 `ContentIdentifier` 和 Google 的
51
+ `Camera:MotionPhoto`,所以这段私有 ID 它根本不看。结果就是时间线里多出一个孤立小视频,下载给两个
52
+ 文件;Windows 上那小段视频还常常**黑屏只剩声音**(vivo 录的是全范围 `yuvj420p`,硬件解码器拒播)。
53
+
54
+ ### 它做什么
55
+
56
+ ```
57
+ JPG + MP4 ──► 可选转码 ──► 合并 ──► 一个标准 Motion Photo JPEG
58
+ (像素与 EXIF 逐字节原样)
59
+ ```
60
+
61
+ 1. **配对** —— 优先用 vivo 私有 ID(相册另存/改名也不怕),没有 ID 时退回「同名 + 同目录 + 片段 ≤ 10 秒」。
62
+ 2. **转码**(可选)—— 全范围片段重编码成 limited-range H.264/BT.709,修掉 Windows 黑屏。
63
+ 3. **合并** —— 把片段追加到 JPEG 尾部并写入 `Container:Directory` XMP,成为标准动态照片。
64
+ 4. **关联** —— Immich 里给图片写 `livePhotoVideoId`、隐藏那个小视频,和 Immich 自己处理 iPhone 动态照片的做法一致。
65
+
66
+ ### 不同来源都能用
67
+
68
+ | 来源 | 会遇到什么 | 工具怎么处理 |
69
+ | --- | --- | --- |
70
+ | 手机 DCIM 原样拷出 | 正常双文件 | 直接配对合并 |
71
+ | 网盘同步目录(OneDrive/坚果云/NAS) | `Thumbs.db`、`._xxx.jpg`、`@eaDir/`、`.crdownload` 半成品、`IMG_1 (1).jpg` 副本 | 自动跳过垃圾与半成品;副本与原图同组时**优先原始文件名**;可递归子目录并保持目录结构 |
72
+ | 从相册「另存/导出」再拷出来 | 文件名被改(`DSC_0001 copy.jpg`)、剪辑丢了拍摄时间(报「缺少可比较的拍摄时间」) | 先按 vivo 私有 ID 认人(改名也能配上);ID 没了就读 JPEG 的 EXIF,把拍摄时间补到剪辑上再合并 |
73
+ | 只想要 .MOV(Apple 形态) | 默认不处理 | `--include-mov` |
74
+
75
+ ### 三种用法
76
+
77
+ | 场景 | 命令 |
78
+ | --- | --- |
79
+ | 照片还在电脑上,入库前处理 | `mpb convert ~/Pictures/phone -o ~/Pictures/fixed --transcode` |
80
+ | 已经进了 Immich,不想重传 | `mpb apply-pair --image-id <uuid>`(单张)/ `mpb apply-batch --owner <邮箱>`(全库,可续跑) |
81
+ | 想全自动 | 上传即关联(Immich workflow + `mpb serve`),定时再自动合并(`mpb auto-merge`,每 6 小时) |
82
+
83
+ **已入库的照片不用重传**:原地替换图片文件,资产 ID、相册、标签、收藏、拍摄时间全部保留;每对
84
+ 先备份到独立目录,可中断、可续跑,回滚就是把备份拷回去。
85
+
86
+ ### 哪些 vivo 机型真的需要它
87
+
88
+ 同是 vivo,出片格式按机型分两代:
89
+
90
+ | 机型 | 落盘形态 | 需要本工具吗 |
91
+ | --- | --- | --- |
92
+ | **X300 / X300 Pro**(2025-10 起)、X300 Ultra、X300s、X500 系列 | 标准单文件 Motion Photo(JPEG + 追加 MP4 + 标准 XMP),Immich 原生识别 | 不需要 |
93
+ | **X100 / X200 系列**(含 X100 Ultra、X200 Pro Mini)及更早 | 同名 `JPG` + `MP4` 两个文件,靠私有 ID 配对 | **需要** |
94
+
95
+ 新机型只是把私有 JSON 保留成「双尾」(MP4 `ftyp` 前一份、文件末尾一份),正文已经是标准结构。
96
+
97
+ 固件升级不会回填:X200 系列在 OriginOS 6 上仍复现双文件。所以老机型想要「拍照即单文件」,只能靠
98
+ 入库后自动合并 —— 效果等同于新机型出片,区别是手机上那两份原件还在。
99
+
100
+ ### 验证过什么
101
+
102
+ - ~1000 对真实 vivo X100 Ultra 动态照片(跨两个账号)走完整流程。
103
+ - 合并后的 JPEG:像素解码哈希与原件**完全一致**,内嵌片段可被 `ffprobe` 正常读出(H.264 + AAC)。
104
+ - 配对:把其中一半 `mp4` 改名成 `gallery-renamed-13.mp4` 后,仍按私有 ID 全部正确配上。
105
+ - Immich 端:图片 `livePhotoVideoId` 已写、视频 `hidden`、视频挪进 `encoded-video/`,下载只给一个文件。
106
+
107
+ ---
108
+
109
+ ## The problem
110
+
111
+ Immich recognises two kinds of live photo:
112
+
113
+ | Kind | How it is detected |
114
+ | --- | --- |
115
+ | Apple Live Photo | `HEIC` + `MOV` paired by `ContentIdentifier` metadata |
116
+ | Android Motion Photo | a single JPEG with the video embedded (`Camera:MotionPhoto` XMP or `MicroVideo`) |
117
+ | **vivo / iQOO live photo** | **neither** — two plain files joined only by a private `vivo Live Photo ID` that Immich never reads |
118
+
119
+ So a vivo live photo lands as **two assets**: the picture, plus a 2-second clip sitting next to it
120
+ in the timeline. Downloading gives you two files instead of one moving picture. And on Windows the
121
+ clip often plays as a **black frame with audio**, because vivo records it full-range (`yuvj420p`)
122
+ and the hardware decoder refuses it.
123
+
124
+ The same shape (a `JPG` and a same-stem `MP4`) shows up on some OPPO / realme builds.
125
+
126
+ ## What this does
127
+
128
+
129
+ ```
130
+ JPG + MP4 ──► optional transcode ──► merge ──► one Motion Photo JPEG
131
+ (pixels + EXIF copied byte-for-byte)
132
+ ```
133
+
134
+ 1. **Pair** — by vivo's private `livephoto` id when both files carry one, otherwise by
135
+ same folder + same filename stem with a clip ≤ 10 s. The id survives a gallery re-save; the
136
+ filename does not.
137
+ 2. **Transcode** (optional) — re-encode full-range clips to limited-range 4:2:0 BT.709 so Windows
138
+ browsers and the Photos app play them instead of showing black. Clean clips are skipped.
139
+ 3. **Merge** — append the clip to the JPEG and write the `Container:Directory` XMP block.
140
+ 4. **Link** — in Immich the image gets `livePhotoVideoId` and the clip is hidden, which is exactly
141
+ what Immich does for a phone it recognises.
142
+
143
+ ## Install
144
+
145
+ ```bash
146
+ git clone https://github.com/duruonanni/motion-photo-bridge
147
+ cd motion-photo-bridge
148
+ python3 -m venv .venv && . .venv/bin/activate
149
+ pip install .
150
+ ```
151
+
152
+ The merger is vendored under `motion_photo_bridge/_vendor` (MIT, see
153
+ `NOTICE.md`) — there is no second package to install. Until the project is on
154
+ PyPI, `pip install git+…` still needs `git` on the machine; on a machine without
155
+ git, install the wheel from the release:
156
+
157
+ ```bash
158
+ pip install ./motion_photo_bridge-0.2.1-py3-none-any.whl
159
+ ```
160
+
161
+ `ffmpeg` and `ffprobe` must be on `PATH` — `mpb doctor` checks that (and only
162
+ checks Immich when you pass `--immich`).
163
+
164
+ ## Inspect a file, offline
165
+
166
+ Open [`docs/viewer.html`](docs/viewer.html) in a browser and drop a JPEG on it. It tells you whether
167
+ the file is a Motion Photo, which spec it uses and how big the embedded clip is — and lets you play
168
+ it. No server, no upload, works straight from `file://`.
169
+
170
+ ## Convert files before uploading
171
+
172
+ ```bash
173
+ mpb scan ~/Pictures/phone # what would be paired
174
+ mpb convert ~/Pictures/phone -o ~/Pictures/fixed --transcode
175
+ ```
176
+
177
+ `convert` writes `<stem>.jpg` containing the clip. **The originals are never modified.**
178
+
179
+ ## Fix a library you already have
180
+
181
+ This is the part most tools skip. If the split pairs are already imported you do **not** have to
182
+ delete and re-upload — asset IDs, albums, tags, favourites and dates all survive.
183
+
184
+ ```bash
185
+ # one pair
186
+ mpb apply-pair --image-id <asset-uuid> --dry-run
187
+ mpb apply-pair --image-id <asset-uuid>
188
+
189
+ # the whole library, resumable
190
+ mpb apply-batch --owner you@example.com --dry-run
191
+ mpb apply-batch --owner you@example.com
192
+ ```
193
+
194
+ Per pair it:
195
+
196
+ 1. backs the original `jpg` + `mp4` up;
197
+ 2. transcodes + merges;
198
+ 3. replaces the image's file **in place** — the path and the asset ID never change;
199
+ 4. moves the clip into Immich's `encoded-video/` folder, the folder Immich treats as "embedded
200
+ video", so downloads skip it;
201
+ 5. updates `checksum`, `originalPath`, `duration` and `fileSizeInByte` to match the new file.
202
+
203
+ It refuses to merge when both files carry a private id and the ids **disagree** — that pair was
204
+ matched on filenames alone, and merging it would attach the wrong clip.
205
+
206
+ It is **idempotent and resumable**: a pair whose clip already lives under `encoded-video/` is
207
+ skipped, so you can stop and restart freely. Every pair is backed up first, and rolling back means
208
+ copying the backup over the file again.
209
+
210
+ These commands drive Immich through `docker compose exec … psql`, so they expect a local Compose
211
+ deployment and **three environment variables** (no machine-specific defaults are baked in):
212
+
213
+ ```bash
214
+ export IMMICH_COMPOSE_DIR=~/immich # folder with docker-compose.yml / compose.yaml
215
+ export IMMICH_MEDIA_DIR=~/immich/library # host path of the upload folder
216
+ export MPB_BACKUP_DIR=~/immich-backup/vivo # where originals are copied before merging
217
+ mpb doctor --immich # verifies the three paths + the database
218
+ ```
219
+
220
+ `apply-pair` is safe to re-run: it refuses to touch a file that is already a single Motion Photo.
221
+
222
+ Verify a pair afterwards:
223
+
224
+ ```bash
225
+ python3 tools/verify_apply.py <image-uuid> <video-uuid>
226
+ ```
227
+
228
+ It reports the asset state and the contents of the download archive — you want to see **one** file
229
+ there.
230
+
231
+ ## Keep it automatic
232
+
233
+ Two layers, both optional and independent:
234
+
235
+ **1. Link on upload** (seconds). Immich has no third-party plugin loader yet, but since **v3.0** it
236
+ ships workflows with a webhook action, and that is the supported extension point:
237
+
238
+ ```
239
+ phone uploads JPG + MP4
240
+ │
241
+ ▼
242
+ Immich ── Workflow (AssetCreate, filename matches \.mp4$) ──► POST
243
+ │ │
244
+ ▼ ▼
245
+ asset in library motion-photo-bridge serve
246
+ (finds the same-stem image,
247
+ links it through the Immich
248
+ API; Immich then hides the clip)
249
+ ```
250
+
251
+ ```bash
252
+ mpb serve --port 9100
253
+ ```
254
+
255
+ It needs an Immich API key for the account that owns the photos (`asset.read` +
256
+ `asset.update`) in `API_KEY`, and `IMMICH_URL` pointing at your instance. Then, from that same
257
+ account, paste [`docs/immich-workflow.json`](docs/immich-workflow.json) into
258
+ **Utilities → Workflows** and point its `url` somewhere Immich can reach — on a Docker bridge
259
+ network that is usually `http://172.17.0.1:9100/…`.
260
+
261
+ > Workflows are scoped to their owner: create it with the account that uploads the photos, not as
262
+ > an admin acting on someone else's behalf.
263
+
264
+ **2. Link + merge on a timer** (minutes, every 6 h). The webhook can only link: merging rewrites
265
+ the stored file, which is not something to do inside an upload event. `tools/auto_merge.py` does
266
+ both stages, is idempotent, and prints nothing when there is no work:
267
+
268
+ ```bash
269
+ python3 tools/auto_merge.py --dry-run # what is pending
270
+ python3 tools/auto_merge.py # link (id-first) + merge, all owners with vivo photos
271
+ python3 tools/auto_merge.py --owner you@example.com --limit 10
272
+ ```
273
+
274
+ Run it from cron:
275
+
276
+ ```cron
277
+ 0 */6 * * * cd ~/motion-photo-bridge && .venv/bin/python tools/auto_merge.py --quiet
278
+ ```
279
+
280
+ Apple-style `JPG + MOV` pairs (`still-image-time`, `com.apple.quicktime.content.identifier`) are
281
+ linked but **not** merged — they are already a coherent live photo, and merging gains nothing.
282
+ Pass `--include-mov` if you want them turned into single files anyway.
283
+
284
+ Test the whole chain:
285
+
286
+ ```bash
287
+ IMMICH_VIVO_PASSWORD=... python3 tools/test_e2e_immich.sh
288
+ ```
289
+
290
+ It uploads a real pair, waits for the link and the hidden flag, then trashes the test assets. The
291
+ clip content is randomised on purpose: identical bytes get deduplicated by Immich, no `AssetCreate`
292
+ event fires, and the test passes for the wrong reason.
293
+
294
+ ## Troubleshooting
295
+
296
+ **`Ambiguous — missing comparable capture time`.** The merger pairs a JPEG with its clip by
297
+ comparing capture times, and some JPEGs carry no date at all (hand-exported frames, some gallery
298
+ re-exports). Write one in first — this refuses to touch a file that already has EXIF:
299
+
300
+ ```bash
301
+ python3 tools/stamp_capture_time.py photo.jpg "2026:01:01 12:00:00"
302
+ ```
303
+
304
+ **Times come out 8 hours apart.** The merger compares the JPEG's local `DateTimeOriginal` against
305
+ the clip's `creation_time` normalised to local time. Re-encoding a clip drops its original stamp, so
306
+ pass the photo's capture time when transcoding — `tools/apply_pair.py` takes it from Immich's
307
+ `dateTimeOriginal`.
308
+
309
+ **A photo edited in the phone's gallery pairs with the wrong time.** vivo's gallery re-saves an
310
+ edited live photo and rewrites the file name, the mtime and the clip's `creation_time` to the
311
+ *export* moment, while keeping the original `DateTimeOriginal`. **Trust EXIF, not the file name:**
312
+ one such photo is named `IMG_20261005_000310` but its EXIF says `2026-10-04 14:09:23` — three
313
+ seconds after `IMG_20261004_140920`, while the minute its name claims contains no photos at all.
314
+ This is also why pairing prefers the private id over the filename.
315
+
316
+ **Windows plays a black frame (audio only).** The clip is full-range `yuvj420p` and Windows hardware
317
+ decoding chokes on it. `mpb convert --transcode` re-encodes it:
318
+
319
+ ```bash
320
+ ffmpeg -i in.mp4 -vf "scale=in_range=full:out_range=limited,format=yuv420p" \
321
+ -c:v libx264 -profile:v high -crf 20 -color_range tv \
322
+ -colorspace bt709 -color_primaries bt709 -color_trc bt709 \
323
+ -c:a aac -b:a 96k -movflags +faststart out.mp4
324
+ ```
325
+
326
+ Before blaming the merge, check the same file in another player: Linux/Chromium software decoding
327
+ shows these clips fine, which is what made this look like an Immich bug at first.
328
+
329
+ **`mpb convert` prints FAIL and a reason.** The upstream merger exits `0` even when it declines to
330
+ pair a file, so the CLI verifies the artefact and reports the merger's own reason (`Ambiguous`,
331
+ `Rejected`, …) instead of trusting the exit code.
332
+
333
+ ## Scope and limits
334
+
335
+ - Pairs are only touched when the clip is ≤ 10 s (or carries the same private id as the photo).
336
+ Anything else is reported and skipped.
337
+ - Original files are never deleted — `convert` writes new files, and the library mode keeps a backup.
338
+ - JPEG pixels and the entire EXIF segment are copied unchanged (checked by hashing the decoded
339
+ pixels before and after: identical).
340
+ - Verified against ~1000 pairs from a vivo X100 Ultra, across two Immich accounts.
341
+ - No Immich source changes. This talks to the official API and to workflows, so your instance stays
342
+ a stock Immich — including across upgrades.
343
+
344
+ ## Credits
345
+
346
+ - Merging is delegated to [`oneone1995/android-motion-photo-merger`](https://github.com/oneone1995/android-motion-photo-merger)
347
+ (MIT) — it does the JPEG/XMP work correctly.
348
+ - Thanks to the Immich maintainers for shipping workflows with a webhook action instead of forcing
349
+ people to fork.
350
+
351
+ ## License
352
+
353
+ MIT