fix: 代码审查 P2 建议项修复(22 项体验优化)

EditorView (8 项):
- 时长警告图标区分:分享警告改用 square.and.arrow.up
- coverExtractionTask 竞态防护:新增 isViewActive 守卫
- sensoryFeedback 优化:缩放触觉仅在手势结束时触发
- iPad 右侧面板增加水平内边距
- 预设列表/兼容模式/AI 区域硬编码间距替换为 DesignTokens
- 诊断按钮 padding 替换为 DesignTokens
- generateButton 补充 accessibilityLabel

PresetManager + RecentWorksManager (5 项):
- iCloud 合并回写 + 防循环标志位
- iCloud 配额防护(>900KB 跳过写入)
- QuotaViolation/AccountChange 事件处理
- EditingPreset 自定义 Codable(decodeIfPresent + 默认值)
- RecentWork aspectRatio 枚举化
- 清理 saveToLocalOnly 死代码

ResultView + ProcessingView + HomeView (5 项):
- ResultView animateIn 改用 structured concurrency
- ProcessingView 阶段数提取为常量
- ProcessingView 脉冲动画去重
- HomeView 删除触觉升级为 .warning
- HomeView Cancel 按钮本地化

LivePhotoCore + AppState (4 项):
- coverImageURL 参数去重,内部从 exportParams 读取
- progress 回调钳位 min(pct, 1.0)
- CropRect 新增 clamped() 归一化校验
- AppState 初始化错误增加 os.Logger 日志

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
empty
2026-02-08 00:34:44 +08:00
parent f3bcaf4651
commit c826689ee4
8 changed files with 146 additions and 80 deletions

View File

