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:
@@ -82,13 +82,23 @@ public struct CropRect: Codable, Sendable, Hashable {
|
|||||||
/// 全画幅(不裁剪)
|
/// 全画幅(不裁剪)
|
||||||
public static let full = CropRect()
|
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 {
|
public func toPixelRect(videoSize: CGSize) -> CGRect {
|
||||||
CGRect(
|
let safe = clamped()
|
||||||
x: x * videoSize.width,
|
return CGRect(
|
||||||
y: y * videoSize.height,
|
x: safe.x * videoSize.width,
|
||||||
width: width * videoSize.width,
|
y: safe.y * videoSize.height,
|
||||||
height: height * videoSize.height
|
width: safe.width * videoSize.width,
|
||||||
|
height: safe.height * videoSize.height
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -521,10 +531,10 @@ public actor LivePhotoBuilder {
|
|||||||
public func buildResources(
|
public func buildResources(
|
||||||
workId: UUID = UUID(),
|
workId: UUID = UUID(),
|
||||||
sourceVideoURL: URL,
|
sourceVideoURL: URL,
|
||||||
coverImageURL: URL? = nil,
|
|
||||||
exportParams: ExportParams = ExportParams(),
|
exportParams: ExportParams = ExportParams(),
|
||||||
progress: (@Sendable (LivePhotoBuildProgress) -> Void)? = nil
|
progress: (@Sendable (LivePhotoBuildProgress) -> Void)? = nil
|
||||||
) async throws -> LivePhotoBuildOutput {
|
) async throws -> LivePhotoBuildOutput {
|
||||||
|
let coverImageURL = exportParams.coverImageURL
|
||||||
let assetIdentifier = UUID().uuidString
|
let assetIdentifier = UUID().uuidString
|
||||||
let paths = try cacheManager.makeWorkPaths(workId: workId)
|
let paths = try cacheManager.makeWorkPaths(workId: workId)
|
||||||
|
|
||||||
@@ -1070,7 +1080,7 @@ public actor LivePhotoBuilder {
|
|||||||
if let sampleBuffer = videoReaderOutput.copyNextSampleBuffer() {
|
if let sampleBuffer = videoReaderOutput.copyNextSampleBuffer() {
|
||||||
currentFrameCount += 1
|
currentFrameCount += 1
|
||||||
let pct = Double(currentFrameCount) / Double(frameCount)
|
let pct = Double(currentFrameCount) / Double(frameCount)
|
||||||
progress(pct)
|
progress(min(pct, 1.0))
|
||||||
videoWriterInput.append(sampleBuffer)
|
videoWriterInput.append(sampleBuffer)
|
||||||
} else {
|
} else {
|
||||||
videoWriterInput.markAsFinished()
|
videoWriterInput.markAsFinished()
|
||||||
@@ -1192,14 +1202,12 @@ public actor LivePhotoWorkflow {
|
|||||||
public func buildSaveValidate(
|
public func buildSaveValidate(
|
||||||
workId: UUID = UUID(),
|
workId: UUID = UUID(),
|
||||||
sourceVideoURL: URL,
|
sourceVideoURL: URL,
|
||||||
coverImageURL: URL? = nil,
|
|
||||||
exportParams: ExportParams = ExportParams(),
|
exportParams: ExportParams = ExportParams(),
|
||||||
progress: (@Sendable (LivePhotoBuildProgress) -> Void)? = nil
|
progress: (@Sendable (LivePhotoBuildProgress) -> Void)? = nil
|
||||||
) async throws -> LivePhotoWorkflowResult {
|
) async throws -> LivePhotoWorkflowResult {
|
||||||
let output = try await builder.buildResources(
|
let output = try await builder.buildResources(
|
||||||
workId: workId,
|
workId: workId,
|
||||||
sourceVideoURL: sourceVideoURL,
|
sourceVideoURL: sourceVideoURL,
|
||||||
coverImageURL: coverImageURL,
|
|
||||||
exportParams: exportParams,
|
exportParams: exportParams,
|
||||||
progress: progress
|
progress: progress
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
// App 全局状态管理 + 页面导航状态机
|
// App 全局状态管理 + 页面导航状态机
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import os
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import PhotosUI
|
import PhotosUI
|
||||||
import LivePhotoCore
|
import LivePhotoCore
|
||||||
@@ -34,11 +35,13 @@ final class AppState {
|
|||||||
private var workflow: LivePhotoWorkflow?
|
private var workflow: LivePhotoWorkflow?
|
||||||
private var currentProcessingTask: Task<LivePhotoWorkflowResult?, Never>?
|
private var currentProcessingTask: Task<LivePhotoWorkflowResult?, Never>?
|
||||||
private var currentWorkId: UUID?
|
private var currentWorkId: UUID?
|
||||||
|
private let logger = Logger(subsystem: "ToLivePhoto", category: "AppState")
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
do {
|
do {
|
||||||
workflow = try LivePhotoWorkflow()
|
workflow = try LivePhotoWorkflow()
|
||||||
} catch {
|
} catch {
|
||||||
|
logger.error("Failed to init LivePhotoWorkflow: \(error.localizedDescription, privacy: .public)")
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("Failed to init LivePhotoWorkflow: \(error)")
|
print("Failed to init LivePhotoWorkflow: \(error)")
|
||||||
#endif
|
#endif
|
||||||
@@ -117,7 +120,6 @@ final class AppState {
|
|||||||
let result = try await workflow.buildSaveValidate(
|
let result = try await workflow.buildSaveValidate(
|
||||||
workId: workId,
|
workId: workId,
|
||||||
sourceVideoURL: videoURL,
|
sourceVideoURL: videoURL,
|
||||||
coverImageURL: exportParams.coverImageURL,
|
|
||||||
exportParams: exportParams
|
exportParams: exportParams
|
||||||
) { progress in
|
) { progress in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
|
|||||||
@@ -18,6 +18,37 @@ struct EditingPreset: Codable, Identifiable, Hashable {
|
|||||||
let trimDuration: Double
|
let trimDuration: Double
|
||||||
let aiEnhance: Bool
|
let aiEnhance: Bool
|
||||||
let compatibilityMode: 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 个预设)
|
/// 预设管理器(UserDefaults + iCloud 持久化,最多 10 个预设)
|
||||||
@@ -31,6 +62,8 @@ final class PresetManager: ObservableObject {
|
|||||||
private let storageKey = "editing_presets_v1"
|
private let storageKey = "editing_presets_v1"
|
||||||
private let iCloudKey = "editing_presets_v1"
|
private let iCloudKey = "editing_presets_v1"
|
||||||
private let iCloudStore = NSUbiquitousKeyValueStore.default
|
private let iCloudStore = NSUbiquitousKeyValueStore.default
|
||||||
|
/// 防止 iCloud 合并回写触发循环
|
||||||
|
private var isHandlingICloudChange = false
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
loadFromStorage()
|
loadFromStorage()
|
||||||
@@ -92,10 +125,19 @@ final class PresetManager: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isHandlingICloudChange = true
|
||||||
|
defer { isHandlingICloudChange = false }
|
||||||
|
|
||||||
switch reason {
|
switch reason {
|
||||||
case NSUbiquitousKeyValueStoreServerChange,
|
case NSUbiquitousKeyValueStoreServerChange,
|
||||||
NSUbiquitousKeyValueStoreInitialSyncChange:
|
NSUbiquitousKeyValueStoreInitialSyncChange:
|
||||||
mergeFromICloud()
|
mergeFromICloud()
|
||||||
|
case NSUbiquitousKeyValueStoreQuotaViolationChange:
|
||||||
|
#if DEBUG
|
||||||
|
print("[PresetManager] iCloud quota violated — data may not sync")
|
||||||
|
#endif
|
||||||
|
case NSUbiquitousKeyValueStoreAccountChange:
|
||||||
|
mergeFromICloud()
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -114,7 +156,7 @@ final class PresetManager: ObservableObject {
|
|||||||
|
|
||||||
merged.sort { $0.createdAt > $1.createdAt }
|
merged.sort { $0.createdAt > $1.createdAt }
|
||||||
presets = Array(merged.prefix(maxCount))
|
presets = Array(merged.prefix(maxCount))
|
||||||
saveToLocalOnly()
|
persistToStorage()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 持久化
|
// MARK: - 持久化
|
||||||
@@ -138,6 +180,18 @@ final class PresetManager: ObservableObject {
|
|||||||
do {
|
do {
|
||||||
let data = try JSONEncoder().encode(presets)
|
let data = try JSONEncoder().encode(presets)
|
||||||
UserDefaults.standard.set(data, forKey: storageKey)
|
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)
|
iCloudStore.set(data, forKey: iCloudKey)
|
||||||
} catch {
|
} catch {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
@@ -145,15 +199,4 @@ final class PresetManager: ObservableObject {
|
|||||||
#endif
|
#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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import Combine
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import LivePhotoCore
|
||||||
import UIKit
|
import UIKit
|
||||||
import Photos
|
import Photos
|
||||||
|
|
||||||
@@ -15,18 +16,20 @@ struct RecentWork: Codable, Identifiable, Hashable {
|
|||||||
let id: UUID
|
let id: UUID
|
||||||
let createdAt: Date
|
let createdAt: Date
|
||||||
let assetLocalIdentifier: String // PHAsset 的 localIdentifier
|
let assetLocalIdentifier: String // PHAsset 的 localIdentifier
|
||||||
let aspectRatioRaw: String // AspectRatioTemplate.rawValue
|
let aspectRatioRaw: String // AspectRatioTemplate.rawValue(保留以兼容旧数据)
|
||||||
let compatibilityMode: Bool
|
let compatibilityMode: Bool
|
||||||
|
|
||||||
|
/// 类型安全的枚举访问,rawValue 无法匹配时返回 nil
|
||||||
|
var aspectRatio: AspectRatioTemplate? {
|
||||||
|
AspectRatioTemplate(rawValue: aspectRatioRaw)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 优先使用枚举的 displayName,fallback 到 raw string
|
||||||
var aspectRatioDisplayName: String {
|
var aspectRatioDisplayName: String {
|
||||||
switch aspectRatioRaw {
|
if let template = aspectRatio {
|
||||||
case "original": return String(localized: "aspectRatio.original")
|
return template.displayName
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
return aspectRatioRaw
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +44,8 @@ final class RecentWorksManager: ObservableObject {
|
|||||||
private let userDefaultsKey = "recent_works_v1"
|
private let userDefaultsKey = "recent_works_v1"
|
||||||
private let iCloudKey = "recent_works_v1"
|
private let iCloudKey = "recent_works_v1"
|
||||||
private let iCloudStore = NSUbiquitousKeyValueStore.default
|
private let iCloudStore = NSUbiquitousKeyValueStore.default
|
||||||
|
/// 防止 iCloud 合并回写触发循环
|
||||||
|
private var isHandlingICloudChange = false
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
loadFromStorage()
|
loadFromStorage()
|
||||||
@@ -130,10 +135,19 @@ final class RecentWorksManager: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isHandlingICloudChange = true
|
||||||
|
defer { isHandlingICloudChange = false }
|
||||||
|
|
||||||
switch reason {
|
switch reason {
|
||||||
case NSUbiquitousKeyValueStoreServerChange,
|
case NSUbiquitousKeyValueStoreServerChange,
|
||||||
NSUbiquitousKeyValueStoreInitialSyncChange:
|
NSUbiquitousKeyValueStoreInitialSyncChange:
|
||||||
mergeFromICloud()
|
mergeFromICloud()
|
||||||
|
case NSUbiquitousKeyValueStoreQuotaViolationChange:
|
||||||
|
#if DEBUG
|
||||||
|
print("[RecentWorksManager] iCloud quota violated — data may not sync")
|
||||||
|
#endif
|
||||||
|
case NSUbiquitousKeyValueStoreAccountChange:
|
||||||
|
mergeFromICloud()
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -152,7 +166,7 @@ final class RecentWorksManager: ObservableObject {
|
|||||||
|
|
||||||
merged.sort { $0.createdAt > $1.createdAt }
|
merged.sort { $0.createdAt > $1.createdAt }
|
||||||
recentWorks = Array(merged.prefix(maxCount))
|
recentWorks = Array(merged.prefix(maxCount))
|
||||||
saveToLocalOnly()
|
saveToStorage()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 持久化
|
// MARK: - 持久化
|
||||||
@@ -176,18 +190,19 @@ final class RecentWorksManager: ObservableObject {
|
|||||||
do {
|
do {
|
||||||
let data = try JSONEncoder().encode(recentWorks)
|
let data = try JSONEncoder().encode(recentWorks)
|
||||||
UserDefaults.standard.set(data, forKey: userDefaultsKey)
|
UserDefaults.standard.set(data, forKey: userDefaultsKey)
|
||||||
iCloudStore.set(data, forKey: iCloudKey)
|
|
||||||
} catch {
|
|
||||||
#if DEBUG
|
|
||||||
print("[RecentWorksManager] Failed to encode: \(error)")
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func saveToLocalOnly() {
|
// 标志位为 true 时只写本地,防止 iCloud 写入循环
|
||||||
do {
|
guard !isHandlingICloudChange else { return }
|
||||||
let data = try JSONEncoder().encode(recentWorks)
|
|
||||||
UserDefaults.standard.set(data, forKey: userDefaultsKey)
|
// 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 {
|
} catch {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("[RecentWorksManager] Failed to encode: \(error)")
|
print("[RecentWorksManager] Failed to encode: \(error)")
|
||||||
|
|||||||
@@ -59,10 +59,14 @@ struct EditorView: View {
|
|||||||
@State private var presetName = ""
|
@State private var presetName = ""
|
||||||
@State private var showPresetPicker = false
|
@State private var showPresetPicker = false
|
||||||
|
|
||||||
|
// 视图生命周期
|
||||||
|
@State private var isViewActive = true
|
||||||
|
|
||||||
// 触觉反馈触发
|
// 触觉反馈触发
|
||||||
@State private var generateTapCount: Int = 0
|
@State private var generateTapCount: Int = 0
|
||||||
@State private var presetSavedCount: Int = 0
|
@State private var presetSavedCount: Int = 0
|
||||||
@State private var coverImportCount: Int = 0
|
@State private var coverImportCount: Int = 0
|
||||||
|
@State private var cropScaleHapticCount: Int = 0
|
||||||
|
|
||||||
/// 是否使用 iPad 分栏布局(regular 宽度 + 横屏)
|
/// 是否使用 iPad 分栏布局(regular 宽度 + 横屏)
|
||||||
private var useIPadLayout: Bool {
|
private var useIPadLayout: Bool {
|
||||||
@@ -80,9 +84,11 @@ struct EditorView: View {
|
|||||||
.navigationTitle(String(localized: "editor.title"))
|
.navigationTitle(String(localized: "editor.title"))
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
isViewActive = true
|
||||||
loadVideo()
|
loadVideo()
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
|
isViewActive = false
|
||||||
player?.pause()
|
player?.pause()
|
||||||
coverExtractionTask?.cancel()
|
coverExtractionTask?.cancel()
|
||||||
coverExtractionTask = nil
|
coverExtractionTask = nil
|
||||||
@@ -90,7 +96,7 @@ struct EditorView: View {
|
|||||||
}
|
}
|
||||||
.sensoryFeedback(.selection, trigger: selectedAspectRatio)
|
.sensoryFeedback(.selection, trigger: selectedAspectRatio)
|
||||||
.sensoryFeedback(.impact(weight: .medium), trigger: generateTapCount)
|
.sensoryFeedback(.impact(weight: .medium), trigger: generateTapCount)
|
||||||
.sensoryFeedback(.impact(weight: .light), trigger: lastCropScale)
|
.sensoryFeedback(.impact(weight: .light), trigger: cropScaleHapticCount)
|
||||||
.sensoryFeedback(.success, trigger: presetSavedCount)
|
.sensoryFeedback(.success, trigger: presetSavedCount)
|
||||||
.sensoryFeedback(.selection, trigger: coverImportCount)
|
.sensoryFeedback(.selection, trigger: coverImportCount)
|
||||||
}
|
}
|
||||||
@@ -147,6 +153,7 @@ struct EditorView: View {
|
|||||||
presetSection
|
presetSection
|
||||||
generateButton
|
generateButton
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, DesignTokens.Spacing.lg)
|
||||||
.padding(.vertical, DesignTokens.Spacing.lg)
|
.padding(.vertical, DesignTokens.Spacing.lg)
|
||||||
}
|
}
|
||||||
.frame(minWidth: 320, maxWidth: 420)
|
.frame(minWidth: 320, maxWidth: 420)
|
||||||
@@ -163,6 +170,7 @@ struct EditorView: View {
|
|||||||
}
|
}
|
||||||
.onEnded { value in
|
.onEnded { value in
|
||||||
lastCropScale = max(1.0, min(3.0, lastCropScale * value))
|
lastCropScale = max(1.0, min(3.0, lastCropScale * value))
|
||||||
|
cropScaleHapticCount += 1
|
||||||
},
|
},
|
||||||
DragGesture()
|
DragGesture()
|
||||||
.onChanged { value in
|
.onChanged { value in
|
||||||
@@ -371,7 +379,7 @@ struct EditorView: View {
|
|||||||
|
|
||||||
if trimEnd - trimStart > 3 {
|
if trimEnd - trimStart > 3 {
|
||||||
HStack(alignment: .top, spacing: DesignTokens.Spacing.sm) {
|
HStack(alignment: .top, spacing: DesignTokens.Spacing.sm) {
|
||||||
Image(systemName: "exclamationmark.triangle")
|
Image(systemName: "square.and.arrow.up")
|
||||||
.foregroundStyle(Color.accentOrange)
|
.foregroundStyle(Color.accentOrange)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
Text(String(localized: "editor.durationShareWarning"))
|
Text(String(localized: "editor.durationShareWarning"))
|
||||||
@@ -434,7 +442,7 @@ struct EditorView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "wand.and.stars.inverse")
|
Image(systemName: "wand.and.stars.inverse")
|
||||||
.foregroundStyle(Color.accentPurple)
|
.foregroundStyle(Color.accentPurple)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) {
|
||||||
Text(String(localized: "editor.aiEnhance"))
|
Text(String(localized: "editor.aiEnhance"))
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
Text(String(localized: "editor.aiEnhanceDescription"))
|
Text(String(localized: "editor.aiEnhanceDescription"))
|
||||||
@@ -473,7 +481,7 @@ struct EditorView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if aiEnhanceEnabled && !aiModelDownloading {
|
if aiEnhanceEnabled && !aiModelDownloading {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) {
|
||||||
if aiModelNeedsDownload {
|
if aiModelNeedsDownload {
|
||||||
HStack(spacing: DesignTokens.Spacing.xs) {
|
HStack(spacing: DesignTokens.Spacing.xs) {
|
||||||
Image(systemName: "arrow.down.circle")
|
Image(systemName: "arrow.down.circle")
|
||||||
@@ -538,7 +546,7 @@ struct EditorView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "gearshape.2")
|
Image(systemName: "gearshape.2")
|
||||||
.foregroundStyle(.tint)
|
.foregroundStyle(.tint)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) {
|
||||||
Text(String(localized: "editor.compatibilityMode"))
|
Text(String(localized: "editor.compatibilityMode"))
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
Text(String(localized: "editor.compatibilityDescription"))
|
Text(String(localized: "editor.compatibilityDescription"))
|
||||||
@@ -550,7 +558,7 @@ struct EditorView: View {
|
|||||||
.tint(.accentColor)
|
.tint(.accentColor)
|
||||||
|
|
||||||
if compatibilityMode {
|
if compatibilityMode {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) {
|
||||||
HStack(spacing: DesignTokens.Spacing.xs) {
|
HStack(spacing: DesignTokens.Spacing.xs) {
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.foregroundStyle(.green)
|
.foregroundStyle(.green)
|
||||||
@@ -630,7 +638,7 @@ struct EditorView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
}
|
}
|
||||||
.padding(.top, 2)
|
.padding(.top, DesignTokens.Spacing.xs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -703,7 +711,7 @@ struct EditorView: View {
|
|||||||
applyPreset(preset)
|
applyPreset(preset)
|
||||||
showPresetPicker = false
|
showPresetPicker = false
|
||||||
} label: {
|
} label: {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) {
|
||||||
Text(preset.name)
|
Text(preset.name)
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.foregroundStyle(.primary)
|
.foregroundStyle(.primary)
|
||||||
@@ -754,6 +762,7 @@ struct EditorView: View {
|
|||||||
) {
|
) {
|
||||||
startProcessing()
|
startProcessing()
|
||||||
}
|
}
|
||||||
|
.accessibilityLabel(String(localized: "editor.generateButton"))
|
||||||
.padding(.top, DesignTokens.Spacing.sm)
|
.padding(.top, DesignTokens.Spacing.sm)
|
||||||
.padding(.bottom, DesignTokens.Spacing.sm)
|
.padding(.bottom, DesignTokens.Spacing.sm)
|
||||||
}
|
}
|
||||||
@@ -814,6 +823,7 @@ struct EditorView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
|
guard isViewActive else { return }
|
||||||
videoDuration = durationSeconds
|
videoDuration = durationSeconds
|
||||||
trimEnd = max(0.1, min(1.0, durationSeconds))
|
trimEnd = max(0.1, min(1.0, durationSeconds))
|
||||||
keyFrameTime = trimEnd / 2
|
keyFrameTime = trimEnd / 2
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ struct HomeView: View {
|
|||||||
showRecentWorks = true
|
showRecentWorks = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sensoryFeedback(.impact(weight: .light), trigger: deleteWorkCount)
|
.sensoryFeedback(.warning, trigger: deleteWorkCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Hero 区域
|
// MARK: - Hero 区域
|
||||||
@@ -248,7 +248,7 @@ struct HomeView: View {
|
|||||||
Button(String(localized: "home.clearAll"), role: .destructive) {
|
Button(String(localized: "home.clearAll"), role: .destructive) {
|
||||||
recentWorks.clearAll()
|
recentWorks.clearAll()
|
||||||
}
|
}
|
||||||
Button("Cancel", role: .cancel) {}
|
Button(String(localized: "common.cancel"), role: .cancel) {}
|
||||||
} message: {
|
} message: {
|
||||||
Text(String(localized: "home.clearAllConfirmMessage"))
|
Text(String(localized: "home.clearAllConfirmMessage"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ struct ProcessingView: View {
|
|||||||
@State private var hasStarted = false
|
@State private var hasStarted = false
|
||||||
@State private var pulseAnimation = false
|
@State private var pulseAnimation = false
|
||||||
|
|
||||||
|
/// 处理阶段总数(对应 LivePhotoBuildProgress.Stage 的 case 数量)
|
||||||
|
private let totalStages = 7
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
// 背景
|
// 背景
|
||||||
@@ -89,7 +92,6 @@ struct ProcessingView: View {
|
|||||||
Circle()
|
Circle()
|
||||||
.fill(Color.accentPurple.opacity(0.1))
|
.fill(Color.accentPurple.opacity(0.1))
|
||||||
.frame(width: pulseAnimation ? 175 : 160, height: pulseAnimation ? 175 : 160)
|
.frame(width: pulseAnimation ? 175 : 160, height: pulseAnimation ? 175 : 160)
|
||||||
.animation(.easeInOut(duration: 2.0).repeatForever(autoreverses: true), value: pulseAnimation)
|
|
||||||
|
|
||||||
// 进度环
|
// 进度环
|
||||||
SoftProgressRing(
|
SoftProgressRing(
|
||||||
@@ -133,7 +135,7 @@ struct ProcessingView: View {
|
|||||||
|
|
||||||
// 阶段指示器
|
// 阶段指示器
|
||||||
HStack(spacing: DesignTokens.Spacing.sm) {
|
HStack(spacing: DesignTokens.Spacing.sm) {
|
||||||
ForEach(0..<7) { index in
|
ForEach(0..<totalStages, id: \.self) { index in
|
||||||
Circle()
|
Circle()
|
||||||
.fill(index <= currentStageIndex ? Color.accentPurple : Color.softPressed)
|
.fill(index <= currentStageIndex ? Color.accentPurple : Color.softPressed)
|
||||||
.frame(width: 8, height: 8)
|
.frame(width: 8, height: 8)
|
||||||
@@ -225,7 +227,7 @@ struct ProcessingView: View {
|
|||||||
|
|
||||||
/// 全局进度 = (阶段序号 + 阶段内fraction) / 总阶段数
|
/// 全局进度 = (阶段序号 + 阶段内fraction) / 总阶段数
|
||||||
private var overallProgress: Double {
|
private var overallProgress: Double {
|
||||||
let totalStages = 7.0
|
let totalStages = Double(self.totalStages)
|
||||||
let stageFraction = appState.processingProgress?.fraction ?? 0
|
let stageFraction = appState.processingProgress?.fraction ?? 0
|
||||||
return (Double(currentStageIndex) + stageFraction) / totalStages
|
return (Double(currentStageIndex) + stageFraction) / totalStages
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,8 +55,17 @@ struct ResultView: View {
|
|||||||
.navigationTitle(String(localized: "result.title"))
|
.navigationTitle(String(localized: "result.title"))
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.navigationBarBackButtonHidden(true)
|
.navigationBarBackButtonHidden(true)
|
||||||
.onAppear {
|
.task {
|
||||||
animateIn()
|
// 入场动画(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 {
|
.task {
|
||||||
guard isSuccess else { return }
|
guard isSuccess else { return }
|
||||||
@@ -203,29 +212,6 @@ struct ResultView: View {
|
|||||||
!workflowResult.savedAssetId.isEmpty
|
!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
|
// MARK: - 加载 Live Photo
|
||||||
|
|
||||||
private func loadLivePhoto() async {
|
private func loadLivePhoto() async {
|
||||||
|
|||||||
Reference in New Issue
Block a user