Files
to-live-photo/Sources/LivePhotoCore/HEICMakerNotePatcher.swift
empty 299415a530 feat: 初始化 Live Photo 项目结构
- 添加 PRD、技术规范、交互规范文档 (V0.2)
- 创建 Swift Package 和 Xcode 项目
- 实现 LivePhotoCore 基础模块
- 添加 HEIC MakerNote 元数据写入功能
- 创建项目结构文档和任务清单
- 添加 .gitignore 忽略规则
2025-12-14 16:21:20 +08:00

592 lines
20 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
/// HEIC MakerNotes
/// CGImageDestination Int64 MakerNotes
public enum HEICMakerNoteError: Error, CustomStringConvertible {
case invalidHEIC(String)
case metaNotFound
case iinfNotFound
case ilocNotFound
case exifItemNotFound
case exifLocationNotFound(itemID: UInt32)
case exifPayloadTooSmall
case tiffNotFound
case invalidTIFF(String)
case exifIFDPointerNotFound
case makerNoteTagNotFound
case makerNoteOutOfRange
case makerNoteTooShort(available: Int, required: Int)
public var description: String {
switch self {
case .invalidHEIC(let msg): return "Invalid HEIC: \(msg)"
case .metaNotFound: return "meta box not found"
case .iinfNotFound: return "iinf box not found"
case .ilocNotFound: return "iloc box not found"
case .exifItemNotFound: return "Exif item not found"
case .exifLocationNotFound(let id): return "Exif item location not found for item_ID=\(id)"
case .exifPayloadTooSmall: return "Exif payload too small"
case .tiffNotFound: return "TIFF header not found"
case .invalidTIFF(let msg): return "Invalid TIFF: \(msg)"
case .exifIFDPointerNotFound: return "ExifIFDPointer (0x8769) not found"
case .makerNoteTagNotFound: return "MakerNote tag (0x927C) not found"
case .makerNoteOutOfRange: return "MakerNote data out of range"
case .makerNoteTooShort(let available, let required):
return "MakerNote too short: available=\(available), required=\(required)"
}
}
}
public final class HEICMakerNotePatcher {
// MARK: - Public API
/// MakerNotes HEIC
/// Exif item MakerNote
public static func injectMakerNoteInPlace(fileURL: URL, makerNote: Data) throws {
var fileData = try Data(contentsOf: fileURL, options: [.mappedIfSafe])
//
let metaRange = try findTopLevelBox(type: "meta", in: fileData)
guard let metaRange else { throw HEICMakerNoteError.metaNotFound }
let meta = BoxView(data: fileData, range: metaRange)
let metaChildrenStart = meta.contentStart + 4
guard metaChildrenStart <= meta.end else {
throw HEICMakerNoteError.invalidHEIC("meta content too short")
}
guard let iinfRange = try findChildBox(type: "iinf", within: metaChildrenStart..<meta.end, in: fileData) else {
throw HEICMakerNoteError.iinfNotFound
}
guard let ilocRange = try findChildBox(type: "iloc", within: metaChildrenStart..<meta.end, in: fileData) else {
throw HEICMakerNoteError.ilocNotFound
}
let exifItemID = try parseIINFAndFindExifItemID(data: fileData, iinfRange: iinfRange)
let (locations, ilocInfo) = try parseILOCWithInfo(data: fileData, ilocRange: ilocRange)
guard let exifLoc = locations[exifItemID] else {
throw HEICMakerNoteError.exifLocationNotFound(itemID: exifItemID)
}
let exifStart = Int(exifLoc.offset)
let exifLen = Int(exifLoc.length)
guard exifStart >= 0, exifLen > 0, exifStart + exifLen <= fileData.count else {
throw HEICMakerNoteError.exifPayloadTooSmall
}
// Exif item
let existingExif = fileData.subdata(in: exifStart..<(exifStart + exifLen))
// Exif item MakerNote
let newExif = try buildNewExifItem(existingExif: existingExif, newMakerNote: makerNote)
if newExif.count <= exifLen {
// Exif
var paddedExif = newExif
if paddedExif.count < exifLen {
paddedExif.append(Data(repeating: 0x00, count: exifLen - paddedExif.count))
}
fileData.replaceSubrange(exifStart..<(exifStart + exifLen), with: paddedExif)
} else {
// Exif iloc
let newExifOffset = fileData.count
fileData.append(newExif)
// iloc offset length
try updateILOC(
in: &fileData,
ilocRange: ilocRange,
ilocInfo: ilocInfo,
itemID: exifItemID,
newOffset: UInt64(newExifOffset),
newLength: UInt64(newExif.count)
)
}
try fileData.write(to: fileURL, options: [.atomic])
}
// MARK: - Build New Exif Item
/// Exif item MakerNote
private static func buildNewExifItem(existingExif: Data, newMakerNote: Data) throws -> Data {
guard existingExif.count >= 10 else {
throw HEICMakerNoteError.exifPayloadTooSmall
}
// Exif item
// 4 bytes: TIFF header offset ( 6 "Exif\0\0" )
// 4 bytes: "Exif"
// 2 bytes: \0\0
// TIFF
let tiffOffsetValue = existingExif.readUInt32BE(at: 0)
let tiffStart = 4 + Int(tiffOffsetValue)
guard tiffStart + 8 <= existingExif.count else {
throw HEICMakerNoteError.tiffNotFound
}
//
let endianMarker = existingExif.subdata(in: tiffStart..<(tiffStart + 2))
let isBigEndian: Bool
if endianMarker == Data([0x4D, 0x4D]) {
isBigEndian = true
} else if endianMarker == Data([0x49, 0x49]) {
isBigEndian = false
} else {
throw HEICMakerNoteError.invalidTIFF("Invalid endian marker")
}
// TIFF Big-Endian Apple
var newTiff = Data()
// TIFF Header: "MM" + 0x002A + IFD0 offset (8)
newTiff.append(contentsOf: [0x4D, 0x4D]) // Big-endian
newTiff.append(contentsOf: [0x00, 0x2A]) // TIFF magic
newTiff.append(contentsOf: [0x00, 0x00, 0x00, 0x08]) // IFD0 offset = 8
// IFD0: 1 entry (ExifIFDPointer)
// Entry count: 1
newTiff.append(contentsOf: [0x00, 0x01])
// Entry: ExifIFDPointer (0x8769)
let exifIFDOffset: UInt32 = 8 + 2 + 12 + 4 // = 26 (IFD0 )
newTiff.append(contentsOf: [0x87, 0x69]) // tag
newTiff.append(contentsOf: [0x00, 0x04]) // type = LONG
newTiff.append(contentsOf: [0x00, 0x00, 0x00, 0x01]) // count = 1
newTiff.appendUInt32BE(exifIFDOffset) // value = offset to Exif IFD
// Next IFD offset: 0 (no more IFDs)
newTiff.append(contentsOf: [0x00, 0x00, 0x00, 0x00])
// Exif IFD: 1 entry (MakerNote)
let makerNoteDataOffset: UInt32 = exifIFDOffset + 2 + 12 + 4 // = 44
newTiff.append(contentsOf: [0x00, 0x01]) // entry count
// Entry: MakerNote (0x927C)
newTiff.append(contentsOf: [0x92, 0x7C]) // tag
newTiff.append(contentsOf: [0x00, 0x07]) // type = UNDEFINED
newTiff.appendUInt32BE(UInt32(newMakerNote.count)) // count
newTiff.appendUInt32BE(makerNoteDataOffset) // offset to MakerNote data
// Next IFD offset: 0
newTiff.append(contentsOf: [0x00, 0x00, 0x00, 0x00])
// MakerNote data
newTiff.append(newMakerNote)
// Exif item
var newExifItem = Data()
// 4 bytes: offset to TIFF (= 6, "Exif\0\0")
newExifItem.append(contentsOf: [0x00, 0x00, 0x00, 0x06])
// "Exif\0\0"
newExifItem.append(contentsOf: [0x45, 0x78, 0x69, 0x66, 0x00, 0x00])
// TIFF data
newExifItem.append(newTiff)
return newExifItem
}
// MARK: - Box Parsing
private struct BoxHeader {
let type: String
let size: Int
let headerSize: Int
let contentStart: Int
let end: Int
}
private struct BoxView {
let data: Data
let range: Range<Int>
var start: Int { range.lowerBound }
var end: Int { range.upperBound }
var header: BoxHeader {
let size32 = Int(data.readUInt32BE(at: start))
let type = data.readFourCC(at: start + 4)
if size32 == 1 {
let size64 = Int(data.readUInt64BE(at: start + 8))
return BoxHeader(
type: type,
size: size64,
headerSize: 16,
contentStart: start + 16,
end: start + size64
)
} else if size32 == 0 {
return BoxHeader(
type: type,
size: data.count - start,
headerSize: 8,
contentStart: start + 8,
end: data.count
)
} else {
return BoxHeader(
type: type,
size: size32,
headerSize: 8,
contentStart: start + 8,
end: start + size32
)
}
}
var contentStart: Int { header.contentStart }
}
private static func findTopLevelBox(type: String, in data: Data) throws -> Range<Int>? {
var cursor = 0
while cursor + 8 <= data.count {
let box = try readBoxHeader(at: cursor, data: data)
if box.type == type { return cursor..<box.end }
cursor = box.end
}
return nil
}
private static func findChildBox(type: String, within range: Range<Int>, in data: Data) throws -> Range<Int>? {
var cursor = range.lowerBound
while cursor + 8 <= range.upperBound {
let box = try readBoxHeader(at: cursor, data: data)
if box.type == type { return cursor..<min(box.end, range.upperBound) }
cursor = box.end
}
return nil
}
private static func readBoxHeader(at offset: Int, data: Data) throws -> BoxHeader {
guard offset + 8 <= data.count else {
throw HEICMakerNoteError.invalidHEIC("box header out of bounds")
}
let size32 = Int(data.readUInt32BE(at: offset))
let type = data.readFourCC(at: offset + 4)
if size32 == 1 {
guard offset + 16 <= data.count else {
throw HEICMakerNoteError.invalidHEIC("large size box header out of bounds")
}
let size64 = Int(data.readUInt64BE(at: offset + 8))
guard size64 >= 16 else {
throw HEICMakerNoteError.invalidHEIC("invalid box size")
}
return BoxHeader(type: type, size: size64, headerSize: 16, contentStart: offset + 16, end: offset + size64)
} else if size32 == 0 {
return BoxHeader(type: type, size: data.count - offset, headerSize: 8, contentStart: offset + 8, end: data.count)
} else {
guard size32 >= 8 else {
throw HEICMakerNoteError.invalidHEIC("invalid box size")
}
return BoxHeader(type: type, size: size32, headerSize: 8, contentStart: offset + 8, end: offset + size32)
}
}
// MARK: - iinf / infe Parsing
private static func parseIINFAndFindExifItemID(data: Data, iinfRange: Range<Int>) throws -> UInt32 {
let iinf = BoxView(data: data, range: iinfRange).header
var cursor = iinf.contentStart
guard cursor + 4 <= iinf.end else {
throw HEICMakerNoteError.invalidHEIC("iinf too short")
}
let version = data.readUInt8(at: cursor)
cursor += 4
let entryCount: UInt32
if version == 0 {
entryCount = UInt32(data.readUInt16BE(at: cursor))
cursor += 2
} else {
entryCount = data.readUInt32BE(at: cursor)
cursor += 4
}
var foundExif: UInt32?
var scanned: UInt32 = 0
while cursor + 8 <= iinf.end, scanned < entryCount {
let infe = try readBoxHeader(at: cursor, data: data)
guard infe.type == "infe" else {
cursor = infe.end
continue
}
var p = infe.contentStart
guard p + 4 <= infe.end else {
throw HEICMakerNoteError.invalidHEIC("infe too short")
}
let infeVersion = data.readUInt8(at: p)
p += 4
let itemID: UInt32
if infeVersion >= 3 {
itemID = data.readUInt32BE(at: p); p += 4
} else {
itemID = UInt32(data.readUInt16BE(at: p)); p += 2
}
p += 2 // item_protection_index
var itemType = ""
if infeVersion >= 2 {
guard p + 4 <= infe.end else {
throw HEICMakerNoteError.invalidHEIC("infe item_type out of bounds")
}
itemType = data.readFourCC(at: p)
p += 4
}
if itemType == "Exif" {
foundExif = itemID
break
}
cursor = infe.end
scanned += 1
}
guard let exifID = foundExif else {
throw HEICMakerNoteError.exifItemNotFound
}
return exifID
}
// MARK: - iloc Parsing
private struct ItemLocation {
let offset: UInt64
let length: UInt64
}
private struct ILOCInfo {
let version: UInt8
let offsetSize: Int
let lengthSize: Int
let baseOffsetSize: Int
let indexSize: Int
let itemEntries: [UInt32: ILOCItemEntry]
}
private struct ILOCItemEntry {
let itemID: UInt32
let extentOffsetPosition: Int // extent_offset
let extentLengthPosition: Int // extent_length
}
private static func parseILOCWithInfo(data: Data, ilocRange: Range<Int>) throws -> ([UInt32: ItemLocation], ILOCInfo) {
let iloc = BoxView(data: data, range: ilocRange).header
var cursor = iloc.contentStart
guard cursor + 4 <= iloc.end else {
throw HEICMakerNoteError.invalidHEIC("iloc too short")
}
let version = data.readUInt8(at: cursor)
cursor += 4
guard cursor + 2 <= iloc.end else {
throw HEICMakerNoteError.invalidHEIC("iloc header out of bounds")
}
let offsetSize = Int(data.readUInt8(at: cursor) >> 4)
let lengthSize = Int(data.readUInt8(at: cursor) & 0x0F)
cursor += 1
let baseOffsetSize = Int(data.readUInt8(at: cursor) >> 4)
let indexSize = Int(data.readUInt8(at: cursor) & 0x0F)
cursor += 1
let itemCount: UInt32
if version < 2 {
guard cursor + 2 <= iloc.end else {
throw HEICMakerNoteError.invalidHEIC("iloc item_count out of bounds")
}
itemCount = UInt32(data.readUInt16BE(at: cursor))
cursor += 2
} else {
guard cursor + 4 <= iloc.end else {
throw HEICMakerNoteError.invalidHEIC("iloc item_count out of bounds")
}
itemCount = data.readUInt32BE(at: cursor)
cursor += 4
}
var locations: [UInt32: ItemLocation] = [:]
var itemEntries: [UInt32: ILOCItemEntry] = [:]
for _ in 0..<itemCount {
guard cursor + 2 <= iloc.end else { break }
let itemID: UInt32
if version < 2 {
itemID = UInt32(data.readUInt16BE(at: cursor)); cursor += 2
} else {
guard cursor + 4 <= iloc.end else { break }
itemID = data.readUInt32BE(at: cursor); cursor += 4
}
if version == 1 || version == 2 {
guard cursor + 2 <= iloc.end else { break }
cursor += 2
}
guard cursor + 2 <= iloc.end else { break }
cursor += 2
guard cursor + baseOffsetSize <= iloc.end else { break }
let baseOffset = try data.readUIntBE(at: cursor, size: baseOffsetSize)
cursor += baseOffsetSize
guard cursor + 2 <= iloc.end else { break }
let extentCount = Int(data.readUInt16BE(at: cursor))
cursor += 2
var firstExtentOffset: UInt64 = 0
var firstExtentLength: UInt64 = 0
var extentOffsetPos = 0
var extentLengthPos = 0
for e in 0..<extentCount {
if (version == 1 || version == 2) && indexSize > 0 {
guard cursor + indexSize <= iloc.end else { break }
cursor += indexSize
}
guard cursor + offsetSize + lengthSize <= iloc.end else { break }
if e == 0 {
extentOffsetPos = cursor
}
let extentOffset = try data.readUIntBE(at: cursor, size: offsetSize)
cursor += offsetSize
if e == 0 {
extentLengthPos = cursor
}
let extentLength = try data.readUIntBE(at: cursor, size: lengthSize)
cursor += lengthSize
if e == 0 {
firstExtentOffset = extentOffset
firstExtentLength = extentLength
}
}
let fileOffset = baseOffset + firstExtentOffset
if firstExtentLength > 0 {
locations[itemID] = ItemLocation(offset: fileOffset, length: firstExtentLength)
itemEntries[itemID] = ILOCItemEntry(
itemID: itemID,
extentOffsetPosition: extentOffsetPos,
extentLengthPosition: extentLengthPos
)
}
}
let info = ILOCInfo(
version: version,
offsetSize: offsetSize,
lengthSize: lengthSize,
baseOffsetSize: baseOffsetSize,
indexSize: indexSize,
itemEntries: itemEntries
)
return (locations, info)
}
private static func updateILOC(
in fileData: inout Data,
ilocRange: Range<Int>,
ilocInfo: ILOCInfo,
itemID: UInt32,
newOffset: UInt64,
newLength: UInt64
) throws {
guard let entry = ilocInfo.itemEntries[itemID] else {
throw HEICMakerNoteError.exifLocationNotFound(itemID: itemID)
}
// offset
fileData.writeUIntBE(at: entry.extentOffsetPosition, value: newOffset, size: ilocInfo.offsetSize)
// length
fileData.writeUIntBE(at: entry.extentLengthPosition, value: newLength, size: ilocInfo.lengthSize)
}
// MARK: - EXIF/TIFF Patching
enum Endian {
case little
case big
}
}
// MARK: - Data Extensions
private extension Data {
func readUInt8(at offset: Int) -> UInt8 {
self[self.index(self.startIndex, offsetBy: offset)]
}
func readUInt16BE(at offset: Int) -> UInt16 {
let b0 = UInt16(readUInt8(at: offset))
let b1 = UInt16(readUInt8(at: offset + 1))
return (b0 << 8) | b1
}
func readUInt32BE(at offset: Int) -> UInt32 {
let b0 = UInt32(readUInt8(at: offset))
let b1 = UInt32(readUInt8(at: offset + 1))
let b2 = UInt32(readUInt8(at: offset + 2))
let b3 = UInt32(readUInt8(at: offset + 3))
return (b0 << 24) | (b1 << 16) | (b2 << 8) | b3
}
func readUInt64BE(at offset: Int) -> UInt64 {
var v: UInt64 = 0
for i in 0..<8 {
v = (v << 8) | UInt64(readUInt8(at: offset + i))
}
return v
}
func readFourCC(at offset: Int) -> String {
let bytes = self.subdata(in: offset..<(offset + 4))
return String(bytes: bytes, encoding: .ascii) ?? "????"
}
func readUIntBE(at offset: Int, size: Int) throws -> UInt64 {
if size == 0 { return 0 }
guard offset + size <= count else {
throw HEICMakerNoteError.invalidHEIC("Variable-length integer out of bounds")
}
var v: UInt64 = 0
for i in 0..<size {
v = (v << 8) | UInt64(readUInt8(at: offset + i))
}
return v
}
mutating func appendUInt32BE(_ value: UInt32) {
append(UInt8((value >> 24) & 0xFF))
append(UInt8((value >> 16) & 0xFF))
append(UInt8((value >> 8) & 0xFF))
append(UInt8(value & 0xFF))
}
mutating func writeUIntBE(at offset: Int, value: UInt64, size: Int) {
for i in 0..<size {
let byteIndex = self.index(self.startIndex, offsetBy: offset + i)
let shift = (size - 1 - i) * 8
self[byteIndex] = UInt8((value >> shift) & 0xFF)
}
}
}