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:
empty
2026-02-08 00:19:54 +08:00
parent ec2e0a3ce5
commit f3bcaf4651
7 changed files with 10887 additions and 10826 deletions

View File

@@ -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

View File

@@ -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]

View File

@@ -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
}
} }
} }
} }

View File

@@ -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
} }
} }
} }

View File

@@ -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)
} }
} }

View File

@@ -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)
} }
} }
} }