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.
- motion_photo_bridge-0.2.2/LICENSE +21 -0
- motion_photo_bridge-0.2.2/PKG-INFO +353 -0
- motion_photo_bridge-0.2.2/README.md +336 -0
- motion_photo_bridge-0.2.2/motion_photo_bridge/__init__.py +0 -0
- motion_photo_bridge-0.2.2/motion_photo_bridge/_vendor/NOTICE.md +9 -0
- motion_photo_bridge-0.2.2/motion_photo_bridge/_vendor/__init__.py +1 -0
- motion_photo_bridge-0.2.2/motion_photo_bridge/_vendor/vivo_motion_photo/LICENSE +21 -0
- motion_photo_bridge-0.2.2/motion_photo_bridge/_vendor/vivo_motion_photo/__init__.py +6 -0
- motion_photo_bridge-0.2.2/motion_photo_bridge/_vendor/vivo_motion_photo/__main__.py +6 -0
- motion_photo_bridge-0.2.2/motion_photo_bridge/_vendor/vivo_motion_photo/cli.py +302 -0
- motion_photo_bridge-0.2.2/motion_photo_bridge/_vendor/vivo_motion_photo/cli_entry.py +17 -0
- motion_photo_bridge-0.2.2/motion_photo_bridge/_vendor/vivo_motion_photo/gui.py +1343 -0
- motion_photo_bridge-0.2.2/motion_photo_bridge/_vendor/vivo_motion_photo/gui_entry.py +17 -0
- motion_photo_bridge-0.2.2/motion_photo_bridge/_vendor/vivo_motion_photo/jpeg.py +1557 -0
- motion_photo_bridge-0.2.2/motion_photo_bridge/_vendor/vivo_motion_photo/metadata.py +203 -0
- motion_photo_bridge-0.2.2/motion_photo_bridge/_vendor/vivo_motion_photo/models.py +195 -0
- motion_photo_bridge-0.2.2/motion_photo_bridge/_vendor/vivo_motion_photo/mp4.py +1012 -0
- motion_photo_bridge-0.2.2/motion_photo_bridge/_vendor/vivo_motion_photo/paths.py +242 -0
- motion_photo_bridge-0.2.2/motion_photo_bridge/_vendor/vivo_motion_photo/source_actions.py +1073 -0
- motion_photo_bridge-0.2.2/motion_photo_bridge/_vendor/vivo_motion_photo/workflow.py +1043 -0
- motion_photo_bridge-0.2.2/motion_photo_bridge/auto_merge.py +251 -0
- motion_photo_bridge-0.2.2/motion_photo_bridge/cli.py +296 -0
- motion_photo_bridge-0.2.2/motion_photo_bridge/doctor.py +111 -0
- motion_photo_bridge-0.2.2/motion_photo_bridge/exif.py +113 -0
- motion_photo_bridge-0.2.2/motion_photo_bridge/immich_apply.py +217 -0
- motion_photo_bridge-0.2.2/motion_photo_bridge/immich_batch.py +91 -0
- motion_photo_bridge-0.2.2/motion_photo_bridge/pairing.py +175 -0
- motion_photo_bridge-0.2.2/motion_photo_bridge/sql.py +26 -0
- motion_photo_bridge-0.2.2/motion_photo_bridge/transcode.py +94 -0
- motion_photo_bridge-0.2.2/motion_photo_bridge/vivo.py +114 -0
- motion_photo_bridge-0.2.2/motion_photo_bridge/webhook.py +195 -0
- motion_photo_bridge-0.2.2/motion_photo_bridge.egg-info/PKG-INFO +353 -0
- motion_photo_bridge-0.2.2/motion_photo_bridge.egg-info/SOURCES.txt +41 -0
- motion_photo_bridge-0.2.2/motion_photo_bridge.egg-info/dependency_links.txt +1 -0
- motion_photo_bridge-0.2.2/motion_photo_bridge.egg-info/entry_points.txt +2 -0
- motion_photo_bridge-0.2.2/motion_photo_bridge.egg-info/requires.txt +3 -0
- motion_photo_bridge-0.2.2/motion_photo_bridge.egg-info/top_level.txt +1 -0
- motion_photo_bridge-0.2.2/pyproject.toml +34 -0
- motion_photo_bridge-0.2.2/setup.cfg +4 -0
- motion_photo_bridge-0.2.2/tests/test_backup_paths.py +64 -0
- motion_photo_bridge-0.2.2/tests/test_exif_time.py +28 -0
- motion_photo_bridge-0.2.2/tests/test_pairing_messy.py +114 -0
- 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
|
+

|
|
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
|