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
|
||||
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 {
|
||||
@@ -399,13 +418,22 @@ public actor LivePhotoValidator {
|
||||
}
|
||||
|
||||
return await withCheckedContinuation { continuation in
|
||||
let resumeOnce = ResumeOnce()
|
||||
let options = PHLivePhotoRequestOptions()
|
||||
options.deliveryMode = .highQualityFormat
|
||||
PHImageManager.default().requestLivePhoto(
|
||||
for: asset,
|
||||
targetSize: CGSize(width: 1, height: 1),
|
||||
contentMode: .aspectFit,
|
||||
options: nil
|
||||
) { livePhoto, _ in
|
||||
continuation.resume(returning: livePhoto)
|
||||
options: options
|
||||
) { livePhoto, info in
|
||||
// 跳过降级版本,等待完整版本
|
||||
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
|
||||
)
|
||||
|
||||
// 根据用户选择的裁剪时长动态计算目标时长(上限 5 秒)
|
||||
let trimmedSeconds = min(max(exportParams.trimEnd - exportParams.trimStart, 0.5), 5.0)
|
||||
// 从实际裁剪后的视频读取真实时长,避免 trimEnd 超过视频实际时长导致偏差
|
||||
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)
|
||||
progress?(LivePhotoBuildProgress(stage: .normalize, fraction: 0.5))
|
||||
let scaledVideoURL = try await scaleVideoToTargetDuration(
|
||||
@@ -535,8 +565,19 @@ public actor LivePhotoBuilder {
|
||||
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))
|
||||
|
||||
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,
|
||||
queue: .main
|
||||
) { [weak self] notification in
|
||||
guard let self else { return }
|
||||
let userInfo = notification.userInfo
|
||||
let reason = userInfo?[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int
|
||||
let keys = userInfo?[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String]
|
||||
|
||||
@@ -88,17 +88,21 @@ final class RecentWorksManager: ObservableObject {
|
||||
let identifiers = recentWorks.map { $0.assetLocalIdentifier }
|
||||
guard !identifiers.isEmpty else { return }
|
||||
|
||||
let fetchResult = PHAsset.fetchAssets(withLocalIdentifiers: identifiers, options: nil)
|
||||
var existingIds = Set<String>()
|
||||
fetchResult.enumerateObjects { asset, _, _ in
|
||||
existingIds.insert(asset.localIdentifier)
|
||||
}
|
||||
Task.detached { [identifiers] in
|
||||
let fetchResult = PHAsset.fetchAssets(withLocalIdentifiers: identifiers, options: nil)
|
||||
var existingIds = Set<String>()
|
||||
fetchResult.enumerateObjects { asset, _, _ in
|
||||
existingIds.insert(asset.localIdentifier)
|
||||
}
|
||||
|
||||
let originalCount = recentWorks.count
|
||||
recentWorks.removeAll { !existingIds.contains($0.assetLocalIdentifier) }
|
||||
|
||||
if recentWorks.count != originalCount {
|
||||
saveToStorage()
|
||||
await MainActor.run { [weak self, existingIds] in
|
||||
guard let self else { return }
|
||||
let before = self.recentWorks.count
|
||||
self.recentWorks.removeAll { !existingIds.contains($0.assetLocalIdentifier) }
|
||||
if self.recentWorks.count != before {
|
||||
self.saveToStorage()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +114,6 @@ final class RecentWorksManager: ObservableObject {
|
||||
object: iCloudStore,
|
||||
queue: .main
|
||||
) { [weak self] notification in
|
||||
guard let self else { return }
|
||||
let userInfo = notification.userInfo
|
||||
let reason = userInfo?[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int
|
||||
let keys = userInfo?[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String]
|
||||
@@ -197,27 +200,35 @@ final class RecentWorksManager: ObservableObject {
|
||||
@MainActor
|
||||
final class ThumbnailLoader: ObservableObject {
|
||||
@Published var thumbnail: UIImage?
|
||||
@Published private(set) var isLoading = false
|
||||
|
||||
func load(assetId: String, targetSize: CGSize = CGSize(width: 200, height: 300)) {
|
||||
let result = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil)
|
||||
guard let asset = result.firstObject else {
|
||||
thumbnail = nil
|
||||
return
|
||||
}
|
||||
guard !isLoading else { return }
|
||||
isLoading = true
|
||||
|
||||
let options = PHImageRequestOptions()
|
||||
options.deliveryMode = .opportunistic
|
||||
options.isNetworkAccessAllowed = true
|
||||
options.resizeMode = .fast
|
||||
Task.detached { [weak self] in
|
||||
let result = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil)
|
||||
guard let asset = result.firstObject else {
|
||||
await MainActor.run { [weak self] in
|
||||
self?.isLoading = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
PHImageManager.default().requestImage(
|
||||
for: asset,
|
||||
targetSize: targetSize,
|
||||
contentMode: .aspectFill,
|
||||
options: options
|
||||
) { [weak self] image, _ in
|
||||
Task { @MainActor in
|
||||
self?.thumbnail = image
|
||||
let options = PHImageRequestOptions()
|
||||
options.deliveryMode = .opportunistic
|
||||
options.isNetworkAccessAllowed = true
|
||||
|
||||
PHImageManager.default().requestImage(
|
||||
for: asset,
|
||||
targetSize: targetSize,
|
||||
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)
|
||||
.tint(Color.accentPurple)
|
||||
|
||||
Text(String(format: "%.0f%%", aiModelDownloadProgress * 100))
|
||||
Text(aiModelDownloadProgress, format: .percent.precision(.fractionLength(0)))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.textSecondary)
|
||||
}
|
||||
@@ -618,8 +618,12 @@ struct EditorView: View {
|
||||
|
||||
if let actionText = suggestion.actionText {
|
||||
Button {
|
||||
withAnimation {
|
||||
compatibilityMode = true
|
||||
if let action = suggestion.action {
|
||||
action()
|
||||
} else {
|
||||
withAnimation {
|
||||
compatibilityMode = true
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text(actionText)
|
||||
@@ -685,42 +689,45 @@ struct EditorView: View {
|
||||
@ViewBuilder
|
||||
private var presetPickerSheet: some View {
|
||||
NavigationStack {
|
||||
if PresetManager.shared.presets.isEmpty {
|
||||
ContentUnavailableView(
|
||||
String(localized: "editor.presetEmpty"),
|
||||
systemImage: "bookmark",
|
||||
description: Text(String(localized: "editor.presetEmptyHint"))
|
||||
)
|
||||
}
|
||||
List {
|
||||
ForEach(PresetManager.shared.presets) { preset in
|
||||
Button {
|
||||
applyPreset(preset)
|
||||
showPresetPicker = false
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(preset.name)
|
||||
.font(.body)
|
||||
.foregroundStyle(.primary)
|
||||
HStack(spacing: DesignTokens.Spacing.sm) {
|
||||
Text(preset.aspectRatio.displayName)
|
||||
Text("·")
|
||||
Text(String(format: String(localized: "editor.durationSeconds"), preset.trimDuration))
|
||||
if preset.aiEnhance {
|
||||
Text("· AI")
|
||||
}
|
||||
if preset.compatibilityMode {
|
||||
Text("· " + String(localized: "editor.compatibilityShort"))
|
||||
Group {
|
||||
if PresetManager.shared.presets.isEmpty {
|
||||
ContentUnavailableView(
|
||||
String(localized: "editor.presetEmpty"),
|
||||
systemImage: "bookmark",
|
||||
description: Text(String(localized: "editor.presetEmptyHint"))
|
||||
)
|
||||
} else {
|
||||
List {
|
||||
ForEach(PresetManager.shared.presets) { preset in
|
||||
Button {
|
||||
applyPreset(preset)
|
||||
showPresetPicker = false
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(preset.name)
|
||||
.font(.body)
|
||||
.foregroundStyle(.primary)
|
||||
HStack(spacing: DesignTokens.Spacing.sm) {
|
||||
Text(preset.aspectRatio.displayName)
|
||||
Text("·")
|
||||
Text(String(format: String(localized: "editor.durationSeconds"), preset.trimDuration))
|
||||
if preset.aiEnhance {
|
||||
Text("· AI")
|
||||
}
|
||||
if preset.compatibilityMode {
|
||||
Text("· " + String(localized: "editor.compatibilityShort"))
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
for index in indexSet {
|
||||
PresetManager.shared.removePreset(PresetManager.shared.presets[index])
|
||||
.onDelete { indexSet in
|
||||
for index in indexSet {
|
||||
PresetManager.shared.removePreset(PresetManager.shared.presets[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1002,6 +1009,7 @@ struct EditorView: View {
|
||||
|
||||
Task {
|
||||
guard let data = try? await item.loadTransferable(type: Data.self),
|
||||
data.count < 50_000_000,
|
||||
let image = UIImage(data: data) else {
|
||||
return
|
||||
}
|
||||
@@ -1010,12 +1018,23 @@ struct EditorView: View {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
let fileURL = tempDir.appendingPathComponent("custom_cover_\(UUID().uuidString).jpg")
|
||||
if let jpegData = image.jpegData(compressionQuality: 0.95) {
|
||||
try? jpegData.write(to: fileURL)
|
||||
await MainActor.run {
|
||||
cleanupCustomCoverFile()
|
||||
customCoverImage = image
|
||||
customCoverURL = fileURL
|
||||
coverImportCount += 1
|
||||
do {
|
||||
try jpegData.write(to: fileURL)
|
||||
await MainActor.run {
|
||||
cleanupCustomCoverFile()
|
||||
customCoverImage = image
|
||||
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 {
|
||||
errorMessage = String(localized: "home.loadFailed")
|
||||
isLoading = false
|
||||
selectedItem = nil
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
Analytics.shared.log(.importVideoSuccess)
|
||||
appState.navigateTo(.editor(videoURL: movie.url))
|
||||
selectedItem = nil
|
||||
} catch {
|
||||
let format = String(localized: "home.loadError")
|
||||
errorMessage = String(format: format, error.localizedDescription)
|
||||
isLoading = false
|
||||
selectedItem = nil
|
||||
Analytics.shared.logError(.importVideoFail, error: error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,7 +237,10 @@ struct ResultView: View {
|
||||
return
|
||||
}
|
||||
|
||||
let photo = await withCheckedContinuation { continuation in
|
||||
let photo = await withCheckedContinuation { (continuation: CheckedContinuation<PHLivePhoto?, Never>) in
|
||||
let lock = NSLock()
|
||||
var hasResumed = false
|
||||
|
||||
PHLivePhoto.request(
|
||||
withResourceFileURLs: [imageURL, videoURL],
|
||||
placeholderImage: nil,
|
||||
@@ -245,8 +248,19 @@ struct ResultView: View {
|
||||
contentMode: .aspectFit
|
||||
) { result, info in
|
||||
let isDegraded = (info[PHLivePhotoInfoIsDegradedKey] as? Bool) ?? false
|
||||
if !isDegraded {
|
||||
continuation.resume(returning: result)
|
||||
let isCancelled = (info[PHLivePhotoInfoCancelledKey] as? Bool) ?? false
|
||||
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