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))
|
||||
|
||||
Reference in New Issue
Block a user