diff --git a/.DS_Store b/.DS_Store index bcef835..dd413af 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/output/live/samsung_apple_compatible.HEIC b/output/live/samsung_apple_compatible.HEIC deleted file mode 100644 index ce427f7..0000000 Binary files a/output/live/samsung_apple_compatible.HEIC and /dev/null differ diff --git a/output/live/samsung_apple_compatible.MOV b/output/live/samsung_apple_compatible.MOV deleted file mode 100644 index b335b1c..0000000 Binary files a/output/live/samsung_apple_compatible.MOV and /dev/null differ diff --git a/pyheic_struct.egg-info/PKG-INFO b/pyheic_struct.egg-info/PKG-INFO new file mode 100644 index 0000000..d3f4828 --- /dev/null +++ b/pyheic_struct.egg-info/PKG-INFO @@ -0,0 +1,210 @@ +Metadata-Version: 2.4 +Name: pyheic-struct +Version: 0.1.0 +Summary: Low-level utilities for parsing and rebuilding HEIC/HEIF files, with Samsung-to-Apple motion photo conversion helpers. +Author-email: Nymiro Yi +License: MIT +Requires-Python: >=3.10 +Description-Content-Type: text/markdown +Requires-Dist: Pillow>=10.0.0 +Requires-Dist: pillow-heif>=0.15.0 +Provides-Extra: full +Requires-Dist: exiftool-wrapper>=0.5.0; extra == "full" + +# pyheic-struct + +`pyheic-struct` 是一个专注于 HEIF/HEIC 结构化解析、诊断与重建的 Python 工具包。项目在三星动态照片(Motion Photo)与苹果生态兼容场景下积累了大量经验,既提供一键式的命令行转换,也暴露底层 API 便于集成到自定义流程中。 + +## 为什么选择 pyheic-struct? + +- **跨厂商兼容痛点**:三星 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 \ + --output-heic samsung_fixed.HEIC \ + --output-mov samsung_fixed.MOV +``` + +参数说明: + +| 参数 | 说明 | +| ---- | ---- | +| `source` | 必选,原始三星 HEIC 路径 | +| `--output-heic` | 可选,输出 HEIC 路径,默认 `_apple_compatible.HEIC` | +| `--output-mov` | 可选,输出 MOV 路径,默认 `_apple_compatible.MOV` | +| `--skip-mov-tag` | 跳过为 MOV 写入 `ContentIdentifier`(无 `exiftool` 时可用) | + +命令执行时会打印重建流程的关键日志,包括网格合成、ID 校正、临时文件清理等,出现异常时可直接复制日志用于排查。 + +### 作为库调用 + +```python +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"), +) + +# 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 inspect_heic.py samsung_apple_compatible.HEIC +``` + +输出包含 `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/pyheic_struct.egg-info/SOURCES.txt b/pyheic_struct.egg-info/SOURCES.txt new file mode 100644 index 0000000..de13a13 --- /dev/null +++ b/pyheic_struct.egg-info/SOURCES.txt @@ -0,0 +1,24 @@ +README.md +pyproject.toml +pyheic_struct/__init__.py +pyheic_struct/__main__.py +pyheic_struct/base.py +pyheic_struct/builder.py +pyheic_struct/cli.py +pyheic_struct/converter.py +pyheic_struct/heic_file.py +pyheic_struct/heic_types.py +pyheic_struct/parser.py +pyheic_struct.egg-info/PKG-INFO +pyheic_struct.egg-info/SOURCES.txt +pyheic_struct.egg-info/dependency_links.txt +pyheic_struct.egg-info/entry_points.txt +pyheic_struct.egg-info/requires.txt +pyheic_struct.egg-info/top_level.txt +pyheic_struct/handlers/__init__.py +pyheic_struct/handlers/apple_handler.py +pyheic_struct/handlers/base_handler.py +pyheic_struct/handlers/samsung_handler.py +pyheic_struct/targets/__init__.py +pyheic_struct/targets/apple.py +pyheic_struct/targets/base.py \ No newline at end of file diff --git a/pyheic_struct.egg-info/dependency_links.txt b/pyheic_struct.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/pyheic_struct.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/pyheic_struct.egg-info/entry_points.txt b/pyheic_struct.egg-info/entry_points.txt new file mode 100644 index 0000000..250778e --- /dev/null +++ b/pyheic_struct.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[console_scripts] +pyheic-struct = pyheic_struct.cli:main diff --git a/pyheic_struct.egg-info/requires.txt b/pyheic_struct.egg-info/requires.txt new file mode 100644 index 0000000..bd0cc9c --- /dev/null +++ b/pyheic_struct.egg-info/requires.txt @@ -0,0 +1,5 @@ +Pillow>=10.0.0 +pillow-heif>=0.15.0 + +[full] +exiftool-wrapper>=0.5.0 diff --git a/pyheic_struct.egg-info/top_level.txt b/pyheic_struct.egg-info/top_level.txt new file mode 100644 index 0000000..1cafae0 --- /dev/null +++ b/pyheic_struct.egg-info/top_level.txt @@ -0,0 +1 @@ +pyheic_struct diff --git a/pyheic_struct/__pycache__/converter.cpython-314.pyc b/pyheic_struct/__pycache__/converter.cpython-314.pyc index 59b137c..e38358c 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 0a72f7f..9ef530e 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 fde9536..ab25ec9 100644 --- a/pyheic_struct/converter.py +++ b/pyheic_struct/converter.py @@ -115,7 +115,9 @@ def convert_motion_photo( raise RuntimeError("Failed to reconstruct primary image using pillow-heif.") new_content_id = str(uuid.uuid4()).upper() + photo_identifier = str(uuid.uuid4()).upper() print(f"Generated ContentIdentifier: {new_content_id}") + print(f"Generated PhotoIdentifier: {photo_identifier}") mov_path: Optional[Path] if video_data: @@ -157,7 +159,11 @@ def convert_motion_photo( target_adapter=target_adapter, ) - target_adapter.apply_to_flat_heic(flat_heic_file, new_content_id) + target_adapter.apply_to_flat_heic( + flat_heic_file, + new_content_id, + photo_identifier, + ) print("Rebuilding flat HEIC with new metadata...") builder = HEICBuilder(flat_heic_file) diff --git a/pyheic_struct/heic_file.py b/pyheic_struct/heic_file.py index b96c813..cd6e146 100644 --- a/pyheic_struct/heic_file.py +++ b/pyheic_struct/heic_file.py @@ -2,14 +2,21 @@ import os import math import pillow_heif from dataclasses import dataclass +from typing import Optional from PIL import Image from .base import Box from .parser import parse_boxes from .heic_types import ( - ItemLocationBox, PrimaryItemBox, ItemInfoBox, ItemPropertiesBox, - ImageSpatialExtentsBox, ItemReferenceBox, ItemInfoEntryBox, - _read_int + ItemLocationBox, + PrimaryItemBox, + ItemInfoBox, + ItemPropertiesBox, + ImageSpatialExtentsBox, + ItemReferenceBox, + ItemInfoEntryBox, + ItemLocation, + _read_int, ) from .handlers import VendorHandler, resolve_handler @@ -286,6 +293,177 @@ class HEICFile: print(f"Error: Logic failed to find 'infe' box for target ID {target_id} even after check.") return False + @staticmethod + def _normalize_item_id(raw_item_id: int) -> int: + """ + Samsung/other vendors may left-shift item IDs by 16 bits inside `iinf`. + Normalize such IDs back to their canonical value. + """ + return raw_item_id >> 16 if raw_item_id > 0xFFFF else raw_item_id + + def _find_item_id_by_type(self, item_type: str) -> Optional[int]: + """Return the canonical item ID for the first entry whose type matches.""" + if not self._iinf_box: + return None + + normalized = item_type.lower() + for entry in self._iinf_box.entries: + entry_type = getattr(entry, "type", "") or "" + if entry_type.lower() == normalized: + return self._normalize_item_id(entry.item_id) + return None + + def _get_item_location(self, item_id: int) -> Optional[ItemLocation]: + """Fetch the `iloc` entry describing where the item lives inside the file.""" + if not self._iloc_box: + return None + for loc in self._iloc_box.locations: + if loc.item_id == item_id: + return loc + return None + + def _read_item_bytes(self, item_id: int) -> bytes: + """ + Read raw bytes for an item that is stored directly inside `mdat`. + Currently limited to single-extent entries using construction method 0. + """ + mdat_box = self.get_mdat_box() + if not mdat_box: + raise RuntimeError("Cannot read item data: 'mdat' box not available.") + + loc = self._get_item_location(item_id) + if not loc or not loc.extents: + raise RuntimeError(f"Cannot locate item data for ID {item_id}.") + if loc.construction_method not in (0, None): + raise NotImplementedError( + "Reading items stored outside 'mdat' is not implemented." + ) + if len(loc.extents) != 1: + raise NotImplementedError("Only single-extent items are supported.") + + offset, length = loc.extents[0] + mdat_data_start = mdat_box.offset + 8 + relative_start = offset - mdat_data_start + if relative_start < 0 or relative_start + length > len(mdat_box.raw_data): + raise RuntimeError("Calculated item bounds fall outside the 'mdat' payload.") + + return bytes(mdat_box.raw_data[relative_start : relative_start + length]) + + def _write_item_bytes(self, item_id: int, new_payload: bytes) -> None: + """ + Replace the bytes of an item stored inside `mdat`, shifting subsequent data + and updating `iloc` bookkeeping as needed. + """ + mdat_box = self.get_mdat_box() + if not mdat_box: + raise RuntimeError("Cannot write item data: 'mdat' box not available.") + + loc = self._get_item_location(item_id) + if not loc or not loc.extents: + raise RuntimeError(f"Cannot locate item data for ID {item_id}.") + if loc.construction_method not in (0, None): + raise NotImplementedError( + "Writing items stored outside 'mdat' is not implemented." + ) + if len(loc.extents) != 1: + raise NotImplementedError("Only single-extent items are supported.") + + offset, old_length = loc.extents[0] + base_offset = loc.base_offset + old_end = offset + old_length + delta = len(new_payload) - old_length + + mdat_data_start = mdat_box.offset + 8 + relative_start = offset - mdat_data_start + relative_end = relative_start + old_length + + if relative_start < 0 or relative_end > len(mdat_box.raw_data): + raise RuntimeError("Calculated item bounds fall outside the 'mdat' payload.") + + updated_payload = bytearray(mdat_box.raw_data) + updated_payload[relative_start:relative_end] = new_payload + mdat_box.raw_data = bytes(updated_payload) + mdat_box.size = 8 + len(mdat_box.raw_data) + + loc.extents = [(offset, len(new_payload))] + loc.raw_extents = [(offset - base_offset, len(new_payload))] + + if delta == 0: + return + + # Shift subsequent mdat-backed items so their offsets stay accurate. + for other in self._iloc_box.locations if self._iloc_box else []: + if other is loc or other.construction_method not in (0, None): + continue + + original_base = other.base_offset + adjusted_base = original_base + if original_base >= old_end: + adjusted_base = original_base + delta + + new_extents = [] + new_raw_extents = [] + + for rel_offset, length in other.raw_extents: + absolute_offset = original_base + rel_offset + if absolute_offset >= old_end: + absolute_offset += delta + new_extents.append((absolute_offset, length)) + new_raw_extents.append((absolute_offset - adjusted_base, length)) + + other.base_offset = adjusted_base + other.extents = new_extents + other.raw_extents = new_raw_extents + + def set_exif_maker_note(self, maker_note_payload: bytes) -> None: + """ + Inject (or replace) the Apple MakerNote blob inside the Exif item. + """ + try: + import piexif + except ModuleNotFoundError as exc: # pragma: no cover - defensive + raise RuntimeError( + "piexif is required to manipulate Exif metadata. " + "Install the optional dependency via 'pip install piexif'." + ) from exc + + exif_item_id = self._find_item_id_by_type("Exif") + if exif_item_id is None: + raise RuntimeError( + "Temporary HEIC file does not contain an Exif item; " + "cannot embed Apple MakerNote metadata." + ) + + exif_bytes = self._read_item_bytes(exif_item_id) + exif_prefix = b"" + if exif_bytes.startswith(b"\x00\x00\x00\x06Exif"): + exif_prefix = exif_bytes[:4] + exif_bytes = exif_bytes[4:] + + load_attempts = [ + bytearray(exif_bytes), + ] + if not exif_bytes.startswith((b"Exif", b"II", b"MM")): + load_attempts.append(bytearray(b"Exif\x00\x00" + exif_bytes)) + + exif_dict = None + for candidate in load_attempts: + try: + exif_dict = piexif.load(candidate) + break + except Exception: + continue + + if exif_dict is None: + raise RuntimeError("Failed to parse existing Exif payload for MakerNote injection.") + exif_section = exif_dict.setdefault("Exif", {}) + exif_section[piexif.ExifIFD.MakerNote] = maker_note_payload + + new_exif_bytes = piexif.dump(exif_dict) + if exif_prefix: + new_exif_bytes = exif_prefix + new_exif_bytes + self._write_item_bytes(exif_item_id, new_exif_bytes) + def reconstruct_primary_image(self) -> Image.Image | None: """Reconstruct the primary image using pillow-heif (handles grid tiles).""" try: diff --git a/pyheic_struct/targets/__pycache__/apple.cpython-314.pyc b/pyheic_struct/targets/__pycache__/apple.cpython-314.pyc index 5db7d72..a312c5d 100644 Binary files a/pyheic_struct/targets/__pycache__/apple.cpython-314.pyc 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 index 1de3fa9..51bb309 100644 Binary files a/pyheic_struct/targets/__pycache__/base.cpython-314.pyc 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 index c0360fc..2009c99 100644 --- a/pyheic_struct/targets/apple.py +++ b/pyheic_struct/targets/apple.py @@ -1,11 +1,94 @@ from __future__ import annotations +import struct import subprocess from pathlib import Path from .base import TargetAdapter +def _build_apple_maker_note( + content_id: str, + photo_id: str, + *, + capture_request_id: str | None = None, + live_photo_video_index: int = 0, +) -> bytes: + """ + Construct a minimal Apple MakerNote payload containing Live Photo identifiers. + """ + + def _encode_ascii(value: str) -> bytes: + return value.encode("ascii") + b"\x00" + + normalized_content = content_id.upper() + normalized_photo = photo_id.upper() + normalized_request = ( + (capture_request_id or photo_id).upper() + ) + + entries: list[dict[str, object]] = [ + {"tag": 0x0001, "type": 9, "count": 1, "value": 16}, # MakerNoteVersion + { + "tag": 0x0011, + "type": 2, + "count": len(normalized_content) + 1, + "data": _encode_ascii(normalized_content), + }, # ContentIdentifier + {"tag": 0x0014, "type": 9, "count": 1, "value": 12}, # ImageCaptureType (Live Photo) + { + "tag": 0x0017, + "type": 16, + "count": 1, + "data": live_photo_video_index.to_bytes(8, "big", signed=False), + }, # LivePhotoVideoIndex + {"tag": 0x001F, "type": 9, "count": 1, "value": 0}, # PhotosAppFeatureFlags + { + "tag": 0x0020, + "type": 2, + "count": len(normalized_request) + 1, + "data": _encode_ascii(normalized_request), + }, # ImageCaptureRequestID + { + "tag": 0x002B, + "type": 2, + "count": len(normalized_photo) + 1, + "data": _encode_ascii(normalized_photo), + }, # PhotoIdentifier + ] + + header = bytearray(b"Apple iOS\x00\x00\x01MM") + header.extend(struct.pack(">H", len(entries))) + + # Compute the offset where variable-length data begins. + data_offset_base = len(header) + len(entries) * 12 + 4 + variable_data = bytearray() + payload = bytearray(header) + + for entry in entries: + tag = int(entry["tag"]) + entry_type = int(entry["type"]) + count = int(entry["count"]) + data_bytes = entry.get("data") + + if data_bytes is not None: + data_bytes = bytes(data_bytes) + value_offset = data_offset_base + len(variable_data) + payload.extend(struct.pack(">HHII", tag, entry_type, count, value_offset)) + variable_data.extend(data_bytes) + + # Maintain word alignment for TIFF payloads. + if len(data_bytes) % 2: + variable_data.extend(b"\x00") + else: + value = int(entry["value"]) & 0xFFFFFFFF + payload.extend(struct.pack(">HHII", tag, entry_type, count, value)) + + payload.extend(struct.pack(">I", 0)) # next IFD offset + payload.extend(variable_data) + return bytes(payload) + + class AppleTargetAdapter(TargetAdapter): """ 将平面 HEIC 调整为苹果兼容格式,并在 MOV 中写入 ContentIdentifier。 @@ -15,7 +98,7 @@ class AppleTargetAdapter(TargetAdapter): APPLE_BRAND_PAYLOAD = b"heic\x00\x00\x00\x00mif1MiHBMiHEMiPrmiafheictmap" - def apply_to_flat_heic(self, flat_heic, content_id: str) -> None: + def apply_to_flat_heic(self, flat_heic, content_id: str, photo_id: str | None = None) -> None: if not flat_heic._ftyp_box: raise RuntimeError("Temporary HEIC file has no 'ftyp' box.") @@ -28,6 +111,15 @@ class AppleTargetAdapter(TargetAdapter): else: raise RuntimeError("Failed to set ContentIdentifier in flat HEIC.") + resolved_photo_id = photo_id or content_id + maker_note_payload = _build_apple_maker_note( + content_id, + resolved_photo_id, + capture_request_id=resolved_photo_id, + ) + flat_heic.set_exif_maker_note(maker_note_payload) + print("Embedded Apple MakerNote metadata for Live Photo pairing.") + 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 diff --git a/pyheic_struct/targets/base.py b/pyheic_struct/targets/base.py index a527dba..a8cf00e 100644 --- a/pyheic_struct/targets/base.py +++ b/pyheic_struct/targets/base.py @@ -15,7 +15,12 @@ class TargetAdapter(ABC): name: str = "generic" - def apply_to_flat_heic(self, flat_heic: HEICFile, content_id: str) -> None: + def apply_to_flat_heic( + self, + flat_heic: HEICFile, + content_id: str, + photo_id: str | None = None, + ) -> None: """ 在最终写出前,针对目标生态对 HEIC 做品牌/元数据调整。 """ diff --git a/pyproject.toml b/pyproject.toml index 51a546d..fea9c6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ requires-python = ">=3.10" dependencies = [ "Pillow>=10.0.0", "pillow-heif>=0.15.0", + "piexif>=1.1.3", ] [project.optional-dependencies] diff --git a/samsung_apple_compatible.HEIC b/samsung_apple_compatible.HEIC index 644d163..7aa333b 100644 Binary files a/samsung_apple_compatible.HEIC and b/samsung_apple_compatible.HEIC differ diff --git a/samsung_apple_compatible.MOV b/samsung_apple_compatible.MOV index f4eaa1a..e3a25b6 100644 Binary files a/samsung_apple_compatible.MOV and b/samsung_apple_compatible.MOV differ