Add Apple target adapter for HEIC conversion and MOV processing

- Implemented `AppleTargetAdapter` to modify HEIC files for Apple compatibility and inject ContentIdentifier into MOV files.
- Created a base `TargetAdapter` class to define the interface for target adapters.
- Added utility script for converting Samsung HEIC motion photos into Apple-compatible Live Photo pairs.
- Introduced error handling for missing exiftool during MOV processing.
- Updated module structure to include target adapters for HEIC/Motion Photo outputs.
This commit is contained in:
Nymiro
2025-10-20 23:10:04 +13:00
parent 63f148951c
commit 9c480d9edb
25 changed files with 736 additions and 192 deletions
+167 -30
View File
@@ -1,24 +1,66 @@
# pyheic-struct # pyheic-struct
`pyheic-struct` 是一个用于解析、分析以及重建 HEIC/HEIF 文件的 Python 库,专注于不同厂商(如三星、苹果)在实现上的差异。项目同时提供命令行工具与可复用的 API,可嵌入其他项目,帮助你处理跨平台的动态照片与静态 HEIC 内容 `pyheic-struct` 是一个专注于 HEIF/HEIC 结构化解析、诊断与重建的 Python 工具包。项目在三星动态照片(Motion Photo)与苹果生态兼容场景下积累了大量经验,既提供一键式的命令行转换,也暴露底层 API 便于集成到自定义流程中
## 功能特点 ## 为什么选择 pyheic-struct
- **HEIC 结构解析**:读取 `ftyp``meta``iloc``iinf``iprp` 等核心盒信息,便于调试与分析 - **跨厂商兼容痛点**:三星 HEIC 使用网格化主图和自定义 `mpvd` 盒,苹果设备无法直接识别。工具对这些差异做了完整抽象
- **网格图像重建**:支持将三星网格化存储的主图像还原为标准平面图像 - **低层结构可视化**:快速读取 `ftyp``meta``iloc``iinf``iprp``iref` 等关键盒,辅助调试偏移量与引用关系
- **元数据安全重建**通过 `HEICBuilder` 自动处理偏移量与引用更新,生成结构正确的新 HEIC 文件 - **安全重建**`HEICBuilder` 自动重新计算偏移和引用,避免手工修改导致的文件损坏
- **跨厂商兼容转换**:提供 `convert_samsung_motion_photo` 高阶函数,将三星动态照片转换为苹果可识别的 HEIC + MOV 组合,并可选择性写入 `ContentIdentifier` - **CLI + API 双入口**:既可以在命令行完成日常迁移,也能以库的方式嵌入流水线或 GUI 工具
- **命令行工具**:内置 CLI,可直接在终端运行转换任务 - **附带样例资源**:仓库内置多个真实 HEIC/MOV 用例,方便对照分析与学习
## 使用方式 ## 功能总览
### 安装 - **结构解析**`HEICFile` 提供盒树访问、主图 ID 查询、属性映射 (`ipma`) 等能力。
- **网格图像重组**:借助 `pillow-heif` 还原三星的网格化主图,生成平面图像。
- **Motion Photo 拆分**:自动提取嵌入的 MOV 数据并重新生成苹果兼容格式。
- **元数据校正**:修复三星专用的 “shifted ID” 现象,补齐苹果生态所需的 `ContentIdentifier`
- **命令行转换**`pyheic-struct` CLI 一键完成转换、打标签与临时文件清理。
## 基础概念速览
- **HEIF/HEIC**:高效图像容器格式,内部使用多层 Box 结构存储图像、元数据和引用。
- **Motion Photo**:静态图像与短视频打包的格式,三星与苹果在容器层实现差异较大。
- **ContentIdentifier**:苹果生态用于绑定 HEIC 主图和 MOV 视频的元数据键值。
## 环境要求
- Python 3.10+
- 必装依赖:`Pillow>=10.0.0`, `pillow-heif>=0.15.0`
- 可选依赖:`exiftool-wrapper>=0.5.0`(当需要自动写入 MOV 的 `ContentIdentifier` 时调用外部 `exiftool`
- 可执行的 `exiftool`(推荐,但非必须)
- macOS: `brew install exiftool`
- Ubuntu/Debian: `sudo apt install libimage-exiftool-perl`
- Windows: 安装 [ExifTool for Windows](https://exiftool.org/) 并将可执行文件加入 `PATH`
## 安装
仓库当前以源码分发为主,可按需选择以下方式:
```bash ```bash
# 方式一:直接在仓库根目录安装
pip install . pip install .
# 方式二:开发模式安装(便于调试)
pip install -e .[full]
``` ```
### 命令行 若只需要解析功能,可省略 `.[full]`;如果计划写入 `ContentIdentifier`,请启用 `full` 额外安装 `exiftool-wrapper`
## 快速上手
### 样例数据
仓库附带若干示例:
- `samsung.heic`:原始三星 Motion Photo
- `samsung_apple_compatible.HEIC` / `.MOV`:转换后的示例输出
- `apple.HEIC`:苹果原生样例
可以使用它们验证流程或对比结构差异。
### 命令行转换
```bash ```bash
pyheic-struct samsung.heic \ pyheic-struct samsung.heic \
@@ -26,35 +68,130 @@ pyheic-struct samsung.heic \
--output-mov samsung_fixed.MOV --output-mov samsung_fixed.MOV
``` ```
常用参数: 参数说明
- `--output-heic`:指定生成的 HEIC 文件路径
- `--output-mov`:指定生成的 MOV 文件路径
- `--skip-mov-tag`:跳过对 MOV 写入 `ContentIdentifier`(无需 exiftool
### 作为库使用 | 参数 | 说明 |
| ---- | ---- |
| `source` | 必选,原始三星 HEIC 路径 |
| `--output-heic` | 可选,输出 HEIC 路径,默认 `<source>_apple_compatible.HEIC` |
| `--output-mov` | 可选,输出 MOV 路径,默认 `<source>_apple_compatible.MOV` |
| `--skip-mov-tag` | 跳过为 MOV 写入 `ContentIdentifier`(无 `exiftool` 时可用) |
命令执行时会打印重建流程的关键日志,包括网格合成、ID 校正、临时文件清理等,出现异常时可直接复制日志用于排查。
### 作为库调用
```python ```python
from pyheic_struct import ( from pathlib import Path
HEICFile,
HEICBuilder, from pyheic_struct import HEICFile, HEICBuilder, convert_samsung_motion_photo
convert_samsung_motion_photo,
# 1. 解析结构
heic = HEICFile("samsung.heic")
print("主图 Item ID:", heic.get_primary_item_id())
print("所有条目:", list(heic.iter_items()))
# 2. 自定义转换(可覆盖默认输出路径)
heic_path, mov_path = convert_samsung_motion_photo(
"samsung.heic",
output_still=Path("converted/heic/apple_ready.HEIC"),
output_video=Path("converted/video/apple_ready.MOV"),
) )
# 解析 HEIC 结构 # 3. 进一步加工:加载新的 HEIC 并注入其他元数据
heic = HEICFile("samsung.heic") rebuilt = HEICFile(str(heic_path))
print(heic.get_primary_item_id()) rebuilt.set_content_identifier("INTERNAL-CONTENT-ID")
HEICBuilder(rebuilt).write("converted/heic/with_custom_id.HEIC")
# 执行三星 -> 苹果动态照片转换
heic_path, mov_path = convert_samsung_motion_photo("samsung.heic")
print("新 HEIC", heic_path)
print("新 MOV", mov_path)
``` ```
## 开发调试 ### 独立转换脚本
仓库提供 `scripts/samsung_live_photo.py`,用于在终端中直接生成苹果 Live Photo:
```bash
python3 scripts/samsung_live_photo.py samsung.heic --output-dir output/live
```
脚本默认会为 HEIC 与 MOV 写入同一个 `ContentIdentifier`(依赖系统 `exiftool`)。若暂时无法安装,可附加 `--skip-mov-tag` 跳过 MOV 注入。
### 结构巡检脚本
`inspect_heic.py` 为常用的调试脚本,可快速查看盒结构、偏移和 Vendor 信息:
```bash ```bash
python -m pyheic_struct path/to/samsung.heic
python inspect_heic.py samsung_apple_compatible.HEIC python inspect_heic.py samsung_apple_compatible.HEIC
``` ```
项目要求 Python 3.10 及以上版本。欢迎根据实际需求扩展解析器或加入更多厂商的兼容逻辑 输出包含 `ftyp``iloc``ipma``mpvd` 等关键节点,便于手动验证转换是否成功
## Motion Photo 转换流程解读
1. **载入原始 HEIC**:定位 `ftyp``meta``iloc` 等盒;若未自动识别厂商,会尝试查找三星专有的 `mpvd` 盒。
2. **主图重构**:从三星的网格化结构还原单张平面图像,并以临时 HEIC(`mif1` 品牌)写入。
3. **视频提取**:若存在 Motion Photo 数据,则写出 MOV;在具备 `exiftool` 时写入 `ContentIdentifier`
4. **ID 校正**:处理三星在 `iinf``ipma``iref` 等盒中的 “shifted ID” 现象,确保苹果解析器能正确关联。
5. **元数据注入**:更新 `ftyp` 兼容字符串、写入新的 `ContentIdentifier`,必要时可自定义。
6. **最终重建**`HEICBuilder` 重新计算每个盒的偏移和长度,生成干净的苹果兼容 HEIC;同时清理临时文件。
理解该流程有助于调优转换策略、扩展到其他厂商或容器结构。
## 主要 API 速查
- `pyheic_struct.HEICFile(path)`:读取 HEIC,提供盒访问、`get_motion_photo_data()``reconstruct_primary_image()` 等方法。
- `pyheic_struct.HEICBuilder(heic_file)`:将修改后的 `HEICFile` 写回磁盘,自动处理偏移修正。
- `pyheic_struct.convert_samsung_motion_photo(...)`:高阶入口,封装完整的三星 Motion Photo 转苹果流程。
- `pyheic_struct.handlers.*`:不同厂商的定制 Handler,可扩展以支持更多特性。
- `pyheic_struct.convert_motion_photo(...)`:全新的通用转换入口,可指定 `vendor_hint` 与目标 `TargetAdapter`
- `pyheic_struct.AppleTargetAdapter` / `pyheic_struct.TargetAdapter`:目标生态适配器接口,默认实现负责苹果兼容。
- `pyheic_struct.handlers.VendorHandler`:厂商扩展基类,提供 `matches``extract_motion_video``prepare_flat_heic` 等钩子。
更多内部实现细节可直接阅读 `pyheic_struct/` 包目录下的源代码。
## 扩展 Motion Photo 适配
1. **实现厂商 Handler**:在 `pyheic_struct/handlers/` 中继承 `VendorHandler`,实现 `matches`(自动检测)、`extract_motion_video`(解析嵌入视频)与 `prepare_flat_heic`(修复偏移、元数据)等方法。
2. **注册 Handler**:将新类加入 `HANDLER_REGISTRY`,即可参与自动检测,也可以通过 `vendor_hint="your_vendor"` 强制指定。
3. **自定义目标适配器(可选)**:若需要输出其他生态格式,继承 `TargetAdapter`,覆盖 `apply_to_flat_heic``post_process_mov`
4. **运行全量转换**:调用通用 API `convert_motion_photo(..., vendor_hint="your_vendor", target_adapter=YourAdapter())` 验证流程。
通过这一解耦结构,可以在不修改核心转换逻辑的情况下迭代更多厂商或目标平台的适配。
## 开发与调试
```bash
# 建议使用虚拟环境
python -m venv .venv
source .venv/bin/activate # Windows 用 .venv\Scripts\activate
# 安装依赖
pip install -e .[full]
# 快速验证
python -m pyheic_struct samsung.heic
python inspect_heic.py samsung_apple_compatible.HEIC
```
其它实用脚本:
- `test.py`:对生成的 HEIC 做首段十六进制检查。
- `main.py`:演示性入口,可作为定制脚本模板。
欢迎通过 Issues/PR 提交解析改进或新增厂商 Handler。
## 常见问题
- **提示找不到 `exiftool`**:说明环境未安装或未加入 `PATH`,可临时使用 `--skip-mov-tag` 跳过 MOV 元数据写入。
- **转换后只有 HEIC 没有 MOV**:原始文件可能不包含 Motion Photo 数据,可借助 `inspect_heic.py` 查看是否存在 `mpvd` 盒。
- **苹果仍无法识别**:请确认手动修改流程中是否重新执行了 `HEICBuilder.write()`,以及 `ContentIdentifier` 是否匹配 HEIC/MOV。
- **解析非三星文件**:目前对其他厂商测试有限,解析流程通常可用,但转换逻辑需要自行验证。
## 局限与后续计划
- 尚未覆盖所有 HEIF 扩展盒类型,复杂属性可能需要额外支持。
- 未提供官方测试套件,建议在生产环境前自行编写回归测试。
- 未来计划加入对华为、小米等厂商 Motion Photo 的实验性适配。
## 许可证
MIT License — 可自由用于商业及非商业用途。贡献代码即视为接受该许可条款。
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
+5 -1
View File
@@ -3,9 +3,10 @@
from importlib import metadata from importlib import metadata
from .builder import HEICBuilder from .builder import HEICBuilder
from .converter import convert_samsung_motion_photo from .converter import convert_motion_photo, convert_samsung_motion_photo
from .heic_file import HEICFile from .heic_file import HEICFile
from .heic_types import ItemInfoEntryBox, ItemInfoBox, ItemLocationBox from .heic_types import ItemInfoEntryBox, ItemInfoBox, ItemLocationBox
from .targets import AppleTargetAdapter, TargetAdapter
try: # pragma: no cover - best effort metadata try: # pragma: no cover - best effort metadata
__version__ = metadata.version("pyheic-struct") __version__ = metadata.version("pyheic-struct")
@@ -13,6 +14,9 @@ except metadata.PackageNotFoundError: # pragma: no cover - local checkout
__version__ = "0.0.0" __version__ = "0.0.0"
__all__ = [ __all__ = [
"AppleTargetAdapter",
"TargetAdapter",
"convert_motion_photo",
"convert_samsung_motion_photo", "convert_samsung_motion_photo",
"HEICBuilder", "HEICBuilder",
"HEICFile", "HEICFile",
Binary file not shown.
Binary file not shown.
Binary file not shown.
+107 -123
View File
@@ -1,113 +1,135 @@
"""High-level conversion helpers for Samsung motion photos.""" """High-level conversion helpers for Motion Photos."""
from __future__ import annotations from __future__ import annotations
import os import os
import subprocess
import uuid import uuid
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional, Union
import pillow_heif import pillow_heif
from .builder import HEICBuilder from .builder import HEICBuilder
from .handlers.base_handler import VendorHandler from .handlers import VendorHandler, get_handler_by_name, resolve_handler
from .handlers.samsung_handler import SamsungHandler
from .heic_file import HEICFile from .heic_file import HEICFile
from .heic_types import ItemInfoEntryBox from .targets import AppleTargetAdapter, TargetAdapter
def convert_samsung_motion_photo( HandlerHint = Union[str, VendorHandler, None]
def _select_handler(
heic_file: HEICFile,
vendor_hint: HandlerHint,
target_adapter: TargetAdapter,
) -> VendorHandler:
"""Resolve the vendor handler to operate on this file."""
handler: VendorHandler
if isinstance(vendor_hint, VendorHandler):
handler = vendor_hint
elif isinstance(vendor_hint, str):
if vendor_hint.lower() == "auto":
handler = heic_file.handler or resolve_handler(heic_file)
else:
handler = get_handler_by_name(vendor_hint)
else:
handler = heic_file.handler or resolve_handler(heic_file)
if not handler.__class__.matches(heic_file):
print(
f"Warning: Handler '{handler.name}' did not positively match heuristics for this file. "
"Proceeding anyway."
)
if not handler.supports_target(target_adapter):
raise ValueError(
f"Handler '{handler.name}' does not support target '{target_adapter.name}'."
)
heic_file.handler = handler
return handler
def convert_motion_photo(
source: str | os.PathLike, source: str | os.PathLike,
*, *,
vendor_hint: HandlerHint = None,
target_adapter: TargetAdapter | None = None,
output_still: Optional[str | os.PathLike] = None, output_still: Optional[str | os.PathLike] = None,
output_video: Optional[str | os.PathLike] = None, output_video: Optional[str | os.PathLike] = None,
inject_content_id_into_mov: bool = True, inject_content_id_into_mov: bool = True,
) -> tuple[Path, Optional[Path]]: ) -> tuple[Path, Optional[Path]]:
""" """
Convert a Samsung motion photo into an Apple-compatible HEIC + MOV pair. Convert a Motion Photo HEIC into a target-compatible HEIC(+MOV) 组合。
Parameters Parameters
---------- ----------
source: source:
Path to the Samsung `.heic` file to convert. 输入 HEIC 文件路径。
vendor_hint:
可选的厂商提示,支持 handler 名称(如 ``"samsung"``)、``VendorHandler`` 实例
或 ``"auto"``(默认行为)。
target_adapter:
目标生态适配器。默认使用 :class:`AppleTargetAdapter`。
output_still: output_still:
Optional output path for the converted HEIC. Defaults to 可选的输出 HEIC 路径,默认 ``<source>_apple_compatible.HEIC``。
``<source>_apple_compatible.HEIC``.
output_video: output_video:
Optional output path for the extracted MOV. Defaults to 可选的输出 MOV 路径,默认 ``<source>_apple_compatible.MOV``。
``<source>_apple_compatible.MOV``.
inject_content_id_into_mov: inject_content_id_into_mov:
When True (default), ``exiftool`` is invoked to set the 是否在 MOV 文件中写入 ``ContentIdentifier``。
``QuickTime:ContentIdentifier`` of the generated MOV file. If
``exiftool`` is not available the MOV is still created without the tag.
Returns Returns
------- -------
(Path, Optional[Path]) (Path, Optional[Path])
Tuple containing the path to the new HEIC file and the MOV path (if one 返回生成的 HEIC 路径以及 MOV 路径(若生成)。
was produced).
""" """
target_adapter = target_adapter or AppleTargetAdapter()
source_path = Path(source) source_path = Path(source)
if not source_path.is_file(): if not source_path.is_file():
raise FileNotFoundError(f"Samsung HEIC not found: {source_path}") raise FileNotFoundError(f"HEIC file not found: {source_path}")
base = source_path.with_suffix("") base = source_path.with_suffix("")
heic_path = Path(output_still) if output_still else Path(f"{base}_apple_compatible.HEIC") heic_path = Path(output_still) if output_still else Path(f"{base}_apple_compatible.HEIC")
mov_path = Path(output_video) if output_video else Path(f"{base}_apple_compatible.MOV") mov_output_path = Path(output_video) if output_video else Path(f"{base}_apple_compatible.MOV")
temp_flat_path = Path(f"{base}_temp_flat.HEIC") temp_flat_path = Path(f"{base}_temp_flat.HEIC")
print(f"--- Converting {source_path.name} to Apple format (V17 Fix) ---") print(f"--- Converting {source_path.name} using target '{target_adapter.name}' ---")
original_heic_file = HEICFile(str(source_path)) original_heic_file = HEICFile(str(source_path))
handler = _select_handler(original_heic_file, vendor_hint, target_adapter)
# Extract video data if present print(f"Detected handler: {handler.name}")
video_data = original_heic_file.get_motion_photo_data()
if not video_data:
if not original_heic_file.handler or isinstance(original_heic_file.handler, VendorHandler):
print("Vendor not auto-detected. Manually checking for Samsung 'mpvd' box...")
mpvd_box = original_heic_file.find_box('mpvd')
if mpvd_box:
original_heic_file.handler = SamsungHandler()
video_data = original_heic_file.get_motion_photo_data()
# Rebuild the primary image video_data = handler.extract_motion_video(original_heic_file)
print("Reconstructing grid image from Samsung file...") if video_data:
pil_image = original_heic_file.reconstruct_primary_image() print("Embedded motion photo data detected.")
if not pil_image: else:
print("No embedded motion photo data found via handler.")
print("Reconstructing primary image using vendor handler...")
pil_image = handler.reconstruct_primary_image(original_heic_file)
if pil_image is None:
raise RuntimeError("Failed to reconstruct primary image using pillow-heif.") raise RuntimeError("Failed to reconstruct primary image using pillow-heif.")
new_content_id = str(uuid.uuid4()).upper() new_content_id = str(uuid.uuid4()).upper()
print(f"Generated ContentIdentifier: {new_content_id}") print(f"Generated ContentIdentifier: {new_content_id}")
# Save MOV if video data exists mov_path: Optional[Path]
if video_data: if video_data:
print(f"Saving extracted video data to {mov_path}...") print(f"Saving extracted video data to {mov_output_path}...")
mov_path.write_bytes(video_data) mov_output_path.write_bytes(video_data)
target_adapter.post_process_mov(
if inject_content_id_into_mov: mov_output_path,
try: new_content_id,
print("Attempting to inject ContentIdentifier into .MOV file (requires exiftool)...") inject_content_id_into_mov,
subprocess.run( )
[ mov_path = mov_output_path
"exiftool",
f"-QuickTime:ContentIdentifier={new_content_id}",
"-overwrite_original",
str(mov_path),
],
check=True,
capture_output=True,
text=True,
)
print("Successfully injected ContentIdentifier into .MOV.")
except Exception as exc: # pragma: no cover - diagnostic path
print("Warning: Could not inject ContentIdentifier into .MOV.")
print(" (This is normal if 'exiftool' is not installed.)")
print(f" Error: {exc}")
else: else:
mov_path = None mov_path = None
print("Info: No motion photo data found. Only a still HEIC will be created.") print("Info: No motion photo data will be exported as MOV.")
try: try:
print(f"Saving temporary flat HEIC file to {temp_flat_path}...") print(f"Saving temporary flat HEIC file to {temp_flat_path}...")
@@ -125,74 +147,17 @@ def convert_samsung_motion_photo(
raise RuntimeError(f"Failed to save temporary HEIC file: {exc}") from exc raise RuntimeError(f"Failed to save temporary HEIC file: {exc}") from exc
try: try:
print("Loading temporary flat HEIC for metadata injection...") print("Loading temporary flat HEIC for metadata transformation...")
flat_heic_file = HEICFile(str(temp_flat_path)) flat_heic_file = HEICFile(str(temp_flat_path))
if flat_heic_file._ftyp_box: handler.prepare_flat_heic(
print("Modifying 'ftyp' box to be Apple compatible (heic, MiHB, MiHE...)...") original_heic=original_heic_file,
apple_ftyp_raw_data = b"heic\x00\x00\x00\x00mif1MiHBMiHEMiPrmiafheictmap" flat_heic=flat_heic_file,
flat_heic_file._ftyp_box.raw_data = apple_ftyp_raw_data content_id=new_content_id,
flat_heic_file._ftyp_box.size = len(apple_ftyp_raw_data) + 8 target_adapter=target_adapter,
else: )
raise RuntimeError("Temporary HEIC file has no 'ftyp' box.")
print("Checking for shifted IDs in temporary file (V17 Full Fix)...") target_adapter.apply_to_flat_heic(flat_heic_file, new_content_id)
if flat_heic_file._iinf_box and flat_heic_file._iloc_box and flat_heic_file._iprp_box:
correct_ids = {loc.item_id for loc in flat_heic_file._iloc_box.locations}
iinf_children_to_fix = [
child
for child in flat_heic_file._iinf_box.children
if isinstance(child, ItemInfoEntryBox) and (child.item_id >> 16) in correct_ids
]
shifted_id_map: dict[int, int] = {}
if iinf_children_to_fix:
print(f" Found {len(iinf_children_to_fix)} shifted 'infe' boxes. Mapping IDs for reference...")
for infe_box in iinf_children_to_fix:
unshifted_id = infe_box.item_id >> 16
if unshifted_id in correct_ids:
shifted_id_map[infe_box.item_id] = unshifted_id
print(f" - Mapping 'infe' ID {infe_box.item_id} -> {unshifted_id}")
else:
print(" 'infe' boxes seem correct. No shift detected.")
if flat_heic_file._iprp_box.ipma:
ipma_entries = flat_heic_file._iprp_box.ipma.entries
keys_to_fix = [key for key in ipma_entries if key in shifted_id_map]
if keys_to_fix:
print(f" Found {len(keys_to_fix)} shifted 'ipma' entries. Fixing them...")
for shifted_key in keys_to_fix:
correct_key = shifted_id_map[shifted_key]
print(f" - Fixing 'ipma' key {shifted_key} -> {correct_key}")
entry_data = ipma_entries.pop(shifted_key)
entry_data.item_id = correct_key
ipma_entries[correct_key] = entry_data
else:
print(f" 'ipma' entries seem correct. (Keys: {list(ipma_entries.keys())})")
if flat_heic_file._iref_box:
iref_refs = flat_heic_file._iref_box.references
refs_fixed = 0
for ref_type in iref_refs:
keys_to_fix = [key for key in iref_refs[ref_type] if key in shifted_id_map]
if keys_to_fix:
refs_fixed += len(keys_to_fix)
for shifted_key in keys_to_fix:
correct_key = shifted_id_map[shifted_key]
print(f" - Fixing 'iref' key [{ref_type}] {shifted_key} -> {correct_key}")
iref_refs[ref_type][correct_key] = iref_refs[ref_type].pop(shifted_key)
if refs_fixed > 0:
print(f" Fixed {refs_fixed} 'iref' entries.")
else:
print(" 'iref' entries seem correct.")
if flat_heic_file.set_content_identifier(new_content_id):
print("Successfully set ContentIdentifier in flat HEIC.")
else:
raise RuntimeError("Failed to set ContentIdentifier in flat HEIC.")
print("Rebuilding flat HEIC with new metadata...") print("Rebuilding flat HEIC with new metadata...")
builder = HEICBuilder(flat_heic_file) builder = HEICBuilder(flat_heic_file)
@@ -210,3 +175,22 @@ def convert_samsung_motion_photo(
print("\n" + "=" * 40 + "\n") print("\n" + "=" * 40 + "\n")
return heic_path, mov_path return heic_path, mov_path
def convert_samsung_motion_photo(
source: str | os.PathLike,
*,
output_still: Optional[str | os.PathLike] = None,
output_video: Optional[str | os.PathLike] = None,
inject_content_id_into_mov: bool = True,
) -> tuple[Path, Optional[Path]]:
"""
Backwards-compatible Samsung 转换入口,等价于 `vendor_hint="samsung"`。
"""
return convert_motion_photo(
source,
vendor_hint="samsung",
output_still=output_still,
output_video=output_video,
inject_content_id_into_mov=inject_content_id_into_mov,
)
+45 -2
View File
@@ -1,7 +1,50 @@
"""Vendor-specific handlers used during HEIC parsing.""" """Vendor-specific handlers and registry utilities."""
from __future__ import annotations
from typing import Iterable, Type
from .apple_handler import AppleHandler from .apple_handler import AppleHandler
from .base_handler import VendorHandler from .base_handler import VendorHandler
from .samsung_handler import SamsungHandler from .samsung_handler import SamsungHandler
__all__ = ["AppleHandler", "SamsungHandler", "VendorHandler"] HANDLER_REGISTRY: tuple[Type[VendorHandler], ...] = (
SamsungHandler,
AppleHandler,
)
def iter_handlers() -> Iterable[Type[VendorHandler]]:
"""Return every registered vendor handler class."""
return HANDLER_REGISTRY
def get_handler_by_name(name: str) -> VendorHandler:
"""Instantiate a handler by its declared ``name`` attribute."""
lowered = name.lower()
for handler_cls in HANDLER_REGISTRY:
if handler_cls.name == lowered:
return handler_cls()
raise ValueError(f"Unknown vendor handler: {name}")
def resolve_handler(heic_file) -> VendorHandler:
"""
Auto-detect a handler based on file heuristics.
Handlers are queried by ascending ``priority`` value.
"""
for handler_cls in sorted(HANDLER_REGISTRY, key=lambda cls: cls.priority):
if handler_cls.matches(heic_file):
return handler_cls()
return VendorHandler()
__all__ = [
"AppleHandler",
"SamsungHandler",
"VendorHandler",
"get_handler_by_name",
"iter_handlers",
"resolve_handler",
]
+26 -5
View File
@@ -1,14 +1,35 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from .base_handler import VendorHandler from .base_handler import VendorHandler
if TYPE_CHECKING:
from ..heic_file import HEICFile
from ..targets.base import TargetAdapter
class AppleHandler(VendorHandler): class AppleHandler(VendorHandler):
""" """
Handles Apple-specific HEIC features. Handles Apple-specific HEIC features.
Apple's motion photos (Live Photos) are stored in a separate MOV file, Apple's motion photos (Live Photos) are stored in a separate MOV file,
so we won't find an embedded video here. so we won't find an embedded video here.
""" """
# We override the method to make it explicit that Apple files are handled,
# even though the result is the same as the base class. name = "apple"
def find_motion_photo_offset(self, heic_file) -> int | None: priority = 50
# We could add logic here later to find the ContentIdentifier
@classmethod
def matches(cls, heic_file: HEICFile) -> bool:
brands = heic_file.get_compatible_brands()
# 只要检测到 heic/mif1/MiHB/MiHE 等苹果常见品牌即可视为苹果文件。
return any(brand in {"heic", "mif1", "mihb", "mihe"} for brand in brands)
def find_motion_photo_offset(self, heic_file: HEICFile) -> int | None:
print("Apple HEIC detected. Motion photo video is in a separate .mov file, not embedded.") print("Apple HEIC detected. Motion photo video is in a separate .mov file, not embedded.")
return None return None
def supports_target(self, target_adapter: TargetAdapter) -> bool:
# Apple 文件可作为源,被转换到任意目标。目前默认支持。
return True
+69 -6
View File
@@ -1,17 +1,80 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
from abc import ABC
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING: if TYPE_CHECKING:
from ..heic_file import HEICFile from ..heic_file import HEICFile
from ..targets.base import TargetAdapter
class VendorHandler:
class VendorHandler(ABC):
""" """
Abstract base class for vendor-specific logic. Base class for vendor-specific Motion Photo 适配逻辑。
设计理念:
- `matches` 用于自动检测厂商;
- `extract_motion_video` 负责读取嵌入式视频;
- `prepare_flat_heic` 提供对中间平面 HEIC 的修复机会;
- 其余钩子可在必要时扩展。
""" """
#: 供配置或日志使用的处理器名称。
name: str = "generic"
#: 自动检测时的优先级(值越小越优先)。
priority: int = 100
@classmethod
def matches(cls, heic_file: HEICFile) -> bool:
"""
判断当前 handler 是否适用于给定 HEIC 文件。
子类应实现轻量级检测逻辑(如检查 `ftyp` 品牌、特定 Box)。
"""
return False
def find_motion_photo_offset(self, heic_file: HEICFile) -> int | None: def find_motion_photo_offset(self, heic_file: HEICFile) -> int | None:
""" """
Tries to find the byte offset of the embedded motion photo video. 返回嵌入式 Motion Photo 视频数据的偏移量。
Returns the offset if found, otherwise None. 默认实现返回 None,表明不存在嵌入视频。
""" """
# Default behavior: no motion photo found.
return None return None
def extract_motion_video(self, heic_file: HEICFile) -> Optional[bytes]:
"""
读取嵌入式视频数据。若无法定位则返回 None。
"""
offset = self.find_motion_photo_offset(heic_file)
if offset is None:
return None
with open(heic_file.filepath, "rb") as file_obj:
file_obj.seek(offset)
return file_obj.read()
def reconstruct_primary_image(self, heic_file: HEICFile):
"""
还原主图像。默认委托给 `HEICFile.reconstruct_primary_image`。
"""
return heic_file.reconstruct_primary_image()
def prepare_flat_heic(
self,
original_heic: HEICFile,
flat_heic: HEICFile,
*,
content_id: str,
target_adapter: TargetAdapter,
) -> None:
"""
针对厂商特有结构(如偏移表、Shifted IDs)进行修复或补丁。
默认实现为空操作。
"""
return None
def supports_target(self, target_adapter: TargetAdapter) -> bool:
"""
用于声明 Handler 是否支持目标适配器。默认支持所有目标。
"""
return True
+92 -7
View File
@@ -1,20 +1,105 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from .base_handler import VendorHandler from .base_handler import VendorHandler
from ..heic_types import ItemInfoEntryBox
if TYPE_CHECKING:
from ..heic_file import HEICFile
from ..targets.base import TargetAdapter
class SamsungHandler(VendorHandler): class SamsungHandler(VendorHandler):
"""Handles Samsung-specific HEIC features, like the embedded mpvd box.""" """Handles Samsung-specific HEIC features, like the embedded mpvd box."""
def find_motion_photo_offset(self, heic_file) -> int | None: name = "samsung"
priority = 10
@classmethod
def matches(cls, heic_file: HEICFile) -> bool:
brands = heic_file.get_compatible_brands()
if any("samsung" in brand for brand in brands):
return True
# 三星 Motion Photo 通常包含 mpvd 盒
if heic_file.find_box("mpvd"):
return True
return False
def find_motion_photo_offset(self, heic_file: HEICFile) -> int | None:
""" """
Searches for the 'mpvd' box which contains the video data. Searches for the 'mpvd' box which contains the video data.
""" """
# --- START FIX --- mpvd_box = heic_file.find_box("mpvd")
# 使用 heic_file.find_box 进行递归搜索,因为 mpvd 嵌套在 meta 中
mpvd_box = heic_file.find_box('mpvd')
if mpvd_box: if mpvd_box:
print(f"Samsung 'mpvd' box found at offset {mpvd_box.offset}") print(f"Samsung 'mpvd' box found at offset {mpvd_box.offset}")
# 视频数据在 8 字节头部之后开始 # 视频数据在 8 字节头部之后开始
return mpvd_box.offset + 8 return mpvd_box.offset + 8
# --- END FIX ---
print("Samsung HEIC detected, but no 'mpvd' box found.") print("Samsung HEIC detected, but no 'mpvd' box found.")
return None return None
def prepare_flat_heic(
self,
original_heic: HEICFile,
flat_heic: HEICFile,
*,
content_id: str,
target_adapter: TargetAdapter,
) -> None:
"""
修复三星特有的 shifted Item IDs、ipma/ipco/iref 引用等问题。
"""
if not (flat_heic._iinf_box and flat_heic._iloc_box and flat_heic._iprp_box):
return
correct_ids = {loc.item_id for loc in flat_heic._iloc_box.locations}
iinf_children_to_fix = [
child
for child in flat_heic._iinf_box.children
if isinstance(child, ItemInfoEntryBox) and (child.item_id >> 16) in correct_ids
]
shifted_id_map: dict[int, int] = {}
if iinf_children_to_fix:
print(f" Found {len(iinf_children_to_fix)} shifted 'infe' boxes. Mapping IDs for reference...")
for infe_box in iinf_children_to_fix:
unshifted_id = infe_box.item_id >> 16
if unshifted_id in correct_ids:
shifted_id_map[infe_box.item_id] = unshifted_id
print(f" - Mapping 'infe' ID {infe_box.item_id} -> {unshifted_id}")
else:
print(" 'infe' boxes seem correct. No shift detected.")
if shifted_id_map and flat_heic._iprp_box.ipma:
ipma_entries = flat_heic._iprp_box.ipma.entries
keys_to_fix = [key for key in ipma_entries if key in shifted_id_map]
if keys_to_fix:
print(f" Found {len(keys_to_fix)} shifted 'ipma' entries. Fixing them...")
for shifted_key in keys_to_fix:
correct_key = shifted_id_map[shifted_key]
print(f" - Fixing 'ipma' key {shifted_key} -> {correct_key}")
entry_data = ipma_entries.pop(shifted_key)
entry_data.item_id = correct_key
ipma_entries[correct_key] = entry_data
else:
print(f" 'ipma' entries seem correct. (Keys: {list(ipma_entries.keys())})")
if shifted_id_map and flat_heic._iref_box:
iref_refs = flat_heic._iref_box.references
refs_fixed = 0
for ref_type in list(iref_refs.keys()):
keys_to_fix = [key for key in iref_refs[ref_type] if key in shifted_id_map]
if keys_to_fix:
refs_fixed += len(keys_to_fix)
for shifted_key in keys_to_fix:
correct_key = shifted_id_map[shifted_key]
print(f" - Fixing 'iref' key [{ref_type}] {shifted_key} -> {correct_key}")
iref_refs[ref_type][correct_key] = iref_refs[ref_type].pop(shifted_key)
if refs_fixed > 0:
print(f" Fixed {refs_fixed} 'iref' entries.")
else:
print(" 'iref' entries seem correct.")
+34 -18
View File
@@ -1,7 +1,6 @@
import os import os
import math import math
import pillow_heif import pillow_heif
from io import BytesIO
from dataclasses import dataclass from dataclasses import dataclass
from PIL import Image from PIL import Image
@@ -12,9 +11,7 @@ from .heic_types import (
ImageSpatialExtentsBox, ItemReferenceBox, ItemInfoEntryBox, ImageSpatialExtentsBox, ItemReferenceBox, ItemInfoEntryBox,
_read_int _read_int
) )
from .handlers.base_handler import VendorHandler from .handlers import VendorHandler, resolve_handler
from .handlers.apple_handler import AppleHandler
from .handlers.samsung_handler import SamsungHandler
@dataclass @dataclass
class Grid: class Grid:
@@ -81,6 +78,32 @@ class HEICFile:
return box return box
return None return None
def get_compatible_brands(self) -> set[str]:
"""
Return the normalized set of brands listed in the `ftyp` box.
"""
if not self._ftyp_box or not self._ftyp_box.raw_data:
return set()
raw = self._ftyp_box.raw_data
brands: set[str] = set()
if len(raw) >= 4:
primary = raw[:4].decode("ascii", errors="ignore").strip("\x00").lower()
if primary:
brands.add(primary)
# Skip 4-byte minor version
for offset in range(8, len(raw), 4):
chunk = raw[offset:offset + 4]
if len(chunk) < 4:
break
brand = chunk.decode("ascii", errors="ignore").strip("\x00").lower()
if brand:
brands.add(brand)
return brands
def _remove_box_recursive(self, box_type: str, box_list: list[Box]) -> bool: def _remove_box_recursive(self, box_type: str, box_list: list[Box]) -> bool:
"""Internal helper used by `remove_box_by_type`.""" """Internal helper used by `remove_box_by_type`."""
for i, box in enumerate(box_list): for i, box in enumerate(box_list):
@@ -344,12 +367,7 @@ class HEICFile:
for entry in self._iinf_box.entries: print(f" - {entry}") for entry in self._iinf_box.entries: print(f" - {entry}")
def _detect_vendor(self): def _detect_vendor(self):
if self._ftyp_box: self.handler = resolve_handler(self)
compatible_brands = self._ftyp_box.raw_data[4:].decode('ascii', errors='ignore')
if 'samsung' in compatible_brands.lower(): self.handler = SamsungHandler()
elif 'apple' in compatible_brands.lower() or 'MiHB' in compatible_brands: self.handler = AppleHandler()
else: self.handler = VendorHandler()
else: self.handler = VendorHandler()
def get_item_data(self, item_id: int) -> bytes | None: def get_item_data(self, item_id: int) -> bytes | None:
if not self._iloc_box: if not self._iloc_box:
@@ -389,14 +407,12 @@ class HEICFile:
return b''.join(data_chunks) return b''.join(data_chunks)
def get_motion_photo_data(self) -> bytes | None: def get_motion_photo_data(self) -> bytes | None:
if not self.handler: self._detect_vendor() if not self.handler:
offset = self.handler.find_motion_photo_offset(self) self._detect_vendor()
if offset is not None: data = self.handler.extract_motion_video(self)
print("Found motion photo data at offset.") if data is not None:
with open(self.filepath, 'rb') as f: print("Found motion photo data via vendor handler.")
f.seek(offset) return data
return f.read()
return None
def get_thumbnail_data(self) -> bytes | None: def get_thumbnail_data(self) -> bytes | None:
print("Attempting to extract thumbnail data...") print("Attempting to extract thumbnail data...")
+6
View File
@@ -0,0 +1,6 @@
"""Target adapters for rebuilding HEIC/Motion Photo outputs."""
from .apple import AppleTargetAdapter
from .base import TargetAdapter
__all__ = ["AppleTargetAdapter", "TargetAdapter"]
+52
View File
@@ -0,0 +1,52 @@
from __future__ import annotations
import subprocess
from pathlib import Path
from .base import TargetAdapter
class AppleTargetAdapter(TargetAdapter):
"""
将平面 HEIC 调整为苹果兼容格式,并在 MOV 中写入 ContentIdentifier。
"""
name = "apple"
APPLE_BRAND_PAYLOAD = b"heic\x00\x00\x00\x00mif1MiHBMiHEMiPrmiafheictmap"
def apply_to_flat_heic(self, flat_heic, content_id: str) -> None:
if not flat_heic._ftyp_box:
raise RuntimeError("Temporary HEIC file has no 'ftyp' box.")
print("Modifying 'ftyp' box to be Apple compatible (heic, MiHB, MiHE...)...")
flat_heic._ftyp_box.raw_data = self.APPLE_BRAND_PAYLOAD
flat_heic._ftyp_box.size = len(self.APPLE_BRAND_PAYLOAD) + 8
if flat_heic.set_content_identifier(content_id):
print("Successfully set ContentIdentifier in flat HEIC.")
else:
raise RuntimeError("Failed to set ContentIdentifier in flat HEIC.")
def post_process_mov(self, mov_path: Path, content_id: str, inject_content_id: bool) -> None:
if not inject_content_id or not mov_path.exists():
return
try:
print("Attempting to inject ContentIdentifier into .MOV file (requires exiftool)...")
subprocess.run(
[
"exiftool",
f"-QuickTime:ContentIdentifier={content_id}",
"-overwrite_original",
str(mov_path),
],
check=True,
capture_output=True,
text=True,
)
print("Successfully injected ContentIdentifier into .MOV.")
except Exception as exc: # pragma: no cover - diagnostic path
print("Warning: Could not inject ContentIdentifier into .MOV.")
print(" (This is normal if 'exiftool' is not installed.)")
print(f" Error: {exc}")
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
from abc import ABC
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from ..heic_file import HEICFile
class TargetAdapter(ABC):
"""
定义将 HEIC/Motion Photo 转换到目标生态所需的钩子。
"""
name: str = "generic"
def apply_to_flat_heic(self, flat_heic: HEICFile, content_id: str) -> None:
"""
在最终写出前,针对目标生态对 HEIC 做品牌/元数据调整。
"""
return None
def post_process_mov(self, mov_path: Path, content_id: str, inject_content_id: bool) -> None:
"""
针对导出的 MOV 文件执行额外处理(如写入 ContentIdentifier)。
"""
return None
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""
Utility script for converting Samsung HEIC motion photos into
Apple-compatible Live Photo pairs (HEIC + MOV) with matching ContentIdentifier.
"""
from __future__ import annotations
import argparse
import shutil
import sys
from pathlib import Path
from pyheic_struct import AppleTargetAdapter, convert_motion_photo
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
"Convert a Samsung HEIC motion photo into an Apple-compatible Live Photo "
"(HEIC + MOV) while keeping ContentIdentifier aligned."
),
)
parser.add_argument(
"source",
type=Path,
help="Path to the Samsung HEIC motion photo.",
)
parser.add_argument(
"-o",
"--output-dir",
type=Path,
default=None,
help="Optional directory for the generated files. Defaults to the source directory.",
)
parser.add_argument(
"--heic-name",
type=str,
default=None,
help="Optional filename (without directory) for the converted HEIC.",
)
parser.add_argument(
"--mov-name",
type=str,
default=None,
help="Optional filename (without directory) for the converted MOV.",
)
parser.add_argument(
"--skip-mov-tag",
action="store_true",
help="Skip writing ContentIdentifier into the MOV (no exiftool usage).",
)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
source_path = args.source.expanduser().resolve()
if not source_path.is_file():
print(f"Error: HEIC source file not found: {source_path}", file=sys.stderr)
return 1
output_dir = args.output_dir.expanduser().resolve() if args.output_dir else source_path.parent
output_dir.mkdir(parents=True, exist_ok=True)
if not args.skip_mov_tag and shutil.which("exiftool") is None:
print(
"Error: exiftool is required to inject ContentIdentifier into the MOV. "
"Install exiftool or use --skip-mov-tag.",
file=sys.stderr,
)
return 1
heic_filename = args.heic_name or f"{source_path.stem}_apple_compatible.HEIC"
mov_filename = args.mov_name or f"{source_path.stem}_apple_compatible.MOV"
heic_output = output_dir / heic_filename
mov_output = output_dir / mov_filename
print(f"Source HEIC: {source_path}")
print(f"Output HEIC: {heic_output}")
print(f"Output MOV: {mov_output}")
heic_path, mov_path = convert_motion_photo(
source_path,
vendor_hint="samsung",
target_adapter=AppleTargetAdapter(),
output_still=heic_output,
output_video=mov_output,
inject_content_id_into_mov=not args.skip_mov_tag,
)
print("Conversion finished successfully.")
print(f"HEIC saved to: {heic_path}")
if mov_path:
print(f"MOV saved to: {mov_path}")
else:
print("No MOV file generated (no embedded motion photo data found).")
return 0
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())