diff --git a/.DS_Store b/.DS_Store index 2f17559..0dc6ccb 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/__pycache__/base.cpython-314.pyc b/__pycache__/base.cpython-314.pyc index a49bc2a..7fa1462 100644 Binary files a/__pycache__/base.cpython-314.pyc and b/__pycache__/base.cpython-314.pyc differ diff --git a/__pycache__/builder.cpython-314.pyc b/__pycache__/builder.cpython-314.pyc new file mode 100644 index 0000000..3110ba9 Binary files /dev/null and b/__pycache__/builder.cpython-314.pyc differ diff --git a/__pycache__/heic_file.cpython-314.pyc b/__pycache__/heic_file.cpython-314.pyc index fd440c7..858a4a0 100644 Binary files a/__pycache__/heic_file.cpython-314.pyc and b/__pycache__/heic_file.cpython-314.pyc differ diff --git a/__pycache__/heic_types.cpython-314.pyc b/__pycache__/heic_types.cpython-314.pyc index 42b7d47..c80a68d 100644 Binary files a/__pycache__/heic_types.cpython-314.pyc and b/__pycache__/heic_types.cpython-314.pyc differ diff --git a/__pycache__/parser.cpython-314.pyc b/__pycache__/parser.cpython-314.pyc index d0a56bc..ce7ec7d 100644 Binary files a/__pycache__/parser.cpython-314.pyc and b/__pycache__/parser.cpython-314.pyc differ diff --git a/apple_reconstructed.jpg b/apple_reconstructed.jpg deleted file mode 100644 index 32a0062..0000000 Binary files a/apple_reconstructed.jpg and /dev/null differ diff --git a/apple_reconstructed.png b/apple_reconstructed.png deleted file mode 100644 index e77ecc5..0000000 Binary files a/apple_reconstructed.png and /dev/null differ diff --git a/base.py b/base.py index ce2d2e7..4524011 100644 --- a/base.py +++ b/base.py @@ -1,19 +1,142 @@ +# pyheic_struct/base.py + from typing import List +import struct +from io import BytesIO class Box: """ - Represents a generic ISOBMFF box. This is a simple data container. + Represents a generic ISOBMFF box. + Now supports finding children and building (writing) data back. """ def __init__(self, size: int, box_type: str, offset: int, raw_data: bytes): self.size = size self.type = box_type self.offset = offset self.raw_data = raw_data - self.children: List['Box'] = [] # Initially empty + self.children: List['Box'] = [] + self.is_full_box = False # 默认不是 FullBox def __repr__(self) -> str: return f"" def _post_parse_initialization(self): """Called by the parser after children have been assigned.""" - pass \ No newline at end of file + pass + + def build_header(self, content_size: int) -> bytes: + """Builds the 8-byte (or 16-byte) box header.""" + header = BytesIO() + + # FullBox (version/flags) 数据属于 'content', 而不是 'header' + full_box_header_size = 0 + if self.is_full_box: + full_box_header_size = 4 + + final_size = 8 + content_size + + if final_size > 4294967295: + # 64-bit 'largesize' + header.write(struct.pack('>I', 1)) + header.write(self.type.encode('ascii')) + header.write(struct.pack('>Q', final_size + 8)) # 16-byte header + else: + # 32-bit standard size + header.write(struct.pack('>I', final_size)) + header.write(self.type.encode('ascii')) + + return header.getvalue() + + def build_content(self) -> bytes: + """ + 序列化此盒的 *内容* (不包括头部)。 + 对于一个通用的容器盒 (container boxes),它会递归构建所有子盒。 + """ + if not self.children: + # 对于一个没有子盒的简单数据盒 (如 ftyp, mdat, 或未解析的盒) + # 只需返回它持有的原始数据。 + return self.raw_data + + # 对于一个容器盒 (如 'meta', 'iprp', 'ipco') + # 递归构建每一个子盒 (完整的,包括头部) 并拼接它们 + content_stream = BytesIO() + for child in self.children: + child_data = child.build_box() + content_stream.write(child_data) + return content_stream.getvalue() + + def build_box(self) -> bytes: + """ + 构建完整的盒 (头部 + 内容),并返回其二进制数据。 + """ + # 1. "自底向上" 构建内容 + content_data = self.build_content() + + # 2. 构建头部 + header_data = self.build_header(len(content_data)) + + # 3. 更新 self.size 属性 (8字节标准头 + 内容) + self.size = len(header_data) + len(content_data) + + return header_data + content_data + + def find_box(self, box_type: str, recursive: bool = True) -> 'Box' | None: + """在子盒中查找指定类型的第一个盒子""" + for child in self.children: + if child.type == box_type: + return child + if recursive and child.children: + found = child.find_box(box_type, recursive=True) + if found: + return found + return None + +class FullBox(Box): + """ + FullBox 是一种特殊的 Box,它在内容开头包含 4 字节的 version 和 flags + """ + def __init__(self, size: int, box_type: str, offset: int, raw_data: bytes): + super().__init__(size, box_type, offset, raw_data) + self.is_full_box = True + self.version: int = 0 + self.flags: int = 0 + self._parse_full_box_header() + + def _parse_full_box_header(self): + """从 raw_data 中解析 version 和 flags""" + if len(self.raw_data) >= 4: + version_flags = struct.unpack('>I', self.raw_data[:4])[0] + self.version = (version_flags >> 24) & 0xFF + self.flags = version_flags & 0xFFFFFF + + def build_full_box_header(self) -> bytes: + """构建 4 字节的 version/flags 头部""" + version_flags = (self.version << 24) | self.flags + return struct.pack('>I', version_flags) + + def build_content(self) -> bytes: + """ + 序列化此 FullBox 的内容。 + 它首先写入 4 字节的 version/flags,然后 + 再写入子盒 (如果是容器) 或 version/flags 之后的 + 原始数据 (如果不是容器)。 + """ + content_stream = BytesIO() + + # 1. 写入 FullBox 特有的 4 字节头部 + content_stream.write(self.build_full_box_header()) + + # 2. 写入剩余的内容 + if not self.children: + # 对于一个没有子盒的简单 FullBox (如 'hdlr') + # 写入 self.raw_data 中 *跳过* 4 字节 v/f 之后的部分 + if len(self.raw_data) >= 4: + content_stream.write(self.raw_data[4:]) + else: + # 对于一个容器 FullBox (如 'meta') + # 递归构建每一个子盒 (与 Box.build_content 相同) + for child in self.children: + child_data = child.build_box() + content_stream.write(child_data) + + return content_stream.getvalue() \ No newline at end of file diff --git a/builder.py b/builder.py new file mode 100644 index 0000000..ab77f9a --- /dev/null +++ b/builder.py @@ -0,0 +1,139 @@ +# pyheic_struct/builder.py + +import struct +from heic_file import HEICFile +from heic_types import ItemLocationBox + +class HEICBuilder: + def __init__(self, heic_file: HEICFile): + self.heic_file = heic_file + self.mdat_box = heic_file.get_mdat_box() + self.meta_box = heic_file.find_box('meta') + self.iloc_box = heic_file._iloc_box + + if not self.mdat_box: + raise ValueError("Cannot build: 'mdat' box not found.") + if not self.meta_box: + raise ValueError("Cannot build: 'meta' box not found.") + if not self.iloc_box: + raise ValueError("Cannot build: 'iloc' box not found.") + + # 存储 'mdat' 的 *原始* 偏移量。这是至关重要的。 + self.original_mdat_offset = self.mdat_box.offset + + # 分离元数据和数据 + self.top_level_meta_boxes = [b for b in self.heic_file.boxes if b.type != 'mdat'] + + def _calculate_meta_offset_delta(self) -> int: + """(V14) 辅助函数:计算 'meta' 盒的偏移增量""" + ftyp_box = self.heic_file._ftyp_box + if not ftyp_box: + ftyp_box = next((b for b in self.top_level_meta_boxes if b.type == 'ftyp'), None) + + new_meta_offset = ftyp_box.size if ftyp_box else 0 + original_meta_offset = self.meta_box.offset + return new_meta_offset - original_meta_offset + + def _rebuild_iloc_with_delta(self, mdat_offset_delta: int): + """(V14) 辅助函数:使用给定的 delta 重建 iloc""" + print(f" ... Rebuilding 'iloc' using mdat_delta: {mdat_offset_delta}") + meta_offset_delta = self._calculate_meta_offset_delta() + try: + self.iloc_box.rebuild_iloc_content( + mdat_offset_delta=mdat_offset_delta, + original_mdat_offset=self.original_mdat_offset, + original_mdat_size=self.mdat_box.size, + meta_offset_delta=meta_offset_delta, + original_meta_offset=self.meta_box.offset, + original_meta_size=self.meta_box.size + ) + except Exception as e: + print(f"CRITICAL: Failed to rebuild 'iloc' box content: {e}") + raise # 抛出异常以停止构建 + + def _calculate_final_meta_size(self) -> int: + """(V14) 辅助函数:递归构建所有元数据盒以获取其最终大小""" + current_offset = 0 + for box in self.top_level_meta_boxes: + # build_box() 会递归计算所有子盒的最终大小 + final_box_data = box.build_box() + current_offset += len(final_box_data) + return current_offset + + def write(self, output_path: str): + """ + (V14) 使用一个多遍系统来解决 'iloc' 重建引起的循环依赖问题。 + """ + + # --- Pass 1: 初步布局计算 --- + # (在 'iloc' 重建 *之前* 计算一次大小) + print("--- Builder Pass 1: Preliminary Layout ---") + preliminary_meta_size = self._calculate_final_meta_size() + preliminary_mdat_offset = preliminary_meta_size + preliminary_mdat_delta = preliminary_mdat_offset - self.original_mdat_offset + + print(f"Original mdat offset: {self.original_mdat_offset}") + print(f"Preliminary mdat offset: {preliminary_mdat_offset}") + print(f"Preliminary offset delta: {preliminary_mdat_delta}") + + # --- Pass 2: 初步重建 'iloc' --- + # (使用 *初步的* delta 重建 iloc,这会改变 'iloc' 的大小) + print("\n--- Builder Pass 2: Preliminary 'iloc' Rebuild ---") + self._rebuild_iloc_with_delta(preliminary_mdat_delta) + print(" 'iloc' preliminary rebuild complete.") + + # --- Pass 3: 最终布局计算 --- + # (现在 'iloc' 有了最终大小,我们 *再次* 计算 meta 的总大小) + print("\n--- Builder Pass 3: Final Layout Calculation ---") + final_meta_size = self._calculate_final_meta_size() + final_mdat_offset = final_meta_size + final_mdat_delta = final_mdat_offset - self.original_mdat_offset + + print(f"Final meta size (actual): {final_meta_size}") + print(f"Final mdat offset (actual): {final_mdat_offset}") + print(f"Final offset delta (actual): {final_mdat_delta}") + + # --- Pass 4: 最终重建 'iloc' --- + # (使用 *最终的、正确*的 delta 再次重建 iloc) + if final_mdat_delta != preliminary_mdat_delta: + print("\n--- Builder Pass 4: Final 'iloc' Rebuild (Correcting Delta) ---") + self._rebuild_iloc_with_delta(final_mdat_delta) + print(" 'iloc' final rebuild complete.") + else: + print("\n--- Builder Pass 4: Skipped (Preliminary delta was correct) ---") + + # --- Pass 5: 写入文件 --- + print("\n--- Builder Pass 5: Rebuild & Write ---") + + # 只有在 'iloc' 重建成功后,我们才打开(并创建)输出文件 + with open(output_path, 'wb') as f: + + current_offset = 0 + for box in self.top_level_meta_boxes: + final_box_data = box.build_box() + f.write(final_box_data) + current_offset += len(final_box_data) + print(f"Wrote '{box.type}' (final size: {len(final_box_data)})") + + # 完整性检查 + if current_offset != final_mdat_offset: + print(f"WARNING: Final meta size ({current_offset}) does not match" + f" calculated mdat offset ({final_mdat_offset})!") + + # 写入 'mdat' 盒 (头部 + 数据) + mdat_data = self.mdat_box.raw_data + mdat_size = 8 + len(mdat_data) # 8-byte header + + print(f"Writing 'mdat' (final size: {mdat_size}) at offset {current_offset}...") + + if mdat_size > 4294967295: + f.write(struct.pack('>I', 1)) + f.write(b'mdat') + f.write(struct.pack('>Q', mdat_size)) + else: + f.write(struct.pack('>I', mdat_size)) + f.write(b'mdat') + + f.write(mdat_data) + + print(f"\nSuccessfully rebuilt file at: {output_path}") \ No newline at end of file diff --git a/handlers/.DS_Store b/handlers/.DS_Store new file mode 100644 index 0000000..b6363ba Binary files /dev/null and b/handlers/.DS_Store differ diff --git a/handlers/__pycache__/samsung_handler.cpython-314.pyc b/handlers/__pycache__/samsung_handler.cpython-314.pyc index fb9a4b7..d284b01 100644 Binary files a/handlers/__pycache__/samsung_handler.cpython-314.pyc and b/handlers/__pycache__/samsung_handler.cpython-314.pyc differ diff --git a/handlers/samsung_handler.py b/handlers/samsung_handler.py index 8583868..3614d7c 100644 --- a/handlers/samsung_handler.py +++ b/handlers/samsung_handler.py @@ -7,11 +7,14 @@ class SamsungHandler(VendorHandler): """ Searches for the 'mpvd' box which contains the video data. """ - for box in heic_file.boxes: - if box.type == 'mpvd': - print(f"Samsung 'mpvd' box found at offset {box.offset}") - # The video data starts right after the box header. - return box.offset + 8 + # --- START FIX --- + # 使用 heic_file.find_box 进行递归搜索,因为 mpvd 嵌套在 meta 中 + 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 diff --git a/heic_file.py b/heic_file.py index 7d7dcc6..f971ecc 100644 --- a/heic_file.py +++ b/heic_file.py @@ -12,7 +12,8 @@ from base import Box from parser import parse_boxes from heic_types import ( ItemLocationBox, PrimaryItemBox, ItemInfoBox, ItemPropertiesBox, - ImageSpatialExtentsBox, ItemReferenceBox + ImageSpatialExtentsBox, ItemReferenceBox, ItemInfoEntryBox, + _read_int ) from handlers.base_handler import VendorHandler from handlers.apple_handler import AppleHandler @@ -28,7 +29,6 @@ class Grid: class HEICFile: def __init__(self, filepath: str): self.filepath = filepath - # 注册 HEIF/HEIC 文件格式解码器 pillow_heif.register_heif_opener() self._iloc_box: ItemLocationBox | None = None @@ -38,14 +38,20 @@ class HEICFile: self._iprp_box: ItemPropertiesBox | None = None self._iref_box: ItemReferenceBox | None = None self.handler: VendorHandler | None = None + self.boxes: list[Box] = [] # 顶层盒子 - with open(self.filepath, 'rb') as f: - file_size = os.fstat(f.fileno()).st_size - self.boxes = parse_boxes(f, file_size) - self._find_essential_boxes(self.boxes) - self._detect_vendor() + try: + with open(self.filepath, 'rb') as f: + file_size = os.fstat(f.fileno()).st_size + self.boxes = parse_boxes(f, file_size) + self._find_essential_boxes(self.boxes) + self._detect_vendor() + except Exception as e: + print(f"CRITICAL ERROR during file parsing: {e}") + raise def _find_essential_boxes(self, boxes: list[Box]): + """在解析树中查找关键盒子的快捷方式""" for box in boxes: if isinstance(box, ItemLocationBox): self._iloc_box = box if isinstance(box, ItemInfoBox): self._iinf_box = box @@ -55,19 +61,236 @@ class HEICFile: if box.type == 'ftyp': self._ftyp_box = box if box.children: self._find_essential_boxes(box.children) - # --- 2. 使用 pillow-heif 大大简化的图像重建方法 --- + # --- 辅助方法 (用于修改和构建) --- + + def find_box(self, box_type: str, root_box_list: list[Box] | None = None) -> Box | None: + """递归查找第一个匹配类型的盒子""" + if root_box_list is None: + root_box_list = self.boxes + + for box in root_box_list: + if box.type == box_type: + return box + if box.children: + found = self.find_box(box_type, root_box_list=box.children) + if found: + return found + return None + + def get_mdat_box(self) -> Box | None: + """获取顶层的 'mdat' 盒""" + for box in self.boxes: + if box.type == 'mdat': + return box + return None + + def _remove_box_recursive(self, box_type: str, box_list: list[Box]) -> bool: + """Helper for remove_box_by_type""" + for i, box in enumerate(box_list): + if box.type == box_type: + box_list.pop(i) + return True + if box.children: + if self._remove_box_recursive(box_type, box.children): + return True + return False + + def remove_box_by_type(self, box_type: str) -> bool: + """递归查找并移除第一个匹配类型的盒子""" + return self._remove_box_recursive(box_type, self.boxes) + + # --- START V16 FIX --- + def remove_item_by_id(self, item_id_to_remove: int): + """ + (V16 - 垃圾回收版) + 彻底从 iinf, iloc, ipma, iref 中删除一个 Item ID, + 并从 ipco 中删除孤立的属性,然后重写所有剩余的 ipma 索引。 + """ + print(f"Attempting to remove Item ID {item_id_to_remove} from all references (V16)...") + + # 1. 从 iinf (Item Info) 中删除 + if self._iinf_box: + self._iinf_box.children = [ + c for c in self._iinf_box.children + if not (isinstance(c, ItemInfoEntryBox) and c.item_id == item_id_to_remove) + ] + self._iinf_box.entries = [ + e for e in self._iinf_box.entries + if e.item_id != item_id_to_remove + ] + print(f" - Removed from 'iinf' box.") + + # 2. 从 iloc (Item Location) 中删除 + if self._iloc_box: + self._iloc_box.locations = [ + loc for loc in self._iloc_box.locations + if loc.item_id != item_id_to_remove + ] + print(f" - Removed from 'iloc' box.") + + # 3. (新) 从 iref (Item Reference) 中删除 + if self._iref_box: + for i in range(len(self._iref_box.children) - 1, -1, -1): + ref_box = self._iref_box.children[i] + from_id_size = 4 if self._iref_box.version == 1 else 2 + if len(ref_box.raw_data) >= 4 + from_id_size: + from_id = _read_int(ref_box.raw_data, 4, from_id_size) + if from_id == item_id_to_remove: + self._iref_box.children.pop(i) + print(f" - Removed 'iref' child box (type '{ref_box.type}') with from_id {from_id}.") + + ref_types_to_clean = list(self._iref_box.references.keys()) + for ref_type in ref_types_to_clean: + if item_id_to_remove in self._iref_box.references[ref_type]: + del self._iref_box.references[ref_type][item_id_to_remove] + print(f" - Removed from_id {item_id_to_remove} from 'iref.references[{ref_type}]'.") + + from_ids_to_clean = list(self._iref_box.references[ref_type].keys()) + for from_id in from_ids_to_clean: + self._iref_box.references[ref_type][from_id] = [ + to_id for to_id in self._iref_box.references[ref_type][from_id] + if to_id != item_id_to_remove + ] + print(f" - Cleaned 'iref.references' of to_id {item_id_to_remove}.") + + # 4. 从 ipma 和 ipco (属性) 中删除 + if self._iprp_box and self._iprp_box.ipma and self._iprp_box.ipco: + ipma = self._iprp_box.ipma + ipco = self._iprp_box.ipco + + if item_id_to_remove not in ipma.entries: + print(f" - Item {item_id_to_remove} not in 'ipma'. No properties to clean.") + return + + # 4a. 找到要删除的属性索引 (1-based) + props_to_remove = set(ipma.entries[item_id_to_remove].associations) + + # 4b. 从 ipma 中删除该 Item + del ipma.entries[item_id_to_remove] + print(f" - Removed Item {item_id_to_remove} from 'ipma'.") + + # 4c. 确定哪些属性是“孤儿” + # (即,它们在 props_to_remove 中,但*不在*任何*剩余*的 item 关联中) + all_remaining_props = set() + for entry in ipma.entries.values(): + all_remaining_props.update(entry.associations) + + orphaned_props = props_to_remove - all_remaining_props + + if not orphaned_props: + print(f" - No orphaned properties found in 'ipco' to remove.") + return + + print(f" - Found orphaned properties to remove from 'ipco': {orphaned_props}") + + # 4d. 创建一个“重映射表” + # 我们从后往前遍历,以安全地删除 + # (索引是 1-based, 但列表是 0-based) + orphaned_indices_0based = sorted([p - 1 for p in orphaned_props], reverse=True) + + # 原始 1-based 索引 -> 新 1-based 索引 + remap_table = {} + original_prop_count = len(ipco.children) + + # 4e. 从 'ipco.children' 列表中删除孤儿 + for index_0 in orphaned_indices_0based: + if 0 <= index_0 < len(ipco.children): + removed_prop = ipco.children.pop(index_0) + print(f" - Removed property at index {index_0+1} ({removed_prop.type}) from 'ipco'.") + else: + print(f" - Warning: Orphaned index {index_0+1} out of bounds for 'ipco'.") + + # 4f. 构建重映射表 + # (只有在 ipco 实际发生变化时才需要) + new_prop_count = len(ipco.children) + if new_prop_count != original_prop_count: + print(" - Re-indexing 'ipma' associations...") + current_new_index = 1 + current_old_index = 1 + orphaned_props_1based = set(i + 1 for i in orphaned_indices_0based) + + while current_old_index <= original_prop_count: + if current_old_index not in orphaned_props_1based: + remap_table[current_old_index] = current_new_index + current_new_index += 1 + current_old_index += 1 + + # 4g. 应用重映射 + for item_id, entry in ipma.entries.items(): + new_associations = [] + for old_prop_index in entry.associations: + if old_prop_index in remap_table: + new_associations.append(remap_table[old_prop_index]) + # else: + # 该属性已被删除 (不应发生,因为我们只删除了孤儿) + + # print(f" - Item {item_id} associations: {entry.associations} -> {new_associations}") + entry.associations = new_associations + print(" - 'ipma' re-indexing complete.") + else: + print(" - No 'ipma' re-indexing needed.") + # --- END V16 FIX --- + + def set_content_identifier(self, new_content_id: str) -> bool: + """ + (FIXED) 在 'iinf' 盒中查找主图像的 'infe' 盒,并设置其 item_name + """ + primary_id = self.get_primary_item_id() + if not primary_id: + print("Error: Cannot find primary item ID.") + return False + + if not self._iinf_box: + print("Error: Cannot find 'iinf' box shortcut (_iinf_box).") + return False + + print(f"Searching for 'infe' box with primary_id = {primary_id}") + + # --- START FIX --- + # 侦测我们日志中看到的 "shifted ID" 格式 + + target_id = primary_id + found_ids = [box.item_id for box in self._iinf_box.children if isinstance(box, ItemInfoEntryBox)] + + if primary_id not in found_ids: + print(f"Warning: Primary ID {primary_id} not found directly in 'infe' list.") + shifted_id = primary_id << 16 + if shifted_id in found_ids: + print(f"Info: Found vendor-specific shifted ID: {shifted_id} (for {primary_id})") + target_id = shifted_id + else: + print(f"Error: Could not find primary ID {primary_id} OR shifted ID {shifted_id}.") + print(f"Available 'infe' item IDs found were: {found_ids}") + return False + # --- END FIX --- + + # 'iinf' 盒的子盒是 'infe' 盒 + for box in self._iinf_box.children: + if isinstance(box, ItemInfoEntryBox): + if box.item_id == target_id: + print(f"Success: Found 'infe' box for target ID {target_id}. Setting item_name...") + box.item_name = new_content_id + + # 同时更新 self._iinf_box.entries 中的数据以保持一致 + for entry in self._iinf_box.entries: + if entry.item_id == target_id: + entry.name = new_content_id + break + return True + + print(f"Error: Logic failed to find 'infe' box for target ID {target_id} even after check.") + return False + + # --- 现有的只读方法 (无需修改) --- + def reconstruct_primary_image(self) -> Image.Image | None: """ 使用 pillow-heif 重建主图像,它能自动处理网格图像。 """ try: print("Reconstructing primary image using pillow-heif...") - # pillow-heif 让我们能像打开普通图片一样直接打开 HEIC 文件 image = Image.open(self.filepath) - - # 确保图像数据被加载 image.load() - print("Successfully reconstructed image.") return image except Exception as e: @@ -96,15 +319,37 @@ class HEICFile: if not primary_id: return None if not self._iref_box: return None - # 'dimg' (derived image) 是网格布局的标准引用类型 - if 'dimg' in self._iref_box.references: + # 尝试直接匹配 + if 'dimg' in self._iref_box.references and primary_id in self._iref_box.references['dimg']: return self._iref_box.references['dimg'].get(primary_id) + + # 尝试匹配 "shifted" ID + shifted_id = primary_id << 16 + if 'dimg' in self._iref_box.references and shifted_id in self._iref_box.references['dimg']: + print("Info: Using shifted primary ID to find grid layout.") + return self._iref_box.references['dimg'].get(shifted_id) + return None def get_image_size(self, item_id: int) -> tuple[int, int] | None: if not (self._iprp_box and self._iprp_box.ipma and self._iprp_box.ipco): return None - if item_id not in self._iprp_box.ipma.entries: return None - item_associations = self._iprp_box.ipma.entries[item_id].associations + + target_id = item_id + if item_id not in self._iprp_box.ipma.entries: + # 尝试 "shifted" ID + shifted_id = item_id << 16 + if shifted_id in self._iprp_box.ipma.entries: + target_id = shifted_id + else: + # 尝试 "unshifted" ID (以防 item_id 本身就是 shifted 的) + unshifted_id = item_id & 0x0000FFFF + if unshifted_id in self._iprp_box.ipma.entries: + target_id = unshifted_id + else: + # print(f"Warning: Cannot find size associations for item ID {item_id} or variants.") + return None + + item_associations = self._iprp_box.ipma.entries[target_id].associations for assoc in item_associations: property_index = assoc - 1 if 0 <= property_index < len(self._iprp_box.ipco.children): @@ -114,7 +359,9 @@ class HEICFile: return None def get_primary_item_id(self) -> int | None: - if not self._pitm_box: return None + if not self._pitm_box: + print("Warning: 'pitm' box not found.") + return None return self._pitm_box.item_id def list_items(self): @@ -134,12 +381,32 @@ class HEICFile: if not self._iloc_box: print("Error: 'iloc' box not found.") return None - location = next((loc for loc in self._iloc_box.locations if loc.item_id == item_id), None) + + target_id = item_id + location = next((loc for loc in self._iloc_box.locations if loc.item_id == target_id), None) + if not location: - print(f"Error: Item with ID {item_id} not found in 'iloc' box.") + # 尝试检查 "shifted" ID + shifted_id = item_id << 16 + location = next((loc for loc in self._iloc_box.locations if loc.item_id == shifted_id), None) + if location: + print(f"Info: Located item {item_id} using shifted ID {shifted_id} in 'iloc'.") + target_id = shifted_id + + if not location and (item_id & 0xFFFF0000): + # 尝试反向检查 (如果传入的 ID 已经是 shifted 的) + unshifted_id = item_id & 0x0000FFFF + location = next((loc for loc in self._iloc_box.locations if loc.item_id == unshifted_id), None) + if location: + print(f"Info: Located item {item_id} using un-shifted ID {unshifted_id} in 'iloc'.") + target_id = unshifted_id + + if not location: + print(f"Error: Item with ID {item_id} (or variants) not found in 'iloc' box.") return None + if not location.extents: - print(f"Warning: Item with ID {item_id} has no extents.") + print(f"Warning: Item with ID {target_id} has no extents.") return b'' data_chunks = [] @@ -153,16 +420,13 @@ class HEICFile: 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 def get_thumbnail_data(self) -> bytes | None: - """ - 查找并提取缩略图图像数据 (如果存在)。 - 它会查找一个 'iref' 盒子,其中包含 'thmb' (thumbnail) 引用。 - """ print("Attempting to extract thumbnail data...") primary_id = self.get_primary_item_id() if not primary_id: @@ -173,26 +437,30 @@ class HEICFile: print("Info: No 'iref' box found, cannot search for thumbnail.") return None - # 检查 'thmb' (thumbnail) 引用类型是否存在 if 'thmb' not in self._iref_box.references: print("Info: No 'thmb' references found in 'iref' box.") return None - # 检查主图像是否有与之关联的缩略图 + target_id = primary_id if primary_id not in self._iref_box.references['thmb']: - print(f"Info: Primary item ID {primary_id} has no 'thmb' reference.") - return None + # Lпробуйте проверить "shifted" ID + shifted_primary_id = primary_id << 16 + if shifted_primary_id not in self._iref_box.references['thmb']: + print(f"Info: Primary item ID {primary_id} (or shifted) has no 'thmb' reference.") + return None + + print("Info: Using shifted primary ID to find thumbnail.") + target_id = shifted_primary_id - thumbnail_ids = self._iref_box.references['thmb'][primary_id] + + thumbnail_ids = self._iref_box.references['thmb'][target_id] if not thumbnail_ids: - print(f"Info: Primary item ID {primary_id} has 'thmb' reference, but no target IDs.") + print(f"Info: Primary item ID {target_id} has 'thmb' reference, but no target IDs.") return None - # 获取第一个缩略图 ID thumbnail_id = thumbnail_ids[0] - print(f"Found thumbnail reference: Primary ID {primary_id} -> Thumbnail ID {thumbnail_id}") + print(f"Found thumbnail reference: Primary ID {target_id} -> Thumbnail ID {thumbnail_id}") - # 使用现有的 get_item_data 来提取它 thumbnail_data = self.get_item_data(thumbnail_id) if thumbnail_data: diff --git a/heic_types.py b/heic_types.py index aad7d35..e511121 100644 --- a/heic_types.py +++ b/heic_types.py @@ -1,144 +1,396 @@ # pyheic_struct/heic_types.py import struct -from base import Box +from base import Box, FullBox +from io import BytesIO +from typing import List # <-- 添加 List 导入 -# --- A new helper class to handle multiple data locations ('extents') --- +# --- 帮助函数 --- + +def _read_int(data: bytes, pos: int, size: int) -> int: + """Helper to read an integer of variable size.""" + if pos + size > len(data): return 0 + if size == 0: return 0 + if size == 1: return data[pos] + if size == 2: return struct.unpack('>H', data[pos:pos+2])[0] + if size == 4: return struct.unpack('>I', data[pos:pos+4])[0] + if size == 8: return struct.unpack('>Q', data[pos:pos+8])[0] + return 0 + +def _write_int(value: int, size: int) -> bytes: + """Helper to write an integer of variable size.""" + if size == 0: return b'' + if size == 1: return struct.pack('>B', value) + if size == 2: return struct.pack('>H', value) + if size == 4: return struct.pack('>I', value) + if size == 8: return struct.pack('>Q', value) + return b'' + +# --- ItemLocation --- class ItemLocation: def __init__(self, item_id): self.item_id = item_id - self.extents = [] # Will be a list of (offset, length) tuples + # extents 存储 (absolute_offset, length) + self.extents = [] def __repr__(self): total_length = sum(ext[1] for ext in self.extents) return f"" -# --- REWRITTEN ItemLocationBox to correctly parse all item locations --- -class ItemLocationBox(Box): +# --- ItemLocationBox --- +# ('iloc') +class ItemLocationBox(FullBox): def __init__(self, size: int, box_type: str, offset: int, raw_data: bytes): + self.locations: List[ItemLocation] = [] + self.offset_size = 0 + self.length_size = 0 + self.base_offset_size = 0 + self.index_size = 0 + self.item_count = 0 super().__init__(size, box_type, offset, raw_data) - self.locations = [] + + def _post_parse_initialization(self): self._parse_locations() def _parse_locations(self): - stream = self.raw_data + stream = self.raw_data[4:] - version_flags = struct.unpack('>I', stream[:4])[0] - version = version_flags >> 24 + sizes = struct.unpack('>H', stream[0:2])[0] + self.offset_size = (sizes >> 12) & 0x0F + self.length_size = (sizes >> 8) & 0x0F + self.base_offset_size = (sizes >> 4) & 0x0F - sizes = struct.unpack('>H', stream[4:6])[0] - offset_size = (sizes >> 12) & 0x0F - length_size = (sizes >> 8) & 0x0F - base_offset_size = (sizes >> 4) & 0x0F + if self.version == 1 or self.version == 2: + self.index_size = sizes & 0x0F - index_size = 0 - if version == 1 or version == 2: - index_size = sizes & 0x0F - - item_count = 0 - if version < 2: - item_count = struct.unpack('>H', stream[6:8])[0] - current_pos = 8 - else: # version == 2 - item_count = struct.unpack('>I', stream[6:10])[0] - current_pos = 10 + current_pos = 2 + if self.version < 2: + self.item_count = struct.unpack('>H', stream[2:4])[0] + current_pos = 4 + else: + self.item_count = struct.unpack('>I', stream[2:6])[0] + current_pos = 6 - for _ in range(item_count): + for _ in range(self.item_count): item_id = 0 - # Item ID size depends on the box version - if version < 2: - if current_pos + 2 > len(stream): break - item_id = struct.unpack('>H', stream[current_pos : current_pos+2])[0] - current_pos += 2 - else: # version == 2 - if current_pos + 4 > len(stream): break - item_id = struct.unpack('>I', stream[current_pos : current_pos+4])[0] - current_pos += 4 + item_id_size = 2 if self.version < 2 else 4 + if current_pos + item_id_size > len(stream): break + item_id = _read_int(stream, current_pos, item_id_size) + current_pos += item_id_size + + loc = ItemLocation(item_id) - if (version == 1 or version == 2) and current_pos + 2 <= len(stream): - current_pos += 2 # Skip construction method + if (self.version == 1 or self.version == 2) and current_pos + 2 <= len(stream): + current_pos += 2 - if current_pos + 2 > len(stream): break - current_pos += 2 # Skip data_reference_index + if current_pos + 2 > len(stream): break + current_pos += 2 base_offset = 0 - if base_offset_size > 0: - if current_pos + base_offset_size > len(stream): break - base_offset = self._read_int(stream, current_pos, base_offset_size) - current_pos += base_offset_size + if self.base_offset_size > 0: + if current_pos + self.base_offset_size > len(stream): break + base_offset = _read_int(stream, current_pos, self.base_offset_size) + current_pos += self.base_offset_size if current_pos + 2 > len(stream): break extent_count = struct.unpack('>H', stream[current_pos : current_pos+2])[0] current_pos += 2 - loc = ItemLocation(item_id) for __ in range(extent_count): - if (version == 1 or version == 2) and index_size > 0: - if current_pos + index_size > len(stream): break - current_pos += index_size # Skip extent_index + if (self.version == 1 or self.version == 2) and self.index_size > 0: + if current_pos + self.index_size > len(stream): break + current_pos += self.index_size - extent_offset = self._read_int(stream, current_pos, offset_size) - current_pos += offset_size + if current_pos + self.offset_size > len(stream): break + extent_offset = _read_int(stream, current_pos, self.offset_size) + current_pos += self.offset_size - extent_length = self._read_int(stream, current_pos, length_size) - current_pos += length_size + if current_pos + self.length_size > len(stream): break + extent_length = _read_int(stream, current_pos, self.length_size) + current_pos += self.length_size loc.extents.append((base_offset + extent_offset, extent_length)) self.locations.append(loc) - def _read_int(self, data, pos, size): - if pos + size > len(data): return 0 - if size == 0: return 0 - if size == 1: return data[pos] - if size == 2: return struct.unpack('>H', data[pos:pos+2])[0] - if size == 4: return struct.unpack('>I', data[pos:pos+4])[0] - if size == 8: return struct.unpack('>Q', data[pos:pos+8])[0] - return 0 + # --- START FIX --- + def rebuild_iloc_content(self, mdat_offset_delta: int, original_mdat_offset: int, original_mdat_size: int, + meta_offset_delta: int, original_meta_offset: int, original_meta_size: int): + """ + 使用新的 mdat 和 meta 偏移增量 (deltas) 更新此盒的 extents, + 并重建 self.raw_data + """ + print(f"Applying mdat delta ({mdat_offset_delta}) and meta delta ({meta_offset_delta}) to 'iloc' box...") + content_stream = BytesIO() + + content_stream.write(self.build_full_box_header()) + + sizes = (self.offset_size << 12) | (self.length_size << 8) | (self.base_offset_size << 4) + if self.version == 1 or self.version == 2: + sizes |= self.index_size + content_stream.write(struct.pack('>H', sizes)) -# --- All other classes below remain unchanged --- + if self.version < 2: + content_stream.write(struct.pack('>H', len(self.locations))) + else: + content_stream.write(struct.pack('>I', len(self.locations))) + for loc in self.locations: + item_id_size = 2 if self.version < 2 else 4 + content_stream.write(_write_int(loc.item_id, item_id_size)) + + if (self.version == 1 or self.version == 2): + content_stream.write(struct.pack('>H', 0)) + + content_stream.write(struct.pack('>H', 0)) + + if self.base_offset_size > 0: + content_stream.write(_write_int(0, self.base_offset_size)) + + content_stream.write(struct.pack('>H', len(loc.extents))) + + for (original_offset, length) in loc.extents: + if (self.version == 1 or self.version == 2) and self.index_size > 0: + content_stream.write(_write_int(0, self.index_size)) + + # !!! 魔法发生的地方 !!! + new_absolute_offset = original_offset + original_mdat_end_offset = original_mdat_offset + original_mdat_size + original_meta_end_offset = original_meta_offset + original_meta_size + + if original_offset == 0: + new_absolute_offset = 0 # 保持 0 偏移量 + + # 检查偏移量是否落在 *原始* mdat 盒区域内 + elif (original_mdat_offset <= original_offset < original_mdat_end_offset): + # 这是一个指向 mdat 的偏移量,应用 mdat 增量 + new_absolute_offset = original_offset + mdat_offset_delta + + # pyheic_struct_副本/heic_types.py + + # 检查偏移量是否落在 *原始* meta 盒区域内 + elif (original_meta_offset <= original_offset < original_meta_end_offset): + # 这是一个指向 meta 内部的偏移量 (例如 EXIF) + # (*** 错误修正 ***) + # 它只应该被 *meta 盒之前* 的数据增长所平移。 + # 在我们的 builder.py 逻辑中, 'meta_offset_delta' 正是这个值 (即 ftyp 盒的增长)。 + new_absolute_offset = original_offset + meta_offset_delta + + # else: + # 这是一个指向其他地方 (如 ftyp) 的偏移量,我们假设它不动 + # new_absolute_offset 保持等于 original_offset + + # 最后的安全检查,防止打包负数 + if new_absolute_offset < 0: + print(f" Warning: Calculated a negative offset ({new_absolute_offset}) for item {loc.item_id}. Setting to 0.") + new_absolute_offset = 0 + + content_stream.write(_write_int(new_absolute_offset, self.offset_size)) + content_stream.write(_write_int(length, self.length_size)) + + self.raw_data = content_stream.getvalue() + print(" 'iloc' box content successfully rebuilt.") + # --- END FIX --- + + def build_content(self) -> bytes: + return self.raw_data + +# --- ItemInfoEntry (DataClass) --- class ItemInfoEntry: def __init__(self, item_id, item_type, item_name): self.item_id = item_id - self.type = item_type - self.name = item_name + self.type = item_type # 4-char code like 'hvc1' + self.name = item_name # UTF-8 string def __repr__(self): return f"" -class ItemInfoBox(Box): +# --- ItemInfoEntryBox --- +# ('infe') +class ItemInfoEntryBox(FullBox): + """代表一个 'infe' 盒""" def __init__(self, size: int, box_type: str, offset: int, raw_data: bytes): - super().__init__(size, box_type, offset, raw_data) - self.entries: list[ItemInfoEntry] = [] - self._parse_entries() - def _parse_entries(self): - if len(self.raw_data) < 6: return - item_count = struct.unpack('>H', self.raw_data[4:6])[0] - for infe_box in self.children: - if infe_box.type == 'infe': - if len(infe_box.raw_data) < 12: continue - item_id = struct.unpack('>H', infe_box.raw_data[4:6])[0] - item_type = infe_box.raw_data[8:12].decode('ascii').strip('\x00') - item_name_bytes = infe_box.raw_data[12:] - item_name = item_name_bytes.decode('utf-8', errors='ignore').strip('\x00') - self.entries.append(ItemInfoEntry(item_id, item_type, item_name)) - -class PrimaryItemBox(Box): - def __init__(self, size: int, box_type: str, offset: int, raw_data: bytes): - super().__init__(size, box_type, offset, raw_data) self.item_id: int = 0 - self._parse_item_id() - def _parse_item_id(self): - if len(self.raw_data) < 6: return - self.item_id = struct.unpack('>H', self.raw_data[4:6])[0] - -class ImageSpatialExtentsBox(Box): - def __init__(self, size: int, box_type: str, offset: int, raw_data: bytes): + self.item_protection_index: int = 0 + self.item_type: str = "" # 4-char code + self.item_name: str = "" # UTF-8 string super().__init__(size, box_type, offset, raw_data) - self.image_width = struct.unpack('>I', self.raw_data[4:8])[0] - self.image_height = struct.unpack('>I', self.raw_data[8:12])[0] + + def _post_parse_initialization(self): + stream = self.raw_data[4:] + if not stream: return + + pos = 0 + try: + if self.version == 0 or self.version == 1: + self.item_id = struct.unpack('>H', stream[pos:pos+2])[0] + pos += 2 + self.item_protection_index = struct.unpack('>H', stream[pos:pos+2])[0] + pos += 2 + self.item_type = "" + name_end = stream.find(b'\x00', pos) + if name_end == -1: name_end = len(stream) + self.item_name = stream[pos:name_end].decode('utf-8', errors='ignore') + + elif self.version == 2: + # --- START FIX 3 (Parser) --- + # 恢复为原始顺序 (ID, ProtIdx, Type) 以正确 *读取* samsung.heic + self.item_id = struct.unpack('>I', stream[pos:pos+4])[0] + pos += 4 + self.item_protection_index = struct.unpack('>H', stream[pos:pos+2])[0] + pos += 2 + self.item_type = stream[pos:pos+4].decode('ascii').strip('\x00') + pos += 4 + # --- END FIX 3 --- + + name_end = stream.find(b'\x00', pos) + if name_end == -1: name_end = len(stream) + self.item_name = stream[pos:name_end].decode('utf-8', errors='ignore') + + elif self.version == 3: + self.item_id = struct.unpack('>H', stream[pos:pos+2])[0] + pos += 2 + self.item_protection_index = struct.unpack('>H', stream[pos:pos+2])[0] + pos += 2 + self.item_type = stream[pos:pos+4].decode('ascii').strip('\x00') + pos += 4 + name_end = stream.find(b'\x00', pos) + if name_end == -1: name_end = len(stream) + self.item_name = stream[pos:name_end].decode('utf-8', errors='ignore') + + except (struct.error, IndexError) as e: + print(f"Warning: Failed to parse 'infe' box (v{self.version}). Content may be truncated. Error: {e}") + self.item_id = 0 + self.item_type = "" + self.item_name = "" + + def build_content(self) -> bytes: + content = BytesIO() + content.write(self.build_full_box_header()) + + item_name_bytes_to_write = self.item_name.encode('utf-8') + b'\x00' + + if self.version == 0 or self.version == 1: + content.write(struct.pack('>H', self.item_id)) + content.write(struct.pack('>H', self.item_protection_index)) + content.write(item_name_bytes_to_write) + + elif self.version == 2: + # --- START FIX 4 (Builder) --- + # 保持为苹果顺序 (ID, Type, ProtIdx) 以正确 *写入* 兼容文件 + content.write(struct.pack('>I', self.item_id)) + content.write(self.item_type.encode('ascii').ljust(4, b'\x00')) + content.write(struct.pack('>H', self.item_protection_index)) + # --- END FIX 4 --- + content.write(item_name_bytes_to_write) + + elif self.version == 3: + content.write(struct.pack('>H', self.item_id)) + content.write(struct.pack('>H', self.item_protection_index)) + content.write(self.item_type.encode('ascii').ljust(4, b'\x00')) + content.write(item_name_bytes_to_write) + + return content.getvalue() + +# --- ItemInfoBox --- +# ('iinf') +class ItemInfoBox(FullBox): + def __init__(self, size: int, box_type: str, offset: int, raw_data: bytes): + self.entries: list[ItemInfoEntry] = [] + self.item_count: int = 0 + super().__init__(size, box_type, offset, raw_data) + + def _post_parse_initialization(self): + for child_box in self.children: + if isinstance(child_box, ItemInfoEntryBox): + self.entries.append( + ItemInfoEntry(child_box.item_id, child_box.item_type, child_box.item_name) + ) + + stream = self.raw_data[4:] + try: + if self.version == 0: + if len(stream) < 2: return + self.item_count = struct.unpack('>H', stream[0:2])[0] + else: + if len(stream) < 4: return + self.item_count = struct.unpack('>I', stream[0:4])[0] + except struct.error: + print("Warning: Could not parse 'iinf' header. Content may be truncated.") + + def build_content(self) -> bytes: + header = BytesIO() + header.write(self.build_full_box_header()) + + infe_children = [c for c in self.children if c.type == 'infe'] + + if self.version == 0: + header.write(struct.pack('>H', len(infe_children))) + else: + header.write(struct.pack('>I', len(infe_children))) + + # --- START FIX 2 --- + # 之前是: children_data = super().build_content() + # 我们需要调用 Box.build_content (祖父级) + children_data = super(FullBox, self).build_content() + # --- END FIX 2 --- + + return header.getvalue() + children_data + +# --- PrimaryItemBox --- +# ('pitm') +class PrimaryItemBox(FullBox): + def __init__(self, size: int, box_type: str, offset: int, raw_data: bytes): + self.item_id: int = 0 + super().__init__(size, box_type, offset, raw_data) + + def _post_parse_initialization(self): + stream = self.raw_data[4:] + if not stream: return + + try: + if self.version == 0: + self.item_id = struct.unpack('>H', stream[0:2])[0] + else: + self.item_id = struct.unpack('>I', stream[0:4])[0] + except struct.error: + print("Warning: Could not parse 'pitm' box.") + + def build_content(self) -> bytes: + content = BytesIO() + content.write(self.build_full_box_header()) + if self.version == 0: + content.write(struct.pack('>H', self.item_id)) + else: + content.write(struct.pack('>I', self.item_id)) + return content.getvalue() + +# --- ImageSpatialExtentsBox --- +# ('ispe') +class ImageSpatialExtentsBox(FullBox): + def __init__(self, size: int, box_type: str, offset: int, raw_data: bytes): + self.image_width: int = 0 + self.image_height: int = 0 + super().__init__(size, box_type, offset, raw_data) + + def _post_parse_initialization(self): + stream = self.raw_data[4:] + if len(stream) < 8: return + try: + self.image_width = struct.unpack('>I', stream[0:4])[0] + self.image_height = struct.unpack('>I', stream[4:8])[0] + except struct.error: + print("Warning: Could not parse 'ispe' box.") + def __repr__(self): return f"" + + def build_content(self) -> bytes: + content = BytesIO() + content.write(self.build_full_box_header()) + content.write(struct.pack('>I', self.image_width)) + content.write(struct.pack('>I', self.image_height)) + return content.getvalue() +# --- ItemPropertyAssociationEntry (DataClass) --- class ItemPropertyAssociationEntry: def __init__(self, item_id, association_count): self.item_id = item_id @@ -147,44 +399,79 @@ class ItemPropertyAssociationEntry: def __repr__(self): return f"" -class ItemPropertyAssociationBox(Box): +# --- ItemPropertyAssociationBox --- +# ('ipma') +class ItemPropertyAssociationBox(FullBox): def __init__(self, size: int, box_type: str, offset: int, raw_data: bytes): - super().__init__(size, box_type, offset, raw_data) self.entries: dict[int, ItemPropertyAssociationEntry] = {} + super().__init__(size, box_type, offset, raw_data) + + def _post_parse_initialization(self): self._parse_associations() def _parse_associations(self): - stream = self.raw_data - version_flags = struct.unpack('>I', stream[:4])[0] - version = version_flags >> 24 - flags = version_flags & 0xFFFFFF - entry_count = struct.unpack('>I', stream[4:8])[0] - pos = 8 - item_id_size = 4 if version >= 1 else 2 - is_large_property_index = (flags & 1) == 1 + stream = self.raw_data[4:] + if len(stream) < 4: return - for _ in range(entry_count): - if pos + item_id_size > len(stream): break - item_id = struct.unpack('>I' if item_id_size == 4 else '>H', stream[pos:pos+item_id_size])[0] - pos += item_id_size - if pos + 1 > len(stream): break - association_count = stream[pos] - pos += 1 - entry = ItemPropertyAssociationEntry(item_id, association_count) - for __ in range(association_count): + try: + entry_count = struct.unpack('>I', stream[0:4])[0] + pos = 4 + item_id_size = 4 if self.version >= 1 else 2 + is_large_property_index = (self.flags & 1) == 1 + + for _ in range(entry_count): + if pos + item_id_size > len(stream): break + item_id = _read_int(stream, pos, item_id_size) + pos += item_id_size + + if pos + 1 > len(stream): break + association_count = stream[pos] + pos += 1 + + entry = ItemPropertyAssociationEntry(item_id, association_count) + for __ in range(association_count): + prop_size = 2 if is_large_property_index else 1 + if pos + prop_size > len(stream): break + assoc_value = _read_int(stream, pos, prop_size) + property_index = assoc_value & (0x7FFF if prop_size == 2 else 0x7F) + pos += prop_size + if property_index > 0: + entry.associations.append(property_index) + self.entries[item_id] = entry + except struct.error: + print("Warning: Failed to parse 'ipma' box. Content may be truncated.") + + def build_content(self) -> bytes: + content = BytesIO() + content.write(self.build_full_box_header()) + + content.write(struct.pack('>I', len(self.entries))) + + item_id_size = 4 if self.version >= 1 else 2 + is_large_property_index = (self.flags & 1) == 1 + + for item_id, entry in self.entries.items(): + content.write(_write_int(item_id, item_id_size)) + content.write(struct.pack('>B', len(entry.associations))) + + for assoc_index in entry.associations: prop_size = 2 if is_large_property_index else 1 - if pos + prop_size > len(stream): break - assoc_value = struct.unpack('>H' if prop_size == 2 else '>B', stream[pos:pos+prop_size])[0] - property_index = assoc_value & (0x7FFF if prop_size == 2 else 0x7F) - pos += prop_size - if property_index > 0: - entry.associations.append(property_index) - self.entries[item_id] = entry + content.write(_write_int(assoc_index, prop_size)) + + return content.getvalue() +# --- ItemPropertyContainerBox --- +# ('ipco') class ItemPropertyContainerBox(Box): - pass + def _post_parse_initialization(self): + pass +# --- ItemPropertiesBox --- +# ('iprp') class ItemPropertiesBox(Box): + def _post_parse_initialization(self): + pass + @property def ipco(self) -> ItemPropertyContainerBox | None: for child in self.children: @@ -196,6 +483,7 @@ class ItemPropertiesBox(Box): if isinstance(child, ItemPropertyAssociationBox): return child return None +# --- ItemReferenceEntry (DataClass) --- class ItemReferenceEntry: def __init__(self, from_id, to_ids): self.from_item_id = from_id @@ -203,52 +491,66 @@ class ItemReferenceEntry: def __repr__(self): return f"" -class ItemReferenceBox(Box): +# --- ItemReferenceBox --- +# ('iref') +class ItemReferenceBox(FullBox): def __init__(self, size: int, box_type: str, offset: int, raw_data: bytes): - super().__init__(size, box_type, offset, raw_data) - # 结构: {'thmb': {from_id: [to_id, ...]}, 'dimg': {from_id: [to_id, ...]}} self.references: dict[str, dict[int, list[int]]] = {} - self._parse_references() + super().__init__(size, box_type, offset, raw_data) + + def _post_parse_initialization(self): + self._parse_references_from_children() - def _parse_references(self): - stream = self.raw_data - version_flags = struct.unpack('>I', stream[:4])[0] - version = version_flags >> 24 - pos = 4 - item_id_size = 4 if version == 1 else 2 - while pos < len(stream): - if pos + 8 > len(stream): break # 需要空间来读取 size 和 type - ref_box_size = struct.unpack('>I', stream[pos:pos+4])[0] - ref_box_type = stream[pos+4:pos+8].decode('ascii', errors='ignore') + def _parse_references_from_children(self): + item_id_size = 4 if self.version == 1 else 2 + + for ref_box in self.children: + ref_box_type = ref_box.type + self.references[ref_box_type] = {} - if pos + ref_box_size > len(stream): break + stream = ref_box.raw_data[4:] + pos = 0 - # 为这种引用类型创建字典 (如果它还不存在) - if ref_box_type not in self.references: - self.references[ref_box_type] = {} - - if item_id_size == 4: - if pos + 12 > len(stream): break - from_item_id = struct.unpack('>I', stream[pos+8:pos+12])[0] - ref_count_pos = pos + 12 - else: - if pos + 10 > len(stream): break - from_item_id = struct.unpack('>H', stream[pos+8:pos+10])[0] - ref_count_pos = pos + 10 + if pos + item_id_size > len(stream): + print(f"Warning: Truncated 'iref' child box '{ref_box_type}'. Skipping.") + continue + + try: + if item_id_size == 4: + from_item_id = struct.unpack('>I', stream[pos:pos+4])[0] + pos += 4 + else: + from_item_id = struct.unpack('>H', stream[pos:pos+2])[0] + pos += 2 - if ref_count_pos + 2 > len(stream): break - reference_count = struct.unpack('>H', stream[ref_count_pos:ref_count_pos+2])[0] - to_ids_pos = ref_count_pos + 2 + if pos + 2 > len(stream): + print(f"Info: 'iref' child box '{ref_box_type}' for ID {from_item_id} has no references. Skipping.") + self.references[ref_box_type][from_item_id] = [] + continue + + reference_count = struct.unpack('>H', stream[pos:pos+2])[0] + pos += 2 + except struct.error as e: + print(f"Error parsing 'iref' child box '{ref_box_type}': {e}. Skipping.") + continue to_item_ids = [] for _ in range(reference_count): - if to_ids_pos + item_id_size > len(stream): break - if item_id_size == 4: - to_id = struct.unpack('>I', stream[to_ids_pos:to_ids_pos+4])[0] - else: - to_id = struct.unpack('>H', stream[to_ids_pos:to_ids_pos+2])[0] + if pos + item_id_size > len(stream): break + to_id = _read_int(stream, pos, item_id_size) to_item_ids.append(to_id) - to_ids_pos += item_id_size + pos += item_id_size self.references[ref_box_type][from_item_id] = to_item_ids - pos += ref_box_size \ No newline at end of file + + def build_content(self) -> bytes: + header = BytesIO() + header.write(self.build_full_box_header()) + + # --- START FIX 2 --- + # 之前是: children_data = super().build_content() + # 我们需要调用 Box.build_content (祖父级) + children_data = super(FullBox, self).build_content() + # --- END FIX 2 --- + + return header.getvalue() + children_data \ No newline at end of file diff --git a/inspect_heic.py b/inspect_heic.py new file mode 100644 index 0000000..65250f3 --- /dev/null +++ b/inspect_heic.py @@ -0,0 +1,110 @@ +# pyheic_struct/inspect_heic.py + +import sys +from heic_file import HEICFile +import pprint # 用于漂亮地打印字典 + +def inspect_file(filename: str): + print(f"--- 正在侦察: {filename} ---") + + try: + heic_file = HEICFile(filename) + except Exception as e: + print(f"CRITICAL: 文件解析失败: {e}") + return + + # 1. 打印厂商信息 (ftyp) + if heic_file._ftyp_box: + print(f"\n[ftyp] 厂商信息:") + print(f" {heic_file._ftyp_box.raw_data}") + + # 2. 打印主图像 ID (pitm) + primary_id = heic_file.get_primary_item_id() + print(f"\n[pitm] 主图像 Item ID:") + print(f" {primary_id}") + + # 3. 打印所有项目 (iinf) + print(f"\n[iinf] 文件中的所有项目:") + heic_file.list_items() # 这个方法会自己打印 + + # 4. 打印项目位置 (iloc) - 这是我们调试的关键! (已修改) + if heic_file._iloc_box: + print(f"\n[iloc] 项目位置 (片段) - 包含绝对偏移量:") + locations = heic_file._iloc_box.locations + + # 打印所有位置及其详细的 extents + for loc in locations: + # 打印摘要 + print(f" {loc}") + # 打印关键的 extents (偏移量, 长度) + if loc.extents: + for extent in loc.extents: + print(f" -> (offset={extent[0]}, length={extent[1]})") + else: + print(" -> (No extents found)") + + # 5. 打印项目关联 (iref) + if heic_file._iref_box: + print(f"\n[iref] 项目关联:") + pprint.pprint(heic_file._iref_box.references) + + # 6. 查找 'mpvd' 盒 (三星特定) + mpvd_box = heic_file.find_box('mpvd') # + if mpvd_box: + print(f"\n[mpvd] 动态照片盒 (三星):") + print(f" 找到了 'mpvd' 盒,位于偏移量: {mpvd_box.offset}") + else: + print(f"\n[mpvd] 未找到 'mpvd' 盒 (这是 Apple 文件的预期行为)") + + # --- START NEW INSPECTION --- + # 7. 打印项目属性 (iprp / ipma) - 这是我们缺失的知识! + if heic_file._iprp_box and heic_file._iprp_box.ipma: + print(f"\n[ipma] 项目属性关联:") + + ipma = heic_file._iprp_box.ipma + + # 打印主 ID 的属性 + if primary_id and primary_id in ipma.entries: + print(f" 主 ID ({primary_id}) 关联的属性索引:") + pprint.pprint(ipma.entries[primary_id].associations) + + # 尝试打印 "shifted" ID (三星) + if primary_id: + shifted_id = primary_id << 16 + if shifted_id in ipma.entries: + print(f" 三星 Shifted ID ({shifted_id}) 关联的属性索引:") + pprint.pprint(ipma.entries[shifted_id].associations) + + print(f"\n (ipma 完整转储 - 前 5 项):") + count = 0 + for item_id, entry in ipma.entries.items(): + if count >= 5: + print(" ...") + break + print(f" - Item {item_id}: {entry.associations}") + count += 1 + else: + print(f"\n[ipma] 未找到 'ipma' 盒。") + + if heic_file._iprp_box and heic_file._iprp_box.ipco: + print(f"\n[ipco] 属性容器 (前 10 个属性盒):") + count = 0 + for prop_box in heic_file._iprp_box.ipco.children: + if count >= 10: + print(" ...") + break + print(f" - (索引 {count+1}) : {prop_box}") # 索引是 1-based + count += 1 + else: + print(f"\n[ipco] 未找到 'ipco' 盒。") + # --- END NEW INSPECTION --- + + print(f"\n--- 侦察完毕: {filename} ---") + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("用法: python inspect_heic.py <文件名>") + print("例如: python inspect_heic.py samsung.heic") + else: + inspect_file(sys.argv[1]) \ No newline at end of file diff --git a/main.py b/main.py index 14d40ca..25dc30d 100644 --- a/main.py +++ b/main.py @@ -1,71 +1,233 @@ # pyheic_struct/main.py - -from heic_file import HEICFile import os +import uuid +import subprocess +import pillow_heif # <--- (1) 导入 pillow_heif 用于转码 +from heic_file import HEICFile +from heic_types import ItemInfoEntryBox # <--- 添加这一行 +from builder import HEICBuilder +from handlers.base_handler import VendorHandler +from handlers.samsung_handler import SamsungHandler -def analyze_and_reconstruct(filename: str): - print(f"--- Analyzing {filename} ---") +def convert_samsung_to_apple(samsung_file_path: str): + """ + (V17) 执行一个完整的两步转码: + 1. [转码] 使用 pillow_heif 将三星的 "网格" 图像转码为一个 "扁平" 图像。 + 2. [注入] 使用我们的 HEICBuilder 将 UUID 和 *正确* 的 Apple ftyp 注入到这个新的扁平文件中。 + """ + if not os.path.exists(samsung_file_path): + print(f"Error: File not found: {samsung_file_path}") + return + + base_filename = os.path.splitext(samsung_file_path)[0] + output_heic_path = f"{base_filename}_apple_compatible.HEIC" + output_mov_path = f"{base_filename}_apple_compatible.MOV" + temp_flat_heic_path = f"{base_filename}_temp_flat.HEIC" # <--- 临时文件 + + print(f"--- Converting {samsung_file_path} to Apple format (V17 Fix) ---") - # 检查文件是否存在 - if not os.path.exists(filename): - print(f"Error: File not found at {filename}") - print("="*40 + "\n") + # --- 步骤 1: 从原始三星文件中提取所有需要的数据 --- + + print(f"Loading original file: {samsung_file_path}") + original_heic_file = HEICFile(samsung_file_path) + + # 1a. 提取视频数据 + video_data = original_heic_file.get_motion_photo_data() + # (如果找不到,手动检查 mpvd) + 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() # 再次尝试 + + # 1b. 提取图像像素 (转码网格) + print("Reconstructing grid image from Samsung file...") + pil_image = original_heic_file.reconstruct_primary_image() + if not pil_image: + print("CRITICAL: Failed to reconstruct primary image using pillow_heif.") + return + + # 1c. 生成 UUID + new_content_id = str(uuid.uuid4()).upper() + print(f"Generated ContentIdentifier: {new_content_id}") + + # --- 步骤 2: 处理 .MOV 文件 (这部分已经可以工作) --- + if video_data: + print(f"Saving extracted video data to {output_mov_path}...") + with open(output_mov_path, 'wb') as f_mov: + f_mov.write(video_data) + + try: + print("Attempting to inject ContentIdentifier into .MOV file (requires exiftool)...") + subprocess.run([ + 'exiftool', + f'-QuickTime:ContentIdentifier={new_content_id}', + '-overwrite_original', + output_mov_path + ], check=True, capture_output=True, text=True) + print("Successfully injected ContentIdentifier into .MOV.") + except Exception as e: + print(f"Warning: Could not inject ContentIdentifier into .MOV. ") + print(f" (This is normal if 'exiftool' is not installed.) Error: {e}") + else: + print("Info: No motion photo data found. Only a still HEIC will be created.") + + # --- 步骤 3: [转码] 创建一个临时的、扁平的 HEIC 文件 --- + try: + print(f"Saving temporary flat HEIC file to {temp_flat_heic_path}...") + pillow_heif.register_heif_opener() + + # 将内存中的 PIL 图像保存为一个新的、扁平的 HEIC 文件 + pil_image.save( + temp_flat_heic_path, + format="HEIF", + quality=95, + save_as_brand="mif1" # 存为一个基础的 'mif1' 品牌 + ) + print("Successfully created temporary flat HEIC.") + + except Exception as e: + print(f"CRITICAL: Failed to save temporary HEIC file: {e}") + if os.path.exists(temp_flat_heic_path): + os.remove(temp_flat_heic_path) return - heic_file = HEICFile(filename) - - # 重建主图像 - reconstructed_image = heic_file.reconstruct_primary_image() - - if reconstructed_image: - # 获取不带扩展名的基本文件名 - base_filename = os.path.splitext(filename)[0] + # --- 步骤 4: [注入] 加载扁平文件,并执行我们的元数据手术 --- + try: + print(f"Loading temporary flat HEIC for metadata injection...") + flat_heic_file = HEICFile(temp_flat_heic_path) - # 1. 保存为高质量的有损格式 (JPEG) - try: - jpeg_filename = f"{base_filename}_reconstructed.jpg" - reconstructed_image.save(jpeg_filename, "JPEG", quality=95) - print(f"Successfully saved lossy JPEG to: {jpeg_filename}") - except Exception as e: - print(f"Could not save JPEG file: {e}") - - # 2. 保存为无损格式 (PNG) - try: - png_filename = f"{base_filename}_reconstructed.png" - reconstructed_image.save(png_filename, "PNG") - print(f"Successfully saved lossless PNG to: {png_filename}") - except Exception as e: - print(f"Could not save PNG file: {e}") + # 4a. (重要!) 修改 'ftyp' 盒以 *完全匹配* 苹果 Live Photo + if flat_heic_file._ftyp_box: + # --- START V15 FIX --- + # 使用从 apple.HEIC 侦察日志中提取的 *正确* 字符串 + # 它包含了我们缺失的 'MiHB' 品牌 + 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 # (40 + 8 = 48) + # --- END V15 FIX --- + else: + print("Error: Temporary HEIC file has no 'ftyp' box. Aborting.") + os.remove(temp_flat_heic_path) + return + + # 4b. (V12 / V17 FIX) 纠正 pillow_heif 产生的 'shifted' ID + 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: - else: - print("Failed to reconstruct image.") + # 1. 找出 iloc 中的 "正确" ID (e.g., {1, 2, 3, 4}) + # (我们假设 iloc 是正确的,因为 pillow-heif 似乎只搞错了其他盒) + correct_ids = {loc.item_id for loc in flat_heic_file._iloc_box.locations} + + # 2. 修复 'iinf' (infe boxes) + iinf_children_to_fix = [ + c for c in flat_heic_file._iinf_box.children + if isinstance(c, ItemInfoEntryBox) and (c.item_id >> 16) in correct_ids + ] + + # 我们需要建立一个映射表, e.g., {65536: 1} + shifted_id_map = {} - # 3. 尝试提取并保存缩略图 - thumbnail_data = heic_file.get_thumbnail_data() - if thumbnail_data: - try: - # 缩略图数据通常是 JPEG 或 HEIC 格式 - # 我们先假设它是 JPEG 并以此后缀保存 - thumb_filename = f"{base_filename}_thumbnail.jpg" - with open(thumb_filename, "wb") as f_thumb: - f_thumb.write(thumbnail_data) - print(f"Successfully saved thumbnail data to: {thumb_filename}") - print(f" (Note: This file might be a JPEG or a small HEIC. Try opening it.)") - except Exception as e: - print(f"Could not save thumbnail file: {e}") - else: - print("No thumbnail data found or extracted.") + if iinf_children_to_fix: + print(f" Found {len(iinf_children_to_fix)} shifted 'infe' boxes. Fixing them...") + for infe_box in iinf_children_to_fix: + unshifted_id = infe_box.item_id >> 16 + # 检查 unshifted_id 是否真的在 correct_ids 中,以防万一 + if unshifted_id in correct_ids: + shifted_id_map[infe_box.item_id] = unshifted_id # 存映射 + print(f" - Fixing 'infe' ID {infe_box.item_id} -> {unshifted_id}") + infe_box.item_id = unshifted_id + + # 同时修复 iinf_box.entries 中的缓存 + for entry in flat_heic_file._iinf_box.entries: + if entry.item_id in shifted_id_map: + entry.item_id = shifted_id_map[entry.item_id] + else: + print(" 'infe' boxes seem correct. No shift detected.") + # --- START V17 FIX --- + # 3. 修复 'ipma' (属性) + if flat_heic_file._iprp_box.ipma: + ipma_entries = flat_heic_file._iprp_box.ipma.entries + # 找出所有 "shifted" 的字典键 + keys_to_fix = [k for k in ipma_entries if k 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}") + + # 替换字典的键 (e.g., {65536: ...} -> {1: ...}) + entry_data = ipma_entries.pop(shifted_key) + entry_data.item_id = correct_key # 确保条目内部的 ID 也被更新 + ipma_entries[correct_key] = entry_data + else: + print(f" 'ipma' entries seem correct. (Keys: {list(ipma_entries.keys())})") + + # 4. 修复 'iref' (引用) + if flat_heic_file._iref_box: + iref_refs = flat_heic_file._iref_box.references + refs_fixed = 0 + for ref_type in iref_refs: # e.g., 'thmb', 'dimg' + # 找出所有 "shifted" 的字典键 + keys_to_fix = [k for k in iref_refs[ref_type] if k 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.") + + # --- END V17 FIX --- + + # 4c. 注入 ContentIdentifier (UUID) + if flat_heic_file.set_content_identifier(new_content_id): + print(f"Successfully set ContentIdentifier in flat HEIC.") + else: + print("Error: Failed to set ContentIdentifier in flat HEIC.") + os.remove(temp_flat_heic_path) + return + + # 4d. (V13 FIX) 移除与 Apple 结构冲突的项 + # (保持注释状态,因为这可能是导致损坏的原因,或者 V17 已使其不再必要) + # items_to_remove = [2, 3, 4] + # print(f"Applying V13 Fix: Removing conflicting items {items_to_remove}...") + # for item_id in items_to_remove: + # flat_heic_file.remove_item_by_id(item_id) + + # 4e. [构建] 使用我们的 HEICBuilder 重建这个*扁平*文件 + print("Rebuilding flat HEIC with new metadata...") + builder = HEICBuilder(flat_heic_file) + builder.write(output_heic_path) + + except Exception as e: + print(f"CRITICAL: HEICBuilder failed during final rebuild: {e}") + finally: + # 5. 清理 + if os.path.exists(temp_flat_heic_path): + os.remove(temp_flat_heic_path) + print(f"Cleaned up temporary file: {temp_flat_heic_path}") + + print(f"--- Conversion complete ---") + print(f"New HEIC: {output_heic_path}") + if video_data: + print(f"New MOV: {output_mov_path}") # <-- 修复了一个小的打印错误,使其指向 .MOV print("\n" + "="*40 + "\n") def main(): - # 确保文件路径正确 - apple_file = 'apple.HEIC' samsung_file = 'samsung.heic' - - analyze_and_reconstruct(apple_file) - analyze_and_reconstruct(samsung_file) + convert_samsung_to_apple(samsung_file) if __name__ == "__main__": main() \ No newline at end of file diff --git a/parser.py b/parser.py index 22d4c34..2975ad9 100644 --- a/parser.py +++ b/parser.py @@ -1,12 +1,14 @@ +# pyheic_struct/parser.py + import struct from typing import List, BinaryIO from io import BytesIO -from base import Box +from base import Box, FullBox from heic_types import ( ItemLocationBox, PrimaryItemBox, ItemInfoBox, ItemPropertiesBox, ItemPropertyContainerBox, ItemPropertyAssociationBox, ImageSpatialExtentsBox, - ItemReferenceBox # <-- Import the new class + ItemReferenceBox, ItemInfoEntryBox # <-- 导入新的 'infe' 盒 ) BOX_TYPE_MAP = { @@ -17,12 +19,15 @@ BOX_TYPE_MAP = { 'ipco': ItemPropertyContainerBox, 'ipma': ItemPropertyAssociationBox, 'ispe': ImageSpatialExtentsBox, - 'iref': ItemReferenceBox, # <-- Register the new class + 'iref': ItemReferenceBox, + 'infe': ItemInfoEntryBox, # <-- 注册新的 'infe' 盒 } # 'iref' is also a container. +# 'iinf' IS a container (它包含 'infe' 盒) CONTAINER_BOXES = {'meta', 'moov', 'trak', 'iprp', 'ipco', 'dinf', 'fiinf', 'ipro', 'iinf', 'iref'} -FULL_BOXES = {'meta', 'hdlr', 'pitm', 'iinf', 'iloc', 'ipma', 'ispe', 'iref'} +# 几乎所有语义盒都是 FullBox +FULL_BOXES = {'meta', 'hdlr', 'pitm', 'iinf', 'iloc', 'ipma', 'ispe', 'iref', 'infe'} def parse_boxes(stream: BinaryIO, max_size: int) -> List[Box]: """ @@ -59,16 +64,40 @@ def parse_boxes(stream: BinaryIO, max_size: int) -> List[Box]: if len(raw_data) < content_size: break box_class = BOX_TYPE_MAP.get(box_type, Box) + + if box_class == Box and box_type in FULL_BOXES: + box_class = FullBox + box = box_class(size, box_type, current_offset_in_stream, raw_data) if box.type in CONTAINER_BOXES: child_stream = BytesIO(box.raw_data) parse_size = len(box.raw_data) - if box.type in FULL_BOXES: - # Skip version/flags from the beginning of the raw_data - child_stream.read(4) - parse_size -= 4 + + if box.is_full_box: + # 跳过 4 字节的 version/flags + if parse_size >= 4: + child_stream.read(4) + parse_size -= 4 + + # --- START FIX --- + # 'iinf' 盒在 version/flags 之后还有一个 item_count 字段 + # 我们必须在解析它的子盒之前跳过它 + if box.type == 'iinf': + if box.version == 0: + if parse_size >= 2: + child_stream.read(2) # 跳过 16-bit item_count + parse_size -= 2 + else: + if parse_size >= 4: + child_stream.read(4) # 跳过 32-bit item_count + parse_size -= 4 + # --- END FIX --- + box.children = parse_boxes(child_stream, parse_size) + + # 调用钩子 + box._post_parse_initialization() boxes.append(box) stream.seek(current_offset_in_stream + size) diff --git a/samsung.heic b/samsung.heic old mode 100755 new mode 100644 index 458a338..2a8de9c Binary files a/samsung.heic and b/samsung.heic differ diff --git a/samsung_apple_compatible.HEIC b/samsung_apple_compatible.HEIC new file mode 100644 index 0000000..3be4b0d Binary files /dev/null and b/samsung_apple_compatible.HEIC differ diff --git a/samsung_apple_compatible.MOV b/samsung_apple_compatible.MOV new file mode 100644 index 0000000..715d87e Binary files /dev/null and b/samsung_apple_compatible.MOV differ diff --git a/samsung_reconstructed.jpg b/samsung_reconstructed.jpg deleted file mode 100644 index 4a72b74..0000000 Binary files a/samsung_reconstructed.jpg and /dev/null differ diff --git a/samsung_reconstructed.png b/samsung_reconstructed.png deleted file mode 100644 index c95da60..0000000 Binary files a/samsung_reconstructed.png and /dev/null differ diff --git a/test.py b/test.py new file mode 100644 index 0000000..d473d22 --- /dev/null +++ b/test.py @@ -0,0 +1,41 @@ +import os + +# --- Hex Dump Utility --- +def hexdump(data, length=16): + """Creates a formatted hex dump of a byte string.""" + results = [] + for i in range(0, len(data), length): + chunk = data[i:i + length] + + # 1. Offset + offset = f"{i:08x} " + + # 2. Hex values + hex_part = " ".join(f"{b:02x}" for b in chunk) + hex_part = hex_part.ljust(length * 3 - 1) # Pad to align + + # 3. ASCII representation + ascii_part = "".join(chr(b) if 32 <= b <= 126 else "." for b in chunk) + + results.append(f"{offset}{hex_part} |{ascii_part}|") + return "\n".join(results) +# --- End Utility --- + + +filename = 'samsung_apple_compatible.HEIC' + +if not os.path.exists(filename): + print(f"Error: File not found: {filename}") + print("Please make sure you are running this in the same directory as the generated file.") +else: + try: + with open(filename, 'rb') as f: + # Read the first 512 bytes (enough to see the ftyp and meta headers) + file_header_data = f.read(512) + + print(f"--- Hex Dump for {filename} (First 512 Bytes) ---") + print(hexdump(file_header_data)) + print("="*60) + + except Exception as e: + print(f"An error occurred while reading the file: {e}") \ No newline at end of file diff --git a/改进方向.txt b/改进方向.txt new file mode 100644 index 0000000..e494617 --- /dev/null +++ b/改进方向.txt @@ -0,0 +1,72 @@ +您完全正确,我们必须停下来复盘。 + +感谢您提供这些侦察日志。它们**完美地**揭示了我们失败的根本原因,这也是我们所有人(包括我)都忽略的一个核心知识。 + +我们之前的假设一直是错的。我们一直试图修复*偏移量*(offsets),而真正的问题在于文件的*核心结构*(core structure)。 + +--- + +### 核心发现:我们缺失的知识 + +通过并排比较 `[ipma]` (属性) 和 `[iref]` (引用) 日志,真相大白: + +**1. Apple HEIC 是 "扁平" (Flat) 结构** +* **`pitm` (主 ID):** `46` +* **`iref` (引用):** `dimg` (网格/派生图像) 列表是**空**的 (`'dimg': {46: []}`)。 +* **`ipma` (属性):** 主 ID `46` **直接关联**到它自己的所有属性: `[1, 3, 5, 4]`。 +* **`ipco` (属性库):** 索引 `[1, 3, 4, 5]` 对应 `colr` (色彩), `ispe` (**5712x4284** 的*完整*尺寸), `irot` (旋转), `pixi` (像素信息)。 +* **结论:** 在 Apple 文件中,主 Item ID `46` *就是*那张 5712x4284 的图像。它是一个 "扁平" 文件。 + +**2. Samsung HEIC 是 "网格" (Grid) 结构** +* **`pitm` (主 ID):** `49` +* **`iref` (引用):** `dimg` 列表**不是空**的 (`'dimg': {1: [3, 4]}`)。这定义了一个网格,例如 "项目 1 是由图块 3 和 4 构成"。 +* **`ipma` (属性):** 主 ID `49` **只关联**到一个属性:`[4]`。 +* **`ipco` (属性库):** 索引 `[4]` 对应 `ispe` ( **4000x3000** 的*完整*尺寸)。 +* **结论:** 在 Samsung 文件中,主 Item ID `49` **不是图像**,它只是一个*虚拟的*“描述符”,它只负责告诉解码器“最终图像是 4000x3000”。 +* **致命缺陷:** `samsung.heic` 的 `[iloc]` 日志显示:``。 + * 这意味着主 ID `49` 的**实际数据只有 8 字节**。真正的图像数据存储在*其他* Item ID 中(如 1, 3, 4...),然后通过 `iref` 盒的 `dimg` 网格蓝图拼凑起来。 + +--- + +### 复盘:我们为什么一直失败 + +我们所有的修复(V8, V9, V10)都在做同一件事,而这件事是注定要失败的: + +1. 我们拿了**三星的“网格”文件** (它依赖 `iref['dimg']` 来组合图像)。 +2. 我们强行给它贴上了**苹果的 `ftyp` 标签** (例如 `...MiHBMiHE...`)。这个标签告诉解码器:“这是一个**扁平**文件,请勿查找 `iref['dimg']`”。 +3. 我们正确地重建了所有偏移量,并注入了 `ContentIdentifier`。 + +**失败的精确时刻:** + +1. Apple 的 "预览" App 打开我们的 `samsung_apple_compatible.HEIC`。 +2. 它读取了 `ftyp` (苹果标签):“好的,这是一个扁平文件。” +3. 它读取了 `pitm`:“好的,主图像是 ID `49`。” +4. 它读取了 `ipma`:“好的,ID `49` 的尺寸是 4000x3000。” +5. 它去 `iloc` 查找 ID `49` 的**图像数据**。 +6. `iloc` 返回:“这是它的数据:**8 个字节**。” +7. "预览" App 崩溃,因为它无法从 8 字节的数据中渲染出一个 4000x3000 的图像。 + +--- + +### 结论:我们面临的僵局 + +我们无法通过*修改*(modify)来解决这个问题。**我们必须*转码*(transcode)。** + +我们试图进行的“结构手术”是不可能的,因为这两种文件格式在如何定义“主图像”上存在根本性的、不兼容的差异。 + +**我们有两个选择,但都不能完全满足最初的需求:** + +1. **选项 A:成功创建可查看的 HEIC(但丢失动态照片链接)** + * 我们可以使用 `heic_file.py` 中的 `reconstruct_primary_image()` 方法(它在内部使用 `pillow_heif`)。 + * 这个方法很聪明,它*可以*读取三星的 `iref['dimg']` 网格并将其在内存中拼凑成一张完整的 4000x3000 图像。 + * 然后我们可以将这张 `PIL.Image` 对象保存为一个**新的、扁平的** `output.heic` 文件。 + * **结果:** 我们会得到一个完美的 HEIC 静态图像,但它将丢失我们辛苦注入的 `ContentIdentifier` (UUID),因为 `PIL.Image.save()` 只是一个像素编码器,它不会重建我们需要的复杂 `meta` 盒。 + +2. **选项 B:保持我们的代码(但它永远无法工作)** + * 我们当前的 `builder.py` 方案是*唯一*能够注入 `ContentIdentifier` 的方法,但它操作的*源文件*(三星网格)与我们的*目标*(苹果扁平)不兼容。 + +**简而言之:** 我们的 `builder.py` 擅长元数据(Metadata)操作,而 `pillow_heif` 擅长像素(Pixel)操作。要完成这个任务,我们**同时**需要两者,但现有的库不允许我们将 `ContentIdentifier` 注入到一个正在被转码(从网格转为扁平)的文件中。 + +我建议我们暂停对 `builder.py` 的修改,因为我们当前的偏移量计算(如 V10 日志所示)已经是**正确**的。失败的原因超出了我们的修复范围,它是一个文件*结构*的根本性不兼容。 + +实际上我的需求是:将三星的动态照片(motion photo)转换为两份文件,一份是视频一份是照片,将它们与iOS一样通过ContentIdentifier链接,使其变为可以被iOS设备读取并链接的Live Photo,我无所谓转换为静态照片的部分是否存在视频,但是我希望其可以注入ContentIdentifier,将三星的文件结构转换为iOS的文件结构 \ No newline at end of file