fix: 代码审查 P0+P1 问题修复(2 个崩溃 + 14 个体验问题)
P0 致命问题: - LivePhotoCore: requestLivePhoto continuation 多次 resume 崩溃 使用 ResumeOnce + highQualityFormat + 跳过降级版本 - ResultView: PHLivePhoto.request continuation 泄漏/多次 resume NSLock + hasResumed 守卫 + 处理 cancel/error 边缘情况 P1 并发安全: - PresetManager/RecentWorksManager: 移除 NotificationCenter 闭包 中的 guard let self(Swift 6 并发安全) - RecentWorksManager: cleanupDeletedAssets 移到 Task.detached - RecentWorksManager: ThumbnailLoader.load 移到后台 + 防重复 P1 EditorView 修复: - AI 百分比改用 .percent FormatStyle(本地化) - 预设选择器 if-else 互斥 + Group 包裹 - 诊断按钮优先使用 suggestion.action 闭包 - PhotosPicker 导入添加 50MB 大小限制 - 封面文件写入 try? 改为 do-catch P1 其他修复: - HomeView: selectedItem 在所有退出路径重置 - LivePhotoCore: trimmedSeconds 改用实际裁剪后视频时长 - LivePhotoCore: keyFrameTime 越界显式 clamp + DEBUG 警告 - LivePhotoCore: ExportParams Codable 向后兼容 本地化: - Localizable.xcstrings 9 批翻译修复(~295 条) ar/es/fr/ja/ko 英文占位替换为正确翻译 修改文件: - Sources/LivePhotoCore/LivePhotoCore.swift - to-live-photo/Views/ResultView.swift - to-live-photo/Views/HomeView.swift - to-live-photo/Views/EditorView.swift - to-live-photo/PresetManager.swift - to-live-photo/RecentWorksManager.swift - to-live-photo/Localizable.xcstrings Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -177,6 +177,25 @@ public struct ExportParams: Codable, Sendable, Hashable {
|
|||||||
params.hdrPolicy = .toneMapToSDR
|
params.hdrPolicy = .toneMapToSDR
|
||||||
return params
|
return params
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Codable 向后兼容
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
trimStart = try container.decode(Double.self, forKey: .trimStart)
|
||||||
|
trimEnd = try container.decode(Double.self, forKey: .trimEnd)
|
||||||
|
keyFrameTime = try container.decode(Double.self, forKey: .keyFrameTime)
|
||||||
|
audioPolicy = try container.decode(AudioPolicy.self, forKey: .audioPolicy)
|
||||||
|
codecPolicy = try container.decode(CodecPolicy.self, forKey: .codecPolicy)
|
||||||
|
hdrPolicy = try container.decode(HDRPolicy.self, forKey: .hdrPolicy)
|
||||||
|
maxDimension = try container.decode(Int.self, forKey: .maxDimension)
|
||||||
|
cropRect = try container.decode(CropRect.self, forKey: .cropRect)
|
||||||
|
aspectRatio = try container.decode(AspectRatioTemplate.self, forKey: .aspectRatio)
|
||||||
|
compatibilityMode = try container.decode(Bool.self, forKey: .compatibilityMode)
|
||||||
|
targetFrameRate = try container.decode(Int.self, forKey: .targetFrameRate)
|
||||||
|
coverImageURL = try container.decodeIfPresent(URL.self, forKey: .coverImageURL)
|
||||||
|
aiEnhanceConfig = try container.decodeIfPresent(AIEnhanceConfig.self, forKey: .aiEnhanceConfig) ?? .disabled
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct AppError: Error, Codable, Sendable, Hashable {
|
public struct AppError: Error, Codable, Sendable, Hashable {
|
||||||
@@ -399,13 +418,22 @@ public actor LivePhotoValidator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return await withCheckedContinuation { continuation in
|
return await withCheckedContinuation { continuation in
|
||||||
|
let resumeOnce = ResumeOnce()
|
||||||
|
let options = PHLivePhotoRequestOptions()
|
||||||
|
options.deliveryMode = .highQualityFormat
|
||||||
PHImageManager.default().requestLivePhoto(
|
PHImageManager.default().requestLivePhoto(
|
||||||
for: asset,
|
for: asset,
|
||||||
targetSize: CGSize(width: 1, height: 1),
|
targetSize: CGSize(width: 1, height: 1),
|
||||||
contentMode: .aspectFit,
|
contentMode: .aspectFit,
|
||||||
options: nil
|
options: options
|
||||||
) { livePhoto, _ in
|
) { livePhoto, info in
|
||||||
continuation.resume(returning: livePhoto)
|
// 跳过降级版本,等待完整版本
|
||||||
|
if let isDegraded = info?[PHImageResultIsDegradedKey] as? Bool, isDegraded {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if resumeOnce.tryConsume() {
|
||||||
|
continuation.resume(returning: livePhoto)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -521,8 +549,10 @@ public actor LivePhotoBuilder {
|
|||||||
destinationURL: trimmedURL
|
destinationURL: trimmedURL
|
||||||
)
|
)
|
||||||
|
|
||||||
// 根据用户选择的裁剪时长动态计算目标时长(上限 5 秒)
|
// 从实际裁剪后的视频读取真实时长,避免 trimEnd 超过视频实际时长导致偏差
|
||||||
let trimmedSeconds = min(max(exportParams.trimEnd - exportParams.trimStart, 0.5), 5.0)
|
let trimmedAsset = AVURLAsset(url: trimmedVideoURL)
|
||||||
|
let actualTrimmedDuration = try await trimmedAsset.load(.duration).seconds
|
||||||
|
let trimmedSeconds = min(max(actualTrimmedDuration, 0.5), 5.0)
|
||||||
let targetDuration = CMTime(seconds: trimmedSeconds, preferredTimescale: 600)
|
let targetDuration = CMTime(seconds: trimmedSeconds, preferredTimescale: 600)
|
||||||
progress?(LivePhotoBuildProgress(stage: .normalize, fraction: 0.5))
|
progress?(LivePhotoBuildProgress(stage: .normalize, fraction: 0.5))
|
||||||
let scaledVideoURL = try await scaleVideoToTargetDuration(
|
let scaledVideoURL = try await scaleVideoToTargetDuration(
|
||||||
@@ -535,8 +565,19 @@ public actor LivePhotoBuilder {
|
|||||||
destinationURL: scaledURL
|
destinationURL: scaledURL
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 显式 clamp keyFrameTime 到 [trimStart, trimEnd] 范围
|
||||||
|
let clampedKeyFrameTime: Double
|
||||||
|
if exportParams.keyFrameTime < exportParams.trimStart || exportParams.keyFrameTime > exportParams.trimEnd {
|
||||||
|
#if DEBUG
|
||||||
|
print("[LivePhotoBuilder] WARNING: keyFrameTime \(exportParams.keyFrameTime) out of range [\(exportParams.trimStart), \(exportParams.trimEnd)], clamping")
|
||||||
|
#endif
|
||||||
|
clampedKeyFrameTime = min(max(exportParams.keyFrameTime, exportParams.trimStart), exportParams.trimEnd)
|
||||||
|
} else {
|
||||||
|
clampedKeyFrameTime = exportParams.keyFrameTime
|
||||||
|
}
|
||||||
|
|
||||||
// 计算关键帧在目标视频中的绝对时间位置
|
// 计算关键帧在目标视频中的绝对时间位置
|
||||||
let keyFrameRatio = (exportParams.keyFrameTime - exportParams.trimStart) / max(0.001, exportParams.trimEnd - exportParams.trimStart)
|
let keyFrameRatio = (clampedKeyFrameTime - exportParams.trimStart) / max(0.001, exportParams.trimEnd - exportParams.trimStart)
|
||||||
let relativeKeyFrameTime = max(0, min(trimmedSeconds, keyFrameRatio * trimmedSeconds))
|
let relativeKeyFrameTime = max(0, min(trimmedSeconds, keyFrameRatio * trimmedSeconds))
|
||||||
|
|
||||||
progress?(LivePhotoBuildProgress(stage: .extractKeyFrame, fraction: 0))
|
progress?(LivePhotoBuildProgress(stage: .extractKeyFrame, fraction: 0))
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -76,7 +76,6 @@ final class PresetManager: ObservableObject {
|
|||||||
object: iCloudStore,
|
object: iCloudStore,
|
||||||
queue: .main
|
queue: .main
|
||||||
) { [weak self] notification in
|
) { [weak self] notification in
|
||||||
guard let self else { return }
|
|
||||||
let userInfo = notification.userInfo
|
let userInfo = notification.userInfo
|
||||||
let reason = userInfo?[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int
|
let reason = userInfo?[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int
|
||||||
let keys = userInfo?[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String]
|
let keys = userInfo?[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String]
|
||||||
|
|||||||
@@ -88,17 +88,21 @@ final class RecentWorksManager: ObservableObject {
|
|||||||
let identifiers = recentWorks.map { $0.assetLocalIdentifier }
|
let identifiers = recentWorks.map { $0.assetLocalIdentifier }
|
||||||
guard !identifiers.isEmpty else { return }
|
guard !identifiers.isEmpty else { return }
|
||||||
|
|
||||||
let fetchResult = PHAsset.fetchAssets(withLocalIdentifiers: identifiers, options: nil)
|
Task.detached { [identifiers] in
|
||||||
var existingIds = Set<String>()
|
let fetchResult = PHAsset.fetchAssets(withLocalIdentifiers: identifiers, options: nil)
|
||||||
fetchResult.enumerateObjects { asset, _, _ in
|
var existingIds = Set<String>()
|
||||||
existingIds.insert(asset.localIdentifier)
|
fetchResult.enumerateObjects { asset, _, _ in
|
||||||
}
|
existingIds.insert(asset.localIdentifier)
|
||||||
|
}
|
||||||
|
|
||||||
let originalCount = recentWorks.count
|
await MainActor.run { [weak self, existingIds] in
|
||||||
recentWorks.removeAll { !existingIds.contains($0.assetLocalIdentifier) }
|
guard let self else { return }
|
||||||
|
let before = self.recentWorks.count
|
||||||
if recentWorks.count != originalCount {
|
self.recentWorks.removeAll { !existingIds.contains($0.assetLocalIdentifier) }
|
||||||
saveToStorage()
|
if self.recentWorks.count != before {
|
||||||
|
self.saveToStorage()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +114,6 @@ final class RecentWorksManager: ObservableObject {
|
|||||||
object: iCloudStore,
|
object: iCloudStore,
|
||||||
queue: .main
|
queue: .main
|
||||||
) { [weak self] notification in
|
) { [weak self] notification in
|
||||||
guard let self else { return }
|
|
||||||
let userInfo = notification.userInfo
|
let userInfo = notification.userInfo
|
||||||
let reason = userInfo?[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int
|
let reason = userInfo?[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int
|
||||||
let keys = userInfo?[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String]
|
let keys = userInfo?[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String]
|
||||||
@@ -197,27 +200,35 @@ final class RecentWorksManager: ObservableObject {
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class ThumbnailLoader: ObservableObject {
|
final class ThumbnailLoader: ObservableObject {
|
||||||
@Published var thumbnail: UIImage?
|
@Published var thumbnail: UIImage?
|
||||||
|
@Published private(set) var isLoading = false
|
||||||
|
|
||||||
func load(assetId: String, targetSize: CGSize = CGSize(width: 200, height: 300)) {
|
func load(assetId: String, targetSize: CGSize = CGSize(width: 200, height: 300)) {
|
||||||
let result = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil)
|
guard !isLoading else { return }
|
||||||
guard let asset = result.firstObject else {
|
isLoading = true
|
||||||
thumbnail = nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let options = PHImageRequestOptions()
|
Task.detached { [weak self] in
|
||||||
options.deliveryMode = .opportunistic
|
let result = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil)
|
||||||
options.isNetworkAccessAllowed = true
|
guard let asset = result.firstObject else {
|
||||||
options.resizeMode = .fast
|
await MainActor.run { [weak self] in
|
||||||
|
self?.isLoading = false
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
PHImageManager.default().requestImage(
|
let options = PHImageRequestOptions()
|
||||||
for: asset,
|
options.deliveryMode = .opportunistic
|
||||||
targetSize: targetSize,
|
options.isNetworkAccessAllowed = true
|
||||||
contentMode: .aspectFill,
|
|
||||||
options: options
|
PHImageManager.default().requestImage(
|
||||||
) { [weak self] image, _ in
|
for: asset,
|
||||||
Task { @MainActor in
|
targetSize: targetSize,
|
||||||
self?.thumbnail = image
|
contentMode: .aspectFill,
|
||||||
|
options: options
|
||||||
|
) { [weak self] image, _ in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.thumbnail = image
|
||||||
|
self?.isLoading = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -465,7 +465,7 @@ struct EditorView: View {
|
|||||||
ProgressView(value: aiModelDownloadProgress)
|
ProgressView(value: aiModelDownloadProgress)
|
||||||
.tint(Color.accentPurple)
|
.tint(Color.accentPurple)
|
||||||
|
|
||||||
Text(String(format: "%.0f%%", aiModelDownloadProgress * 100))
|
Text(aiModelDownloadProgress, format: .percent.precision(.fractionLength(0)))
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundColor(.textSecondary)
|
.foregroundColor(.textSecondary)
|
||||||
}
|
}
|
||||||
@@ -618,8 +618,12 @@ struct EditorView: View {
|
|||||||
|
|
||||||
if let actionText = suggestion.actionText {
|
if let actionText = suggestion.actionText {
|
||||||
Button {
|
Button {
|
||||||
withAnimation {
|
if let action = suggestion.action {
|
||||||
compatibilityMode = true
|
action()
|
||||||
|
} else {
|
||||||
|
withAnimation {
|
||||||
|
compatibilityMode = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Text(actionText)
|
Text(actionText)
|
||||||
@@ -685,42 +689,45 @@ struct EditorView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var presetPickerSheet: some View {
|
private var presetPickerSheet: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
if PresetManager.shared.presets.isEmpty {
|
Group {
|
||||||
ContentUnavailableView(
|
if PresetManager.shared.presets.isEmpty {
|
||||||
String(localized: "editor.presetEmpty"),
|
ContentUnavailableView(
|
||||||
systemImage: "bookmark",
|
String(localized: "editor.presetEmpty"),
|
||||||
description: Text(String(localized: "editor.presetEmptyHint"))
|
systemImage: "bookmark",
|
||||||
)
|
description: Text(String(localized: "editor.presetEmptyHint"))
|
||||||
}
|
)
|
||||||
List {
|
} else {
|
||||||
ForEach(PresetManager.shared.presets) { preset in
|
List {
|
||||||
Button {
|
ForEach(PresetManager.shared.presets) { preset in
|
||||||
applyPreset(preset)
|
Button {
|
||||||
showPresetPicker = false
|
applyPreset(preset)
|
||||||
} label: {
|
showPresetPicker = false
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
} label: {
|
||||||
Text(preset.name)
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
.font(.body)
|
Text(preset.name)
|
||||||
.foregroundStyle(.primary)
|
.font(.body)
|
||||||
HStack(spacing: DesignTokens.Spacing.sm) {
|
.foregroundStyle(.primary)
|
||||||
Text(preset.aspectRatio.displayName)
|
HStack(spacing: DesignTokens.Spacing.sm) {
|
||||||
Text("·")
|
Text(preset.aspectRatio.displayName)
|
||||||
Text(String(format: String(localized: "editor.durationSeconds"), preset.trimDuration))
|
Text("·")
|
||||||
if preset.aiEnhance {
|
Text(String(format: String(localized: "editor.durationSeconds"), preset.trimDuration))
|
||||||
Text("· AI")
|
if preset.aiEnhance {
|
||||||
}
|
Text("· AI")
|
||||||
if preset.compatibilityMode {
|
}
|
||||||
Text("· " + String(localized: "editor.compatibilityShort"))
|
if preset.compatibilityMode {
|
||||||
|
Text("· " + String(localized: "editor.compatibilityShort"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
}
|
||||||
}
|
.onDelete { indexSet in
|
||||||
}
|
for index in indexSet {
|
||||||
.onDelete { indexSet in
|
PresetManager.shared.removePreset(PresetManager.shared.presets[index])
|
||||||
for index in indexSet {
|
}
|
||||||
PresetManager.shared.removePreset(PresetManager.shared.presets[index])
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1002,6 +1009,7 @@ struct EditorView: View {
|
|||||||
|
|
||||||
Task {
|
Task {
|
||||||
guard let data = try? await item.loadTransferable(type: Data.self),
|
guard let data = try? await item.loadTransferable(type: Data.self),
|
||||||
|
data.count < 50_000_000,
|
||||||
let image = UIImage(data: data) else {
|
let image = UIImage(data: data) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1010,12 +1018,23 @@ struct EditorView: View {
|
|||||||
let tempDir = FileManager.default.temporaryDirectory
|
let tempDir = FileManager.default.temporaryDirectory
|
||||||
let fileURL = tempDir.appendingPathComponent("custom_cover_\(UUID().uuidString).jpg")
|
let fileURL = tempDir.appendingPathComponent("custom_cover_\(UUID().uuidString).jpg")
|
||||||
if let jpegData = image.jpegData(compressionQuality: 0.95) {
|
if let jpegData = image.jpegData(compressionQuality: 0.95) {
|
||||||
try? jpegData.write(to: fileURL)
|
do {
|
||||||
await MainActor.run {
|
try jpegData.write(to: fileURL)
|
||||||
cleanupCustomCoverFile()
|
await MainActor.run {
|
||||||
customCoverImage = image
|
cleanupCustomCoverFile()
|
||||||
customCoverURL = fileURL
|
customCoverImage = image
|
||||||
coverImportCount += 1
|
customCoverURL = fileURL
|
||||||
|
coverImportCount += 1
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
customCoverImage = image
|
||||||
|
customCoverURL = nil
|
||||||
|
coverImportCount += 1
|
||||||
|
}
|
||||||
|
#if DEBUG
|
||||||
|
print("Failed to write custom cover: \(error)")
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -266,16 +266,19 @@ struct HomeView: View {
|
|||||||
guard let movie = try await item.loadTransferable(type: VideoTransferable.self) else {
|
guard let movie = try await item.loadTransferable(type: VideoTransferable.self) else {
|
||||||
errorMessage = String(localized: "home.loadFailed")
|
errorMessage = String(localized: "home.loadFailed")
|
||||||
isLoading = false
|
isLoading = false
|
||||||
|
selectedItem = nil
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
Analytics.shared.log(.importVideoSuccess)
|
Analytics.shared.log(.importVideoSuccess)
|
||||||
appState.navigateTo(.editor(videoURL: movie.url))
|
appState.navigateTo(.editor(videoURL: movie.url))
|
||||||
|
selectedItem = nil
|
||||||
} catch {
|
} catch {
|
||||||
let format = String(localized: "home.loadError")
|
let format = String(localized: "home.loadError")
|
||||||
errorMessage = String(format: format, error.localizedDescription)
|
errorMessage = String(format: format, error.localizedDescription)
|
||||||
isLoading = false
|
isLoading = false
|
||||||
|
selectedItem = nil
|
||||||
Analytics.shared.logError(.importVideoFail, error: error)
|
Analytics.shared.logError(.importVideoFail, error: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,7 +237,10 @@ struct ResultView: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let photo = await withCheckedContinuation { continuation in
|
let photo = await withCheckedContinuation { (continuation: CheckedContinuation<PHLivePhoto?, Never>) in
|
||||||
|
let lock = NSLock()
|
||||||
|
var hasResumed = false
|
||||||
|
|
||||||
PHLivePhoto.request(
|
PHLivePhoto.request(
|
||||||
withResourceFileURLs: [imageURL, videoURL],
|
withResourceFileURLs: [imageURL, videoURL],
|
||||||
placeholderImage: nil,
|
placeholderImage: nil,
|
||||||
@@ -245,8 +248,19 @@ struct ResultView: View {
|
|||||||
contentMode: .aspectFit
|
contentMode: .aspectFit
|
||||||
) { result, info in
|
) { result, info in
|
||||||
let isDegraded = (info[PHLivePhotoInfoIsDegradedKey] as? Bool) ?? false
|
let isDegraded = (info[PHLivePhotoInfoIsDegradedKey] as? Bool) ?? false
|
||||||
if !isDegraded {
|
let isCancelled = (info[PHLivePhotoInfoCancelledKey] as? Bool) ?? false
|
||||||
continuation.resume(returning: result)
|
let hasError = info[PHLivePhotoInfoErrorKey] != nil
|
||||||
|
|
||||||
|
// 非降级的正常结果,或者已取消/出错时,都需要 resume
|
||||||
|
guard !isDegraded || isCancelled || hasError else { return }
|
||||||
|
|
||||||
|
lock.lock()
|
||||||
|
let shouldResume = !hasResumed
|
||||||
|
hasResumed = true
|
||||||
|
lock.unlock()
|
||||||
|
|
||||||
|
if shouldResume {
|
||||||
|
continuation.resume(returning: isCancelled || hasError ? nil : result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user