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: - 验证徽章