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
+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: