diff --git a/CLAUDE.md b/CLAUDE.md
index d96c3de..a765382 100644
--- a/CLAUDE.md
+++ b/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` 获取上下文。
diff --git a/Sources/LivePhotoCore/LivePhotoCore.swift b/Sources/LivePhotoCore/LivePhotoCore.swift
index f96537f..028e0e0 100644
--- a/Sources/LivePhotoCore/LivePhotoCore.swift
+++ b/Sources/LivePhotoCore/LivePhotoCore.swift
@@ -134,6 +134,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 +149,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 +163,7 @@ public struct ExportParams: Codable, Sendable, Hashable {
self.aspectRatio = aspectRatio
self.compatibilityMode = compatibilityMode
self.targetFrameRate = targetFrameRate
+ self.coverImageURL = coverImageURL
self.aiEnhanceConfig = aiEnhanceConfig
}
@@ -518,8 +521,9 @@ public actor LivePhotoBuilder {
destinationURL: trimmedURL
)
- // 关键:将视频变速到约 1 秒,与 metadata.mov 的时间标记匹配
- let targetDuration = CMTimeMake(value: 550, timescale: 600) // ~0.917 秒,与 live-wallpaper 一致
+ // 根据用户选择的裁剪时长动态计算目标时长(上限 5 秒)
+ let trimmedSeconds = min(max(exportParams.trimEnd - exportParams.trimStart, 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 +535,9 @@ public actor LivePhotoBuilder {
destinationURL: scaledURL
)
- // 计算关键帧时间:目标视频的中间位置(0.5 秒处,与 metadata.mov 的 still-image-time 匹配)
- let relativeKeyFrameTime = 0.5 // 固定为 0.5 秒,与 metadata.mov 匹配
+ // 计算关键帧在目标视频中的绝对时间位置
+ let keyFrameRatio = (exportParams.keyFrameTime - 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(
diff --git a/to-live-photo/to-live-photo.xcodeproj/project.pbxproj b/to-live-photo/to-live-photo.xcodeproj/project.pbxproj
index de9c5b9..4a25de1 100644
--- a/to-live-photo/to-live-photo.xcodeproj/project.pbxproj
+++ b/to-live-photo/to-live-photo.xcodeproj/project.pbxproj
@@ -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;
@@ -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;
diff --git a/to-live-photo/to-live-photo/AppState.swift b/to-live-photo/to-live-photo/AppState.swift
index ebdd72a..828be46 100644
--- a/to-live-photo/to-live-photo/AppState.swift
+++ b/to-live-photo/to-live-photo/AppState.swift
@@ -117,7 +117,7 @@ final class AppState {
let result = try await workflow.buildSaveValidate(
workId: workId,
sourceVideoURL: videoURL,
- coverImageURL: nil,
+ coverImageURL: exportParams.coverImageURL,
exportParams: exportParams
) { progress in
Task { @MainActor in
diff --git a/to-live-photo/to-live-photo/Assets.xcassets/LaunchAccent.colorset/Contents.json b/to-live-photo/to-live-photo/Assets.xcassets/LaunchAccent.colorset/Contents.json
new file mode 100644
index 0000000..8603fbe
--- /dev/null
+++ b/to-live-photo/to-live-photo/Assets.xcassets/LaunchAccent.colorset/Contents.json
@@ -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
+ }
+}
diff --git a/to-live-photo/to-live-photo/Assets.xcassets/LaunchBackground.colorset/Contents.json b/to-live-photo/to-live-photo/Assets.xcassets/LaunchBackground.colorset/Contents.json
new file mode 100644
index 0000000..fca0765
--- /dev/null
+++ b/to-live-photo/to-live-photo/Assets.xcassets/LaunchBackground.colorset/Contents.json
@@ -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
+ }
+}
diff --git a/to-live-photo/to-live-photo/Assets.xcassets/LaunchSubtitle.colorset/Contents.json b/to-live-photo/to-live-photo/Assets.xcassets/LaunchSubtitle.colorset/Contents.json
new file mode 100644
index 0000000..c74c2c7
--- /dev/null
+++ b/to-live-photo/to-live-photo/Assets.xcassets/LaunchSubtitle.colorset/Contents.json
@@ -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
+ }
+}
diff --git a/to-live-photo/to-live-photo/Base.lproj/LaunchScreen.storyboard b/to-live-photo/to-live-photo/Base.lproj/LaunchScreen.storyboard
new file mode 100644
index 0000000..08a2aa4
--- /dev/null
+++ b/to-live-photo/to-live-photo/Base.lproj/LaunchScreen.storyboard
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/to-live-photo/to-live-photo/DesignSystem.swift b/to-live-photo/to-live-photo/DesignSystem.swift
index 1c57c9b..3339679 100644
--- a/to-live-photo/to-live-photo/DesignSystem.swift
+++ b/to-live-photo/to-live-photo/DesignSystem.swift
@@ -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) -> ClosedRange {
+ 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: - 预览
diff --git a/to-live-photo/to-live-photo/Localizable.xcstrings b/to-live-photo/to-live-photo/Localizable.xcstrings
index 19cca8b..15a0c06 100644
--- a/to-live-photo/to-live-photo/Localizable.xcstrings
+++ b/to-live-photo/to-live-photo/Localizable.xcstrings
@@ -1,6 +1,12 @@
{
"sourceLanguage" : "zh-Hans",
"strings" : {
+ "·" : {
+
+ },
+ "· AI" : {
+
+ },
"%@, %@" : {
"localizations" : {
"ar" : {
@@ -212,6 +218,19 @@
}
}
},
+ "accessibility.pageIndicator" : {
+
+ },
+ "accessibility.pageOf %lld %lld" : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "accessibility.pageOf %1$lld %2$lld"
+ }
+ }
+ }
+ },
"accessibility.pause" : {
"extractionState" : "manual",
"localizations" : {
@@ -317,6 +336,12 @@
}
}
}
+ },
+ "accessibility.processingStage" : {
+
+ },
+ "accessibility.progress" : {
+
},
"accessibility.settings" : {
"extractionState" : "manual",
@@ -370,6 +395,9 @@
}
}
}
+ },
+ "accessibility.slider" : {
+
},
"aspectRatio.classic" : {
"extractionState" : "manual",
@@ -635,6 +663,9 @@
}
}
}
+ },
+ "Cancel" : {
+
},
"common.calculating" : {
"extractionState" : "manual",
@@ -2226,6 +2257,59 @@
}
}
},
+ "editor.compatibilityShort" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "متوافق"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Compat"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Compat."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Compat."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "互換"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "호환"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "兼容"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "相容"
+ }
+ }
+ }
+ },
"editor.coverFrame" : {
"extractionState" : "manual",
"localizations" : {
@@ -2438,6 +2522,112 @@
}
}
},
+ "editor.coverFromAlbum" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "اختيار من الألبوم"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Choose from Album"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Elegir del álbum"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Choisir depuis l'album"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "アルバムから選択"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "앨범에서 선택"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "从相册选择"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "從相簿選擇"
+ }
+ }
+ }
+ },
+ "editor.coverReset" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "إعادة تعيين الغلاف"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Reset Cover"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Restablecer portada"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Réinitialiser la couverture"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "カバーをリセット"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "커버 초기화"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "重置封面"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "重置封面"
+ }
+ }
+ }
+ },
"editor.diagnosisHDR" : {
"extractionState" : "manual",
"localizations" : {
@@ -2868,49 +3058,49 @@
"ar" : {
"stringUnit" : {
"state" : "translated",
- "value" : "المدة الموصى بها لخلفية Live Photo: 1-1.5 ثانية"
+ "value" : "المدة الموصى بها لخلفية Live Photo: 1-5 ثوانٍ"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Live Photo wallpaper recommended duration: 1-1.5 seconds"
+ "value" : "Live Photo wallpaper recommended duration: 1-5 seconds"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Duración recomendada para fondo Live Photo: 1-1.5 segundos"
+ "value" : "Duración recomendada para fondo Live Photo: 1-5 segundos"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Durée recommandée pour fond d'écran Live Photo: 1-1.5 secondes"
+ "value" : "Durée recommandée pour fond d'écran Live Photo: 1-5 secondes"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Live Photo壁紙推奨時間:1〜1.5秒"
+ "value" : "Live Photo壁紙推奨時間:1〜5秒"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Live Photo 배경화면 권장 길이: 1-1.5초"
+ "value" : "Live Photo 배경화면 권장 길이: 1-5초"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Live Photo 壁纸推荐时长:1 ~ 1.5 秒"
+ "value" : "Live Photo 壁纸推荐时长:1 ~ 5 秒"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Live Photo 桌布建議時長:1 ~ 1.5 秒"
+ "value" : "Live Photo 桌布建議時長:1 ~ 5 秒"
}
}
}
@@ -2968,6 +3158,112 @@
}
}
},
+ "editor.durationShareWarning" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "سيتم اقتطاع Live Photo الأطول من 3 ثوانٍ إلى ~3 ثوانٍ عند المشاركة"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Live Photos longer than 3s will be trimmed to ~3s when shared"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Las Live Photos de más de 3s se recortarán a ~3s al compartir"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Les Live Photos de plus de 3s seront réduites à ~3s lors du partage"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "3秒を超えるLive Photoは共有時に約3秒にカットされます"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "3초를 초과하는 Live Photo는 공유 시 약 3초로 잘립니다"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "超过 3 秒的 Live Photo 在分享时会被系统截断为约 3 秒"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "超過 3 秒的 Live Photo 在分享時會被系統截斷為約 3 秒"
+ }
+ }
+ }
+ },
+ "editor.durationWallpaperWarning" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "قد لا تعمل Live Photo الأطول من ثانيتين كخلفية متحركة لشاشة القفل"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Live Photos longer than 2s may not animate when set as Lock Screen wallpaper"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Las Live Photos de más de 2s podrían no animarse como fondo de pantalla de bloqueo"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Les Live Photos de plus de 2s peuvent ne pas s'animer en fond d'écran de verrouillage"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "2秒を超えるLive Photoはロック画面の壁紙でアニメーションしない場合があります"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "2초를 초과하는 Live Photo는 잠금 화면 배경으로 설정 시 애니메이션이 재생되지 않을 수 있습니다"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "超过 2 秒的 Live Photo 设为锁屏壁纸时可能无法播放动态效果"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "超過 2 秒的 Live Photo 設為鎖定畫面桌布時可能無法播放動態效果"
+ }
+ }
+ }
+ },
"editor.framerate30fps" : {
"extractionState" : "manual",
"localizations" : {
@@ -3286,6 +3582,483 @@
}
}
},
+ "editor.presetEmpty" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "لا توجد إعدادات مسبقة"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No Presets"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sin ajustes preestablecidos"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aucun préréglage"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "プリセットなし"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "프리셋 없음"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "暂无预设"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "暫無預設"
+ }
+ }
+ }
+ },
+ "editor.presetEmptyHint" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "احفظ الإعدادات المسبقة في صفحة التحرير لإعادة استخدامها بسرعة"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Save parameter presets on the editor page for quick reuse"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Guarda ajustes preestablecidos en la página del editor para reutilizarlos rápidamente"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Enregistrez des préréglages sur la page d'édition pour les réutiliser rapidement"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "エディターページでパラメータプリセットを保存して、次回すぐに使えます"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "편집 페이지에서 매개변수 프리셋을 저장하여 다음에 빠르게 사용하세요"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "在编辑页面保存参数预设,方便下次快速使用"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "在編輯頁面儲存參數預設,方便下次快速使用"
+ }
+ }
+ }
+ },
+ "editor.presetLoad" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "تحميل"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Load"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cargar"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Charger"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "読み込み"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "불러오기"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "加载"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "載入"
+ }
+ }
+ }
+ },
+ "editor.presetNamePlaceholder" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "اسم الإعداد المسبق"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Preset name"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nombre del ajuste"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nom du préréglage"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "プリセット名"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "프리셋 이름"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "预设名称"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "預設名稱"
+ }
+ }
+ }
+ },
+ "editor.presetPickerTitle" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "اختيار إعداد مسبق"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Choose Preset"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Elegir ajuste"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Choisir un préréglage"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "プリセットを選択"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "프리셋 선택"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "选择预设"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "選擇預設"
+ }
+ }
+ }
+ },
+ "editor.presetSave" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "حفظ"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Save"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Guardar"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Enregistrer"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "保存"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "저장"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "保存"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "儲存"
+ }
+ }
+ }
+ },
+ "editor.presetSaveConfirm" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "حفظ"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Save"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Guardar"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Enregistrer"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "保存"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "저장"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "保存"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "儲存"
+ }
+ }
+ }
+ },
+ "editor.presetSaveTitle" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "حفظ الإعداد المسبق"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Save Preset"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Guardar ajuste"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Enregistrer le préréglage"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "プリセットを保存"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "프리셋 저장"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "保存预设"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "儲存預設"
+ }
+ }
+ }
+ },
+ "editor.presetTitle" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "الإعدادات المسبقة"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Presets"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ajustes preestablecidos"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Préréglages"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "プリセット"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "프리셋"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "预设"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "預設"
+ }
+ }
+ }
+ },
"editor.resolution720p" : {
"extractionState" : "manual",
"localizations" : {
@@ -3498,6 +4271,218 @@
}
}
},
+ "home.clearAll" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "مسح الكل"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Clear All"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Borrar todo"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tout effacer"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "すべて削除"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "모두 지우기"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "清空全部"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "清空全部"
+ }
+ }
+ }
+ },
+ "home.clearAllConfirmMessage" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "سيتم مسح جميع الأعمال الأخيرة. لا يمكن التراجع عن هذا الإجراء."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "This will remove all recent works. This action cannot be undone."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Se eliminarán todos los trabajos recientes. Esta acción no se puede deshacer."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tous les travaux récents seront supprimés. Cette action est irréversible."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "すべての最近の作品記録が削除されます。この操作は取り消せません。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "모든 최근 작품 기록이 삭제됩니다. 이 작업은 취소할 수 없습니다."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "将清空所有最近作品记录,此操作不可撤销"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "將清空所有最近作品記錄,此操作不可撤銷"
+ }
+ }
+ }
+ },
+ "home.clearAllConfirmTitle" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "تأكيد المسح"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Confirm Clear"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Confirmar borrado"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Confirmer la suppression"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "全削除の確認"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "전체 삭제 확인"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "确认清空"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "確認清空"
+ }
+ }
+ }
+ },
+ "home.deleteWork" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "حذف"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Delete"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Eliminar"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Supprimer"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "削除"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "삭제"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "删除"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "刪除"
+ }
+ }
+ }
+ },
"home.emptyHint" : {
"extractionState" : "manual",
"localizations" : {
@@ -3603,9 +4588,6 @@
}
}
}
- },
- "home.loadError %@" : {
-
},
"home.loadFailed" : {
"extractionState" : "manual",
@@ -4457,6 +5439,9 @@
},
"Live Photo" : {
+ },
+ "Live Photo Studio" : {
+
},
"onboarding.aiEnhance.description" : {
"extractionState" : "manual",
@@ -9228,6 +10213,112 @@
}
}
},
+ "result.livePhotoPreview.accessibilityLabel" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "معاينة Live Photo"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Live Photo preview"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vista previa de Live Photo"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aperçu Live Photo"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Live Photoプレビュー"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Live Photo 미리보기"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Live Photo 预览"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Live Photo 預覽"
+ }
+ }
+ }
+ },
+ "result.livePhotoPreview.hint" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "اضغط مطولاً لمعاينة Live Photo"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Long press to preview Live Photo"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mantén presionado para previsualizar Live Photo"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Appui long pour prévisualiser la Live Photo"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "長押しでLive Photoをプレビュー"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "길게 눌러 Live Photo 미리보기"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "长按预览 Live Photo 动态效果"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "長按預覽 Live Photo 動態效果"
+ }
+ }
+ }
+ },
"result.saved" : {
"extractionState" : "manual",
"localizations" : {
diff --git a/to-live-photo/to-live-photo/PresetManager.swift b/to-live-photo/to-live-photo/PresetManager.swift
new file mode 100644
index 0000000..32a5c51
--- /dev/null
+++ b/to-live-photo/to-live-photo/PresetManager.swift
@@ -0,0 +1,160 @@
+//
+// 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
+}
+
+/// 预设管理器(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
+
+ 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
+ guard let self else { return }
+ 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
+ }
+
+ switch reason {
+ case NSUbiquitousKeyValueStoreServerChange,
+ NSUbiquitousKeyValueStoreInitialSyncChange:
+ 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))
+ saveToLocalOnly()
+ }
+
+ // 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)
+ iCloudStore.set(data, forKey: iCloudKey)
+ } catch {
+ #if DEBUG
+ print("[PresetManager] Failed to encode: \(error)")
+ #endif
+ }
+ }
+
+ private func saveToLocalOnly() {
+ do {
+ let data = try JSONEncoder().encode(presets)
+ UserDefaults.standard.set(data, forKey: storageKey)
+ } catch {
+ #if DEBUG
+ print("[PresetManager] Failed to encode: \(error)")
+ #endif
+ }
+ }
+}
diff --git a/to-live-photo/to-live-photo/RecentWorksManager.swift b/to-live-photo/to-live-photo/RecentWorksManager.swift
index d3f4b1b..0204f79 100644
--- a/to-live-photo/to-live-photo/RecentWorksManager.swift
+++ b/to-live-photo/to-live-photo/RecentWorksManager.swift
@@ -30,7 +30,7 @@ struct RecentWork: Codable, Identifiable, Hashable {
}
}
-/// 最近作品管理器
+/// 最近作品管理器(支持 iCloud 同步)
@MainActor
final class RecentWorksManager: ObservableObject {
static let shared = RecentWorksManager()
@@ -39,9 +39,12 @@ 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
private init() {
loadFromStorage()
+ setupICloudSync()
}
/// 添加新作品记录
@@ -99,6 +102,56 @@ final class RecentWorksManager: ObservableObject {
}
}
+ // MARK: - iCloud 同步
+
+ private func setupICloudSync() {
+ NotificationCenter.default.addObserver(
+ forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
+ object: iCloudStore,
+ queue: .main
+ ) { [weak self] notification in
+ guard let self else { return }
+ let userInfo = notification.userInfo
+ let reason = userInfo?[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int
+ let keys = userInfo?[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String]
+ 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
+ }
+
+ switch reason {
+ case NSUbiquitousKeyValueStoreServerChange,
+ NSUbiquitousKeyValueStoreInitialSyncChange:
+ 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))
+ saveToLocalOnly()
+ }
+
// MARK: - 持久化
private func loadFromStorage() {
@@ -117,6 +170,18 @@ final class RecentWorksManager: ObservableObject {
}
private func saveToStorage() {
+ do {
+ let data = try JSONEncoder().encode(recentWorks)
+ UserDefaults.standard.set(data, forKey: userDefaultsKey)
+ iCloudStore.set(data, forKey: iCloudKey)
+ } catch {
+ #if DEBUG
+ print("[RecentWorksManager] Failed to encode: \(error)")
+ #endif
+ }
+ }
+
+ private func saveToLocalOnly() {
do {
let data = try JSONEncoder().encode(recentWorks)
UserDefaults.standard.set(data, forKey: userDefaultsKey)
diff --git a/to-live-photo/to-live-photo/Views/EditorView.swift b/to-live-photo/to-live-photo/Views/EditorView.swift
index 1eee7c9..5086e27 100644
--- a/to-live-photo/to-live-photo/Views/EditorView.swift
+++ b/to-live-photo/to-live-photo/Views/EditorView.swift
@@ -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?
+ @State private var coverExtractionToken = UUID()
// 比例模板相关
@State private var selectedAspectRatio: AspectRatioTemplate = .fullScreen
@@ -46,8 +49,20 @@ 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 generateTapCount: Int = 0
+ @State private var presetSavedCount: Int = 0
+ @State private var coverImportCount: Int = 0
/// 是否使用 iPad 分栏布局(regular 宽度 + 横屏)
private var useIPadLayout: Bool {
@@ -69,10 +84,15 @@ struct EditorView: View {
}
.onDisappear {
player?.pause()
+ coverExtractionTask?.cancel()
+ coverExtractionTask = nil
+ cleanupCustomCoverFile()
}
.sensoryFeedback(.selection, trigger: selectedAspectRatio)
.sensoryFeedback(.impact(weight: .medium), trigger: generateTapCount)
.sensoryFeedback(.impact(weight: .light), trigger: lastCropScale)
+ .sensoryFeedback(.success, trigger: presetSavedCount)
+ .sensoryFeedback(.selection, trigger: coverImportCount)
}
// MARK: - iPhone 布局(单列滚动)
@@ -92,6 +112,7 @@ struct EditorView: View {
keyFrameSection
aiEnhanceSection
compatibilitySection
+ presetSection
generateButton
}
.padding(.horizontal, DesignTokens.Spacing.xl)
@@ -123,6 +144,7 @@ struct EditorView: View {
keyFrameSection
aiEnhanceSection
compatibilitySection
+ presetSection
generateButton
}
.padding(.vertical, DesignTokens.Spacing.lg)
@@ -241,8 +263,9 @@ struct EditorView: View {
}
HStack(spacing: DesignTokens.Spacing.md) {
- if let coverImage {
- Image(uiImage: coverImage)
+ let displayImage = customCoverImage ?? coverImage
+ if let displayImage {
+ Image(uiImage: displayImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 80, height: 120)
@@ -264,12 +287,41 @@ struct EditorView: View {
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: - 时长控制
@@ -290,20 +342,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: "exclamationmark.triangle")
+ .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))
@@ -558,6 +637,106 @@ 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 {
+ if PresetManager.shared.presets.isEmpty {
+ ContentUnavailableView(
+ String(localized: "editor.presetEmpty"),
+ systemImage: "bookmark",
+ description: Text(String(localized: "editor.presetEmptyHint"))
+ )
+ }
+ List {
+ ForEach(PresetManager.shared.presets) { preset in
+ Button {
+ applyPreset(preset)
+ showPresetPicker = false
+ } label: {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(preset.name)
+ .font(.body)
+ .foregroundStyle(.primary)
+ HStack(spacing: DesignTokens.Spacing.sm) {
+ Text(preset.aspectRatio.displayName)
+ Text("·")
+ Text(String(format: String(localized: "editor.durationSeconds"), preset.trimDuration))
+ if preset.aiEnhance {
+ Text("· AI")
+ }
+ if preset.compatibilityMode {
+ Text("· " + String(localized: "editor.compatibilityShort"))
+ }
+ }
+ .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 {
@@ -578,7 +757,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
@@ -592,9 +771,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
// 检测高帧率
@@ -622,13 +802,13 @@ struct EditorView: View {
}
await MainActor.run {
- videoNaturalSize = absSize
+ videoNaturalSize = safeSize
}
}
await MainActor.run {
videoDuration = durationSeconds
- trimEnd = min(1.0, durationSeconds)
+ trimEnd = max(0.1, min(1.0, durationSeconds))
keyFrameTime = trimEnd / 2
player = AVPlayer(url: videoURL)
player?.play()
@@ -649,26 +829,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
@@ -691,7 +891,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
@@ -714,6 +915,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 }
@@ -756,6 +968,66 @@ 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),
+ 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) {
+ try? jpegData.write(to: fileURL)
+ await MainActor.run {
+ cleanupCustomCoverFile()
+ customCoverImage = image
+ customCoverURL = fileURL
+ coverImportCount += 1
+ }
+ }
+ }
+ }
+
+ 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: [
@@ -775,6 +1047,7 @@ struct EditorView: View {
keyFrameTime: keyFrameTime,
cropRect: cropRect,
aspectRatio: selectedAspectRatio,
+ coverImageURL: customCoverURL,
aiEnhanceConfig: aiEnhanceEnabled ? .standard : .disabled
)
diff --git a/to-live-photo/to-live-photo/Views/HomeView.swift b/to-live-photo/to-live-photo/Views/HomeView.swift
index 544773e..cdf6e53 100644
--- a/to-live-photo/to-live-photo/Views/HomeView.swift
+++ b/to-live-photo/to-live-photo/Views/HomeView.swift
@@ -18,6 +18,8 @@ struct HomeView: View {
@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) {
@@ -65,6 +67,7 @@ struct HomeView: View {
showRecentWorks = true
}
}
+ .sensoryFeedback(.impact(weight: .light), trigger: deleteWorkCount)
}
// MARK: - Hero 区域
@@ -207,6 +210,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)
@@ -216,17 +229,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("Cancel", role: .cancel) {}
+ } message: {
+ Text(String(localized: "home.clearAllConfirmMessage"))
+ }
}
+ @MainActor
private func handleSelectedItem(_ item: PhotosPickerItem?) async {
guard let item else { return }
@@ -245,7 +273,8 @@ struct HomeView: View {
Analytics.shared.log(.importVideoSuccess)
appState.navigateTo(.editor(videoURL: movie.url))
} 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)
}
@@ -279,25 +308,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)
}
}
@@ -305,6 +345,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
@@ -364,9 +405,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)
}
diff --git a/to-live-photo/to-live-photo/Views/ProcessingView.swift b/to-live-photo/to-live-photo/Views/ProcessingView.swift
index 11ac0dc..19a925f 100644
--- a/to-live-photo/to-live-photo/Views/ProcessingView.swift
+++ b/to-live-photo/to-live-photo/Views/ProcessingView.swift
@@ -93,11 +93,12 @@ struct ProcessingView: View {
// 进度环
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 +107,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)
}
}
}
@@ -221,6 +223,13 @@ struct ProcessingView: View {
}
}
+ /// 全局进度 = (阶段序号 + 阶段内fraction) / 总阶段数
+ private var overallProgress: Double {
+ let totalStages = 7.0
+ let stageFraction = appState.processingProgress?.fraction ?? 0
+ return (Double(currentStageIndex) + stageFraction) / totalStages
+ }
+
private var stageIcon: String {
guard let stage = appState.processingProgress?.stage else {
return "hourglass"
diff --git a/to-live-photo/to-live-photo/Views/ResultView.swift b/to-live-photo/to-live-photo/Views/ResultView.swift
index 93b34d3..3c21b49 100644
--- a/to-live-photo/to-live-photo/Views/ResultView.swift
+++ b/to-live-photo/to-live-photo/Views/ResultView.swift
@@ -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
@@ -52,6 +58,38 @@ struct ResultView: View {
.onAppear {
animateIn()
}
+ .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) {
+ LivePhotoPreviewView(livePhoto: livePhoto)
+ .aspectRatio(ratio, contentMode: .fit)
+ .frame(maxHeight: 360)
+ .clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.lg))
+ .accessibilityLabel(String(localized: "result.livePhotoPreview.accessibilityLabel"))
+ }
+ .frame(maxWidth: .infinity)
+ }
+
+ 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: - 结果图标
@@ -187,6 +225,81 @@ struct ResultView: View {
showButtons = true
}
}
+
+ // MARK: - 加载 Live Photo
+
+ 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
+ }
+
+ let photo = await withCheckedContinuation { continuation in
+ PHLivePhoto.request(
+ withResourceFileURLs: [imageURL, videoURL],
+ placeholderImage: nil,
+ targetSize: .zero,
+ contentMode: .aspectFit
+ ) { result, info in
+ let isDegraded = (info[PHLivePhotoInfoIsDegradedKey] as? Bool) ?? false
+ if !isDegraded {
+ continuation.resume(returning: result)
+ }
+ }
+ }
+
+ if let photo {
+ withAnimation(.easeIn(duration: 0.3)) {
+ livePhoto = photo
+ }
+ }
+ }
+}
+
+// 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
+ }
+ }
+ }
}
// MARK: - 验证徽章