heic重构建

This commit is contained in:
Nymiro
2025-10-20 20:14:58 +13:00
parent 366e2f7c82
commit 70130d9e6b
25 changed files with 1504 additions and 255 deletions
Vendored
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 MiB

+126 -3
View File
@@ -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"<Box '{self.type}' size={self.size} offset={self.offset}>"
def _post_parse_initialization(self):
"""Called by the parser after children have been assigned."""
pass
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()
+139
View File
@@ -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}")
BIN
View File
Binary file not shown.
Binary file not shown.
+8 -5
View File
@@ -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
+301 -33
View File
@@ -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:
+455 -153
View File
@@ -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"<ItemLocation ID={self.item_id} extents={len(self.extents)} total_size={total_length}>"
# --- 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"<ItemInfoEntry ID={self.item_id} type='{self.type}' name='{self.name}'>"
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"<ImageSpatialExtentsBox width={self.image_width} height={self.image_height}>"
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"<ItemPropertyAssociationEntry item_id={self.item_id} associations={self.associations}>"
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"<ItemReferenceEntry from={self.from_item_id} to={self.to_item_ids}>"
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
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
+110
View File
@@ -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])
+215 -53
View File
@@ -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()
+37 -8
View File
@@ -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)
Executable → Regular
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 MiB

+41
View File
@@ -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}")
+72
View File
@@ -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]` 日志显示:`<ItemLocation ID=49 extents=1 total_size=8>`。
* 这意味着主 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的文件结构