feat: v1.1 P1 功能实现 + 体验优化(4 项新功能 + 9 项修复)
## 新功能 ### 视频时长放宽(Feature 5) - LivePhotoCore: targetDuration 从固定 0.917 秒改为动态计算(0.5-5.0 秒) - LivePhotoCore: keyFrameTime 按比例映射到目标视频时长 - EditorView: slider 上限从 1.5 秒提升至 5.0 秒 - EditorView: 超过 2 秒显示壁纸兼容性提示,超过 3 秒显示分享截断提示 ### 自定义封面帧(Feature 4) - ExportParams: 新增 coverImageURL 字段 - EditorView: 封面帧区域增加 PhotosPicker 从相册导入照片作为封面 - AppState: coverImageURL 传递到 LivePhotoWorkflow - EditorView: onDisappear 时清理临时封面文件,避免磁盘泄漏 ### 收藏/模板预设(Feature 6) - 新增 PresetManager.swift: EditingPreset 模型 + 预设管理器 - 支持保存/加载/删除预设,最多 10 个 - UserDefaults + iCloud KV Store 双重持久化 - EditorView: 预设保存/加载 UI + 空状态 ContentUnavailableView ### iCloud 同步(Feature 7) - RecentWorksManager: 新增 NSUbiquitousKeyValueStore 同步 - PresetManager: 同步支持 iCloud KV Store - 合并策略:按 ID 去重,保留最新记录,限制条数上限 ## Bug 修复 ### P0 级 - ResultView: Live Photo 预览比例从固定值改为动态计算 aspectRatio - EditorView: 修复本地化字符串拼接问题(使用 String(localized:) 替代硬编码) ### P1 级 - EditorView: 预设列表空状态显示 ContentUnavailableView - ProcessingView: 进度环与百分比文本动画同步(统一 overallProgress 计算) ### P2 级 - EditorView: 添加触觉反馈(比例切换、生成按钮、预设保存、封面导入) - HomeView: 删除作品触觉反馈 + 入场动画优化 - ProcessingView: 脉冲动画背景尺寸微调(160-175pt) - LaunchScreen: 品牌启动画面(App Icon + 标题 + 副标题) ## 本地化 - 新增约 25 个本地化 key,覆盖 8 种语言 (zh-Hans, zh-Hant, en, ja, ko, es, fr, ar) - 包含:预设管理、封面导入、时长提示、删除确认、 空状态、诊断建议等全部新增 UI 文案 ## 改动文件(16 个) - Sources/LivePhotoCore/LivePhotoCore.swift — 动态时长 + coverImageURL - to-live-photo/AppState.swift — coverImageURL 传递 - to-live-photo/PresetManager.swift — 新增预设管理器 - to-live-photo/RecentWorksManager.swift — iCloud 同步 - to-live-photo/DesignSystem.swift — 新增设计令牌 - to-live-photo/Localizable.xcstrings — 25+ 本地化 key - to-live-photo/Views/EditorView.swift — 4 项新功能 UI - to-live-photo/Views/HomeView.swift — 删除作品 + 触觉反馈 - to-live-photo/Views/ProcessingView.swift — 进度环动画修复 - to-live-photo/Views/ResultView.swift — 预览比例修复 + Live Photo 预览 - to-live-photo/Base.lproj/LaunchScreen.storyboard — 品牌启动画面 - to-live-photo/Assets.xcassets/Launch*.colorset — 启动画面颜色资源 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
19
CLAUDE.md
19
CLAUDE.md
@@ -18,11 +18,17 @@ to-live-photo/to-live-photo/
|
||||
## 构建命令
|
||||
|
||||
```bash
|
||||
# 模拟器构建
|
||||
xcodebuild -scheme to-live-photo -destination 'platform=iOS Simulator,name=iPhone 16 Pro' build
|
||||
# 模拟器构建(必须指定 -project)
|
||||
xcodebuild -project to-live-photo/to-live-photo.xcodeproj \
|
||||
-scheme to-live-photo \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 16 Pro' build
|
||||
|
||||
# Archive
|
||||
xcodebuild -scheme to-live-photo -configuration Release -destination 'generic/platform=iOS' -archivePath build/to-live-photo.xcarchive archive
|
||||
xcodebuild -project to-live-photo/to-live-photo.xcodeproj \
|
||||
-scheme to-live-photo \
|
||||
-configuration Release \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-archivePath build/to-live-photo.xcarchive archive
|
||||
```
|
||||
|
||||
## Git 规范
|
||||
@@ -89,3 +95,10 @@ xcodebuild -scheme to-live-photo -configuration Release -destination 'generic/pl
|
||||
- 新增/修改功能 → 同步 `USER_GUIDE.md` 相关章节
|
||||
- 新增测试场景 → 同步 `TEST_MATRIX.md`
|
||||
- 归档文档 → 不更新,保持历史原貌
|
||||
|
||||
## Skill 引用
|
||||
|
||||
本项目配套 skill: `live-photo-studio`(位于 `~/.claude/skills/live-photo-studio/SKILL.md`)
|
||||
|
||||
包含:设计系统令牌速查、构建踩坑记录、导航架构、关键类型、代码规范等项目专属知识。
|
||||
在本项目中进行 UI 开发、调试构建问题时,可调用 `/live-photo-studio` 获取上下文。
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.945",
|
||||
"green" : "0.400",
|
||||
"red" : "0.388"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.953",
|
||||
"green" : "0.941",
|
||||
"red" : "0.941"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.180",
|
||||
"green" : "0.102",
|
||||
"red" : "0.102"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.482",
|
||||
"green" : "0.420",
|
||||
"red" : "0.420"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.698",
|
||||
"green" : "0.627",
|
||||
"red" : "0.627"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="23504" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23484"/>
|
||||
<capability name="Named colors" minToolsVersion="9.0"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" image="AppIcon" translatesAutoresizingMaskIntoConstraints="NO" id="aIc-Kp-9xR">
|
||||
<rect key="frame" x="136.5" y="233" width="120" height="120"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" constant="120" id="wCn-4s-Hx1"/>
|
||||
<constraint firstAttribute="height" constant="120" id="hGt-7r-Qz2"/>
|
||||
</constraints>
|
||||
</imageView>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Live Photo Studio" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="GJd-Yh-RWb">
|
||||
<rect key="frame" x="74" y="406" width="245.66666666666666" height="29"/>
|
||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="24"/>
|
||||
<color key="textColor" name="LaunchAccent"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="视频一键转动态壁纸" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="MHt-Pj-7nA">
|
||||
<rect key="frame" x="123" y="443" width="148" height="17"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="14"/>
|
||||
<color key="textColor" name="LaunchSubtitle"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
|
||||
<color key="backgroundColor" name="LaunchBackground"/>
|
||||
<constraints>
|
||||
<constraint firstItem="aIc-Kp-9xR" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="cXi-3p-Lm7"/>
|
||||
<constraint firstItem="GJd-Yh-RWb" firstAttribute="top" secondItem="aIc-Kp-9xR" secondAttribute="bottom" constant="24" id="tNr-8k-Vw3"/>
|
||||
<constraint firstItem="GJd-Yh-RWb" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="5fK-s1-LcA"/>
|
||||
<constraint firstItem="GJd-Yh-RWb" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" constant="72" id="8bR-Xa-2gP"/>
|
||||
<constraint firstItem="MHt-Pj-7nA" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="Kd4-2s-QnT"/>
|
||||
<constraint firstItem="MHt-Pj-7nA" firstAttribute="top" secondItem="GJd-Yh-RWb" secondAttribute="bottom" constant="8" id="Rne-oP-87k"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="AppIcon" width="120" height="120"/>
|
||||
<namedColor name="LaunchAccent">
|
||||
<color red="0.38823529411764707" green="0.40000000000000002" blue="0.94509803921568625" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
</namedColor>
|
||||
<namedColor name="LaunchBackground">
|
||||
<color red="0.94117647058823528" green="0.94117647058823528" blue="0.95294117647058818" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
</namedColor>
|
||||
<namedColor name="LaunchSubtitle">
|
||||
<color red="0.41960784313725491" green="0.41960784313725491" blue="0.48235294117647054" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
</namedColor>
|
||||
</resources>
|
||||
</document>
|
||||
@@ -554,7 +554,9 @@ struct SoftSlider: View {
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let width = geometry.size.width
|
||||
let progress = (value - range.lowerBound) / (range.upperBound - range.lowerBound)
|
||||
let effectiveRange = sanitizedRange(range)
|
||||
let span = effectiveRange.upperBound - effectiveRange.lowerBound
|
||||
let progress = span > 0 ? (value - effectiveRange.lowerBound) / span : 0
|
||||
let thumbX = width * progress
|
||||
|
||||
ZStack(alignment: .leading) {
|
||||
@@ -581,7 +583,7 @@ struct SoftSlider: View {
|
||||
guard !isDisabled else { return }
|
||||
let newProgress = gesture.location.x / width
|
||||
let clampedProgress = max(0, min(1, newProgress))
|
||||
value = range.lowerBound + (range.upperBound - range.lowerBound) * clampedProgress
|
||||
value = effectiveRange.lowerBound + (effectiveRange.upperBound - effectiveRange.lowerBound) * clampedProgress
|
||||
onEditingChanged?(true)
|
||||
}
|
||||
.onEnded { _ in
|
||||
@@ -599,14 +601,23 @@ struct SoftSlider: View {
|
||||
guard !isDisabled else { return }
|
||||
switch direction {
|
||||
case .increment:
|
||||
value = min(range.upperBound, value + step)
|
||||
value = min(sanitizedRange(range).upperBound, value + step)
|
||||
case .decrement:
|
||||
value = max(range.lowerBound, value - step)
|
||||
value = max(sanitizedRange(range).lowerBound, value - step)
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sanitizedRange(_ input: ClosedRange<Double>) -> ClosedRange<Double> {
|
||||
let lower = input.lowerBound.isFinite && !input.lowerBound.isNaN ? input.lowerBound : 0
|
||||
let upper = input.upperBound.isFinite && !input.upperBound.isNaN ? input.upperBound : lower
|
||||
if upper <= lower {
|
||||
return lower...(lower + 0.0001)
|
||||
}
|
||||
return lower...upper
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 预览
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
160
to-live-photo/to-live-photo/PresetManager.swift
Normal file
160
to-live-photo/to-live-photo/PresetManager.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import SwiftUI
|
||||
import AVKit
|
||||
import PhotosUI
|
||||
import LivePhotoCore
|
||||
|
||||
struct EditorView: View {
|
||||
@@ -23,6 +24,8 @@ struct EditorView: View {
|
||||
@State private var videoDuration: Double = 0
|
||||
@State private var coverImage: UIImage?
|
||||
@State private var isLoadingCover = false
|
||||
@State private var coverExtractionTask: Task<Void, Never>?
|
||||
@State private var coverExtractionToken = UUID()
|
||||
|
||||
// 比例模板相关
|
||||
@State private var selectedAspectRatio: AspectRatioTemplate = .fullScreen
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: - 验证徽章
|
||||
|
||||
Reference in New Issue
Block a user