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

View File

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

View File

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

View File

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

View File

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

View File

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