diff --git a/README.md b/README.md index e320fe2..3ab2556 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,66 @@ # 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` 等核心盒信息,便于调试与分析。 -- **网格图像重建**:支持将三星网格化存储的主图像还原为标准平面图像。 -- **元数据安全重建**:通过 `HEICBuilder` 自动处理偏移量与引用更新,生成结构正确的新 HEIC 文件。 -- **跨厂商兼容转换**:提供 `convert_samsung_motion_photo` 高阶函数,将三星动态照片转换为苹果可识别的 HEIC + MOV 组合,并可选择性写入 `ContentIdentifier`。 -- **命令行工具**:内置 CLI,可直接在终端运行转换任务。 +- **跨厂商兼容痛点**:三星 HEIC 使用网格化主图和自定义 `mpvd` 盒,苹果设备无法直接识别。工具对这些差异做了完整抽象。 +- **低层结构可视化**:快速读取 `ftyp`、`meta`、`iloc`、`iinf`、`iprp`、`iref` 等关键盒,辅助调试偏移量与引用关系。 +- **安全重建**:`HEICBuilder` 会自动重新计算偏移和引用,避免手工修改导致的文件损坏。 +- **CLI + API 双入口**:既可以在命令行完成日常迁移,也能以库的方式嵌入流水线或 GUI 工具。 +- **附带样例资源**:仓库内置多个真实 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 +# 方式一:直接在仓库根目录安装 pip install . + +# 方式二:开发模式安装(便于调试) +pip install -e .[full] ``` -### 命令行 +若只需要解析功能,可省略 `.[full]`;如果计划写入 `ContentIdentifier`,请启用 `full` 额外安装 `exiftool-wrapper`。 + +## 快速上手 + +### 样例数据 + +仓库附带若干示例: + +- `samsung.heic`:原始三星 Motion Photo +- `samsung_apple_compatible.HEIC` / `.MOV`:转换后的示例输出 +- `apple.HEIC`:苹果原生样例 + +可以使用它们验证流程或对比结构差异。 + +### 命令行转换 ```bash pyheic-struct samsung.heic \ @@ -26,35 +68,130 @@ pyheic-struct samsung.heic \ --output-mov samsung_fixed.MOV ``` -常用参数: -- `--output-heic`:指定生成的 HEIC 文件路径 -- `--output-mov`:指定生成的 MOV 文件路径 -- `--skip-mov-tag`:跳过对 MOV 写入 `ContentIdentifier`(无需 exiftool) +参数说明: -### 作为库使用 +| 参数 | 说明 | +| ---- | ---- | +| `source` | 必选,原始三星 HEIC 路径 | +| `--output-heic` | 可选,输出 HEIC 路径,默认 `_apple_compatible.HEIC` | +| `--output-mov` | 可选,输出 MOV 路径,默认 `_apple_compatible.MOV` | +| `--skip-mov-tag` | 跳过为 MOV 写入 `ContentIdentifier`(无 `exiftool` 时可用) | + +命令执行时会打印重建流程的关键日志,包括网格合成、ID 校正、临时文件清理等,出现异常时可直接复制日志用于排查。 + +### 作为库调用 ```python -from pyheic_struct import ( - HEICFile, - HEICBuilder, - convert_samsung_motion_photo, +from pathlib import Path + +from pyheic_struct import HEICFile, HEICBuilder, 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 结构 -heic = HEICFile("samsung.heic") -print(heic.get_primary_item_id()) - -# 执行三星 -> 苹果动态照片转换 -heic_path, mov_path = convert_samsung_motion_photo("samsung.heic") -print("新 HEIC:", heic_path) -print("新 MOV:", mov_path) +# 3. 进一步加工:加载新的 HEIC 并注入其他元数据 +rebuilt = HEICFile(str(heic_path)) +rebuilt.set_content_identifier("INTERNAL-CONTENT-ID") +HEICBuilder(rebuilt).write("converted/heic/with_custom_id.HEIC") ``` -## 开发调试 +### 独立转换脚本 + +仓库提供 `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 -python -m pyheic_struct path/to/samsung.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 — 可自由用于商业及非商业用途。贡献代码即视为接受该许可条款。 diff --git a/apple.MOV b/apple.MOV new file mode 100644 index 0000000..68f3ab4 Binary files /dev/null and b/apple.MOV differ diff --git a/output/live/samsung_apple_compatible.HEIC b/output/live/samsung_apple_compatible.HEIC new file mode 100644 index 0000000..ce427f7 Binary files /dev/null and b/output/live/samsung_apple_compatible.HEIC differ diff --git a/output/live/samsung_apple_compatible.MOV b/output/live/samsung_apple_compatible.MOV new file mode 100644 index 0000000..b335b1c Binary files /dev/null and b/output/live/samsung_apple_compatible.MOV differ diff --git a/pyheic_struct/__init__.py b/pyheic_struct/__init__.py index 7313080..e7e3d36 100644 --- a/pyheic_struct/__init__.py +++ b/pyheic_struct/__init__.py @@ -3,9 +3,10 @@ from importlib import metadata 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_types import ItemInfoEntryBox, ItemInfoBox, ItemLocationBox +from .targets import AppleTargetAdapter, TargetAdapter try: # pragma: no cover - best effort metadata __version__ = metadata.version("pyheic-struct") @@ -13,6 +14,9 @@ except metadata.PackageNotFoundError: # pragma: no cover - local checkout __version__ = "0.0.0" __all__ = [ + "AppleTargetAdapter", + "TargetAdapter", + "convert_motion_photo", "convert_samsung_motion_photo", "HEICBuilder", "HEICFile", diff --git a/pyheic_struct/__pycache__/__init__.cpython-314.pyc b/pyheic_struct/__pycache__/__init__.cpython-314.pyc index 2b1d606..0086f19 100644 Binary files a/pyheic_struct/__pycache__/__init__.cpython-314.pyc and b/pyheic_struct/__pycache__/__init__.cpython-314.pyc differ diff --git a/pyheic_struct/__pycache__/converter.cpython-314.pyc b/pyheic_struct/__pycache__/converter.cpython-314.pyc index 8707e48..59b137c 100644 Binary files a/pyheic_struct/__pycache__/converter.cpython-314.pyc and b/pyheic_struct/__pycache__/converter.cpython-314.pyc differ diff --git a/pyheic_struct/__pycache__/heic_file.cpython-314.pyc b/pyheic_struct/__pycache__/heic_file.cpython-314.pyc index dc307a1..0a72f7f 100644 Binary files a/pyheic_struct/__pycache__/heic_file.cpython-314.pyc and b/pyheic_struct/__pycache__/heic_file.cpython-314.pyc differ diff --git a/pyheic_struct/converter.py b/pyheic_struct/converter.py index 6744096..fde9536 100644 --- a/pyheic_struct/converter.py +++ b/pyheic_struct/converter.py @@ -1,113 +1,135 @@ -"""High-level conversion helpers for Samsung motion photos.""" +"""High-level conversion helpers for Motion Photos.""" from __future__ import annotations import os -import subprocess import uuid from pathlib import Path -from typing import Optional +from typing import Optional, Union import pillow_heif from .builder import HEICBuilder -from .handlers.base_handler import VendorHandler -from .handlers.samsung_handler import SamsungHandler +from .handlers import VendorHandler, get_handler_by_name, resolve_handler 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, *, + vendor_hint: HandlerHint = None, + target_adapter: TargetAdapter | None = None, 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]]: """ - Convert a Samsung motion photo into an Apple-compatible HEIC + MOV pair. + Convert a Motion Photo HEIC into a target-compatible HEIC(+MOV) 组合。 Parameters ---------- source: - Path to the Samsung `.heic` file to convert. + 输入 HEIC 文件路径。 + vendor_hint: + 可选的厂商提示,支持 handler 名称(如 ``"samsung"``)、``VendorHandler`` 实例 + 或 ``"auto"``(默认行为)。 + target_adapter: + 目标生态适配器。默认使用 :class:`AppleTargetAdapter`。 output_still: - Optional output path for the converted HEIC. Defaults to - ``_apple_compatible.HEIC``. + 可选的输出 HEIC 路径,默认 ``_apple_compatible.HEIC``。 output_video: - Optional output path for the extracted MOV. Defaults to - ``_apple_compatible.MOV``. + 可选的输出 MOV 路径,默认 ``_apple_compatible.MOV``。 inject_content_id_into_mov: - When True (default), ``exiftool`` is invoked to set the - ``QuickTime:ContentIdentifier`` of the generated MOV file. If - ``exiftool`` is not available the MOV is still created without the tag. + 是否在 MOV 文件中写入 ``ContentIdentifier``。 Returns ------- (Path, Optional[Path]) - Tuple containing the path to the new HEIC file and the MOV path (if one - was produced). + 返回生成的 HEIC 路径以及 MOV 路径(若生成)。 """ + target_adapter = target_adapter or AppleTargetAdapter() + source_path = Path(source) 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("") 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") - 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)) + handler = _select_handler(original_heic_file, vendor_hint, target_adapter) - # Extract video data if present - 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() + print(f"Detected handler: {handler.name}") - # Rebuild the primary image - print("Reconstructing grid image from Samsung file...") - pil_image = original_heic_file.reconstruct_primary_image() - if not pil_image: + video_data = handler.extract_motion_video(original_heic_file) + if video_data: + print("Embedded motion photo data detected.") + 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.") new_content_id = str(uuid.uuid4()).upper() print(f"Generated ContentIdentifier: {new_content_id}") - # Save MOV if video data exists + mov_path: Optional[Path] if video_data: - print(f"Saving extracted video data to {mov_path}...") - mov_path.write_bytes(video_data) - - if inject_content_id_into_mov: - try: - print("Attempting to inject ContentIdentifier into .MOV file (requires exiftool)...") - subprocess.run( - [ - "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}") + print(f"Saving extracted video data to {mov_output_path}...") + mov_output_path.write_bytes(video_data) + target_adapter.post_process_mov( + mov_output_path, + new_content_id, + inject_content_id_into_mov, + ) + mov_path = mov_output_path else: 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: 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 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)) - if flat_heic_file._ftyp_box: - print("Modifying 'ftyp' box to be Apple compatible (heic, MiHB, MiHE...)...") - apple_ftyp_raw_data = b"heic\x00\x00\x00\x00mif1MiHBMiHEMiPrmiafheictmap" - flat_heic_file._ftyp_box.raw_data = apple_ftyp_raw_data - flat_heic_file._ftyp_box.size = len(apple_ftyp_raw_data) + 8 - else: - raise RuntimeError("Temporary HEIC file has no 'ftyp' box.") + handler.prepare_flat_heic( + original_heic=original_heic_file, + flat_heic=flat_heic_file, + content_id=new_content_id, + target_adapter=target_adapter, + ) - print("Checking for shifted IDs in temporary file (V17 Full Fix)...") - 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.") + target_adapter.apply_to_flat_heic(flat_heic_file, new_content_id) print("Rebuilding flat HEIC with new metadata...") builder = HEICBuilder(flat_heic_file) @@ -210,3 +175,22 @@ def convert_samsung_motion_photo( print("\n" + "=" * 40 + "\n") 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, + ) diff --git a/pyheic_struct/handlers/__init__.py b/pyheic_struct/handlers/__init__.py index 6d32cc7..7d7aeeb 100644 --- a/pyheic_struct/handlers/__init__.py +++ b/pyheic_struct/handlers/__init__.py @@ -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 .base_handler import VendorHandler 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", +] diff --git a/pyheic_struct/handlers/__pycache__/__init__.cpython-314.pyc b/pyheic_struct/handlers/__pycache__/__init__.cpython-314.pyc index 82bce1f..51ae87e 100644 Binary files a/pyheic_struct/handlers/__pycache__/__init__.cpython-314.pyc and b/pyheic_struct/handlers/__pycache__/__init__.cpython-314.pyc differ diff --git a/pyheic_struct/handlers/__pycache__/apple_handler.cpython-314.pyc b/pyheic_struct/handlers/__pycache__/apple_handler.cpython-314.pyc index c65136a..9381bc3 100644 Binary files a/pyheic_struct/handlers/__pycache__/apple_handler.cpython-314.pyc and b/pyheic_struct/handlers/__pycache__/apple_handler.cpython-314.pyc differ diff --git a/pyheic_struct/handlers/__pycache__/base_handler.cpython-314.pyc b/pyheic_struct/handlers/__pycache__/base_handler.cpython-314.pyc index e2103e2..9f553fd 100644 Binary files a/pyheic_struct/handlers/__pycache__/base_handler.cpython-314.pyc and b/pyheic_struct/handlers/__pycache__/base_handler.cpython-314.pyc differ diff --git a/pyheic_struct/handlers/__pycache__/samsung_handler.cpython-314.pyc b/pyheic_struct/handlers/__pycache__/samsung_handler.cpython-314.pyc index 79e21b8..b64a9cb 100644 Binary files a/pyheic_struct/handlers/__pycache__/samsung_handler.cpython-314.pyc and b/pyheic_struct/handlers/__pycache__/samsung_handler.cpython-314.pyc differ diff --git a/pyheic_struct/handlers/apple_handler.py b/pyheic_struct/handlers/apple_handler.py index 81e00b9..cd4b54c 100644 --- a/pyheic_struct/handlers/apple_handler.py +++ b/pyheic_struct/handlers/apple_handler.py @@ -1,14 +1,35 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + from .base_handler import VendorHandler +if TYPE_CHECKING: + from ..heic_file import HEICFile + from ..targets.base import TargetAdapter + + class AppleHandler(VendorHandler): """ Handles Apple-specific HEIC features. + Apple's motion photos (Live Photos) are stored in a separate MOV file, 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. - def find_motion_photo_offset(self, heic_file) -> int | None: - # We could add logic here later to find the ContentIdentifier + + name = "apple" + priority = 50 + + @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.") - return None \ No newline at end of file + return None + + def supports_target(self, target_adapter: TargetAdapter) -> bool: + # Apple 文件可作为源,被转换到任意目标。目前默认支持。 + return True diff --git a/pyheic_struct/handlers/base_handler.py b/pyheic_struct/handlers/base_handler.py index 0be2c7d..0e84c4e 100644 --- a/pyheic_struct/handlers/base_handler.py +++ b/pyheic_struct/handlers/base_handler.py @@ -1,17 +1,80 @@ from __future__ import annotations -from typing import TYPE_CHECKING + +from abc import ABC +from typing import TYPE_CHECKING, Optional if TYPE_CHECKING: 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: """ - Tries to find the byte offset of the embedded motion photo video. - Returns the offset if found, otherwise None. + 返回嵌入式 Motion Photo 视频数据的偏移量。 + 默认实现返回 None,表明不存在嵌入视频。 """ - # Default behavior: no motion photo found. 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 diff --git a/pyheic_struct/handlers/samsung_handler.py b/pyheic_struct/handlers/samsung_handler.py index 3614d7c..657a3eb 100644 --- a/pyheic_struct/handlers/samsung_handler.py +++ b/pyheic_struct/handlers/samsung_handler.py @@ -1,20 +1,105 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + 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): """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. """ - # --- START FIX --- - # 使用 heic_file.find_box 进行递归搜索,因为 mpvd 嵌套在 meta 中 - mpvd_box = heic_file.find_box('mpvd') + mpvd_box = heic_file.find_box("mpvd") if mpvd_box: print(f"Samsung 'mpvd' box found at offset {mpvd_box.offset}") # 视频数据在 8 字节头部之后开始 return mpvd_box.offset + 8 - # --- END FIX --- - + print("Samsung HEIC detected, but no 'mpvd' box found.") - return None \ No newline at end of file + 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.") diff --git a/pyheic_struct/heic_file.py b/pyheic_struct/heic_file.py index b4cdf9c..b96c813 100644 --- a/pyheic_struct/heic_file.py +++ b/pyheic_struct/heic_file.py @@ -1,7 +1,6 @@ import os import math import pillow_heif -from io import BytesIO from dataclasses import dataclass from PIL import Image @@ -12,9 +11,7 @@ from .heic_types import ( ImageSpatialExtentsBox, ItemReferenceBox, ItemInfoEntryBox, _read_int ) -from .handlers.base_handler import VendorHandler -from .handlers.apple_handler import AppleHandler -from .handlers.samsung_handler import SamsungHandler +from .handlers import VendorHandler, resolve_handler @dataclass class Grid: @@ -81,6 +78,32 @@ class HEICFile: return box 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: """Internal helper used by `remove_box_by_type`.""" for i, box in enumerate(box_list): @@ -344,12 +367,7 @@ class HEICFile: for entry in self._iinf_box.entries: print(f" - {entry}") def _detect_vendor(self): - if self._ftyp_box: - 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() + self.handler = resolve_handler(self) def get_item_data(self, item_id: int) -> bytes | None: if not self._iloc_box: @@ -389,14 +407,12 @@ class HEICFile: return b''.join(data_chunks) def get_motion_photo_data(self) -> bytes | None: - if not self.handler: self._detect_vendor() - offset = self.handler.find_motion_photo_offset(self) - if offset is not None: - print("Found motion photo data at offset.") - with open(self.filepath, 'rb') as f: - f.seek(offset) - return f.read() - return None + if not self.handler: + self._detect_vendor() + data = self.handler.extract_motion_video(self) + if data is not None: + print("Found motion photo data via vendor handler.") + return data def get_thumbnail_data(self) -> bytes | None: print("Attempting to extract thumbnail data...") diff --git a/pyheic_struct/targets/__init__.py b/pyheic_struct/targets/__init__.py new file mode 100644 index 0000000..35d5f08 --- /dev/null +++ b/pyheic_struct/targets/__init__.py @@ -0,0 +1,6 @@ +"""Target adapters for rebuilding HEIC/Motion Photo outputs.""" + +from .apple import AppleTargetAdapter +from .base import TargetAdapter + +__all__ = ["AppleTargetAdapter", "TargetAdapter"] diff --git a/pyheic_struct/targets/__pycache__/__init__.cpython-314.pyc b/pyheic_struct/targets/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..c9a6cd2 Binary files /dev/null and b/pyheic_struct/targets/__pycache__/__init__.cpython-314.pyc differ diff --git a/pyheic_struct/targets/__pycache__/apple.cpython-314.pyc b/pyheic_struct/targets/__pycache__/apple.cpython-314.pyc new file mode 100644 index 0000000..5db7d72 Binary files /dev/null and b/pyheic_struct/targets/__pycache__/apple.cpython-314.pyc differ diff --git a/pyheic_struct/targets/__pycache__/base.cpython-314.pyc b/pyheic_struct/targets/__pycache__/base.cpython-314.pyc new file mode 100644 index 0000000..1de3fa9 Binary files /dev/null and b/pyheic_struct/targets/__pycache__/base.cpython-314.pyc differ diff --git a/pyheic_struct/targets/apple.py b/pyheic_struct/targets/apple.py new file mode 100644 index 0000000..c0360fc --- /dev/null +++ b/pyheic_struct/targets/apple.py @@ -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}") diff --git a/pyheic_struct/targets/base.py b/pyheic_struct/targets/base.py new file mode 100644 index 0000000..a527dba --- /dev/null +++ b/pyheic_struct/targets/base.py @@ -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 diff --git a/scripts/samsung_live_photo.py b/scripts/samsung_live_photo.py new file mode 100644 index 0000000..4363e43 --- /dev/null +++ b/scripts/samsung_live_photo.py @@ -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())