@@ -82,13 +82,23 @@ public struct CropRect: Codable, Sendable, Hashable {
///
public static let full = CropRect()
/// [0, 1] CropRect x+width <= 1, y+height <= 1
public func clamped() -> CropRect {
let clampedX = min(max(x, 0), 1)
let clampedY = min(max(y, 0), 1)
let clampedW = min(max(width, 0), 1 - clampedX)
let clampedH = min(max(height, 0), 1 - clampedY)
return CropRect(x: clampedX, y: clampedY, width: clampedW, height: clampedH)
}
///
public func toPixelRect(videoSize: CGSize) -> CGRect {
CGRect(
x: x * videoSize.width,
y: y * videoSize.height,
width: width * videoSize.width,
height: height * videoSize.height
let safe = clamped()
return CGRect(
x: safe.x * videoSize.width,
y: safe.y * videoSize.height,
width: safe.width * videoSize.width,
height: safe.height * videoSize.height
)
}
}
@@ -521,10 +531,10 @@ public actor LivePhotoBuilder {
public func buildResources(
workId: UUID = UUID(),
sourceVideoURL: URL,
coverImageURL: URL? = nil,
exportParams: ExportParams = ExportParams(),
progress: (@Sendable (LivePhotoBuildProgress) -> Void)? = nil
) async throws -> LivePhotoBuildOutput {
let coverImageURL = exportParams.coverImageURL
let assetIdentifier = UUID().uuidString
let paths = try cacheManager.makeWorkPaths(workId: workId)
@@ -1070,7 +1080,7 @@ public actor LivePhotoBuilder {
if let sampleBuffer = videoReaderOutput.copyNextSampleBuffer() {
currentFrameCount += 1
let pct = Double(currentFrameCount) / Double(frameCount)
progress(pct)
progress(min(pct, 1.0))
videoWriterInput.append(sampleBuffer)
} else {
videoWriterInput.markAsFinished()
@@ -1192,14 +1202,12 @@ public actor LivePhotoWorkflow {
public func buildSaveValidate(
workId: UUID = UUID(),
sourceVideoURL: URL,
coverImageURL: URL? = nil,
exportParams: ExportParams = ExportParams(),
progress: (@Sendable (LivePhotoBuildProgress) -> Void)? = nil
) async throws -> LivePhotoWorkflowResult {
let output = try await builder.buildResources(
workId: workId,
sourceVideoURL: sourceVideoURL,
coverImageURL: coverImageURL,
exportParams: exportParams,
progress: progress
)

View File

@@ -5,6 +5,7 @@
// App +
//
import os
import SwiftUI
import PhotosUI
import LivePhotoCore
@@ -34,11 +35,13 @@ final class AppState {
private var workflow: LivePhotoWorkflow?
private var currentProcessingTask: Task<LivePhotoWorkflowResult?, Never>?
private var currentWorkId: UUID?
private let logger = Logger(subsystem: "ToLivePhoto", category: "AppState")
init() {
do {
workflow = try LivePhotoWorkflow()
} catch {
logger.error("Failed to init LivePhotoWorkflow: \(error.localizedDescription, privacy: .public)")
#if DEBUG
print("Failed to init LivePhotoWorkflow: \(error)")
#endif
@@ -117,7 +120,6 @@ final class AppState {
let result = try await workflow.buildSaveValidate(
workId: workId,
sourceVideoURL: videoURL,
coverImageURL: exportParams.coverImageURL,
exportParams: exportParams
) { progress in
Task { @MainActor in

View File

@@ -18,6 +18,37 @@ struct EditingPreset: Codable, Identifiable, Hashable {
let trimDuration: Double
let aiEnhance: Bool
let compatibilityMode: Bool
/// 使 decodeIfPresent +
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
aspectRatio = try container.decodeIfPresent(AspectRatioTemplate.self, forKey: .aspectRatio) ?? .original
trimDuration = try container.decodeIfPresent(Double.self, forKey: .trimDuration) ?? 3.0
aiEnhance = try container.decodeIfPresent(Bool.self, forKey: .aiEnhance) ?? false
compatibilityMode = try container.decodeIfPresent(Bool.self, forKey: .compatibilityMode) ?? false
}
/// memberwise init 使
init(
id: UUID,
name: String,
createdAt: Date,
aspectRatio: AspectRatioTemplate,
trimDuration: Double,
aiEnhance: Bool,
compatibilityMode: Bool
) {
self.id = id
self.name = name
self.createdAt = createdAt
self.aspectRatio = aspectRatio
self.trimDuration = trimDuration
self.aiEnhance = aiEnhance
self.compatibilityMode = compatibilityMode
}
}
/// UserDefaults + iCloud 10
@@ -31,6 +62,8 @@ final class PresetManager: ObservableObject {
private let storageKey = "editing_presets_v1"
private let iCloudKey = "editing_presets_v1"
private let iCloudStore = NSUbiquitousKeyValueStore.default
/// iCloud
private var isHandlingICloudChange = false
private init() {
loadFromStorage()
@@ -92,10 +125,19 @@ final class PresetManager: ObservableObject {
return
}
isHandlingICloudChange = true
defer { isHandlingICloudChange = false }
switch reason {
case NSUbiquitousKeyValueStoreServerChange,
NSUbiquitousKeyValueStoreInitialSyncChange:
mergeFromICloud()
case NSUbiquitousKeyValueStoreQuotaViolationChange:
#if DEBUG
print("[PresetManager] iCloud quota violated — data may not sync")
#endif
case NSUbiquitousKeyValueStoreAccountChange:
mergeFromICloud()
default:
break
}
@@ -114,7 +156,7 @@ final class PresetManager: ObservableObject {
merged.sort { $0.createdAt > $1.createdAt }
presets = Array(merged.prefix(maxCount))
saveToLocalOnly()
persistToStorage()
}
// MARK: -
@@ -138,6 +180,18 @@ final class PresetManager: ObservableObject {
do {
let data = try JSONEncoder().encode(presets)
UserDefaults.standard.set(data, forKey: storageKey)
// true iCloud
guard !isHandlingICloudChange else { return }
// iCloud KVS key 1MB 100KB
if data.count > 900_000 {
#if DEBUG
print("[PresetManager] Data size \(data.count) exceeds iCloud safe limit, skipping iCloud write")
#endif
return
}
iCloudStore.set(data, forKey: iCloudKey)
} catch {
#if DEBUG
@@ -145,15 +199,4 @@ final class PresetManager: ObservableObject {
#endif
}
}
private func saveToLocalOnly() {
do {
let data = try JSONEncoder().encode(presets)
UserDefaults.standard.set(data, forKey: storageKey)
} catch {
#if DEBUG
print("[PresetManager] Failed to encode: \(error)")
#endif
}
}
}

View File

@@ -7,6 +7,7 @@
import Combine
import Foundation
import LivePhotoCore
import UIKit
import Photos
@@ -15,18 +16,20 @@ struct RecentWork: Codable, Identifiable, Hashable {
let id: UUID
let createdAt: Date
let assetLocalIdentifier: String // PHAsset localIdentifier
let aspectRatioRaw: String // AspectRatioTemplate.rawValue
let aspectRatioRaw: String // AspectRatioTemplate.rawValue
let compatibilityMode: Bool
/// 访rawValue nil
var aspectRatio: AspectRatioTemplate? {
AspectRatioTemplate(rawValue: aspectRatioRaw)
}
/// 使 displayNamefallback raw string
var aspectRatioDisplayName: String {
switch aspectRatioRaw {
case "original": return String(localized: "aspectRatio.original")
case "lock_screen": return String(localized: "aspectRatio.lockScreen")
case "full_screen": return String(localized: "aspectRatio.fullScreen")
case "classic": return String(localized: "aspectRatio.classic")
case "square": return String(localized: "aspectRatio.square")
default: return aspectRatioRaw
if let template = aspectRatio {
return template.displayName
}
return aspectRatioRaw
}
}
@@ -41,6 +44,8 @@ final class RecentWorksManager: ObservableObject {
private let userDefaultsKey = "recent_works_v1"
private let iCloudKey = "recent_works_v1"
private let iCloudStore = NSUbiquitousKeyValueStore.default
/// iCloud
private var isHandlingICloudChange = false
private init() {
loadFromStorage()
@@ -130,10 +135,19 @@ final class RecentWorksManager: ObservableObject {
return
}
isHandlingICloudChange = true
defer { isHandlingICloudChange = false }
switch reason {
case NSUbiquitousKeyValueStoreServerChange,
NSUbiquitousKeyValueStoreInitialSyncChange:
mergeFromICloud()
case NSUbiquitousKeyValueStoreQuotaViolationChange:
#if DEBUG
print("[RecentWorksManager] iCloud quota violated — data may not sync")
#endif
case NSUbiquitousKeyValueStoreAccountChange:
mergeFromICloud()
default:
break
}
@@ -152,7 +166,7 @@ final class RecentWorksManager: ObservableObject {
merged.sort { $0.createdAt > $1.createdAt }
recentWorks = Array(merged.prefix(maxCount))
saveToLocalOnly()
saveToStorage()
}
// MARK: -
@@ -176,18 +190,19 @@ final class RecentWorksManager: ObservableObject {
do {
let data = try JSONEncoder().encode(recentWorks)
UserDefaults.standard.set(data, forKey: userDefaultsKey)
iCloudStore.set(data, forKey: iCloudKey)
} catch {
#if DEBUG
print("[RecentWorksManager] Failed to encode: \(error)")
#endif
}
}
private func saveToLocalOnly() {
do {
let data = try JSONEncoder().encode(recentWorks)
UserDefaults.standard.set(data, forKey: userDefaultsKey)
// true iCloud
guard !isHandlingICloudChange else { return }
// iCloud KVS key 1MB 100KB
if data.count > 900_000 {
#if DEBUG
print("[RecentWorksManager] Data size \(data.count) exceeds iCloud safe limit, skipping iCloud write")
#endif
return
}
iCloudStore.set(data, forKey: iCloudKey)
} catch {
#if DEBUG
print("[RecentWorksManager] Failed to encode: \(error)")

View File

@@ -59,10 +59,14 @@ struct EditorView: View {
@State private var presetName = ""
@State private var showPresetPicker = false
//
@State private var isViewActive = true
//
@State private var generateTapCount: Int = 0
@State private var presetSavedCount: Int = 0
@State private var coverImportCount: Int = 0
@State private var cropScaleHapticCount: Int = 0
/// 使 iPad regular +
private var useIPadLayout: Bool {
@@ -80,9 +84,11 @@ struct EditorView: View {
.navigationTitle(String(localized: "editor.title"))
.navigationBarTitleDisplayMode(.inline)
.onAppear {
isViewActive = true
loadVideo()
}
.onDisappear {
isViewActive = false
player?.pause()
coverExtractionTask?.cancel()
coverExtractionTask = nil
@@ -90,7 +96,7 @@ struct EditorView: View {
}
.sensoryFeedback(.selection, trigger: selectedAspectRatio)
.sensoryFeedback(.impact(weight: .medium), trigger: generateTapCount)
.sensoryFeedback(.impact(weight: .light), trigger: lastCropScale)
.sensoryFeedback(.impact(weight: .light), trigger: cropScaleHapticCount)
.sensoryFeedback(.success, trigger: presetSavedCount)
.sensoryFeedback(.selection, trigger: coverImportCount)
}
@@ -147,6 +153,7 @@ struct EditorView: View {
presetSection
generateButton
}
.padding(.horizontal, DesignTokens.Spacing.lg)
.padding(.vertical, DesignTokens.Spacing.lg)
}
.frame(minWidth: 320, maxWidth: 420)
@@ -163,6 +170,7 @@ struct EditorView: View {
}
.onEnded { value in
lastCropScale = max(1.0, min(3.0, lastCropScale * value))
cropScaleHapticCount += 1
},
DragGesture()
.onChanged { value in
@@ -371,7 +379,7 @@ struct EditorView: View {
if trimEnd - trimStart > 3 {
HStack(alignment: .top, spacing: DesignTokens.Spacing.sm) {
Image(systemName: "exclamationmark.triangle")
Image(systemName: "square.and.arrow.up")
.foregroundStyle(Color.accentOrange)
.font(.caption)
Text(String(localized: "editor.durationShareWarning"))
@@ -434,7 +442,7 @@ struct EditorView: View {
HStack {
Image(systemName: "wand.and.stars.inverse")
.foregroundStyle(Color.accentPurple)
VStack(alignment: .leading, spacing: 2) {
VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) {
Text(String(localized: "editor.aiEnhance"))
.font(.headline)
Text(String(localized: "editor.aiEnhanceDescription"))
@@ -473,7 +481,7 @@ struct EditorView: View {
}
if aiEnhanceEnabled && !aiModelDownloading {
VStack(alignment: .leading, spacing: 6) {
VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) {
if aiModelNeedsDownload {
HStack(spacing: DesignTokens.Spacing.xs) {
Image(systemName: "arrow.down.circle")
@@ -538,7 +546,7 @@ struct EditorView: View {
HStack {
Image(systemName: "gearshape.2")
.foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 2) {
VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) {
Text(String(localized: "editor.compatibilityMode"))
.font(.headline)
Text(String(localized: "editor.compatibilityDescription"))
@@ -550,7 +558,7 @@ struct EditorView: View {
.tint(.accentColor)
if compatibilityMode {
VStack(alignment: .leading, spacing: 6) {
VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) {
HStack(spacing: DesignTokens.Spacing.xs) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
@@ -630,7 +638,7 @@ struct EditorView: View {
.font(.caption)
.fontWeight(.medium)
}
.padding(.top, 2)
.padding(.top, DesignTokens.Spacing.xs)
}
}
}
@@ -703,7 +711,7 @@ struct EditorView: View {
applyPreset(preset)
showPresetPicker = false
} label: {
VStack(alignment: .leading, spacing: 4) {
VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) {
Text(preset.name)
.font(.body)
.foregroundStyle(.primary)
@@ -754,6 +762,7 @@ struct EditorView: View {
) {
startProcessing()
}
.accessibilityLabel(String(localized: "editor.generateButton"))
.padding(.top, DesignTokens.Spacing.sm)
.padding(.bottom, DesignTokens.Spacing.sm)
}
@@ -814,6 +823,7 @@ struct EditorView: View {
}
await MainActor.run {
guard isViewActive else { return }
videoDuration = durationSeconds
trimEnd = max(0.1, min(1.0, durationSeconds))
keyFrameTime = trimEnd / 2

View File

@@ -67,7 +67,7 @@ struct HomeView: View {
showRecentWorks = true
}
}
.sensoryFeedback(.impact(weight: .light), trigger: deleteWorkCount)
.sensoryFeedback(.warning, trigger: deleteWorkCount)
}
// MARK: - Hero
@@ -248,7 +248,7 @@ struct HomeView: View {
Button(String(localized: "home.clearAll"), role: .destructive) {
recentWorks.clearAll()
}
Button("Cancel", role: .cancel) {}
Button(String(localized: "common.cancel"), role: .cancel) {}
} message: {
Text(String(localized: "home.clearAllConfirmMessage"))
}

View File

@@ -17,6 +17,9 @@ struct ProcessingView: View {
@State private var hasStarted = false
@State private var pulseAnimation = false
/// LivePhotoBuildProgress.Stage case
private let totalStages = 7
var body: some View {
ZStack {
//
@@ -89,7 +92,6 @@ struct ProcessingView: View {
Circle()
.fill(Color.accentPurple.opacity(0.1))
.frame(width: pulseAnimation ? 175 : 160, height: pulseAnimation ? 175 : 160)
.animation(.easeInOut(duration: 2.0).repeatForever(autoreverses: true), value: pulseAnimation)
//
SoftProgressRing(
@@ -133,7 +135,7 @@ struct ProcessingView: View {
//
HStack(spacing: DesignTokens.Spacing.sm) {
ForEach(0..<7) { index in
ForEach(0..<totalStages, id: \.self) { index in
Circle()
.fill(index <= currentStageIndex ? Color.accentPurple : Color.softPressed)
.frame(width: 8, height: 8)
@@ -225,7 +227,7 @@ struct ProcessingView: View {
/// = ( + fraction) /
private var overallProgress: Double {
let totalStages = 7.0
let totalStages = Double(self.totalStages)
let stageFraction = appState.processingProgress?.fraction ?? 0
return (Double(currentStageIndex) + stageFraction) / totalStages
}

View File

@@ -55,8 +55,17 @@ struct ResultView: View {
.navigationTitle(String(localized: "result.title"))
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true)
.onAppear {
animateIn()
.task {
// structured concurrency
withAnimation { showIcon = true }
if isSuccess {
try? await Task.sleep(for: .seconds(0.2))
celebrationParticles = true
}
try? await Task.sleep(for: .seconds(0.3))
showContent = true
try? await Task.sleep(for: .seconds(0.2))
showButtons = true
}
.task {
guard isSuccess else { return }
@@ -203,29 +212,6 @@ struct ResultView: View {
!workflowResult.savedAssetId.isEmpty
}
// MARK: -
private func animateIn() {
//
withAnimation {
showIcon = true
}
if isSuccess {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
celebrationParticles = true
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
showContent = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
showButtons = true
}
}
// MARK: - Live Photo
private func loadLivePhoto() async {