Compare commits
2 Commits
143c471714
...
683cebd5fa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
683cebd5fa | ||
|
|
e041cacd7d |
@@ -1,17 +1,39 @@
|
|||||||
# Live Photo 无法设置为动态壁纸:根因记录
|
# Live Photo 动态壁纸兼容性:根因与解决方案
|
||||||
|
|
||||||
## 现象
|
## 现象
|
||||||
- 生成的 Live Photo 在相册中可识别、可播放,但在“用作壁纸”时提示“动态不可用”。
|
- 生成的 Live Photo 在相册中可识别、可播放,但在"用作壁纸"时提示"动态不可用"。
|
||||||
|
|
||||||
## 关键发现(本地文件元数据)
|
## 根因分析
|
||||||
|
|
||||||
|
### 问题代码(初始版本 299415a)
|
||||||
|
- `Sources/LivePhotoCore/LivePhotoCore.swift` 中 `metadataItemForStillImageTime()` 将 `com.apple.quicktime.still-image-time` 的 value 写成 **-1**(Int8)。
|
||||||
|
- 代码注释错误地认为"竞品使用 0xFF (-1)"——实际上竞品(live-wallpaper)使用的是 **0**。
|
||||||
|
|
||||||
|
### 关键发现
|
||||||
- `/Users/yuanjiantsui/Downloads/paired.mov` 中的 timed metadata:`StillImageTime` 为 **-1**(int8)。
|
- `/Users/yuanjiantsui/Downloads/paired.mov` 中的 timed metadata:`StillImageTime` 为 **-1**(int8)。
|
||||||
- `exiftool` 输出示例:`[Track3] StillImageTime : -1`
|
- `exiftool` 输出示例:`[Track3] StillImageTime : -1`
|
||||||
|
|
||||||
## 代码根因
|
## 解决方案(来自 live-wallpaper 项目)
|
||||||
- `Sources/LivePhotoCore/LivePhotoCore.swift:842`:`LivePhotoBuilder.metadataItemForStillImageTime()` 将 `com.apple.quicktime.still-image-time` 的 value 写成 `-1`。
|
|
||||||
- 建议改为 `0`(int8)。
|
|
||||||
- 仍用 timed metadata group 的 `timeRange.start` 表达关键帧时间。
|
|
||||||
|
|
||||||
## 额外建议(兼容性)
|
### 修复方式(提交 a8b334e)
|
||||||
- 移除非标准的 mdta keys:`Sample Time` / `Sample Duration`(当前写入到 `assetWriter.metadata`)。
|
1. **复制 metadata.mov 文件**:从 live-wallpaper 项目复制 `metadata.mov`(MD5: 9c3a827031283513b28844514dbe44d5)
|
||||||
- 若仍不兼容,优先尝试 H.264、30fps、SDR、2~3 秒时长作为壁纸兼容模式。
|
2. **采用 metadata track 复制策略**:不再手动创建 still-image-time 元数据,而是从预制的 metadata.mov 复制整个 metadata track
|
||||||
|
|
||||||
|
### live-wallpaper 项目的正确实现
|
||||||
|
- `LivePhotoCreator.swift:52`: `"com.apple.quicktime.still-image-time": 0` // **值为 0,不是 -1**
|
||||||
|
- `LivePhotoCreator.swift:110`: `createMetadataItem(identifier: "com.apple.quicktime.still-image-time", value: 0)`
|
||||||
|
- `Converter4Video.swift:88-187`: 使用 AVAssetReaderTrackOutput + AVAssetWriterInput 复制 metadata track
|
||||||
|
|
||||||
|
### 两个项目的关键对齐点
|
||||||
|
| 特性 | live-wallpaper | to-live-photo(修复后)|
|
||||||
|
|-----|---------------|----------------------|
|
||||||
|
| metadata.mov | ✅ 有 | ✅ 有(同一文件,MD5 相同)|
|
||||||
|
| still-image-time 值 | 0 | 从 metadata.mov 复制(隐式为 0)|
|
||||||
|
| 分辨率策略 | 固定 1080×1920 | 可配置,默认 maxDimension=1920 |
|
||||||
|
| 视频时长 | CMTimeMake(550, 600) ≈ 0.917s | 同 |
|
||||||
|
|
||||||
|
## 技术要点总结
|
||||||
|
|
||||||
|
1. **StillImageTime 必须为 0**:-1 会导致壁纸设置时"动态不可用"
|
||||||
|
2. **metadata track 复制优于手动创建**:预制的 metadata.mov 包含完整的元数据结构,比手动构建更可靠
|
||||||
|
3. **分辨率不是壁纸兼容性的决定因素**:核心问题是元数据格式
|
||||||
@@ -1,591 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -850,8 +850,17 @@ public actor LivePhotoBuilder {
|
|||||||
exifDict[kCGImagePropertyExifPixelYDimension as String] = height
|
exifDict[kCGImagePropertyExifPixelYDimension as String] = height
|
||||||
imageProperties[kCGImagePropertyExifDictionary as String] = exifDict
|
imageProperties[kCGImagePropertyExifDictionary as String] = exifDict
|
||||||
|
|
||||||
// 简化方案:只设置 ContentIdentifier,不注入复杂的 MakerNotes
|
// HEIC Metadata 策略说明(2024.01 验证通过)
|
||||||
// 竞品也只使用 ContentIdentifier,这足以让 Photos 识别 Live Photo
|
// =========================================
|
||||||
|
// 只设置 ContentIdentifier(key "17"),无需注入复杂的二进制 MakerNotes。
|
||||||
|
//
|
||||||
|
// 原因:
|
||||||
|
// 1. 锁屏壁纸兼容性的关键是 MOV 中的 still-image-time 元数据(必须为 0),
|
||||||
|
// 而非 HEIC 的 MakerNotes 结构。参见项目记忆:livephoto_wallpaper_root_cause_still_image_time
|
||||||
|
// 2. iOS 17+ 放宽了对 Live Photo 元数据的要求,ContentIdentifier 配对即可
|
||||||
|
// 3. 竞品(live-wallpaper)验证:只使用 ContentIdentifier 即可通过壁纸设置
|
||||||
|
//
|
||||||
|
// 历史:曾开发 HEICMakerNotePatcher 进行复杂二进制注入,但测试表明无必要,已删除。
|
||||||
let assetIdentifierKey = "17" // Content Identifier
|
let assetIdentifierKey = "17" // Content Identifier
|
||||||
|
|
||||||
var makerAppleDict: [String: Any] = [:]
|
var makerAppleDict: [String: Any] = [:]
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// 用于修复 HEIC 文件中的 Apple MakerNotes,添加 LivePhotoVideoIndex 字段
|
|
||||||
/// CGImageDestination 无法正确写入 Int64 类型的 MakerNotes 字段,
|
|
||||||
/// 所以我们使用预制的模板并在运行时替换关键字段
|
|
||||||
public struct MakerNotesPatcher {
|
|
||||||
|
|
||||||
// MARK: - 模板中的偏移量(基于原生 iPhone 照片的 MakerNotes 分析)
|
|
||||||
|
|
||||||
/// ContentIdentifier 在 MakerNotes 模板中的偏移(36 字节 ASCII UUID + null)
|
|
||||||
private static let contentIdentifierOffset = 0x580 // 1408
|
|
||||||
private static let contentIdentifierLength = 36
|
|
||||||
|
|
||||||
/// LivePhotoVideoIndex 在 MakerNotes 模板中的偏移(8 字节 Big-Endian Int64)
|
|
||||||
private static let livePhotoVideoIndexOffset = 0x5a6 // 1446
|
|
||||||
private static let livePhotoVideoIndexLength = 8
|
|
||||||
|
|
||||||
/// 原生 iPhone MakerNotes 模板(从 iPhone 13 Pro Max 拍摄的 Live Photo 提取)
|
|
||||||
/// 包含完整的 Apple MakerNotes 结构,需要替换 ContentIdentifier 和 LivePhotoVideoIndex
|
|
||||||
private static let makerNotesTemplate: Data = {
|
|
||||||
// Base64 编码的 MakerNotes 模板
|
|
||||||
let base64 = """
|
|
||||||
QXBwbGUgaU9TAAABTU0APQABAAkAAAABAAAAEAACAAcAAAIAAAAC8AADAAcAAABoAAAE8AAEAAkA\
|
|
||||||
AAABAAAAAQAFAAkAAAABAAAAqQAGAAkAAAABAAAApQAHAAkAAAABAAAAAQAIAAoAAAADAAAFWAAM\
|
|
||||||
AAoAAAACAAAFcAANAAkAAAABAAAAFwAOAAkAAAABAAAABAAQAAkAAAABAAAAAQARAAIAAAAlAAAF\
|
|
||||||
gAAUAAkAAAABAAAACgAXABAAAAABAAAFpgAZAAkAAAABAAAAAgAaAAIAAAAGAAAFrgAfAAkAAAAB\
|
|
||||||
AAAAAAAgAAIAAAAlAAAFtAAhAAoAAAABAAAF2gAjAAkAAAACAAAF4gAlABAAAAABAAAF6gAmAAkA\
|
|
||||||
AAABAAAAAwAnAAoAAAABAAAF8gAoAAkAAAABAAAAAQArAAIAAAAlAAAF+gAtAAkAAAABAAATXAAu\
|
|
||||||
AAkAAAABAAAAAQAvAAkAAAABAAAAMAAwAAoAAAABAAAGIAAzAAkAAAABAAAQAAA0AAkAAAABAAAA\
|
|
||||||
BAA1AAkAAAABAAAAAwA2AAkAAAABAADnJAA3AAkAAAABAAAABAA4AAkAAAABAAACPgA5AAkAAAAB\
|
|
||||||
AAAAAAA6AAkAAAABAAAAAAA7AAkAAAABAAAAAAA8AAkAAAABAAAABAA9AAkAAAABAAAAAAA/AAkA\
|
|
||||||
AAABAAAAOwBAAAcAAABQAAAGKABBAAkAAAABAAAAAABCAAkAAAABAAAAAABDAAkAAAABAAAAAABE\
|
|
||||||
AAkAAAABAAAAAABFAAkAAAABAAAAAABGAAkAAAABAAAAAABIAAkAAAABAAACPgBJAAkAAAABAAAA\
|
|
||||||
AABKAAkAAAABAAAAAgBNAAoAAAABAAAGeABOAAcAAAB5AAAGgABPAAcAAAArAAAG+gBSAAkAAAAB\
|
|
||||||
AAAAAQBTAAkAAAABAAAAAQBVAAkAAAABAAAAAQBYAAkAAAABAAAHAwBgAAkAAAABAAASAABhAAkA\
|
|
||||||
AAABAAAAGgAAAAC9AtMCxAKWAlsCIALqAbwBkgFrAUYBJQEIAfEA3QDMAAMDMwNAA/QCmQJWAg0C\
|
|
||||||
1QGoAX4BUwEtAQ8B9QDhAM0ALANqA2oDTAPrAnUCKALpAb4BhgFWATABEAH0AN4AyQA9A7oDwgNP\
|
|
||||||
A7gCXAIYAugBqwF2AUwBJgEGAeoA0gC+ANECrwPSAxIDZAIVAt4BqwF8AVABKQEHAekA0AC6AKgA\
|
|
||||||
zwFCAnYCBAK/AZYBcwFQAS8BDQHvANQAvQCpAJgAigDUAP8AGgEVARoB5QC6AMUAsACsAJkAggBy\
|
|
||||||
AGQAXABTAGAAaABqAGYAXABMAEcARwBCAEEAPgAvACQAJAAlACoARgBIAEcARAA/ADcAMwAvACoA\
|
|
||||||
KAAnACEAHQAfAB8AIAAxADIAMQAwAC4AKwAnACcAJQAfABwAGgAXABkAEwAUACkAKgAmACQAIgAg\
|
|
||||||
ACEAIgAgAB4AHAAZABgAFAAXABUAIgAcABoAGwAaABcAHAAdABkAGAAYABgAFwAaABgAGAAaABsA\
|
|
||||||
GgAYABUAEwAWABcAEwAWABcAFQAVABYAEwAUABoAFwAWABMAEgATABAADwATABMAEgANAA8ADwAP\
|
|
||||||
AAwAEwAVABIAEwANABUAEwATABIADAAPAAsAEAASAA8ADgARABEADwAMAAwADwAWABMAEgASABQA\
|
|
||||||
DQAPAAoADAAOAGJwbGlzdDAw1AECAwQFBgcIVWZsYWdzVXZhbHVlWXRpbWVzY2FsZVVlcG9jaBAB\
|
|
||||||
EwABVEUBR7fxEjuaygAQAAgRFx0nLS84PQAAAAAAAAEBAAAAAAAAAAkAAAAAAAAAAAAAAAAAAAA/\
|
|
||||||
///J3gAANk3//8R4AAe+////5bUAAVy+AAAAOwAAAQAAAAAnAAABAAAAAAAAAAAAAAAAAAAAALtA\
|
|
||||||
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABI\
|
|
||||||
RUlDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\
|
|
||||||
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\
|
|
||||||
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAbwBwAGwAaQBzAHQAMAAwANQBAgMEBQYHCFEzUTFR\
|
|
||||||
MlEwEAQiAAAAACQ/AAAAEAEIERMVFxkbICUAAAAAAAABAQAAAAAAAAAJAAAAAAAAAAAAAAAAAAAA\
|
|
||||||
JwAC8/UAABV+YnBsaXN0MDDSAQIDSFExUTIQA6IFCtIGBwgJUzIuMVMyLjIjQEsf2IAAAACJQAAA\
|
|
||||||
AAAAAADSBgcLDCM/4hqAAAAAAKNAVMAAAAAAAAgNDxETFhsfIywlOkMAAAAAAAABAQAAAAAAAAAL\
|
|
||||||
AAAAAAAAAAAAAAAAAAAAQQAAAAA=
|
|
||||||
"""
|
|
||||||
return Data(base64Encoded: base64.replacingOccurrences(of: "\\\n", with: "").replacingOccurrences(of: "\n", with: ""))!
|
|
||||||
}()
|
|
||||||
|
|
||||||
/// 创建自定义的 MakerNotes 数据
|
|
||||||
/// - Parameters:
|
|
||||||
/// - contentIdentifier: Live Photo 的 Content Identifier (UUID 字符串格式)
|
|
||||||
/// - livePhotoVideoIndex: Live Photo Video Index (通常是帧索引的 Float32 bitPattern)
|
|
||||||
/// - Returns: 修改后的 MakerNotes 数据
|
|
||||||
public static func createMakerNotes(
|
|
||||||
contentIdentifier: String,
|
|
||||||
livePhotoVideoIndex: Int64
|
|
||||||
) -> Data {
|
|
||||||
var data = makerNotesTemplate
|
|
||||||
|
|
||||||
// 替换 ContentIdentifier
|
|
||||||
let uuidData = contentIdentifier.data(using: .ascii)!
|
|
||||||
let paddedUUID = uuidData + Data(repeating: 0, count: max(0, contentIdentifierLength - uuidData.count))
|
|
||||||
data.replaceSubrange(contentIdentifierOffset..<(contentIdentifierOffset + contentIdentifierLength), with: paddedUUID.prefix(contentIdentifierLength))
|
|
||||||
|
|
||||||
// 替换 LivePhotoVideoIndex (Big-Endian)
|
|
||||||
var bigEndianValue = UInt64(bitPattern: livePhotoVideoIndex).bigEndian
|
|
||||||
let indexData = Data(bytes: &bigEndianValue, count: livePhotoVideoIndexLength)
|
|
||||||
data.replaceSubrange(livePhotoVideoIndexOffset..<(livePhotoVideoIndexOffset + livePhotoVideoIndexLength), with: indexData)
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -41,6 +41,18 @@ enum DesignTokens {
|
|||||||
static let xxxl: CGFloat = 32
|
static let xxxl: CGFloat = 32
|
||||||
static let display: CGFloat = 40
|
static let display: CGFloat = 40
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: 动画
|
||||||
|
enum Animation {
|
||||||
|
/// 按钮、卡片等交互元素的弹性动画
|
||||||
|
static let spring = SwiftUI.Animation.spring(response: 0.3, dampingFraction: 0.6)
|
||||||
|
/// 快速状态切换
|
||||||
|
static let quick = SwiftUI.Animation.easeInOut(duration: 0.15)
|
||||||
|
/// 标准过渡
|
||||||
|
static let standard = SwiftUI.Animation.easeInOut(duration: 0.25)
|
||||||
|
/// 进度条等连续动画
|
||||||
|
static let smooth = SwiftUI.Animation.easeInOut(duration: 0.5)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 颜色系统
|
// MARK: - 颜色系统
|
||||||
@@ -59,7 +71,8 @@ extension Color {
|
|||||||
// MARK: 文字色
|
// MARK: 文字色
|
||||||
static let textPrimary = Color(light: Color(hex: "#2D2D3A"), dark: Color(hex: "#E4E4EB"))
|
static let textPrimary = Color(light: Color(hex: "#2D2D3A"), dark: Color(hex: "#E4E4EB"))
|
||||||
static let textSecondary = Color(light: Color(hex: "#6B6B7B"), dark: Color(hex: "#A0A0B2"))
|
static let textSecondary = Color(light: Color(hex: "#6B6B7B"), dark: Color(hex: "#A0A0B2"))
|
||||||
static let textMuted = Color(light: Color(hex: "#9999A9"), dark: Color(hex: "#6B6B7B"))
|
// 调整 textMuted 以满足 WCAG AA 对比度标准 (4.5:1)
|
||||||
|
static let textMuted = Color(light: Color(hex: "#777788"), dark: Color(hex: "#8888A0"))
|
||||||
|
|
||||||
// MARK: 强调色
|
// MARK: 强调色
|
||||||
static let accentPurple = Color(hex: "#6366F1")
|
static let accentPurple = Color(hex: "#6366F1")
|
||||||
@@ -137,42 +150,71 @@ extension Color {
|
|||||||
// MARK: - Soft UI 阴影
|
// MARK: - Soft UI 阴影
|
||||||
struct SoftShadow: ViewModifier {
|
struct SoftShadow: ViewModifier {
|
||||||
let isPressed: Bool
|
let isPressed: Bool
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
content
|
content
|
||||||
.shadow(
|
.shadow(
|
||||||
color: Color.black.opacity(isPressed ? 0.15 : 0.08),
|
color: shadowDark,
|
||||||
radius: isPressed ? 4 : 8,
|
radius: isPressed ? 4 : 8,
|
||||||
x: isPressed ? 2 : 4,
|
x: isPressed ? 2 : 4,
|
||||||
y: isPressed ? 2 : 4
|
y: isPressed ? 2 : 4
|
||||||
)
|
)
|
||||||
.shadow(
|
.shadow(
|
||||||
color: Color.white.opacity(isPressed ? 0.5 : 0.7),
|
color: shadowLight,
|
||||||
radius: isPressed ? 4 : 8,
|
radius: isPressed ? 4 : 8,
|
||||||
x: isPressed ? -2 : -4,
|
x: isPressed ? -2 : -4,
|
||||||
y: isPressed ? -2 : -4
|
y: isPressed ? -2 : -4
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 深色模式下使用更深的暗影
|
||||||
|
private var shadowDark: Color {
|
||||||
|
colorScheme == .dark
|
||||||
|
? Color.black.opacity(isPressed ? 0.4 : 0.3)
|
||||||
|
: Color.black.opacity(isPressed ? 0.15 : 0.08)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 深色模式下使用微弱的高光(模拟边缘光)
|
||||||
|
private var shadowLight: Color {
|
||||||
|
colorScheme == .dark
|
||||||
|
? Color.white.opacity(isPressed ? 0.03 : 0.05)
|
||||||
|
: Color.white.opacity(isPressed ? 0.5 : 0.7)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SoftInnerShadow: ViewModifier {
|
struct SoftInnerShadow: ViewModifier {
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
content
|
content
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: DesignTokens.Radius.lg)
|
RoundedRectangle(cornerRadius: DesignTokens.Radius.lg)
|
||||||
.stroke(Color.black.opacity(0.06), lineWidth: 1)
|
.stroke(innerShadowDark, lineWidth: 1)
|
||||||
.blur(radius: 2)
|
.blur(radius: 2)
|
||||||
.offset(x: 1, y: 1)
|
.offset(x: 1, y: 1)
|
||||||
.mask(RoundedRectangle(cornerRadius: DesignTokens.Radius.lg).fill(LinearGradient(colors: [.black, .clear], startPoint: .topLeading, endPoint: .bottomTrailing)))
|
.mask(RoundedRectangle(cornerRadius: DesignTokens.Radius.lg).fill(LinearGradient(colors: [.black, .clear], startPoint: .topLeading, endPoint: .bottomTrailing)))
|
||||||
)
|
)
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: DesignTokens.Radius.lg)
|
RoundedRectangle(cornerRadius: DesignTokens.Radius.lg)
|
||||||
.stroke(Color.white.opacity(0.5), lineWidth: 1)
|
.stroke(innerShadowLight, lineWidth: 1)
|
||||||
.blur(radius: 2)
|
.blur(radius: 2)
|
||||||
.offset(x: -1, y: -1)
|
.offset(x: -1, y: -1)
|
||||||
.mask(RoundedRectangle(cornerRadius: DesignTokens.Radius.lg).fill(LinearGradient(colors: [.clear, .black], startPoint: .topLeading, endPoint: .bottomTrailing)))
|
.mask(RoundedRectangle(cornerRadius: DesignTokens.Radius.lg).fill(LinearGradient(colors: [.clear, .black], startPoint: .topLeading, endPoint: .bottomTrailing)))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var innerShadowDark: Color {
|
||||||
|
colorScheme == .dark
|
||||||
|
? Color.black.opacity(0.3)
|
||||||
|
: Color.black.opacity(0.06)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var innerShadowLight: Color {
|
||||||
|
colorScheme == .dark
|
||||||
|
? Color.white.opacity(0.08)
|
||||||
|
: Color.white.opacity(0.5)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension View {
|
extension View {
|
||||||
@@ -315,11 +357,13 @@ struct SoftSecondaryButton: View {
|
|||||||
struct SoftIconButton: View {
|
struct SoftIconButton: View {
|
||||||
let icon: String
|
let icon: String
|
||||||
let isActive: Bool
|
let isActive: Bool
|
||||||
|
let accessibilityLabel: String?
|
||||||
let action: () -> Void
|
let action: () -> Void
|
||||||
|
|
||||||
init(_ icon: String, isActive: Bool = false, action: @escaping () -> Void) {
|
init(_ icon: String, isActive: Bool = false, accessibilityLabel: String? = nil, action: @escaping () -> Void) {
|
||||||
self.icon = icon
|
self.icon = icon
|
||||||
self.isActive = isActive
|
self.isActive = isActive
|
||||||
|
self.accessibilityLabel = accessibilityLabel
|
||||||
self.action = action
|
self.action = action
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,6 +391,9 @@ struct SoftIconButton: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(ScaleButtonStyle())
|
.buttonStyle(ScaleButtonStyle())
|
||||||
|
.accessibilityLabel(accessibilityLabel ?? icon)
|
||||||
|
.accessibilityAddTraits(.isButton)
|
||||||
|
.accessibilityAddTraits(isActive ? .isSelected : [])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,17 +403,20 @@ struct SoftProgressRing: View {
|
|||||||
let size: CGFloat
|
let size: CGFloat
|
||||||
let lineWidth: CGFloat
|
let lineWidth: CGFloat
|
||||||
let gradient: LinearGradient
|
let gradient: LinearGradient
|
||||||
|
let accessibilityLabel: String?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
progress: Double,
|
progress: Double,
|
||||||
size: CGFloat = 120,
|
size: CGFloat = 120,
|
||||||
lineWidth: CGFloat = 8,
|
lineWidth: CGFloat = 8,
|
||||||
gradient: LinearGradient = Color.gradientPrimary
|
gradient: LinearGradient = Color.gradientPrimary,
|
||||||
|
accessibilityLabel: String? = nil
|
||||||
) {
|
) {
|
||||||
self.progress = progress
|
self.progress = progress
|
||||||
self.size = size
|
self.size = size
|
||||||
self.lineWidth = lineWidth
|
self.lineWidth = lineWidth
|
||||||
self.gradient = gradient
|
self.gradient = gradient
|
||||||
|
self.accessibilityLabel = accessibilityLabel
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -394,8 +444,11 @@ struct SoftProgressRing: View {
|
|||||||
.stroke(gradient, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
|
.stroke(gradient, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
|
||||||
.frame(width: size - 16, height: size - 16)
|
.frame(width: size - 16, height: size - 16)
|
||||||
.rotationEffect(.degrees(-90))
|
.rotationEffect(.degrees(-90))
|
||||||
.animation(.easeInOut(duration: 0.5), value: progress)
|
.animation(DesignTokens.Animation.smooth, value: progress)
|
||||||
}
|
}
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityLabel(accessibilityLabel ?? String(localized: "进度"))
|
||||||
|
.accessibilityValue(Text("\(Int(progress * 100))%"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,15 +528,21 @@ struct SoftSlider: View {
|
|||||||
@Binding var value: Double
|
@Binding var value: Double
|
||||||
let range: ClosedRange<Double>
|
let range: ClosedRange<Double>
|
||||||
let gradient: LinearGradient
|
let gradient: LinearGradient
|
||||||
|
let accessibilityLabel: String
|
||||||
|
let step: Double
|
||||||
|
|
||||||
init(
|
init(
|
||||||
value: Binding<Double>,
|
value: Binding<Double>,
|
||||||
in range: ClosedRange<Double>,
|
in range: ClosedRange<Double>,
|
||||||
gradient: LinearGradient = Color.gradientPrimary
|
step: Double = 0.1,
|
||||||
|
gradient: LinearGradient = Color.gradientPrimary,
|
||||||
|
accessibilityLabel: String = ""
|
||||||
) {
|
) {
|
||||||
self._value = value
|
self._value = value
|
||||||
self.range = range
|
self.range = range
|
||||||
|
self.step = step
|
||||||
self.gradient = gradient
|
self.gradient = gradient
|
||||||
|
self.accessibilityLabel = accessibilityLabel
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -521,6 +580,19 @@ struct SoftSlider: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 28)
|
.frame(height: 28)
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityLabel(accessibilityLabel.isEmpty ? String(localized: "滑块") : accessibilityLabel)
|
||||||
|
.accessibilityValue(Text(String(format: "%.1f", value)))
|
||||||
|
.accessibilityAdjustableAction { direction in
|
||||||
|
switch direction {
|
||||||
|
case .increment:
|
||||||
|
value = min(range.upperBound, value + step)
|
||||||
|
case .decrement:
|
||||||
|
value = max(range.lowerBound, value - step)
|
||||||
|
@unknown default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -254,7 +254,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"home.worksCount" : {
|
"home.worksCount %lld" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@@ -1874,6 +1874,78 @@
|
|||||||
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "确定要清空最近作品记录吗?这不会删除相册中的 Live Photo。" } },
|
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "确定要清空最近作品记录吗?这不会删除相册中的 Live Photo。" } },
|
||||||
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "確定要清除最近作品記錄嗎?這不會刪除相簿中的 Live Photo。" } }
|
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "確定要清除最近作品記錄嗎?這不會刪除相簿中的 Live Photo。" } }
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"已选中" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Selected" } },
|
||||||
|
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "已选中" } },
|
||||||
|
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "已選中" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"进度" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Progress" } },
|
||||||
|
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "进度" } },
|
||||||
|
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "進度" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"滑块" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Slider" } },
|
||||||
|
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "滑块" } },
|
||||||
|
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "滑桿" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"accessibility.settings" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Settings" } },
|
||||||
|
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "设置" } },
|
||||||
|
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "設定" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"accessibility.play" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Play" } },
|
||||||
|
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "播放" } },
|
||||||
|
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "播放" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"accessibility.pause" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Pause" } },
|
||||||
|
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "暂停" } },
|
||||||
|
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "暫停" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"accessibility.livePhoto" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Live Photo" } },
|
||||||
|
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "Live Photo 作品" } },
|
||||||
|
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "Live Photo 作品" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"accessibility.aspectRatio" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Aspect ratio %@" } },
|
||||||
|
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "画面比例 %@" } },
|
||||||
|
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "畫面比例 %@" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"accessibility.duration" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Duration" } },
|
||||||
|
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "时长" } },
|
||||||
|
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "時長" } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version" : "1.0"
|
"version" : "1.0"
|
||||||
|
|||||||
@@ -86,8 +86,8 @@ struct EditorView: View {
|
|||||||
compatibilitySection
|
compatibilitySection
|
||||||
generateButton
|
generateButton
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, DesignTokens.Spacing.xl)
|
||||||
.padding(.vertical, 16)
|
.padding(.vertical, DesignTokens.Spacing.lg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,11 +117,11 @@ struct EditorView: View {
|
|||||||
compatibilitySection
|
compatibilitySection
|
||||||
generateButton
|
generateButton
|
||||||
}
|
}
|
||||||
.padding(.vertical, 16)
|
.padding(.vertical, DesignTokens.Spacing.lg)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: 360)
|
.frame(maxWidth: 360)
|
||||||
}
|
}
|
||||||
.padding(24)
|
.padding(DesignTokens.Spacing.xxl)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - iPad 裁剪预览(更大尺寸)
|
// MARK: - iPad 裁剪预览(更大尺寸)
|
||||||
@@ -239,10 +239,10 @@ struct EditorView: View {
|
|||||||
|
|
||||||
Text("选择适合壁纸的比例,锁屏推荐使用「锁屏」或「全屏」")
|
Text("选择适合壁纸的比例,锁屏推荐使用「锁屏」或「全屏」")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundColor(.textSecondary)
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
.background(Color.secondary.opacity(0.1))
|
.background(Color.softElevated)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,26 +271,26 @@ struct EditorView: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
} else {
|
} else {
|
||||||
RoundedRectangle(cornerRadius: 8)
|
RoundedRectangle(cornerRadius: 8)
|
||||||
.fill(Color.secondary.opacity(0.2))
|
.fill(Color.softPressed)
|
||||||
.frame(width: 80, height: 120)
|
.frame(width: 80, height: 120)
|
||||||
.overlay {
|
.overlay {
|
||||||
Image(systemName: "photo")
|
Image(systemName: "photo")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundColor(.textSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("此图片将作为 Live Photo 的静态封面")
|
Text("此图片将作为 Live Photo 的静态封面")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundColor(.textSecondary)
|
||||||
Text("拖动下方滑杆选择封面时刻")
|
Text("拖动下方滑杆选择封面时刻")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundColor(.textSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
.background(Color.secondary.opacity(0.1))
|
.background(Color.softElevated)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,10 +317,10 @@ struct EditorView: View {
|
|||||||
|
|
||||||
Text("Live Photo 壁纸推荐时长:1 ~ 1.5 秒")
|
Text("Live Photo 壁纸推荐时长:1 ~ 1.5 秒")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundColor(.textSecondary)
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
.background(Color.secondary.opacity(0.1))
|
.background(Color.softElevated)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,10 +348,10 @@ struct EditorView: View {
|
|||||||
|
|
||||||
Text("选择视频中的某一帧作为 Live Photo 的封面")
|
Text("选择视频中的某一帧作为 Live Photo 的封面")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundColor(.textSecondary)
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
.background(Color.secondary.opacity(0.1))
|
.background(Color.softElevated)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,7 +368,7 @@ struct EditorView: View {
|
|||||||
.font(.headline)
|
.font(.headline)
|
||||||
Text("使用 AI 提升封面画质")
|
Text("使用 AI 提升封面画质")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundColor(.textSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -388,7 +388,7 @@ struct EditorView: View {
|
|||||||
.scaleEffect(0.8)
|
.scaleEffect(0.8)
|
||||||
Text("正在下载 AI 模型...")
|
Text("正在下载 AI 模型...")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundColor(.textSecondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
ProgressView(value: aiModelDownloadProgress)
|
ProgressView(value: aiModelDownloadProgress)
|
||||||
@@ -396,7 +396,7 @@ struct EditorView: View {
|
|||||||
|
|
||||||
Text(String(format: "%.0f%%", aiModelDownloadProgress * 100))
|
Text(String(format: "%.0f%%", aiModelDownloadProgress * 100))
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundColor(.textSecondary)
|
||||||
}
|
}
|
||||||
.padding(.leading, 4)
|
.padding(.leading, 4)
|
||||||
}
|
}
|
||||||
@@ -434,7 +434,7 @@ struct EditorView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.foregroundStyle(.secondary)
|
.foregroundColor(.textSecondary)
|
||||||
.padding(.leading, 4)
|
.padding(.leading, 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,7 +445,7 @@ struct EditorView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
Text("当前设备不支持 AI 增强")
|
Text("当前设备不支持 AI 增强")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundColor(.textSecondary)
|
||||||
}
|
}
|
||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
}
|
}
|
||||||
@@ -472,7 +472,7 @@ struct EditorView: View {
|
|||||||
.font(.headline)
|
.font(.headline)
|
||||||
Text("适用于较旧设备或生成失败时")
|
Text("适用于较旧设备或生成失败时")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundColor(.textSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -509,12 +509,12 @@ struct EditorView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.foregroundStyle(.secondary)
|
.foregroundColor(.textSecondary)
|
||||||
.padding(.leading, 4)
|
.padding(.leading, 4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
.background(Color.secondary.opacity(0.1))
|
.background(Color.softElevated)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,7 +543,7 @@ struct EditorView: View {
|
|||||||
|
|
||||||
Text(suggestion.description)
|
Text(suggestion.description)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundColor(.textSecondary)
|
||||||
|
|
||||||
if let actionText = suggestion.actionText {
|
if let actionText = suggestion.actionText {
|
||||||
Button {
|
Button {
|
||||||
@@ -804,7 +804,7 @@ struct AspectRatioButton: View {
|
|||||||
VStack(spacing: 4) {
|
VStack(spacing: 4) {
|
||||||
// 比例图标
|
// 比例图标
|
||||||
RoundedRectangle(cornerRadius: 4)
|
RoundedRectangle(cornerRadius: 4)
|
||||||
.stroke(isSelected ? Color.accentColor : Color.secondary, lineWidth: 2)
|
.stroke(isSelected ? Color.accentColor : Color.textSecondary, lineWidth: 2)
|
||||||
.frame(width: iconWidth, height: iconHeight)
|
.frame(width: iconWidth, height: iconHeight)
|
||||||
.background(
|
.background(
|
||||||
isSelected ? Color.accentColor.opacity(0.1) : Color.clear
|
isSelected ? Color.accentColor.opacity(0.1) : Color.clear
|
||||||
@@ -814,14 +814,18 @@ struct AspectRatioButton: View {
|
|||||||
Text(template.displayName)
|
Text(template.displayName)
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.fontWeight(isSelected ? .semibold : .regular)
|
.fontWeight(isSelected ? .semibold : .regular)
|
||||||
.foregroundStyle(isSelected ? .primary : .secondary)
|
.foregroundColor(isSelected ? .textPrimary : .textSecondary)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, DesignTokens.Spacing.sm)
|
||||||
.background(isSelected ? Color.accentColor.opacity(0.1) : Color.clear)
|
.background(isSelected ? Color.accentColor.opacity(0.1) : Color.clear)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.sm))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityLabel(String(localized: "accessibility.aspectRatio \(template.displayName)"))
|
||||||
|
.accessibilityAddTraits(.isButton)
|
||||||
|
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||||||
}
|
}
|
||||||
|
|
||||||
private var iconWidth: CGFloat {
|
private var iconWidth: CGFloat {
|
||||||
@@ -957,7 +961,7 @@ struct ScaleButtonStyle: ButtonStyle {
|
|||||||
configuration.label
|
configuration.label
|
||||||
.scaleEffect(configuration.isPressed ? 0.96 : 1.0)
|
.scaleEffect(configuration.isPressed ? 0.96 : 1.0)
|
||||||
.opacity(configuration.isPressed ? 0.9 : 1.0)
|
.opacity(configuration.isPressed ? 0.9 : 1.0)
|
||||||
.animation(.easeInOut(duration: 0.15), value: configuration.isPressed)
|
.animation(DesignTokens.Animation.quick, value: configuration.isPressed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ struct HomeView: View {
|
|||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
SoftIconButton("gearshape") {
|
SoftIconButton("gearshape", accessibilityLabel: String(localized: "accessibility.settings")) {
|
||||||
appState.navigateTo(.settings)
|
appState.navigateTo(.settings)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -351,13 +351,17 @@ struct RecentWorkCard: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.scaleEffect(isPressed ? 0.97 : 1.0)
|
.scaleEffect(isPressed ? 0.97 : 1.0)
|
||||||
.animation(.spring(response: 0.3, dampingFraction: 0.6), value: isPressed)
|
.animation(DesignTokens.Animation.spring, value: isPressed)
|
||||||
.onLongPressGesture(minimumDuration: .infinity, pressing: { pressing in
|
.onLongPressGesture(minimumDuration: .infinity, pressing: { pressing in
|
||||||
isPressed = pressing
|
isPressed = pressing
|
||||||
}, perform: {})
|
}, perform: {})
|
||||||
.onAppear {
|
.onAppear {
|
||||||
thumbnailLoader.load(assetId: work.assetLocalIdentifier)
|
thumbnailLoader.load(assetId: work.assetLocalIdentifier)
|
||||||
}
|
}
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityLabel(String(localized: "accessibility.livePhoto"))
|
||||||
|
.accessibilityHint(Text("\(work.aspectRatioDisplayName), \(work.createdAt.formatted(.relative(presentation: .named)))"))
|
||||||
|
.accessibilityAddTraits(.isButton)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,7 +370,7 @@ struct HomeButtonStyle: ButtonStyle {
|
|||||||
func makeBody(configuration: Configuration) -> some View {
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
configuration.label
|
configuration.label
|
||||||
.scaleEffect(configuration.isPressed ? 0.97 : 1.0)
|
.scaleEffect(configuration.isPressed ? 0.97 : 1.0)
|
||||||
.animation(.spring(response: 0.3, dampingFraction: 0.6), value: configuration.isPressed)
|
.animation(DesignTokens.Animation.spring, value: configuration.isPressed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ struct SettingsView: View {
|
|||||||
Label(String(localized: "settings.cacheSize"), systemImage: "internaldrive")
|
Label(String(localized: "settings.cacheSize"), systemImage: "internaldrive")
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(cacheSize)
|
Text(cacheSize)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundColor(.textSecondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
@@ -111,7 +111,7 @@ struct SettingsView: View {
|
|||||||
Label(String(localized: "settings.version"), systemImage: "info.circle")
|
Label(String(localized: "settings.version"), systemImage: "info.circle")
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(appVersion)
|
Text(appVersion)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundColor(.textSecondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
@@ -175,7 +175,7 @@ struct SettingsView: View {
|
|||||||
.labelStyle(.iconOnly)
|
.labelStyle(.iconOnly)
|
||||||
case .notDetermined:
|
case .notDetermined:
|
||||||
Label(String(localized: "settings.notDetermined"), systemImage: "questionmark.circle.fill")
|
Label(String(localized: "settings.notDetermined"), systemImage: "questionmark.circle.fill")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundColor(.textSecondary)
|
||||||
.labelStyle(.iconOnly)
|
.labelStyle(.iconOnly)
|
||||||
@unknown default:
|
@unknown default:
|
||||||
EmptyView()
|
EmptyView()
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ struct WallpaperGuideView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.background(Color.secondary.opacity(0.1))
|
.background(Color.softElevated)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -316,7 +316,7 @@ struct FAQRow: View {
|
|||||||
}
|
}
|
||||||
.padding(14)
|
.padding(14)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(Color.secondary.opacity(0.08))
|
.background(Color.softElevated)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user