Compare commits
12 Commits
cfc39c75fc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4015b750d | ||
|
|
d27c119b05 | ||
|
|
4dc6259a29 | ||
|
|
88af6b6682 | ||
|
|
2f6c2d9557 | ||
|
|
c826689ee4 | ||
|
|
f3bcaf4651 | ||
|
|
ec2e0a3ce5 | ||
|
|
846d8ea8d7 | ||
|
|
4588c7c1eb | ||
|
|
a75aeed767 | ||
|
|
1556dfd167 |
19
CLAUDE.md
19
CLAUDE.md
@@ -18,11 +18,17 @@ to-live-photo/to-live-photo/
|
||||
## 构建命令
|
||||
|
||||
```bash
|
||||
# 模拟器构建
|
||||
xcodebuild -scheme to-live-photo -destination 'platform=iOS Simulator,name=iPhone 16 Pro' build
|
||||
# 模拟器构建(必须指定 -project)
|
||||
xcodebuild -project to-live-photo/to-live-photo.xcodeproj \
|
||||
-scheme to-live-photo \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 16 Pro' build
|
||||
|
||||
# Archive
|
||||
xcodebuild -scheme to-live-photo -configuration Release -destination 'generic/platform=iOS' -archivePath build/to-live-photo.xcarchive archive
|
||||
xcodebuild -project to-live-photo/to-live-photo.xcodeproj \
|
||||
-scheme to-live-photo \
|
||||
-configuration Release \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-archivePath build/to-live-photo.xcarchive archive
|
||||
```
|
||||
|
||||
## Git 规范
|
||||
@@ -89,3 +95,10 @@ xcodebuild -scheme to-live-photo -configuration Release -destination 'generic/pl
|
||||
- 新增/修改功能 → 同步 `USER_GUIDE.md` 相关章节
|
||||
- 新增测试场景 → 同步 `TEST_MATRIX.md`
|
||||
- 归档文档 → 不更新,保持历史原貌
|
||||
|
||||
## Skill 引用
|
||||
|
||||
本项目配套 skill: `live-photo-studio`(位于 `~/.claude/skills/live-photo-studio/SKILL.md`)
|
||||
|
||||
包含:设计系统令牌速查、构建踩坑记录、导航架构、关键类型、代码规范等项目专属知识。
|
||||
在本项目中进行 UI 开发、调试构建问题时,可调用 `/live-photo-studio` 获取上下文。
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -134,6 +144,7 @@ public struct ExportParams: Codable, Sendable, Hashable {
|
||||
public var aspectRatio: AspectRatioTemplate
|
||||
public var compatibilityMode: Bool
|
||||
public var targetFrameRate: Int
|
||||
public var coverImageURL: URL?
|
||||
public var aiEnhanceConfig: AIEnhanceConfig
|
||||
|
||||
public init(
|
||||
@@ -148,6 +159,7 @@ public struct ExportParams: Codable, Sendable, Hashable {
|
||||
aspectRatio: AspectRatioTemplate = .original,
|
||||
compatibilityMode: Bool = false,
|
||||
targetFrameRate: Int = 60,
|
||||
coverImageURL: URL? = nil,
|
||||
aiEnhanceConfig: AIEnhanceConfig = .disabled
|
||||
) {
|
||||
self.trimStart = trimStart
|
||||
@@ -161,6 +173,7 @@ public struct ExportParams: Codable, Sendable, Hashable {
|
||||
self.aspectRatio = aspectRatio
|
||||
self.compatibilityMode = compatibilityMode
|
||||
self.targetFrameRate = targetFrameRate
|
||||
self.coverImageURL = coverImageURL
|
||||
self.aiEnhanceConfig = aiEnhanceConfig
|
||||
}
|
||||
|
||||
@@ -174,6 +187,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 {
|
||||
@@ -396,13 +428,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -490,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)
|
||||
|
||||
@@ -518,8 +559,11 @@ public actor LivePhotoBuilder {
|
||||
destinationURL: trimmedURL
|
||||
)
|
||||
|
||||
// 关键:将视频变速到约 1 秒,与 metadata.mov 的时间标记匹配
|
||||
let targetDuration = CMTimeMake(value: 550, timescale: 600) // ~0.917 秒,与 live-wallpaper 一致
|
||||
// 从实际裁剪后的视频读取真实时长,避免 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(
|
||||
sourceURL: trimmedVideoURL,
|
||||
@@ -531,8 +575,20 @@ public actor LivePhotoBuilder {
|
||||
destinationURL: scaledURL
|
||||
)
|
||||
|
||||
// 计算关键帧时间:目标视频的中间位置(0.5 秒处,与 metadata.mov 的 still-image-time 匹配)
|
||||
let relativeKeyFrameTime = 0.5 // 固定为 0.5 秒,与 metadata.mov 匹配
|
||||
// 显式 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 = (clampedKeyFrameTime - exportParams.trimStart) / max(0.001, exportParams.trimEnd - exportParams.trimStart)
|
||||
let relativeKeyFrameTime = max(0, min(trimmedSeconds, keyFrameRatio * trimmedSeconds))
|
||||
|
||||
progress?(LivePhotoBuildProgress(stage: .extractKeyFrame, fraction: 0))
|
||||
let keyPhotoURL = try await resolveKeyPhotoURL(
|
||||
@@ -1024,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()
|
||||
@@ -1146,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
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
| **应用名称** | Live Photo Studio |
|
||||
| **副标题** | 视频一键转动态壁纸 |
|
||||
| **Bundle ID** | xyz.let5see.livephotomaker |
|
||||
| **版本号** | 1.0 |
|
||||
| **版本号** | 1.1.0 |
|
||||
| **Build 号** | 1 |
|
||||
| **分类** | 主类别: Photo & Video / 次类别: Utilities |
|
||||
| **年龄分级** | 4+ |
|
||||
@@ -34,13 +34,14 @@ Live Photo Studio 是一款简单易用的动态壁纸制作工具,让你的
|
||||
|
||||
【一键转换】
|
||||
• 从相册选择视频,一键转换为 Live Photo
|
||||
• 智能时长裁剪,自动适配系统要求
|
||||
• 支持最长 5 秒视频,创作更自由
|
||||
• 支持各种视频格式:MP4、MOV、H.264、HEVC
|
||||
|
||||
【精准编辑】
|
||||
• 多种比例模板:iPhone 锁屏、全面屏、4:3 等
|
||||
• 双指缩放和拖拽,精准裁剪画面
|
||||
• 滑块选择封面帧,预览实时更新
|
||||
• 自定义封面照片,精准选择关键帧
|
||||
• 编辑参数预设,一键应用常用配置
|
||||
|
||||
【AI 超分辨率】
|
||||
• 使用 AI 技术提升封面画质
|
||||
@@ -52,6 +53,10 @@ Live Photo Studio 是一款简单易用的动态壁纸制作工具,让你的
|
||||
• 自动转换为最兼容格式
|
||||
• 支持 4K、HDR、HEVC 等高规格视频
|
||||
|
||||
【智能管理】
|
||||
• iCloud 同步最近作品,多设备无缝切换
|
||||
• 作品历史记录,随时回顾创作
|
||||
|
||||
【设置引导】
|
||||
• 详细的壁纸设置教程
|
||||
• 常见问题解答
|
||||
@@ -77,6 +82,26 @@ Live Photo,动态壁纸,锁屏壁纸,视频转换,AI增强,照片,壁纸,动图,
|
||||
|
||||
## 新功能(版本说明)
|
||||
|
||||
### 1.1.0 版本
|
||||
```
|
||||
更强大的功能,更流畅的体验!
|
||||
|
||||
【新功能】
|
||||
• 视频时长放宽至 5 秒,创作更自由
|
||||
• 自定义封面照片,精准选择关键帧
|
||||
• 编辑参数预设,一键应用常用配置
|
||||
• iCloud 同步最近作品,多设备无缝切换
|
||||
|
||||
【体验优化】
|
||||
• 首页入场动画更流畅
|
||||
• 触觉反馈增强操作体验
|
||||
• 视觉一致性全面提升
|
||||
|
||||
【稳定性】
|
||||
• 修复 2 个崩溃问题
|
||||
• 30+ 项稳定性优化
|
||||
```
|
||||
|
||||
### 1.0 版本
|
||||
```
|
||||
Live Photo Studio 正式发布!
|
||||
@@ -193,20 +218,32 @@ https://let5sne.github.io/xyz.let5see.livephotomaker/terms.html
|
||||
## 版权信息
|
||||
|
||||
```
|
||||
© 2025 let5see.xyz. All rights reserved.
|
||||
© 2025-2026 let5see.xyz. All rights reserved.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 上架检查清单
|
||||
|
||||
### v1.1.0 发布检查
|
||||
|
||||
#### 版本准备
|
||||
- [x] 版本号已更新为 1.1.0 (1)
|
||||
- [x] 代码审查已完成(38项全部修复)
|
||||
- [x] 模拟器构建通过
|
||||
- [ ] 9种语言版本说明已准备
|
||||
- [ ] 截图需要更新(新增预设管理、自定义封面等功能截图)
|
||||
- [ ] Archive 构建
|
||||
- [ ] TestFlight 内部测试
|
||||
- [ ] 提交审核
|
||||
|
||||
### 开发者账号
|
||||
- [ ] Apple Developer Program 已加入($99/年)
|
||||
- [ ] App Store Connect 账号已创建
|
||||
|
||||
### 应用配置
|
||||
- [x] Bundle ID: xyz.let5see.livephotomaker
|
||||
- [x] 版本号: 1.0
|
||||
- [x] 版本号: 1.1.0
|
||||
- [x] 应用图标: 1024x1024
|
||||
- [x] 权限说明文案
|
||||
- [x] 隐私政策(应用内)
|
||||
@@ -220,6 +257,10 @@ https://let5sne.github.io/xyz.let5see.livephotomaker/terms.html
|
||||
|
||||
### 需要准备
|
||||
- [ ] iPhone 截图(至少 6.7" 和 6.5")
|
||||
- [ ] 预设管理功能截图
|
||||
- [ ] 自定义封面功能截图
|
||||
- [ ] 5秒视频支持展示
|
||||
- [ ] iCloud 同步展示
|
||||
- [ ] iPad 截图(如果支持 iPad)
|
||||
|
||||
### 构建上传
|
||||
|
||||
@@ -428,6 +428,168 @@ Live Photo Studio 정식 출시!
|
||||
|
||||
---
|
||||
|
||||
**创建时间**: 2026-01-10
|
||||
**版本**: 1.0.1
|
||||
## 🆕 v1.1.0 版本说明(What's New)
|
||||
|
||||
### zh-Hans (简体中文)
|
||||
```
|
||||
更强大的功能,更流畅的体验!
|
||||
|
||||
【新功能】
|
||||
• 视频时长放宽至 5 秒,创作更自由
|
||||
• 支持从相册导入自定义封面照片
|
||||
• 编辑参数预设管理,一键应用常用配置
|
||||
• iCloud 同步最近作品,多设备无缝切换
|
||||
|
||||
【体验优化】
|
||||
• 首页入场动画更流畅,最近作品卡片尺寸增大
|
||||
• 新增触觉反馈,操作更有手感
|
||||
• 视觉一致性全面提升
|
||||
|
||||
【稳定性】
|
||||
• 修复 2 个可能导致崩溃的问题
|
||||
• 30+ 项稳定性和体验优化
|
||||
```
|
||||
|
||||
### zh-Hant (繁體中文)
|
||||
```
|
||||
更強大的功能,更流暢的體驗!
|
||||
|
||||
【新功能】
|
||||
• 影片時長擴展至 5 秒,創作更自由
|
||||
• 支援從相簿匯入自訂封面照片
|
||||
• 編輯參數預設管理,一鍵套用常用配置
|
||||
• iCloud 同步最近作品,多裝置無縫切換
|
||||
|
||||
【體驗優化】
|
||||
• 首頁入場動畫更流暢,最近作品卡片尺寸增大
|
||||
• 新增觸覺回饋,操作更有手感
|
||||
• 視覺一致性全面提升
|
||||
|
||||
【穩定性】
|
||||
• 修復 2 個可能導致當機的問題
|
||||
• 30+ 項穩定性和體驗優化
|
||||
```
|
||||
|
||||
### en (English)
|
||||
```
|
||||
More powerful features, smoother experience!
|
||||
|
||||
New Features
|
||||
- Extended video duration to 5 seconds for more creative freedom
|
||||
- Import custom cover photos from your library
|
||||
- Editing preset management: save and reuse your favorite configurations
|
||||
- iCloud sync for recent works across all your devices
|
||||
|
||||
Experience Improvements
|
||||
- Smoother home screen animation with larger recent works cards
|
||||
- Added haptic feedback for key operations
|
||||
- Enhanced visual consistency throughout the app
|
||||
|
||||
Stability
|
||||
- Fixed 2 potential crash issues
|
||||
- 30+ stability and experience improvements
|
||||
```
|
||||
|
||||
### es (Español)
|
||||
```
|
||||
Funciones mas potentes, experiencia mas fluida!
|
||||
|
||||
Nuevas Funciones
|
||||
- Duracion de video extendida a 5 segundos para mayor libertad creativa
|
||||
- Importa fotos de portada personalizadas desde tu biblioteca
|
||||
- Gestion de ajustes preestablecidos: guarda y reutiliza tus configuraciones favoritas
|
||||
- Sincronizacion iCloud de trabajos recientes en todos tus dispositivos
|
||||
|
||||
Mejoras de Experiencia
|
||||
- Animacion de inicio mas fluida con tarjetas de trabajos recientes mas grandes
|
||||
- Retroalimentacion haptica para operaciones clave
|
||||
- Consistencia visual mejorada en toda la aplicacion
|
||||
|
||||
Estabilidad
|
||||
- Corregidos 2 problemas potenciales de bloqueo
|
||||
- 30+ mejoras de estabilidad y experiencia
|
||||
```
|
||||
|
||||
### ar (العربية)
|
||||
```
|
||||
ميزات أقوى، تجربة أكثر سلاسة!
|
||||
|
||||
ميزات جديدة
|
||||
- مدة الفيديو ممتدة إلى 5 ثوانٍ لمزيد من الحرية الإبداعية
|
||||
- استيراد صور غلاف مخصصة من مكتبة الصور
|
||||
- إدارة الإعدادات المسبقة: احفظ وأعد استخدام تكويناتك المفضلة
|
||||
- مزامنة iCloud للأعمال الأخيرة عبر جميع أجهزتك
|
||||
|
||||
تحسينات التجربة
|
||||
- رسوم دخول أكثر سلاسة مع بطاقات أعمال أكبر
|
||||
- ردود فعل لمسية للعمليات الرئيسية
|
||||
- اتساق بصري محسّن في جميع أنحاء التطبيق
|
||||
|
||||
الاستقرار
|
||||
- إصلاح مشكلتين قد تؤديان إلى تعطل التطبيق
|
||||
- أكثر من 30 تحسينًا للاستقرار والتجربة
|
||||
```
|
||||
|
||||
### fr (Français)
|
||||
```
|
||||
Des fonctionnalites plus puissantes, une experience plus fluide !
|
||||
|
||||
Nouvelles fonctionnalites
|
||||
- Duree video etendue a 5 secondes pour plus de liberte creative
|
||||
- Importation de photos de couverture personnalisees depuis votre bibliotheque
|
||||
- Gestion des prereglages : enregistrez et reutilisez vos configurations favorites
|
||||
- Synchronisation iCloud des creations recentes sur tous vos appareils
|
||||
|
||||
Ameliorations de l'experience
|
||||
- Animation d'entree plus fluide avec des cartes de projets agrandies
|
||||
- Retour haptique pour les operations cles
|
||||
- Coherence visuelle renforcee dans toute l'application
|
||||
|
||||
Stabilite
|
||||
- Correction de 2 problemes pouvant entrainer des plantages
|
||||
- Plus de 30 ameliorations de stabilite et d'experience
|
||||
```
|
||||
|
||||
### ja (日本語)
|
||||
```
|
||||
より強力な機能、よりスムーズな体験!
|
||||
|
||||
新機能
|
||||
- 動画の長さを5秒まで拡張、より自由な創作が可能に
|
||||
- フォトライブラリからカスタムカバー写真をインポート
|
||||
- 編集プリセット管理:よく使う設定を保存してワンタップで再利用
|
||||
- iCloud同期で全デバイスの最近の作品を共有
|
||||
|
||||
体験の改善
|
||||
- ホーム画面のアニメーションを最適化、作品カードを拡大
|
||||
- 主要操作に触覚フィードバックを追加
|
||||
- アプリ全体のビジュアル一貫性を向上
|
||||
|
||||
安定性
|
||||
- クラッシュの可能性がある2つの問題を修正
|
||||
- 30件以上の安定性と体験の改善
|
||||
```
|
||||
|
||||
### ko (한국어)
|
||||
```
|
||||
더 강력한 기능, 더 부드러운 경험!
|
||||
|
||||
새로운 기능
|
||||
- 동영상 길이 5초로 확장, 더 자유로운 창작 가능
|
||||
- 사진 보관함에서 커스텀 커버 사진 가져오기
|
||||
- 편집 프리셋 관리: 자주 쓰는 설정 저장 후 한 번의 탭으로 재사용
|
||||
- iCloud 동기화로 모든 기기에서 최근 작품 공유
|
||||
|
||||
경험 개선
|
||||
- 홈 화면 애니메이션 최적화, 작품 카드 크기 확대
|
||||
- 주요 작업에 햅틱 피드백 추가
|
||||
- 앱 전체의 시각적 일관성 향상
|
||||
|
||||
안정성
|
||||
- 충돌 가능성이 있는 2가지 문제 수정
|
||||
- 30개 이상의 안정성 및 경험 개선
|
||||
```
|
||||
|
||||
---**最后更新**: 2026-02-08
|
||||
**版本**: 1.1.0
|
||||
**状态**: ✅ 已完成,可直接复制到 App Store Connect
|
||||
|
||||
@@ -414,7 +414,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = Y976PBNGA8;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -422,7 +422,7 @@
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.photography";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
@@ -430,7 +430,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
MARKETING_VERSION = 1.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = xyz.let5see.livephotomaker;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
@@ -449,7 +449,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = Y976PBNGA8;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -457,7 +457,7 @@
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.photography";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
@@ -465,7 +465,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
MARKETING_VERSION = 1.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = xyz.let5see.livephotomaker;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
@@ -483,11 +483,11 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = Y976PBNGA8;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
MARKETING_VERSION = 1.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = xyz.let5see.livephotomakerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -505,11 +505,11 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = Y976PBNGA8;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
MARKETING_VERSION = 1.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = xyz.let5see.livephotomakerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -526,10 +526,10 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = Y976PBNGA8;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
MARKETING_VERSION = 1.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = xyz.let5see.livephotomakerUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -546,10 +546,10 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = Y976PBNGA8;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
MARKETING_VERSION = 1.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = xyz.let5see.livephotomakerUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
|
||||
@@ -133,8 +133,12 @@ final class Analytics {
|
||||
// 记录到统计
|
||||
errorStats.record(code: appError.code, stage: appError.stage?.rawValue)
|
||||
} else {
|
||||
// 通用错误
|
||||
let code = "UNKNOWN"
|
||||
// 从通用错误中提取有用信息
|
||||
let nsError = error as NSError
|
||||
let code = "\(nsError.domain):\(nsError.code)"
|
||||
params["code"] = code
|
||||
params["domain"] = nsError.domain
|
||||
params["nsErrorCode"] = nsError.code
|
||||
errorStats.record(code: code, stage: nil)
|
||||
}
|
||||
|
||||
|
||||
@@ -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: nil,
|
||||
exportParams: exportParams
|
||||
) { progress in
|
||||
Task { @MainActor in
|
||||
@@ -166,13 +168,14 @@ final class AppState {
|
||||
}
|
||||
return nil
|
||||
} catch {
|
||||
let appError = AppError(code: "LPB-901", message: "未知错误", underlyingErrorDescription: error.localizedDescription, suggestedActions: ["重试"])
|
||||
await MainActor.run {
|
||||
self.isProcessing = false
|
||||
self.processingError = AppError(code: "LPB-901", message: "未知错误", underlyingErrorDescription: error.localizedDescription, suggestedActions: ["重试"])
|
||||
self.processingError = appError
|
||||
self.currentWorkId = nil
|
||||
self.currentProcessingTask = nil
|
||||
}
|
||||
Analytics.shared.logError(.buildLivePhotoFail, error: error)
|
||||
Analytics.shared.logAppError(.buildLivePhotoFail, appError: appError)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.945",
|
||||
"green" : "0.400",
|
||||
"red" : "0.388"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.953",
|
||||
"green" : "0.941",
|
||||
"red" : "0.941"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.180",
|
||||
"green" : "0.102",
|
||||
"red" : "0.102"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.482",
|
||||
"green" : "0.420",
|
||||
"red" : "0.420"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.698",
|
||||
"green" : "0.627",
|
||||
"red" : "0.627"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="23504" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23484"/>
|
||||
<capability name="Named colors" minToolsVersion="9.0"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" image="AppIcon" translatesAutoresizingMaskIntoConstraints="NO" id="aIc-Kp-9xR">
|
||||
<rect key="frame" x="136.5" y="233" width="120" height="120"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" constant="120" id="wCn-4s-Hx1"/>
|
||||
<constraint firstAttribute="height" constant="120" id="hGt-7r-Qz2"/>
|
||||
</constraints>
|
||||
</imageView>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Live Photo Studio" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="GJd-Yh-RWb">
|
||||
<rect key="frame" x="74" y="406" width="245.66666666666666" height="29"/>
|
||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="24"/>
|
||||
<color key="textColor" name="LaunchAccent"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="视频一键转动态壁纸" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="MHt-Pj-7nA">
|
||||
<rect key="frame" x="123" y="443" width="148" height="17"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="14"/>
|
||||
<color key="textColor" name="LaunchSubtitle"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
|
||||
<color key="backgroundColor" name="LaunchBackground"/>
|
||||
<constraints>
|
||||
<constraint firstItem="aIc-Kp-9xR" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="cXi-3p-Lm7"/>
|
||||
<constraint firstItem="GJd-Yh-RWb" firstAttribute="top" secondItem="aIc-Kp-9xR" secondAttribute="bottom" constant="24" id="tNr-8k-Vw3"/>
|
||||
<constraint firstItem="GJd-Yh-RWb" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="5fK-s1-LcA"/>
|
||||
<constraint firstItem="GJd-Yh-RWb" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" constant="72" id="8bR-Xa-2gP"/>
|
||||
<constraint firstItem="MHt-Pj-7nA" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="Kd4-2s-QnT"/>
|
||||
<constraint firstItem="MHt-Pj-7nA" firstAttribute="top" secondItem="GJd-Yh-RWb" secondAttribute="bottom" constant="8" id="Rne-oP-87k"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="AppIcon" width="120" height="120"/>
|
||||
<namedColor name="LaunchAccent">
|
||||
<color red="0.38823529411764707" green="0.40000000000000002" blue="0.94509803921568625" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
</namedColor>
|
||||
<namedColor name="LaunchBackground">
|
||||
<color red="0.94117647058823528" green="0.94117647058823528" blue="0.95294117647058818" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
</namedColor>
|
||||
<namedColor name="LaunchSubtitle">
|
||||
<color red="0.41960784313725491" green="0.41960784313725491" blue="0.48235294117647054" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
</namedColor>
|
||||
</resources>
|
||||
</document>
|
||||
@@ -554,7 +554,9 @@ struct SoftSlider: View {
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let width = geometry.size.width
|
||||
let progress = (value - range.lowerBound) / (range.upperBound - range.lowerBound)
|
||||
let effectiveRange = sanitizedRange(range)
|
||||
let span = effectiveRange.upperBound - effectiveRange.lowerBound
|
||||
let progress = span > 0 ? (value - effectiveRange.lowerBound) / span : 0
|
||||
let thumbX = width * progress
|
||||
|
||||
ZStack(alignment: .leading) {
|
||||
@@ -581,7 +583,7 @@ struct SoftSlider: View {
|
||||
guard !isDisabled else { return }
|
||||
let newProgress = gesture.location.x / width
|
||||
let clampedProgress = max(0, min(1, newProgress))
|
||||
value = range.lowerBound + (range.upperBound - range.lowerBound) * clampedProgress
|
||||
value = effectiveRange.lowerBound + (effectiveRange.upperBound - effectiveRange.lowerBound) * clampedProgress
|
||||
onEditingChanged?(true)
|
||||
}
|
||||
.onEnded { _ in
|
||||
@@ -599,14 +601,23 @@ struct SoftSlider: View {
|
||||
guard !isDisabled else { return }
|
||||
switch direction {
|
||||
case .increment:
|
||||
value = min(range.upperBound, value + step)
|
||||
value = min(sanitizedRange(range).upperBound, value + step)
|
||||
case .decrement:
|
||||
value = max(range.lowerBound, value - step)
|
||||
value = max(sanitizedRange(range).lowerBound, value - step)
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sanitizedRange(_ input: ClosedRange<Double>) -> ClosedRange<Double> {
|
||||
let lower = input.lowerBound.isFinite && !input.lowerBound.isNaN ? input.lowerBound : 0
|
||||
let upper = input.upperBound.isFinite && !input.upperBound.isNaN ? input.upperBound : lower
|
||||
if upper <= lower {
|
||||
return lower...(lower + 0.0001)
|
||||
}
|
||||
return lower...upper
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 预览
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
202
to-live-photo/to-live-photo/PresetManager.swift
Normal file
202
to-live-photo/to-live-photo/PresetManager.swift
Normal file
@@ -0,0 +1,202 @@
|
||||
//
|
||||
// PresetManager.swift
|
||||
// to-live-photo
|
||||
//
|
||||
// 编辑参数预设管理器:保存和加载用户自定义预设
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import LivePhotoCore
|
||||
|
||||
/// 编辑参数预设
|
||||
struct EditingPreset: Codable, Identifiable, Hashable {
|
||||
let id: UUID
|
||||
var name: String
|
||||
let createdAt: Date
|
||||
let aspectRatio: AspectRatioTemplate
|
||||
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 个预设)
|
||||
@MainActor
|
||||
final class PresetManager: ObservableObject {
|
||||
static let shared = PresetManager()
|
||||
|
||||
@Published private(set) var presets: [EditingPreset] = []
|
||||
|
||||
private let maxCount = 10
|
||||
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()
|
||||
setupICloudSync()
|
||||
}
|
||||
|
||||
func savePreset(
|
||||
name: String,
|
||||
aspectRatio: AspectRatioTemplate,
|
||||
trimDuration: Double,
|
||||
aiEnhance: Bool,
|
||||
compatibilityMode: Bool
|
||||
) {
|
||||
let preset = EditingPreset(
|
||||
id: UUID(),
|
||||
name: name,
|
||||
createdAt: Date(),
|
||||
aspectRatio: aspectRatio,
|
||||
trimDuration: trimDuration,
|
||||
aiEnhance: aiEnhance,
|
||||
compatibilityMode: compatibilityMode
|
||||
)
|
||||
|
||||
presets.insert(preset, at: 0)
|
||||
|
||||
if presets.count > maxCount {
|
||||
presets = Array(presets.prefix(maxCount))
|
||||
}
|
||||
|
||||
persistToStorage()
|
||||
}
|
||||
|
||||
func removePreset(_ preset: EditingPreset) {
|
||||
presets.removeAll { $0.id == preset.id }
|
||||
persistToStorage()
|
||||
}
|
||||
|
||||
// MARK: - iCloud 同步
|
||||
|
||||
private func setupICloudSync() {
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
|
||||
object: iCloudStore,
|
||||
queue: .main
|
||||
) { [weak self] notification in
|
||||
let userInfo = notification.userInfo
|
||||
let reason = userInfo?[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int
|
||||
let keys = userInfo?[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String]
|
||||
Task { @MainActor [weak self] in
|
||||
self?.handleICloudChange(reason: reason, changedKeys: keys)
|
||||
}
|
||||
}
|
||||
iCloudStore.synchronize()
|
||||
}
|
||||
|
||||
private func handleICloudChange(reason: Int?, changedKeys: [String]?) {
|
||||
guard let reason, let changedKeys,
|
||||
changedKeys.contains(iCloudKey) else {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
private func mergeFromICloud() {
|
||||
guard let data = iCloudStore.data(forKey: iCloudKey),
|
||||
let remotePresets = try? JSONDecoder().decode([EditingPreset].self, from: data) else {
|
||||
return
|
||||
}
|
||||
|
||||
// 合并策略:按 id 去重,保留最新 createdAt,限制 maxCount 条
|
||||
var merged = Dictionary(grouping: presets + remotePresets) { $0.id }
|
||||
.values
|
||||
.compactMap { $0.max(by: { $0.createdAt < $1.createdAt }) }
|
||||
|
||||
merged.sort { $0.createdAt > $1.createdAt }
|
||||
presets = Array(merged.prefix(maxCount))
|
||||
persistToStorage()
|
||||
}
|
||||
|
||||
// MARK: - 持久化
|
||||
|
||||
private func loadFromStorage() {
|
||||
guard let data = UserDefaults.standard.data(forKey: storageKey) else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
presets = try JSONDecoder().decode([EditingPreset].self, from: data)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("[PresetManager] Failed to decode: \(error)")
|
||||
#endif
|
||||
presets = []
|
||||
}
|
||||
}
|
||||
|
||||
private func persistToStorage() {
|
||||
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
|
||||
print("[PresetManager] Failed to encode: \(error)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import LivePhotoCore
|
||||
import UIKit
|
||||
import Photos
|
||||
|
||||
@@ -15,22 +16,24 @@ 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)
|
||||
}
|
||||
|
||||
/// 优先使用枚举的 displayName,fallback 到 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
|
||||
}
|
||||
}
|
||||
|
||||
/// 最近作品管理器
|
||||
/// 最近作品管理器(支持 iCloud 同步)
|
||||
@MainActor
|
||||
final class RecentWorksManager: ObservableObject {
|
||||
static let shared = RecentWorksManager()
|
||||
@@ -39,9 +42,14 @@ final class RecentWorksManager: ObservableObject {
|
||||
|
||||
private let maxCount = 20 // 最多保存 20 条记录
|
||||
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()
|
||||
setupICloudSync()
|
||||
}
|
||||
|
||||
/// 添加新作品记录
|
||||
@@ -85,20 +93,82 @@ 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)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iCloud 同步
|
||||
|
||||
private func setupICloudSync() {
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
|
||||
object: iCloudStore,
|
||||
queue: .main
|
||||
) { [weak self] notification in
|
||||
let userInfo = notification.userInfo
|
||||
let reason = userInfo?[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int
|
||||
let keys = userInfo?[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String]
|
||||
Task { @MainActor [weak self] in
|
||||
self?.handleICloudChange(reason: reason, changedKeys: keys)
|
||||
}
|
||||
}
|
||||
iCloudStore.synchronize()
|
||||
}
|
||||
|
||||
private func handleICloudChange(reason: Int?, changedKeys: [String]?) {
|
||||
guard let reason, let changedKeys,
|
||||
changedKeys.contains(iCloudKey) else {
|
||||
return
|
||||
}
|
||||
|
||||
let originalCount = recentWorks.count
|
||||
recentWorks.removeAll { !existingIds.contains($0.assetLocalIdentifier) }
|
||||
isHandlingICloudChange = true
|
||||
defer { isHandlingICloudChange = false }
|
||||
|
||||
if recentWorks.count != originalCount {
|
||||
saveToStorage()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
private func mergeFromICloud() {
|
||||
guard let data = iCloudStore.data(forKey: iCloudKey),
|
||||
let remoteWorks = try? JSONDecoder().decode([RecentWork].self, from: data) else {
|
||||
return
|
||||
}
|
||||
|
||||
// 合并策略:按 createdAt 去重(相同 assetLocalIdentifier 保留最新),保留最新 20 条
|
||||
var merged = Dictionary(grouping: recentWorks + remoteWorks) { $0.assetLocalIdentifier }
|
||||
.values
|
||||
.compactMap { $0.max(by: { $0.createdAt < $1.createdAt }) }
|
||||
|
||||
merged.sort { $0.createdAt > $1.createdAt }
|
||||
recentWorks = Array(merged.prefix(maxCount))
|
||||
saveToStorage()
|
||||
}
|
||||
|
||||
// MARK: - 持久化
|
||||
|
||||
private func loadFromStorage() {
|
||||
@@ -120,6 +190,19 @@ final class RecentWorksManager: ObservableObject {
|
||||
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)")
|
||||
@@ -132,27 +215,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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import SwiftUI
|
||||
import AVKit
|
||||
import PhotosUI
|
||||
import LivePhotoCore
|
||||
|
||||
struct EditorView: View {
|
||||
@@ -23,6 +24,8 @@ struct EditorView: View {
|
||||
@State private var videoDuration: Double = 0
|
||||
@State private var coverImage: UIImage?
|
||||
@State private var isLoadingCover = false
|
||||
@State private var coverExtractionTask: Task<Void, Never>?
|
||||
@State private var coverExtractionToken = UUID()
|
||||
|
||||
// 比例模板相关
|
||||
@State private var selectedAspectRatio: AspectRatioTemplate = .fullScreen
|
||||
@@ -31,6 +34,8 @@ struct EditorView: View {
|
||||
// 裁剪相关(归一化坐标)
|
||||
@State private var cropOffset: CGSize = .zero // 拖拽偏移
|
||||
@State private var cropScale: CGFloat = 1.0 // 缩放比例
|
||||
@State private var lastCropOffset: CGSize = .zero // 累积偏移
|
||||
@State private var lastCropScale: CGFloat = 1.0 // 累积缩放
|
||||
|
||||
// 兼容模式
|
||||
@State private var compatibilityMode: Bool = false
|
||||
@@ -44,6 +49,25 @@ struct EditorView: View {
|
||||
// 视频诊断
|
||||
@State private var videoDiagnosis: VideoDiagnosis?
|
||||
|
||||
// 自定义封面(从相册导入)
|
||||
@State private var customCoverItem: PhotosPickerItem?
|
||||
@State private var customCoverImage: UIImage?
|
||||
@State private var customCoverURL: URL?
|
||||
|
||||
// 预设相关
|
||||
@State private var showSavePresetAlert = false
|
||||
@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 {
|
||||
horizontalSizeClass == .regular
|
||||
@@ -60,19 +84,29 @@ struct EditorView: View {
|
||||
.navigationTitle(String(localized: "editor.title"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear {
|
||||
isViewActive = true
|
||||
loadVideo()
|
||||
}
|
||||
.onDisappear {
|
||||
isViewActive = false
|
||||
player?.pause()
|
||||
coverExtractionTask?.cancel()
|
||||
coverExtractionTask = nil
|
||||
cleanupCustomCoverFile()
|
||||
}
|
||||
.sensoryFeedback(.selection, trigger: selectedAspectRatio)
|
||||
.sensoryFeedback(.impact(weight: .medium), trigger: generateTapCount)
|
||||
.sensoryFeedback(.impact(weight: .light), trigger: cropScaleHapticCount)
|
||||
.sensoryFeedback(.success, trigger: presetSavedCount)
|
||||
.sensoryFeedback(.selection, trigger: coverImportCount)
|
||||
}
|
||||
|
||||
// MARK: - iPhone 布局(单列滚动)
|
||||
@ViewBuilder
|
||||
private var iPhoneLayout: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
cropPreviewSection
|
||||
VStack(spacing: DesignTokens.Spacing.xl) {
|
||||
cropPreview(height: 360)
|
||||
|
||||
if let diagnosis = videoDiagnosis, !diagnosis.suggestions.isEmpty {
|
||||
diagnosisSection(diagnosis: diagnosis)
|
||||
@@ -84,6 +118,7 @@ struct EditorView: View {
|
||||
keyFrameSection
|
||||
aiEnhanceSection
|
||||
compatibilitySection
|
||||
presetSection
|
||||
generateButton
|
||||
}
|
||||
.padding(.horizontal, DesignTokens.Spacing.xl)
|
||||
@@ -94,10 +129,10 @@ struct EditorView: View {
|
||||
// MARK: - iPad 布局(左右分栏)
|
||||
@ViewBuilder
|
||||
private var iPadLayout: some View {
|
||||
HStack(alignment: .top, spacing: 24) {
|
||||
HStack(alignment: .top, spacing: DesignTokens.Spacing.xxl) {
|
||||
// 左侧:视频预览
|
||||
VStack(spacing: 16) {
|
||||
iPadCropPreviewSection
|
||||
VStack(spacing: DesignTokens.Spacing.lg) {
|
||||
cropPreview(height: 500, dynamicHeight: true)
|
||||
|
||||
if let diagnosis = videoDiagnosis, !diagnosis.suggestions.isEmpty {
|
||||
diagnosisSection(diagnosis: diagnosis)
|
||||
@@ -109,14 +144,16 @@ struct EditorView: View {
|
||||
|
||||
// 右侧:参数控制
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
VStack(spacing: DesignTokens.Spacing.lg) {
|
||||
coverFrameSection
|
||||
durationSection
|
||||
keyFrameSection
|
||||
aiEnhanceSection
|
||||
compatibilitySection
|
||||
presetSection
|
||||
generateButton
|
||||
}
|
||||
.padding(.horizontal, DesignTokens.Spacing.lg)
|
||||
.padding(.vertical, DesignTokens.Spacing.lg)
|
||||
}
|
||||
.frame(minWidth: 320, maxWidth: 420)
|
||||
@@ -124,80 +161,51 @@ struct EditorView: View {
|
||||
.padding(DesignTokens.Spacing.xxl)
|
||||
}
|
||||
|
||||
// MARK: - iPad 裁剪预览(更大尺寸)
|
||||
@ViewBuilder
|
||||
private var iPadCropPreviewSection: some View {
|
||||
GeometryReader { geometry in
|
||||
let containerWidth = geometry.size.width
|
||||
let containerHeight = min(500, geometry.size.width * 1.2)
|
||||
|
||||
ZStack {
|
||||
if let player {
|
||||
VideoPlayer(player: player)
|
||||
.aspectRatio(videoNaturalSize, contentMode: .fit)
|
||||
.scaleEffect(cropScale)
|
||||
.offset(cropOffset)
|
||||
.gesture(
|
||||
SimultaneousGesture(
|
||||
MagnificationGesture()
|
||||
.onChanged { value in
|
||||
cropScale = max(1.0, min(3.0, value))
|
||||
},
|
||||
DragGesture()
|
||||
.onChanged { value in
|
||||
cropOffset = value.translation
|
||||
}
|
||||
)
|
||||
)
|
||||
} else {
|
||||
ProgressView()
|
||||
// MARK: - 裁剪手势
|
||||
private var cropGesture: some Gesture {
|
||||
SimultaneousGesture(
|
||||
MagnificationGesture()
|
||||
.onChanged { value in
|
||||
cropScale = max(1.0, min(3.0, lastCropScale * value))
|
||||
}
|
||||
|
||||
if selectedAspectRatio != .original {
|
||||
CropOverlay(
|
||||
aspectRatio: selectedAspectRatio,
|
||||
containerSize: CGSize(width: containerWidth, height: containerHeight)
|
||||
.onEnded { value in
|
||||
lastCropScale = max(1.0, min(3.0, lastCropScale * value))
|
||||
cropScaleHapticCount += 1
|
||||
},
|
||||
DragGesture()
|
||||
.onChanged { value in
|
||||
cropOffset = CGSize(
|
||||
width: lastCropOffset.width + value.translation.width,
|
||||
height: lastCropOffset.height + value.translation.height
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(width: containerWidth, height: containerHeight)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.background(Color.black.clipShape(RoundedRectangle(cornerRadius: 16)))
|
||||
}
|
||||
.frame(height: 500)
|
||||
.onEnded { value in
|
||||
lastCropOffset = CGSize(
|
||||
width: lastCropOffset.width + value.translation.width,
|
||||
height: lastCropOffset.height + value.translation.height
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 裁剪预览
|
||||
// MARK: - 裁剪预览(共享)
|
||||
@ViewBuilder
|
||||
private var cropPreviewSection: some View {
|
||||
private func cropPreview(height: CGFloat, dynamicHeight: Bool = false) -> some View {
|
||||
GeometryReader { geometry in
|
||||
let containerWidth = geometry.size.width
|
||||
let containerHeight: CGFloat = 360
|
||||
let containerHeight = dynamicHeight ? min(height, geometry.size.width * 1.2) : height
|
||||
|
||||
ZStack {
|
||||
// 视频预览(全画幅)
|
||||
if let player {
|
||||
VideoPlayer(player: player)
|
||||
.aspectRatio(videoNaturalSize, contentMode: .fit)
|
||||
.scaleEffect(cropScale)
|
||||
.offset(cropOffset)
|
||||
.gesture(
|
||||
SimultaneousGesture(
|
||||
MagnificationGesture()
|
||||
.onChanged { value in
|
||||
cropScale = max(1.0, min(3.0, value))
|
||||
},
|
||||
DragGesture()
|
||||
.onChanged { value in
|
||||
cropOffset = value.translation
|
||||
}
|
||||
)
|
||||
)
|
||||
.gesture(cropGesture)
|
||||
} else {
|
||||
ProgressView()
|
||||
}
|
||||
|
||||
// 裁剪框叠加层
|
||||
if selectedAspectRatio != .original {
|
||||
CropOverlay(
|
||||
aspectRatio: selectedAspectRatio,
|
||||
@@ -206,16 +214,16 @@ struct EditorView: View {
|
||||
}
|
||||
}
|
||||
.frame(width: containerWidth, height: containerHeight)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.background(Color.black.clipShape(RoundedRectangle(cornerRadius: 16)))
|
||||
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.lg))
|
||||
.background(Color.black.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.lg)))
|
||||
}
|
||||
.frame(height: 360)
|
||||
.frame(height: height)
|
||||
}
|
||||
|
||||
// MARK: - 比例模板选择
|
||||
@ViewBuilder
|
||||
private var aspectRatioSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: DesignTokens.Spacing.md) {
|
||||
HStack {
|
||||
Image(systemName: "aspectratio")
|
||||
.foregroundStyle(.tint)
|
||||
@@ -223,7 +231,7 @@ struct EditorView: View {
|
||||
.font(.headline)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
HStack(spacing: DesignTokens.Spacing.sm) {
|
||||
ForEach(AspectRatioTemplate.allCases, id: \.self) { template in
|
||||
AspectRatioButton(
|
||||
template: template,
|
||||
@@ -249,7 +257,7 @@ struct EditorView: View {
|
||||
// MARK: - 封面帧预览
|
||||
@ViewBuilder
|
||||
private var coverFrameSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: DesignTokens.Spacing.md) {
|
||||
HStack {
|
||||
Image(systemName: "photo")
|
||||
.foregroundStyle(.tint)
|
||||
@@ -262,15 +270,16 @@ struct EditorView: View {
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
if let coverImage {
|
||||
Image(uiImage: coverImage)
|
||||
HStack(spacing: DesignTokens.Spacing.md) {
|
||||
let displayImage = customCoverImage ?? coverImage
|
||||
if let displayImage {
|
||||
Image(uiImage: displayImage)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 80, height: 120)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.sm))
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
RoundedRectangle(cornerRadius: DesignTokens.Radius.sm)
|
||||
.fill(Color.softPressed)
|
||||
.frame(width: 80, height: 120)
|
||||
.overlay {
|
||||
@@ -279,25 +288,54 @@ struct EditorView: View {
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) {
|
||||
Text(String(localized: "editor.coverFrameHint1"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.textSecondary)
|
||||
Text(String(localized: "editor.coverFrameHint2"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.textSecondary)
|
||||
|
||||
HStack(spacing: DesignTokens.Spacing.sm) {
|
||||
PhotosPicker(
|
||||
selection: $customCoverItem,
|
||||
matching: .images
|
||||
) {
|
||||
Label(String(localized: "editor.coverFromAlbum"), systemImage: "photo.on.rectangle")
|
||||
.font(.caption)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.buttonBorderShape(.capsule)
|
||||
.controlSize(.small)
|
||||
.accessibilityLabel(String(localized: "editor.coverFromAlbum"))
|
||||
|
||||
if customCoverImage != nil {
|
||||
Button {
|
||||
customCoverItem = nil
|
||||
customCoverImage = nil
|
||||
cleanupCustomCoverFile()
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.accessibilityLabel(String(localized: "editor.coverReset"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(DesignTokens.Spacing.lg)
|
||||
.background(Color.softElevated)
|
||||
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
|
||||
.onChange(of: customCoverItem) { _, newItem in
|
||||
loadCustomCover(from: newItem)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 时长控制
|
||||
@ViewBuilder
|
||||
private var durationSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: DesignTokens.Spacing.md) {
|
||||
HStack {
|
||||
Image(systemName: "timer")
|
||||
.foregroundStyle(.tint)
|
||||
@@ -312,20 +350,47 @@ struct EditorView: View {
|
||||
|
||||
SoftSlider(
|
||||
value: $trimEnd,
|
||||
in: 1.0...max(1.0, min(1.5, videoDuration)),
|
||||
in: 1.0...max(1.0, min(5.0, videoDuration)),
|
||||
step: 0.1,
|
||||
gradient: Color.gradientPrimary,
|
||||
accessibilityLabel: String(localized: "editor.videoDuration"),
|
||||
isDisabled: videoDuration < 1.0,
|
||||
onEditingChanged: { _ in
|
||||
updateKeyFrameTime()
|
||||
onEditingChanged: { editing in
|
||||
updateKeyFrameTime(shouldExtractCover: !editing)
|
||||
}
|
||||
)
|
||||
|
||||
Text(String(localized: "editor.durationHint"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.textSecondary)
|
||||
|
||||
// 壁纸兼容性提示
|
||||
if trimEnd - trimStart > 2 {
|
||||
HStack(alignment: .top, spacing: DesignTokens.Spacing.sm) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.foregroundStyle(Color.accentOrange)
|
||||
.font(.caption)
|
||||
Text(String(localized: "editor.durationWallpaperWarning"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.accentOrange)
|
||||
}
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
|
||||
if trimEnd - trimStart > 3 {
|
||||
HStack(alignment: .top, spacing: DesignTokens.Spacing.sm) {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.foregroundStyle(Color.accentOrange)
|
||||
.font(.caption)
|
||||
Text(String(localized: "editor.durationShareWarning"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.accentOrange)
|
||||
}
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.3), value: trimEnd - trimStart > 2)
|
||||
.animation(.easeInOut(duration: 0.3), value: trimEnd - trimStart > 3)
|
||||
.padding(DesignTokens.Spacing.lg)
|
||||
.background(Color.softElevated)
|
||||
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
|
||||
@@ -334,7 +399,7 @@ struct EditorView: View {
|
||||
// MARK: - 封面帧时间选择
|
||||
@ViewBuilder
|
||||
private var keyFrameSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: DesignTokens.Spacing.md) {
|
||||
HStack {
|
||||
Image(systemName: "clock")
|
||||
.foregroundStyle(.tint)
|
||||
@@ -372,12 +437,12 @@ struct EditorView: View {
|
||||
// MARK: - AI 超分辨率开关
|
||||
@ViewBuilder
|
||||
private var aiEnhanceSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: DesignTokens.Spacing.md) {
|
||||
Toggle(isOn: $aiEnhanceEnabled) {
|
||||
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"))
|
||||
@@ -396,8 +461,8 @@ struct EditorView: View {
|
||||
|
||||
// 模型下载进度
|
||||
if aiModelDownloading {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) {
|
||||
HStack(spacing: DesignTokens.Spacing.sm) {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
Text(String(localized: "editor.aiModelDownloading"))
|
||||
@@ -408,17 +473,17 @@ 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)
|
||||
}
|
||||
.padding(.leading, 4)
|
||||
.padding(.leading, DesignTokens.Spacing.xs)
|
||||
}
|
||||
|
||||
if aiEnhanceEnabled && !aiModelDownloading {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) {
|
||||
if aiModelNeedsDownload {
|
||||
HStack(spacing: 4) {
|
||||
HStack(spacing: DesignTokens.Spacing.xs) {
|
||||
Image(systemName: "arrow.down.circle")
|
||||
.foregroundStyle(.orange)
|
||||
.font(.caption)
|
||||
@@ -426,21 +491,21 @@ struct EditorView: View {
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
HStack(spacing: 4) {
|
||||
HStack(spacing: DesignTokens.Spacing.xs) {
|
||||
Image(systemName: "sparkles")
|
||||
.foregroundStyle(Color.accentPurple)
|
||||
.font(.caption)
|
||||
Text(String(localized: "editor.aiResolutionBoost"))
|
||||
.font(.caption)
|
||||
}
|
||||
HStack(spacing: 4) {
|
||||
HStack(spacing: DesignTokens.Spacing.xs) {
|
||||
Image(systemName: "clock")
|
||||
.foregroundStyle(Color.accentPurple)
|
||||
.font(.caption)
|
||||
Text(String(localized: "editor.aiProcessingTime"))
|
||||
.font(.caption)
|
||||
}
|
||||
HStack(spacing: 4) {
|
||||
HStack(spacing: DesignTokens.Spacing.xs) {
|
||||
Image(systemName: "cpu")
|
||||
.foregroundStyle(Color.accentPurple)
|
||||
.font(.caption)
|
||||
@@ -449,11 +514,11 @@ struct EditorView: View {
|
||||
}
|
||||
}
|
||||
.foregroundColor(.textSecondary)
|
||||
.padding(.leading, 4)
|
||||
.padding(.leading, DesignTokens.Spacing.xs)
|
||||
}
|
||||
|
||||
if !AIEnhancer.isAvailable() {
|
||||
HStack(spacing: 4) {
|
||||
HStack(spacing: DesignTokens.Spacing.xs) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.foregroundStyle(.yellow)
|
||||
.font(.caption)
|
||||
@@ -461,7 +526,7 @@ struct EditorView: View {
|
||||
.font(.caption)
|
||||
.foregroundColor(.textSecondary)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
.padding(.top, DesignTokens.Spacing.xs)
|
||||
}
|
||||
}
|
||||
.padding(DesignTokens.Spacing.lg)
|
||||
@@ -476,12 +541,12 @@ struct EditorView: View {
|
||||
// MARK: - 兼容模式开关
|
||||
@ViewBuilder
|
||||
private var compatibilitySection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: DesignTokens.Spacing.md) {
|
||||
Toggle(isOn: $compatibilityMode) {
|
||||
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"))
|
||||
@@ -493,29 +558,29 @@ struct EditorView: View {
|
||||
.tint(.accentColor)
|
||||
|
||||
if compatibilityMode {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 4) {
|
||||
VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) {
|
||||
HStack(spacing: DesignTokens.Spacing.xs) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.font(.caption)
|
||||
Text(String(localized: "editor.resolution720p"))
|
||||
.font(.caption)
|
||||
}
|
||||
HStack(spacing: 4) {
|
||||
HStack(spacing: DesignTokens.Spacing.xs) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.font(.caption)
|
||||
Text(String(localized: "editor.framerate30fps"))
|
||||
.font(.caption)
|
||||
}
|
||||
HStack(spacing: 4) {
|
||||
HStack(spacing: DesignTokens.Spacing.xs) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.font(.caption)
|
||||
Text(String(localized: "editor.codecH264"))
|
||||
.font(.caption)
|
||||
}
|
||||
HStack(spacing: 4) {
|
||||
HStack(spacing: DesignTokens.Spacing.xs) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.font(.caption)
|
||||
@@ -524,7 +589,7 @@ struct EditorView: View {
|
||||
}
|
||||
}
|
||||
.foregroundColor(.textSecondary)
|
||||
.padding(.leading, 4)
|
||||
.padding(.leading, DesignTokens.Spacing.xs)
|
||||
}
|
||||
}
|
||||
.padding(DesignTokens.Spacing.lg)
|
||||
@@ -535,7 +600,7 @@ struct EditorView: View {
|
||||
// MARK: - 诊断建议
|
||||
@ViewBuilder
|
||||
private func diagnosisSection(diagnosis: VideoDiagnosis) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: DesignTokens.Spacing.md) {
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.yellow)
|
||||
@@ -545,12 +610,12 @@ struct EditorView: View {
|
||||
|
||||
ForEach(diagnosis.suggestions.indices, id: \.self) { index in
|
||||
let suggestion = diagnosis.suggestions[index]
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
HStack(alignment: .top, spacing: DesignTokens.Spacing.md) {
|
||||
Image(systemName: suggestion.icon)
|
||||
.foregroundStyle(suggestion.iconColor)
|
||||
.frame(width: 24)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) {
|
||||
Text(suggestion.title)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
@@ -561,15 +626,19 @@ 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)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
.padding(.top, DesignTokens.Spacing.xs)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -580,25 +649,122 @@ struct EditorView: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
|
||||
}
|
||||
|
||||
// MARK: - 预设管理
|
||||
@ViewBuilder
|
||||
private var presetSection: some View {
|
||||
VStack(alignment: .leading, spacing: DesignTokens.Spacing.md) {
|
||||
HStack {
|
||||
Image(systemName: "bookmark")
|
||||
.foregroundStyle(.tint)
|
||||
Text(String(localized: "editor.presetTitle"))
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
showPresetPicker = true
|
||||
} label: {
|
||||
Label(String(localized: "editor.presetLoad"), systemImage: "tray.and.arrow.down")
|
||||
.font(.caption)
|
||||
}
|
||||
.disabled(PresetManager.shared.presets.isEmpty)
|
||||
.accessibilityLabel(String(localized: "editor.presetLoad"))
|
||||
|
||||
Button {
|
||||
presetName = ""
|
||||
showSavePresetAlert = true
|
||||
} label: {
|
||||
Label(String(localized: "editor.presetSave"), systemImage: "tray.and.arrow.up")
|
||||
.font(.caption)
|
||||
}
|
||||
.accessibilityLabel(String(localized: "editor.presetSave"))
|
||||
}
|
||||
}
|
||||
.padding(DesignTokens.Spacing.lg)
|
||||
.background(Color.softElevated)
|
||||
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
|
||||
.alert(String(localized: "editor.presetSaveTitle"), isPresented: $showSavePresetAlert) {
|
||||
TextField(String(localized: "editor.presetNamePlaceholder"), text: $presetName)
|
||||
Button(String(localized: "editor.presetSaveConfirm")) {
|
||||
saveCurrentPreset()
|
||||
}
|
||||
Button(String(localized: "common.cancel"), role: .cancel) {}
|
||||
}
|
||||
.sheet(isPresented: $showPresetPicker) {
|
||||
presetPickerSheet
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var presetPickerSheet: some View {
|
||||
NavigationStack {
|
||||
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: DesignTokens.Spacing.xs) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
for index in indexSet {
|
||||
PresetManager.shared.removePreset(PresetManager.shared.presets[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "editor.presetPickerTitle"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(String(localized: "common.cancel")) {
|
||||
showPresetPicker = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium])
|
||||
}
|
||||
|
||||
// MARK: - 生成按钮
|
||||
@ViewBuilder
|
||||
private var generateButton: some View {
|
||||
Button {
|
||||
SoftPrimaryButton(
|
||||
String(localized: "editor.generateButton"),
|
||||
icon: "wand.and.stars",
|
||||
gradient: Color.gradientPrimary
|
||||
) {
|
||||
startProcessing()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "wand.and.stars")
|
||||
Text(String(localized: "editor.generateButton"))
|
||||
}
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.gradientPrimary)
|
||||
.foregroundColor(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
.buttonStyle(ScaleButtonStyle())
|
||||
.padding(.top, 8)
|
||||
.accessibilityLabel(String(localized: "editor.generateButton"))
|
||||
.padding(.top, DesignTokens.Spacing.sm)
|
||||
.padding(.bottom, DesignTokens.Spacing.sm)
|
||||
}
|
||||
|
||||
// MARK: - 方法
|
||||
@@ -607,7 +773,7 @@ struct EditorView: View {
|
||||
Task {
|
||||
do {
|
||||
let durationCMTime = try await asset.load(.duration)
|
||||
let durationSeconds = durationCMTime.seconds
|
||||
let durationSeconds = sanitizedDuration(durationCMTime.seconds)
|
||||
|
||||
var diagnosis = VideoDiagnosis()
|
||||
diagnosis.duration = durationSeconds
|
||||
@@ -621,9 +787,10 @@ struct EditorView: View {
|
||||
width: abs(transformedSize.width),
|
||||
height: abs(transformedSize.height)
|
||||
)
|
||||
let safeSize = sanitizedVideoSize(absSize)
|
||||
|
||||
// 检测高分辨率(超过 4K)
|
||||
let maxDim = max(absSize.width, absSize.height)
|
||||
let maxDim = max(safeSize.width, safeSize.height)
|
||||
diagnosis.isHighRes = maxDim > 3840
|
||||
|
||||
// 检测高帧率
|
||||
@@ -651,13 +818,14 @@ struct EditorView: View {
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
videoNaturalSize = absSize
|
||||
videoNaturalSize = safeSize
|
||||
}
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
guard isViewActive else { return }
|
||||
videoDuration = durationSeconds
|
||||
trimEnd = min(1.0, durationSeconds)
|
||||
trimEnd = max(0.1, min(1.0, durationSeconds))
|
||||
keyFrameTime = trimEnd / 2
|
||||
player = AVPlayer(url: videoURL)
|
||||
player?.play()
|
||||
@@ -678,26 +846,46 @@ struct EditorView: View {
|
||||
extractCoverFrame()
|
||||
}
|
||||
|
||||
private func updateKeyFrameTime(shouldExtractCover: Bool) {
|
||||
// 确保 keyFrameTime 在有效范围内
|
||||
keyFrameTime = max(trimStart, min(keyFrameTime, trimEnd))
|
||||
if shouldExtractCover {
|
||||
extractCoverFrame()
|
||||
}
|
||||
}
|
||||
|
||||
private func extractCoverFrame() {
|
||||
// 取消上一轮封面提取,避免高频拖动造成并发任务堆积
|
||||
coverExtractionTask?.cancel()
|
||||
|
||||
let token = UUID()
|
||||
coverExtractionToken = token
|
||||
isLoadingCover = true
|
||||
let asset = AVURLAsset(url: videoURL)
|
||||
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
||||
imageGenerator.appliesPreferredTrackTransform = true
|
||||
imageGenerator.maximumSize = CGSize(width: 200, height: 300)
|
||||
imageGenerator.requestedTimeToleranceAfter = CMTime(value: 1, timescale: 100)
|
||||
imageGenerator.requestedTimeToleranceBefore = CMTime(value: 1, timescale: 100)
|
||||
|
||||
let time = CMTime(seconds: keyFrameTime, preferredTimescale: 600)
|
||||
let requestTime = keyFrameTime
|
||||
coverExtractionTask = Task {
|
||||
let asset = AVURLAsset(url: videoURL)
|
||||
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
||||
imageGenerator.appliesPreferredTrackTransform = true
|
||||
imageGenerator.maximumSize = CGSize(width: 200, height: 300)
|
||||
imageGenerator.requestedTimeToleranceAfter = CMTime(value: 1, timescale: 100)
|
||||
imageGenerator.requestedTimeToleranceBefore = CMTime(value: 1, timescale: 100)
|
||||
|
||||
let time = CMTime(seconds: requestTime, preferredTimescale: 600)
|
||||
|
||||
Task {
|
||||
do {
|
||||
let result = try await imageGenerator.image(at: time)
|
||||
try Task.checkCancellation()
|
||||
await MainActor.run {
|
||||
guard coverExtractionToken == token else { return }
|
||||
coverImage = UIImage(cgImage: result.image)
|
||||
isLoadingCover = false
|
||||
}
|
||||
} catch is CancellationError {
|
||||
// 新请求已发起,旧任务被取消属于预期,不更新 UI
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
guard coverExtractionToken == token else { return }
|
||||
isLoadingCover = false
|
||||
}
|
||||
#if DEBUG
|
||||
@@ -710,6 +898,8 @@ struct EditorView: View {
|
||||
private func resetCropState() {
|
||||
cropOffset = .zero
|
||||
cropScale = 1.0
|
||||
lastCropOffset = .zero
|
||||
lastCropScale = 1.0
|
||||
}
|
||||
|
||||
private func calculateCropRect() -> CropRect {
|
||||
@@ -718,7 +908,8 @@ struct EditorView: View {
|
||||
return .full
|
||||
}
|
||||
|
||||
let videoRatio = videoNaturalSize.width / videoNaturalSize.height
|
||||
let safeHeight = max(videoNaturalSize.height, 1)
|
||||
let videoRatio = videoNaturalSize.width / safeHeight
|
||||
|
||||
var cropWidth: CGFloat = 1.0
|
||||
var cropHeight: CGFloat = 1.0
|
||||
@@ -741,6 +932,17 @@ struct EditorView: View {
|
||||
return CropRect(x: cropX, y: cropY, width: cropWidth, height: cropHeight)
|
||||
}
|
||||
|
||||
private func sanitizedDuration(_ value: Double) -> Double {
|
||||
guard value.isFinite, !value.isNaN else { return 0 }
|
||||
return max(0, value)
|
||||
}
|
||||
|
||||
private func sanitizedVideoSize(_ size: CGSize) -> CGSize {
|
||||
let safeWidth = size.width.isFinite && !size.width.isNaN ? max(1, size.width) : 1080
|
||||
let safeHeight = size.height.isFinite && !size.height.isNaN ? max(1, size.height) : 1920
|
||||
return CGSize(width: safeWidth, height: safeHeight)
|
||||
}
|
||||
|
||||
private func checkAndDownloadModel() {
|
||||
guard aiEnhanceEnabled else { return }
|
||||
|
||||
@@ -783,7 +985,80 @@ struct EditorView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func saveCurrentPreset() {
|
||||
let name = presetName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !name.isEmpty else { return }
|
||||
PresetManager.shared.savePreset(
|
||||
name: name,
|
||||
aspectRatio: selectedAspectRatio,
|
||||
trimDuration: trimEnd - trimStart,
|
||||
aiEnhance: aiEnhanceEnabled,
|
||||
compatibilityMode: compatibilityMode
|
||||
)
|
||||
presetSavedCount += 1
|
||||
}
|
||||
|
||||
private func applyPreset(_ preset: EditingPreset) {
|
||||
withAnimation {
|
||||
selectedAspectRatio = preset.aspectRatio
|
||||
trimEnd = min(preset.trimDuration, videoDuration)
|
||||
trimStart = 0
|
||||
aiEnhanceEnabled = preset.aiEnhance
|
||||
compatibilityMode = preset.compatibilityMode
|
||||
updateKeyFrameTime()
|
||||
resetCropState()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadCustomCover(from item: PhotosPickerItem?) {
|
||||
guard let item else {
|
||||
customCoverImage = nil
|
||||
cleanupCustomCoverFile()
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
guard let data = try? await item.loadTransferable(type: Data.self),
|
||||
data.count < 50_000_000,
|
||||
let image = UIImage(data: data) else {
|
||||
return
|
||||
}
|
||||
|
||||
// 保存到临时文件供 LivePhotoCore 使用
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
let fileURL = tempDir.appendingPathComponent("custom_cover_\(UUID().uuidString).jpg")
|
||||
if let jpegData = image.jpegData(compressionQuality: 0.95) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cleanupCustomCoverFile() {
|
||||
if let url = customCoverURL {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
customCoverURL = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func startProcessing() {
|
||||
generateTapCount += 1
|
||||
Analytics.shared.log(.editorGenerateClick, parameters: [
|
||||
"trimStart": trimStart,
|
||||
"trimEnd": trimEnd,
|
||||
@@ -801,6 +1076,7 @@ struct EditorView: View {
|
||||
keyFrameTime: keyFrameTime,
|
||||
cropRect: cropRect,
|
||||
aspectRatio: selectedAspectRatio,
|
||||
coverImageURL: customCoverURL,
|
||||
aiEnhanceConfig: aiEnhanceEnabled ? .standard : .disabled
|
||||
)
|
||||
|
||||
@@ -821,15 +1097,15 @@ struct AspectRatioButton: View {
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
VStack(spacing: 4) {
|
||||
VStack(spacing: DesignTokens.Spacing.xs) {
|
||||
// 比例图标
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
RoundedRectangle(cornerRadius: DesignTokens.Spacing.xs)
|
||||
.stroke(isSelected ? Color.accentPurple : Color.textSecondary, lineWidth: 2)
|
||||
.frame(width: iconWidth, height: iconHeight)
|
||||
.background(
|
||||
isSelected ? Color.accentPurple.opacity(0.1) : Color.clear
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Spacing.xs))
|
||||
|
||||
Text(template.displayName)
|
||||
.font(.caption2)
|
||||
@@ -840,6 +1116,7 @@ struct AspectRatioButton: View {
|
||||
.padding(.vertical, DesignTokens.Spacing.sm)
|
||||
.background(isSelected ? Color.accentPurple.opacity(0.1) : Color.clear)
|
||||
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.sm))
|
||||
.animation(DesignTokens.Animation.quick, value: isSelected)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityElement(children: .ignore)
|
||||
@@ -884,17 +1161,18 @@ struct CropOverlay: View {
|
||||
.mask(
|
||||
Rectangle()
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
RoundedRectangle(cornerRadius: DesignTokens.Radius.sm)
|
||||
.frame(width: cropSize.width, height: cropSize.height)
|
||||
.blendMode(.destinationOut)
|
||||
)
|
||||
)
|
||||
|
||||
// 裁剪框边框
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
RoundedRectangle(cornerRadius: DesignTokens.Radius.sm)
|
||||
.stroke(Color.white, lineWidth: 2)
|
||||
.frame(width: cropSize.width, height: cropSize.height)
|
||||
}
|
||||
.animation(DesignTokens.Animation.standard, value: aspectRatio)
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import SwiftUI
|
||||
import PhotosUI
|
||||
import AVKit
|
||||
import Photos
|
||||
import LivePhotoCore
|
||||
|
||||
struct HomeView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@@ -16,18 +17,28 @@ struct HomeView: View {
|
||||
@State private var selectedItem: PhotosPickerItem?
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var showHero = false
|
||||
@State private var showRecentWorks = false
|
||||
@State private var showClearAllAlert = false
|
||||
@State private var deleteWorkCount: Int = 0
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: DesignTokens.Spacing.xxl) {
|
||||
// 顶部导入区域
|
||||
heroSection
|
||||
.opacity(showHero ? 1 : 0)
|
||||
.offset(y: showHero ? 0 : 20)
|
||||
|
||||
// 最近作品或提示
|
||||
if !recentWorks.recentWorks.isEmpty {
|
||||
recentWorksSection
|
||||
.opacity(showRecentWorks ? 1 : 0)
|
||||
.offset(y: showRecentWorks ? 0 : 20)
|
||||
} else {
|
||||
emptyStateHint
|
||||
.opacity(showRecentWorks ? 1 : 0)
|
||||
.offset(y: showRecentWorks ? 0 : 20)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, DesignTokens.Spacing.xl)
|
||||
@@ -50,7 +61,14 @@ struct HomeView: View {
|
||||
}
|
||||
.onAppear {
|
||||
recentWorks.cleanupDeletedAssets()
|
||||
withAnimation(DesignTokens.Animation.standard) {
|
||||
showHero = true
|
||||
}
|
||||
withAnimation(DesignTokens.Animation.standard.delay(0.2)) {
|
||||
showRecentWorks = true
|
||||
}
|
||||
}
|
||||
.sensoryFeedback(.warning, trigger: deleteWorkCount)
|
||||
}
|
||||
|
||||
// MARK: - Hero 区域
|
||||
@@ -193,6 +211,16 @@ struct HomeView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
// 清空全部按钮
|
||||
Button {
|
||||
showClearAllAlert = true
|
||||
} label: {
|
||||
Text(String(localized: "home.clearAll"))
|
||||
.font(.footnote)
|
||||
.foregroundColor(.accentPink)
|
||||
}
|
||||
.accessibilityLabel(String(localized: "home.clearAll"))
|
||||
|
||||
Text(String(localized: "home.worksCount \(recentWorks.recentWorks.count)"))
|
||||
.font(.footnote)
|
||||
.foregroundColor(.textMuted)
|
||||
@@ -202,17 +230,32 @@ struct HomeView: View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: DesignTokens.Spacing.lg) {
|
||||
ForEach(recentWorks.recentWorks) { work in
|
||||
RecentWorkCard(work: work) {
|
||||
RecentWorkCard(work: work, onTap: {
|
||||
appState.navigateTo(.wallpaperGuide(assetId: work.assetLocalIdentifier))
|
||||
}
|
||||
}, onDelete: {
|
||||
recentWorks.removeWork(work)
|
||||
deleteWorkCount += 1
|
||||
})
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, DesignTokens.Spacing.xs)
|
||||
.padding(.vertical, DesignTokens.Spacing.sm)
|
||||
}
|
||||
}
|
||||
.alert(
|
||||
String(localized: "home.clearAllConfirmTitle"),
|
||||
isPresented: $showClearAllAlert
|
||||
) {
|
||||
Button(String(localized: "home.clearAll"), role: .destructive) {
|
||||
recentWorks.clearAll()
|
||||
}
|
||||
Button(String(localized: "common.cancel"), role: .cancel) {}
|
||||
} message: {
|
||||
Text(String(localized: "home.clearAllConfirmMessage"))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func handleSelectedItem(_ item: PhotosPickerItem?) async {
|
||||
guard let item else { return }
|
||||
|
||||
@@ -224,16 +267,25 @@ 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 {
|
||||
errorMessage = String(localized: "home.loadError \(error.localizedDescription)")
|
||||
let format = String(localized: "home.loadError")
|
||||
errorMessage = String(format: format, error.localizedDescription)
|
||||
isLoading = false
|
||||
Analytics.shared.logError(.importVideoFail, error: error)
|
||||
selectedItem = nil
|
||||
let appError = AppError(
|
||||
code: "IMP-001",
|
||||
message: "视频导入失败",
|
||||
underlyingErrorDescription: error.localizedDescription
|
||||
)
|
||||
Analytics.shared.logAppError(.importVideoFail, appError: appError)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -265,25 +317,36 @@ struct QuickStartStep: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 最近作品卡片
|
||||
// MARK: - 视频传输类型
|
||||
struct VideoTransferable: Transferable {
|
||||
let url: URL
|
||||
|
||||
static var transferRepresentation: some TransferRepresentation {
|
||||
FileRepresentation(contentType: .movie) { video in
|
||||
SentTransferredFile(video.url)
|
||||
} importing: { received in
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
let filename = "import_\(UUID().uuidString).mov"
|
||||
let destURL = tempDir.appendingPathComponent(filename)
|
||||
|
||||
if FileManager.default.fileExists(atPath: destURL.path) {
|
||||
try FileManager.default.removeItem(at: destURL)
|
||||
}
|
||||
try FileManager.default.copyItem(at: received.file, to: destURL)
|
||||
|
||||
return VideoTransferable(url: destURL)
|
||||
FileRepresentation(importedContentType: .movie) { received in
|
||||
try copyToSandboxTemp(from: received.file, preferredExtension: "mov")
|
||||
}
|
||||
|
||||
FileRepresentation(importedContentType: .mpeg4Movie) { received in
|
||||
try copyToSandboxTemp(from: received.file, preferredExtension: "mp4")
|
||||
}
|
||||
|
||||
FileRepresentation(importedContentType: .quickTimeMovie) { received in
|
||||
try copyToSandboxTemp(from: received.file, preferredExtension: "mov")
|
||||
}
|
||||
}
|
||||
|
||||
private static func copyToSandboxTemp(from sourceURL: URL, preferredExtension: String) throws -> VideoTransferable {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
let ext = sourceURL.pathExtension.isEmpty ? preferredExtension : sourceURL.pathExtension
|
||||
let filename = "import_\(UUID().uuidString)." + ext
|
||||
let destURL = tempDir.appendingPathComponent(filename)
|
||||
|
||||
if FileManager.default.fileExists(atPath: destURL.path) {
|
||||
try FileManager.default.removeItem(at: destURL)
|
||||
}
|
||||
try FileManager.default.copyItem(at: sourceURL, to: destURL)
|
||||
return VideoTransferable(url: destURL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,6 +354,7 @@ struct VideoTransferable: Transferable {
|
||||
struct RecentWorkCard: View {
|
||||
let work: RecentWork
|
||||
let onTap: () -> Void
|
||||
var onDelete: (() -> Void)?
|
||||
|
||||
@StateObject private var thumbnailLoader = ThumbnailLoader()
|
||||
@State private var isPressed = false
|
||||
@@ -304,13 +368,13 @@ struct RecentWorkCard: View {
|
||||
Image(uiImage: thumbnail)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 110, height: 150)
|
||||
.frame(width: 130, height: 178)
|
||||
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.95)))
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: DesignTokens.Radius.md)
|
||||
.fill(Color.softPressed)
|
||||
.frame(width: 110, height: 150)
|
||||
.frame(width: 130, height: 178)
|
||||
.overlay {
|
||||
Image(systemName: "livephoto")
|
||||
.font(.system(size: 24))
|
||||
@@ -329,7 +393,7 @@ struct RecentWorkCard: View {
|
||||
}
|
||||
.padding(DesignTokens.Spacing.sm)
|
||||
}
|
||||
.frame(width: 110, height: 150)
|
||||
.frame(width: 130, height: 178)
|
||||
|
||||
// 信息
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
@@ -350,9 +414,15 @@ struct RecentWorkCard: View {
|
||||
.buttonStyle(.plain)
|
||||
.scaleEffect(isPressed ? 0.97 : 1.0)
|
||||
.animation(DesignTokens.Animation.spring, value: isPressed)
|
||||
.onLongPressGesture(minimumDuration: .infinity, pressing: { pressing in
|
||||
isPressed = pressing
|
||||
}, perform: {})
|
||||
.contextMenu {
|
||||
if let onDelete {
|
||||
Button(role: .destructive) {
|
||||
onDelete()
|
||||
} label: {
|
||||
Label(String(localized: "home.deleteWork"), systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
thumbnailLoader.load(assetId: work.assetLocalIdentifier)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
// 背景
|
||||
@@ -54,7 +57,7 @@ struct ProcessingView: View {
|
||||
await startProcessing()
|
||||
}
|
||||
.onAppear {
|
||||
withAnimation(.easeInOut(duration: 1.5).repeatForever(autoreverses: true)) {
|
||||
withAnimation(.easeInOut(duration: 2.0).repeatForever(autoreverses: true)) {
|
||||
pulseAnimation = true
|
||||
}
|
||||
}
|
||||
@@ -88,16 +91,16 @@ struct ProcessingView: View {
|
||||
// 脉冲动画背景
|
||||
Circle()
|
||||
.fill(Color.accentPurple.opacity(0.1))
|
||||
.frame(width: pulseAnimation ? 180 : 160, height: pulseAnimation ? 180 : 160)
|
||||
.animation(.easeInOut(duration: 1.5).repeatForever(autoreverses: true), value: pulseAnimation)
|
||||
.frame(width: pulseAnimation ? 175 : 160, height: pulseAnimation ? 175 : 160)
|
||||
|
||||
// 进度环
|
||||
SoftProgressRing(
|
||||
progress: appState.processingProgress?.fraction ?? 0,
|
||||
progress: overallProgress,
|
||||
size: 140,
|
||||
lineWidth: 10,
|
||||
gradient: stageGradient
|
||||
)
|
||||
.animation(.easeInOut(duration: 0.5), value: overallProgress)
|
||||
|
||||
// 动态图标
|
||||
VStack(spacing: DesignTokens.Spacing.xs) {
|
||||
@@ -106,11 +109,12 @@ struct ProcessingView: View {
|
||||
.foregroundStyle(stageGradient)
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
|
||||
if let progress = appState.processingProgress {
|
||||
Text(String(format: "%.0f%%", progress.fraction * 100))
|
||||
if appState.processingProgress != nil {
|
||||
Text(String(format: "%.0f%%", overallProgress * 100))
|
||||
.font(.headline.bold())
|
||||
.foregroundColor(.textPrimary)
|
||||
.contentTransition(.numericText())
|
||||
.animation(.easeInOut(duration: 0.5), value: overallProgress)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,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)
|
||||
@@ -221,6 +225,13 @@ struct ProcessingView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 全局进度 = (阶段序号 + 阶段内fraction) / 总阶段数
|
||||
private var overallProgress: Double {
|
||||
let totalStages = Double(self.totalStages)
|
||||
let stageFraction = appState.processingProgress?.fraction ?? 0
|
||||
return (Double(currentStageIndex) + stageFraction) / totalStages
|
||||
}
|
||||
|
||||
private var stageIcon: String {
|
||||
guard let stage = appState.processingProgress?.stage else {
|
||||
return "hourglass"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
import LivePhotoCore
|
||||
|
||||
struct ResultView: View {
|
||||
@@ -17,6 +18,7 @@ struct ResultView: View {
|
||||
@State private var showContent = false
|
||||
@State private var showButtons = false
|
||||
@State private var celebrationParticles = false
|
||||
@State private var livePhoto: PHLivePhoto?
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -29,11 +31,15 @@ struct ResultView: View {
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
VStack(spacing: DesignTokens.Spacing.xxxl) {
|
||||
Spacer()
|
||||
|
||||
// 结果图标
|
||||
resultIcon
|
||||
VStack(spacing: DesignTokens.Spacing.xl) {
|
||||
if isSuccess && livePhoto != nil {
|
||||
// Live Photo 预览卡片
|
||||
livePhotoPreview
|
||||
} else {
|
||||
Spacer()
|
||||
// 结果图标
|
||||
resultIcon
|
||||
}
|
||||
|
||||
// 结果信息
|
||||
resultInfo
|
||||
@@ -49,9 +55,53 @@ 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 }
|
||||
await loadLivePhoto()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Live Photo 预览
|
||||
@ViewBuilder
|
||||
private var livePhotoPreview: some View {
|
||||
VStack(spacing: DesignTokens.Spacing.md) {
|
||||
Spacer()
|
||||
|
||||
if let livePhoto {
|
||||
let photoSize = livePhoto.size
|
||||
let ratio = photoSize.width / max(photoSize.height, 1)
|
||||
SoftCard(padding: DesignTokens.Spacing.md) {
|
||||
// PHLivePhotoView 无 intrinsic size,用 Color.clear 撑出正确比例
|
||||
Color.clear
|
||||
.aspectRatio(ratio, contentMode: .fit)
|
||||
.frame(maxHeight: 360)
|
||||
.overlay {
|
||||
LivePhotoPreviewView(livePhoto: livePhoto)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.lg))
|
||||
.accessibilityLabel(String(localized: "result.livePhotoPreview.accessibilityLabel"))
|
||||
}
|
||||
}
|
||||
|
||||
Text(String(localized: "result.livePhotoPreview.hint"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.textSecondary)
|
||||
}
|
||||
.opacity(showContent ? 1 : 0)
|
||||
.offset(y: showContent ? 0 : 20)
|
||||
.animation(.easeOut(duration: 0.4), value: showContent)
|
||||
}
|
||||
|
||||
// MARK: - 结果图标
|
||||
@@ -165,26 +215,92 @@ struct ResultView: View {
|
||||
!workflowResult.savedAssetId.isEmpty
|
||||
}
|
||||
|
||||
// MARK: - 动画
|
||||
// MARK: - 加载 Live Photo
|
||||
|
||||
private func animateIn() {
|
||||
// 串行动画
|
||||
withAnimation {
|
||||
showIcon = true
|
||||
private func loadLivePhoto() async {
|
||||
let imageURL = workflowResult.pairedImageURL
|
||||
let videoURL = workflowResult.pairedVideoURL
|
||||
|
||||
guard FileManager.default.fileExists(atPath: imageURL.path),
|
||||
FileManager.default.fileExists(atPath: videoURL.path) else {
|
||||
return
|
||||
}
|
||||
|
||||
if isSuccess {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||
celebrationParticles = true
|
||||
let photo = await withCheckedContinuation { (continuation: CheckedContinuation<PHLivePhoto?, Never>) in
|
||||
let lock = NSLock()
|
||||
var hasResumed = false
|
||||
|
||||
PHLivePhoto.request(
|
||||
withResourceFileURLs: [imageURL, videoURL],
|
||||
placeholderImage: nil,
|
||||
targetSize: .zero,
|
||||
contentMode: .aspectFit
|
||||
) { result, info in
|
||||
let isDegraded = (info[PHLivePhotoInfoIsDegradedKey] as? Bool) ?? false
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
showContent = true
|
||||
if let photo {
|
||||
withAnimation(.easeIn(duration: 0.3)) {
|
||||
livePhoto = photo
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
showButtons = true
|
||||
// MARK: - Live Photo UIKit 包装器
|
||||
struct LivePhotoPreviewView: UIViewRepresentable {
|
||||
let livePhoto: PHLivePhoto
|
||||
|
||||
func makeUIView(context: Context) -> PHLivePhotoView {
|
||||
let view = PHLivePhotoView()
|
||||
view.contentMode = .scaleAspectFit
|
||||
view.livePhoto = livePhoto
|
||||
view.accessibilityLabel = String(localized: "result.livePhotoPreview.accessibilityLabel")
|
||||
|
||||
let longPress = UILongPressGestureRecognizer(
|
||||
target: context.coordinator,
|
||||
action: #selector(Coordinator.handleLongPress(_:))
|
||||
)
|
||||
longPress.minimumPressDuration = 0.15
|
||||
view.addGestureRecognizer(longPress)
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: PHLivePhotoView, context: Context) {
|
||||
uiView.livePhoto = livePhoto
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject {
|
||||
@objc func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
|
||||
guard let livePhotoView = gesture.view as? PHLivePhotoView else { return }
|
||||
switch gesture.state {
|
||||
case .began:
|
||||
livePhotoView.startPlayback(with: .full)
|
||||
case .ended, .cancelled:
|
||||
livePhotoView.stopPlayback()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,7 @@ struct SettingsView: View {
|
||||
@State private var cacheSize: String = String(localized: "common.calculating")
|
||||
@State private var showingClearCacheAlert = false
|
||||
@State private var showingClearRecentWorksAlert = false
|
||||
@State private var feedbackPackageURL: URL?
|
||||
@State private var showingShareSheet = false
|
||||
@State private var feedbackPackageURL: IdentifiableURL?
|
||||
@State private var showingLanguageChangeAlert = false
|
||||
@State private var showingFeedbackConfirmAlert = false
|
||||
@State private var pendingLanguage: LanguageManager.Language?
|
||||
@@ -182,10 +181,8 @@ struct SettingsView: View {
|
||||
} message: {
|
||||
Text(String(localized: "settings.feedbackConfirmMessage"))
|
||||
}
|
||||
.sheet(isPresented: $showingShareSheet) {
|
||||
if let url = feedbackPackageURL {
|
||||
ShareSheet(activityItems: [url])
|
||||
}
|
||||
.sheet(item: $feedbackPackageURL) { item in
|
||||
ShareSheet(activityItems: [item.url])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,8 +296,7 @@ struct SettingsView: View {
|
||||
let url = await createFeedbackPackage()
|
||||
await MainActor.run {
|
||||
if let url {
|
||||
feedbackPackageURL = url
|
||||
showingShareSheet = true
|
||||
feedbackPackageURL = IdentifiableURL(url: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -425,6 +421,12 @@ extension PHAuthorizationStatus: @retroactive CustomStringConvertible {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Identifiable URL Wrapper
|
||||
struct IdentifiableURL: Identifiable {
|
||||
let id = UUID()
|
||||
let url: URL
|
||||
}
|
||||
|
||||
// MARK: - Share Sheet
|
||||
struct ShareSheet: UIViewControllerRepresentable {
|
||||
let activityItems: [Any]
|
||||
|
||||
Reference in New Issue
Block a user