Compare commits
7 Commits
cfc39c75fc
...
c826689ee4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c826689ee4 | ||
|
|
f3bcaf4651 | ||
|
|
ec2e0a3ce5 | ||
|
|
846d8ea8d7 | ||
|
|
4588c7c1eb | ||
|
|
a75aeed767 | ||
|
|
1556dfd167 |
19
CLAUDE.md
19
CLAUDE.md
@@ -18,11 +18,17 @@ to-live-photo/to-live-photo/
|
|||||||
## 构建命令
|
## 构建命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 模拟器构建
|
# 模拟器构建(必须指定 -project)
|
||||||
xcodebuild -scheme to-live-photo -destination 'platform=iOS Simulator,name=iPhone 16 Pro' build
|
xcodebuild -project to-live-photo/to-live-photo.xcodeproj \
|
||||||
|
-scheme to-live-photo \
|
||||||
|
-destination 'platform=iOS Simulator,name=iPhone 16 Pro' build
|
||||||
|
|
||||||
# Archive
|
# 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 规范
|
## Git 规范
|
||||||
@@ -89,3 +95,10 @@ xcodebuild -scheme to-live-photo -configuration Release -destination 'generic/pl
|
|||||||
- 新增/修改功能 → 同步 `USER_GUIDE.md` 相关章节
|
- 新增/修改功能 → 同步 `USER_GUIDE.md` 相关章节
|
||||||
- 新增测试场景 → 同步 `TEST_MATRIX.md`
|
- 新增测试场景 → 同步 `TEST_MATRIX.md`
|
||||||
- 归档文档 → 不更新,保持历史原貌
|
- 归档文档 → 不更新,保持历史原貌
|
||||||
|
|
||||||
|
## Skill 引用
|
||||||
|
|
||||||
|
本项目配套 skill: `live-photo-studio`(位于 `~/.claude/skills/live-photo-studio/SKILL.md`)
|
||||||
|
|
||||||
|
包含:设计系统令牌速查、构建踩坑记录、导航架构、关键类型、代码规范等项目专属知识。
|
||||||
|
在本项目中进行 UI 开发、调试构建问题时,可调用 `/live-photo-studio` 获取上下文。
|
||||||
|
|||||||
@@ -82,13 +82,23 @@ public struct CropRect: Codable, Sendable, Hashable {
|
|||||||
/// 全画幅(不裁剪)
|
/// 全画幅(不裁剪)
|
||||||
public static let full = CropRect()
|
public static let full = CropRect()
|
||||||
|
|
||||||
|
/// 返回值域限制在 [0, 1] 范围内的新 CropRect,确保 x+width <= 1, y+height <= 1
|
||||||
|
public func clamped() -> CropRect {
|
||||||
|
let clampedX = min(max(x, 0), 1)
|
||||||
|
let clampedY = min(max(y, 0), 1)
|
||||||
|
let clampedW = min(max(width, 0), 1 - clampedX)
|
||||||
|
let clampedH = min(max(height, 0), 1 - clampedY)
|
||||||
|
return CropRect(x: clampedX, y: clampedY, width: clampedW, height: clampedH)
|
||||||
|
}
|
||||||
|
|
||||||
/// 转换为像素坐标
|
/// 转换为像素坐标
|
||||||
public func toPixelRect(videoSize: CGSize) -> CGRect {
|
public func toPixelRect(videoSize: CGSize) -> CGRect {
|
||||||
CGRect(
|
let safe = clamped()
|
||||||
x: x * videoSize.width,
|
return CGRect(
|
||||||
y: y * videoSize.height,
|
x: safe.x * videoSize.width,
|
||||||
width: width * videoSize.width,
|
y: safe.y * videoSize.height,
|
||||||
height: height * videoSize.height
|
width: safe.width * videoSize.width,
|
||||||
|
height: safe.height * videoSize.height
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,6 +144,7 @@ public struct ExportParams: Codable, Sendable, Hashable {
|
|||||||
public var aspectRatio: AspectRatioTemplate
|
public var aspectRatio: AspectRatioTemplate
|
||||||
public var compatibilityMode: Bool
|
public var compatibilityMode: Bool
|
||||||
public var targetFrameRate: Int
|
public var targetFrameRate: Int
|
||||||
|
public var coverImageURL: URL?
|
||||||
public var aiEnhanceConfig: AIEnhanceConfig
|
public var aiEnhanceConfig: AIEnhanceConfig
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
@@ -148,6 +159,7 @@ public struct ExportParams: Codable, Sendable, Hashable {
|
|||||||
aspectRatio: AspectRatioTemplate = .original,
|
aspectRatio: AspectRatioTemplate = .original,
|
||||||
compatibilityMode: Bool = false,
|
compatibilityMode: Bool = false,
|
||||||
targetFrameRate: Int = 60,
|
targetFrameRate: Int = 60,
|
||||||
|
coverImageURL: URL? = nil,
|
||||||
aiEnhanceConfig: AIEnhanceConfig = .disabled
|
aiEnhanceConfig: AIEnhanceConfig = .disabled
|
||||||
) {
|
) {
|
||||||
self.trimStart = trimStart
|
self.trimStart = trimStart
|
||||||
@@ -161,6 +173,7 @@ public struct ExportParams: Codable, Sendable, Hashable {
|
|||||||
self.aspectRatio = aspectRatio
|
self.aspectRatio = aspectRatio
|
||||||
self.compatibilityMode = compatibilityMode
|
self.compatibilityMode = compatibilityMode
|
||||||
self.targetFrameRate = targetFrameRate
|
self.targetFrameRate = targetFrameRate
|
||||||
|
self.coverImageURL = coverImageURL
|
||||||
self.aiEnhanceConfig = aiEnhanceConfig
|
self.aiEnhanceConfig = aiEnhanceConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,6 +187,25 @@ public struct ExportParams: Codable, Sendable, Hashable {
|
|||||||
params.hdrPolicy = .toneMapToSDR
|
params.hdrPolicy = .toneMapToSDR
|
||||||
return params
|
return params
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Codable 向后兼容
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
trimStart = try container.decode(Double.self, forKey: .trimStart)
|
||||||
|
trimEnd = try container.decode(Double.self, forKey: .trimEnd)
|
||||||
|
keyFrameTime = try container.decode(Double.self, forKey: .keyFrameTime)
|
||||||
|
audioPolicy = try container.decode(AudioPolicy.self, forKey: .audioPolicy)
|
||||||
|
codecPolicy = try container.decode(CodecPolicy.self, forKey: .codecPolicy)
|
||||||
|
hdrPolicy = try container.decode(HDRPolicy.self, forKey: .hdrPolicy)
|
||||||
|
maxDimension = try container.decode(Int.self, forKey: .maxDimension)
|
||||||
|
cropRect = try container.decode(CropRect.self, forKey: .cropRect)
|
||||||
|
aspectRatio = try container.decode(AspectRatioTemplate.self, forKey: .aspectRatio)
|
||||||
|
compatibilityMode = try container.decode(Bool.self, forKey: .compatibilityMode)
|
||||||
|
targetFrameRate = try container.decode(Int.self, forKey: .targetFrameRate)
|
||||||
|
coverImageURL = try container.decodeIfPresent(URL.self, forKey: .coverImageURL)
|
||||||
|
aiEnhanceConfig = try container.decodeIfPresent(AIEnhanceConfig.self, forKey: .aiEnhanceConfig) ?? .disabled
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct AppError: Error, Codable, Sendable, Hashable {
|
public struct AppError: Error, Codable, Sendable, Hashable {
|
||||||
@@ -396,13 +428,22 @@ public actor LivePhotoValidator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return await withCheckedContinuation { continuation in
|
return await withCheckedContinuation { continuation in
|
||||||
|
let resumeOnce = ResumeOnce()
|
||||||
|
let options = PHLivePhotoRequestOptions()
|
||||||
|
options.deliveryMode = .highQualityFormat
|
||||||
PHImageManager.default().requestLivePhoto(
|
PHImageManager.default().requestLivePhoto(
|
||||||
for: asset,
|
for: asset,
|
||||||
targetSize: CGSize(width: 1, height: 1),
|
targetSize: CGSize(width: 1, height: 1),
|
||||||
contentMode: .aspectFit,
|
contentMode: .aspectFit,
|
||||||
options: nil
|
options: options
|
||||||
) { livePhoto, _ in
|
) { livePhoto, info in
|
||||||
continuation.resume(returning: livePhoto)
|
// 跳过降级版本,等待完整版本
|
||||||
|
if let isDegraded = info?[PHImageResultIsDegradedKey] as? Bool, isDegraded {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if resumeOnce.tryConsume() {
|
||||||
|
continuation.resume(returning: livePhoto)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -490,10 +531,10 @@ public actor LivePhotoBuilder {
|
|||||||
public func buildResources(
|
public func buildResources(
|
||||||
workId: UUID = UUID(),
|
workId: UUID = UUID(),
|
||||||
sourceVideoURL: URL,
|
sourceVideoURL: URL,
|
||||||
coverImageURL: URL? = nil,
|
|
||||||
exportParams: ExportParams = ExportParams(),
|
exportParams: ExportParams = ExportParams(),
|
||||||
progress: (@Sendable (LivePhotoBuildProgress) -> Void)? = nil
|
progress: (@Sendable (LivePhotoBuildProgress) -> Void)? = nil
|
||||||
) async throws -> LivePhotoBuildOutput {
|
) async throws -> LivePhotoBuildOutput {
|
||||||
|
let coverImageURL = exportParams.coverImageURL
|
||||||
let assetIdentifier = UUID().uuidString
|
let assetIdentifier = UUID().uuidString
|
||||||
let paths = try cacheManager.makeWorkPaths(workId: workId)
|
let paths = try cacheManager.makeWorkPaths(workId: workId)
|
||||||
|
|
||||||
@@ -518,8 +559,11 @@ public actor LivePhotoBuilder {
|
|||||||
destinationURL: trimmedURL
|
destinationURL: trimmedURL
|
||||||
)
|
)
|
||||||
|
|
||||||
// 关键:将视频变速到约 1 秒,与 metadata.mov 的时间标记匹配
|
// 从实际裁剪后的视频读取真实时长,避免 trimEnd 超过视频实际时长导致偏差
|
||||||
let targetDuration = CMTimeMake(value: 550, timescale: 600) // ~0.917 秒,与 live-wallpaper 一致
|
let trimmedAsset = AVURLAsset(url: trimmedVideoURL)
|
||||||
|
let actualTrimmedDuration = try await trimmedAsset.load(.duration).seconds
|
||||||
|
let trimmedSeconds = min(max(actualTrimmedDuration, 0.5), 5.0)
|
||||||
|
let targetDuration = CMTime(seconds: trimmedSeconds, preferredTimescale: 600)
|
||||||
progress?(LivePhotoBuildProgress(stage: .normalize, fraction: 0.5))
|
progress?(LivePhotoBuildProgress(stage: .normalize, fraction: 0.5))
|
||||||
let scaledVideoURL = try await scaleVideoToTargetDuration(
|
let scaledVideoURL = try await scaleVideoToTargetDuration(
|
||||||
sourceURL: trimmedVideoURL,
|
sourceURL: trimmedVideoURL,
|
||||||
@@ -531,8 +575,20 @@ public actor LivePhotoBuilder {
|
|||||||
destinationURL: scaledURL
|
destinationURL: scaledURL
|
||||||
)
|
)
|
||||||
|
|
||||||
// 计算关键帧时间:目标视频的中间位置(0.5 秒处,与 metadata.mov 的 still-image-time 匹配)
|
// 显式 clamp keyFrameTime 到 [trimStart, trimEnd] 范围
|
||||||
let relativeKeyFrameTime = 0.5 // 固定为 0.5 秒,与 metadata.mov 匹配
|
let clampedKeyFrameTime: Double
|
||||||
|
if exportParams.keyFrameTime < exportParams.trimStart || exportParams.keyFrameTime > exportParams.trimEnd {
|
||||||
|
#if DEBUG
|
||||||
|
print("[LivePhotoBuilder] WARNING: keyFrameTime \(exportParams.keyFrameTime) out of range [\(exportParams.trimStart), \(exportParams.trimEnd)], clamping")
|
||||||
|
#endif
|
||||||
|
clampedKeyFrameTime = min(max(exportParams.keyFrameTime, exportParams.trimStart), exportParams.trimEnd)
|
||||||
|
} else {
|
||||||
|
clampedKeyFrameTime = exportParams.keyFrameTime
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算关键帧在目标视频中的绝对时间位置
|
||||||
|
let keyFrameRatio = (clampedKeyFrameTime - exportParams.trimStart) / max(0.001, exportParams.trimEnd - exportParams.trimStart)
|
||||||
|
let relativeKeyFrameTime = max(0, min(trimmedSeconds, keyFrameRatio * trimmedSeconds))
|
||||||
|
|
||||||
progress?(LivePhotoBuildProgress(stage: .extractKeyFrame, fraction: 0))
|
progress?(LivePhotoBuildProgress(stage: .extractKeyFrame, fraction: 0))
|
||||||
let keyPhotoURL = try await resolveKeyPhotoURL(
|
let keyPhotoURL = try await resolveKeyPhotoURL(
|
||||||
@@ -1024,7 +1080,7 @@ public actor LivePhotoBuilder {
|
|||||||
if let sampleBuffer = videoReaderOutput.copyNextSampleBuffer() {
|
if let sampleBuffer = videoReaderOutput.copyNextSampleBuffer() {
|
||||||
currentFrameCount += 1
|
currentFrameCount += 1
|
||||||
let pct = Double(currentFrameCount) / Double(frameCount)
|
let pct = Double(currentFrameCount) / Double(frameCount)
|
||||||
progress(pct)
|
progress(min(pct, 1.0))
|
||||||
videoWriterInput.append(sampleBuffer)
|
videoWriterInput.append(sampleBuffer)
|
||||||
} else {
|
} else {
|
||||||
videoWriterInput.markAsFinished()
|
videoWriterInput.markAsFinished()
|
||||||
@@ -1146,14 +1202,12 @@ public actor LivePhotoWorkflow {
|
|||||||
public func buildSaveValidate(
|
public func buildSaveValidate(
|
||||||
workId: UUID = UUID(),
|
workId: UUID = UUID(),
|
||||||
sourceVideoURL: URL,
|
sourceVideoURL: URL,
|
||||||
coverImageURL: URL? = nil,
|
|
||||||
exportParams: ExportParams = ExportParams(),
|
exportParams: ExportParams = ExportParams(),
|
||||||
progress: (@Sendable (LivePhotoBuildProgress) -> Void)? = nil
|
progress: (@Sendable (LivePhotoBuildProgress) -> Void)? = nil
|
||||||
) async throws -> LivePhotoWorkflowResult {
|
) async throws -> LivePhotoWorkflowResult {
|
||||||
let output = try await builder.buildResources(
|
let output = try await builder.buildResources(
|
||||||
workId: workId,
|
workId: workId,
|
||||||
sourceVideoURL: sourceVideoURL,
|
sourceVideoURL: sourceVideoURL,
|
||||||
coverImageURL: coverImageURL,
|
|
||||||
exportParams: exportParams,
|
exportParams: exportParams,
|
||||||
progress: progress
|
progress: progress
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -422,7 +422,7 @@
|
|||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.photography";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.photography";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = 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_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
@@ -457,7 +457,7 @@
|
|||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.photography";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.photography";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = 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_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
// App 全局状态管理 + 页面导航状态机
|
// App 全局状态管理 + 页面导航状态机
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import os
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import PhotosUI
|
import PhotosUI
|
||||||
import LivePhotoCore
|
import LivePhotoCore
|
||||||
@@ -34,11 +35,13 @@ final class AppState {
|
|||||||
private var workflow: LivePhotoWorkflow?
|
private var workflow: LivePhotoWorkflow?
|
||||||
private var currentProcessingTask: Task<LivePhotoWorkflowResult?, Never>?
|
private var currentProcessingTask: Task<LivePhotoWorkflowResult?, Never>?
|
||||||
private var currentWorkId: UUID?
|
private var currentWorkId: UUID?
|
||||||
|
private let logger = Logger(subsystem: "ToLivePhoto", category: "AppState")
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
do {
|
do {
|
||||||
workflow = try LivePhotoWorkflow()
|
workflow = try LivePhotoWorkflow()
|
||||||
} catch {
|
} catch {
|
||||||
|
logger.error("Failed to init LivePhotoWorkflow: \(error.localizedDescription, privacy: .public)")
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("Failed to init LivePhotoWorkflow: \(error)")
|
print("Failed to init LivePhotoWorkflow: \(error)")
|
||||||
#endif
|
#endif
|
||||||
@@ -117,7 +120,6 @@ final class AppState {
|
|||||||
let result = try await workflow.buildSaveValidate(
|
let result = try await workflow.buildSaveValidate(
|
||||||
workId: workId,
|
workId: workId,
|
||||||
sourceVideoURL: videoURL,
|
sourceVideoURL: videoURL,
|
||||||
coverImageURL: nil,
|
|
||||||
exportParams: exportParams
|
exportParams: exportParams
|
||||||
) { progress in
|
) { progress in
|
||||||
Task { @MainActor 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 {
|
var body: some View {
|
||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
let width = geometry.size.width
|
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
|
let thumbX = width * progress
|
||||||
|
|
||||||
ZStack(alignment: .leading) {
|
ZStack(alignment: .leading) {
|
||||||
@@ -581,7 +583,7 @@ struct SoftSlider: View {
|
|||||||
guard !isDisabled else { return }
|
guard !isDisabled else { return }
|
||||||
let newProgress = gesture.location.x / width
|
let newProgress = gesture.location.x / width
|
||||||
let clampedProgress = max(0, min(1, newProgress))
|
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)
|
onEditingChanged?(true)
|
||||||
}
|
}
|
||||||
.onEnded { _ in
|
.onEnded { _ in
|
||||||
@@ -599,14 +601,23 @@ struct SoftSlider: View {
|
|||||||
guard !isDisabled else { return }
|
guard !isDisabled else { return }
|
||||||
switch direction {
|
switch direction {
|
||||||
case .increment:
|
case .increment:
|
||||||
value = min(range.upperBound, value + step)
|
value = min(sanitizedRange(range).upperBound, value + step)
|
||||||
case .decrement:
|
case .decrement:
|
||||||
value = max(range.lowerBound, value - step)
|
value = max(sanitizedRange(range).lowerBound, value - step)
|
||||||
@unknown default:
|
@unknown default:
|
||||||
break
|
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: - 预览
|
// MARK: - 预览
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
202
to-live-photo/to-live-photo/PresetManager.swift
Normal file
202
to-live-photo/to-live-photo/PresetManager.swift
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
//
|
||||||
|
// PresetManager.swift
|
||||||
|
// to-live-photo
|
||||||
|
//
|
||||||
|
// 编辑参数预设管理器:保存和加载用户自定义预设
|
||||||
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import LivePhotoCore
|
||||||
|
|
||||||
|
/// 编辑参数预设
|
||||||
|
struct EditingPreset: Codable, Identifiable, Hashable {
|
||||||
|
let id: UUID
|
||||||
|
var name: String
|
||||||
|
let createdAt: Date
|
||||||
|
let aspectRatio: AspectRatioTemplate
|
||||||
|
let trimDuration: Double
|
||||||
|
let aiEnhance: Bool
|
||||||
|
let compatibilityMode: Bool
|
||||||
|
|
||||||
|
/// 自定义解码:对所有字段使用 decodeIfPresent + 默认值,确保未来新增字段不会破坏旧数据解码
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
|
||||||
|
name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
|
||||||
|
createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
|
||||||
|
aspectRatio = try container.decodeIfPresent(AspectRatioTemplate.self, forKey: .aspectRatio) ?? .original
|
||||||
|
trimDuration = try container.decodeIfPresent(Double.self, forKey: .trimDuration) ?? 3.0
|
||||||
|
aiEnhance = try container.decodeIfPresent(Bool.self, forKey: .aiEnhance) ?? false
|
||||||
|
compatibilityMode = try container.decodeIfPresent(Bool.self, forKey: .compatibilityMode) ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保留 memberwise init 供代码内部使用
|
||||||
|
init(
|
||||||
|
id: UUID,
|
||||||
|
name: String,
|
||||||
|
createdAt: Date,
|
||||||
|
aspectRatio: AspectRatioTemplate,
|
||||||
|
trimDuration: Double,
|
||||||
|
aiEnhance: Bool,
|
||||||
|
compatibilityMode: Bool
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.createdAt = createdAt
|
||||||
|
self.aspectRatio = aspectRatio
|
||||||
|
self.trimDuration = trimDuration
|
||||||
|
self.aiEnhance = aiEnhance
|
||||||
|
self.compatibilityMode = compatibilityMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预设管理器(UserDefaults + iCloud 持久化,最多 10 个预设)
|
||||||
|
@MainActor
|
||||||
|
final class PresetManager: ObservableObject {
|
||||||
|
static let shared = PresetManager()
|
||||||
|
|
||||||
|
@Published private(set) var presets: [EditingPreset] = []
|
||||||
|
|
||||||
|
private let maxCount = 10
|
||||||
|
private let storageKey = "editing_presets_v1"
|
||||||
|
private let iCloudKey = "editing_presets_v1"
|
||||||
|
private let iCloudStore = NSUbiquitousKeyValueStore.default
|
||||||
|
/// 防止 iCloud 合并回写触发循环
|
||||||
|
private var isHandlingICloudChange = false
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
loadFromStorage()
|
||||||
|
setupICloudSync()
|
||||||
|
}
|
||||||
|
|
||||||
|
func savePreset(
|
||||||
|
name: String,
|
||||||
|
aspectRatio: AspectRatioTemplate,
|
||||||
|
trimDuration: Double,
|
||||||
|
aiEnhance: Bool,
|
||||||
|
compatibilityMode: Bool
|
||||||
|
) {
|
||||||
|
let preset = EditingPreset(
|
||||||
|
id: UUID(),
|
||||||
|
name: name,
|
||||||
|
createdAt: Date(),
|
||||||
|
aspectRatio: aspectRatio,
|
||||||
|
trimDuration: trimDuration,
|
||||||
|
aiEnhance: aiEnhance,
|
||||||
|
compatibilityMode: compatibilityMode
|
||||||
|
)
|
||||||
|
|
||||||
|
presets.insert(preset, at: 0)
|
||||||
|
|
||||||
|
if presets.count > maxCount {
|
||||||
|
presets = Array(presets.prefix(maxCount))
|
||||||
|
}
|
||||||
|
|
||||||
|
persistToStorage()
|
||||||
|
}
|
||||||
|
|
||||||
|
func removePreset(_ preset: EditingPreset) {
|
||||||
|
presets.removeAll { $0.id == preset.id }
|
||||||
|
persistToStorage()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - iCloud 同步
|
||||||
|
|
||||||
|
private func setupICloudSync() {
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
|
||||||
|
object: iCloudStore,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] notification in
|
||||||
|
let userInfo = notification.userInfo
|
||||||
|
let reason = userInfo?[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int
|
||||||
|
let keys = userInfo?[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String]
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.handleICloudChange(reason: reason, changedKeys: keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
iCloudStore.synchronize()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleICloudChange(reason: Int?, changedKeys: [String]?) {
|
||||||
|
guard let reason, let changedKeys,
|
||||||
|
changedKeys.contains(iCloudKey) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isHandlingICloudChange = true
|
||||||
|
defer { isHandlingICloudChange = false }
|
||||||
|
|
||||||
|
switch reason {
|
||||||
|
case NSUbiquitousKeyValueStoreServerChange,
|
||||||
|
NSUbiquitousKeyValueStoreInitialSyncChange:
|
||||||
|
mergeFromICloud()
|
||||||
|
case NSUbiquitousKeyValueStoreQuotaViolationChange:
|
||||||
|
#if DEBUG
|
||||||
|
print("[PresetManager] iCloud quota violated — data may not sync")
|
||||||
|
#endif
|
||||||
|
case NSUbiquitousKeyValueStoreAccountChange:
|
||||||
|
mergeFromICloud()
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func mergeFromICloud() {
|
||||||
|
guard let data = iCloudStore.data(forKey: iCloudKey),
|
||||||
|
let remotePresets = try? JSONDecoder().decode([EditingPreset].self, from: data) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并策略:按 id 去重,保留最新 createdAt,限制 maxCount 条
|
||||||
|
var merged = Dictionary(grouping: presets + remotePresets) { $0.id }
|
||||||
|
.values
|
||||||
|
.compactMap { $0.max(by: { $0.createdAt < $1.createdAt }) }
|
||||||
|
|
||||||
|
merged.sort { $0.createdAt > $1.createdAt }
|
||||||
|
presets = Array(merged.prefix(maxCount))
|
||||||
|
persistToStorage()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 持久化
|
||||||
|
|
||||||
|
private func loadFromStorage() {
|
||||||
|
guard let data = UserDefaults.standard.data(forKey: storageKey) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
presets = try JSONDecoder().decode([EditingPreset].self, from: data)
|
||||||
|
} catch {
|
||||||
|
#if DEBUG
|
||||||
|
print("[PresetManager] Failed to decode: \(error)")
|
||||||
|
#endif
|
||||||
|
presets = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persistToStorage() {
|
||||||
|
do {
|
||||||
|
let data = try JSONEncoder().encode(presets)
|
||||||
|
UserDefaults.standard.set(data, forKey: storageKey)
|
||||||
|
|
||||||
|
// 标志位为 true 时只写本地,防止 iCloud 写入循环
|
||||||
|
guard !isHandlingICloudChange else { return }
|
||||||
|
|
||||||
|
// iCloud KVS 单 key 上限 1MB,留 100KB 余量
|
||||||
|
if data.count > 900_000 {
|
||||||
|
#if DEBUG
|
||||||
|
print("[PresetManager] Data size \(data.count) exceeds iCloud safe limit, skipping iCloud write")
|
||||||
|
#endif
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
iCloudStore.set(data, forKey: iCloudKey)
|
||||||
|
} catch {
|
||||||
|
#if DEBUG
|
||||||
|
print("[PresetManager] Failed to encode: \(error)")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import Combine
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import LivePhotoCore
|
||||||
import UIKit
|
import UIKit
|
||||||
import Photos
|
import Photos
|
||||||
|
|
||||||
@@ -15,22 +16,24 @@ struct RecentWork: Codable, Identifiable, Hashable {
|
|||||||
let id: UUID
|
let id: UUID
|
||||||
let createdAt: Date
|
let createdAt: Date
|
||||||
let assetLocalIdentifier: String // PHAsset 的 localIdentifier
|
let assetLocalIdentifier: String // PHAsset 的 localIdentifier
|
||||||
let aspectRatioRaw: String // AspectRatioTemplate.rawValue
|
let aspectRatioRaw: String // AspectRatioTemplate.rawValue(保留以兼容旧数据)
|
||||||
let compatibilityMode: Bool
|
let compatibilityMode: Bool
|
||||||
|
|
||||||
|
/// 类型安全的枚举访问,rawValue 无法匹配时返回 nil
|
||||||
|
var aspectRatio: AspectRatioTemplate? {
|
||||||
|
AspectRatioTemplate(rawValue: aspectRatioRaw)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 优先使用枚举的 displayName,fallback 到 raw string
|
||||||
var aspectRatioDisplayName: String {
|
var aspectRatioDisplayName: String {
|
||||||
switch aspectRatioRaw {
|
if let template = aspectRatio {
|
||||||
case "original": return String(localized: "aspectRatio.original")
|
return template.displayName
|
||||||
case "lock_screen": return String(localized: "aspectRatio.lockScreen")
|
|
||||||
case "full_screen": return String(localized: "aspectRatio.fullScreen")
|
|
||||||
case "classic": return String(localized: "aspectRatio.classic")
|
|
||||||
case "square": return String(localized: "aspectRatio.square")
|
|
||||||
default: return aspectRatioRaw
|
|
||||||
}
|
}
|
||||||
|
return aspectRatioRaw
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 最近作品管理器
|
/// 最近作品管理器(支持 iCloud 同步)
|
||||||
@MainActor
|
@MainActor
|
||||||
final class RecentWorksManager: ObservableObject {
|
final class RecentWorksManager: ObservableObject {
|
||||||
static let shared = RecentWorksManager()
|
static let shared = RecentWorksManager()
|
||||||
@@ -39,9 +42,14 @@ final class RecentWorksManager: ObservableObject {
|
|||||||
|
|
||||||
private let maxCount = 20 // 最多保存 20 条记录
|
private let maxCount = 20 // 最多保存 20 条记录
|
||||||
private let userDefaultsKey = "recent_works_v1"
|
private let userDefaultsKey = "recent_works_v1"
|
||||||
|
private let iCloudKey = "recent_works_v1"
|
||||||
|
private let iCloudStore = NSUbiquitousKeyValueStore.default
|
||||||
|
/// 防止 iCloud 合并回写触发循环
|
||||||
|
private var isHandlingICloudChange = false
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
loadFromStorage()
|
loadFromStorage()
|
||||||
|
setupICloudSync()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 添加新作品记录
|
/// 添加新作品记录
|
||||||
@@ -85,20 +93,82 @@ final class RecentWorksManager: ObservableObject {
|
|||||||
let identifiers = recentWorks.map { $0.assetLocalIdentifier }
|
let identifiers = recentWorks.map { $0.assetLocalIdentifier }
|
||||||
guard !identifiers.isEmpty else { return }
|
guard !identifiers.isEmpty else { return }
|
||||||
|
|
||||||
let fetchResult = PHAsset.fetchAssets(withLocalIdentifiers: identifiers, options: nil)
|
Task.detached { [identifiers] in
|
||||||
var existingIds = Set<String>()
|
let fetchResult = PHAsset.fetchAssets(withLocalIdentifiers: identifiers, options: nil)
|
||||||
fetchResult.enumerateObjects { asset, _, _ in
|
var existingIds = Set<String>()
|
||||||
existingIds.insert(asset.localIdentifier)
|
fetchResult.enumerateObjects { asset, _, _ in
|
||||||
|
existingIds.insert(asset.localIdentifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
await MainActor.run { [weak self, existingIds] in
|
||||||
|
guard let self else { return }
|
||||||
|
let before = self.recentWorks.count
|
||||||
|
self.recentWorks.removeAll { !existingIds.contains($0.assetLocalIdentifier) }
|
||||||
|
if self.recentWorks.count != before {
|
||||||
|
self.saveToStorage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - iCloud 同步
|
||||||
|
|
||||||
|
private func setupICloudSync() {
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
|
||||||
|
object: iCloudStore,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] notification in
|
||||||
|
let userInfo = notification.userInfo
|
||||||
|
let reason = userInfo?[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int
|
||||||
|
let keys = userInfo?[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String]
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.handleICloudChange(reason: reason, changedKeys: keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
iCloudStore.synchronize()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleICloudChange(reason: Int?, changedKeys: [String]?) {
|
||||||
|
guard let reason, let changedKeys,
|
||||||
|
changedKeys.contains(iCloudKey) else {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let originalCount = recentWorks.count
|
isHandlingICloudChange = true
|
||||||
recentWorks.removeAll { !existingIds.contains($0.assetLocalIdentifier) }
|
defer { isHandlingICloudChange = false }
|
||||||
|
|
||||||
if recentWorks.count != originalCount {
|
switch reason {
|
||||||
saveToStorage()
|
case NSUbiquitousKeyValueStoreServerChange,
|
||||||
|
NSUbiquitousKeyValueStoreInitialSyncChange:
|
||||||
|
mergeFromICloud()
|
||||||
|
case NSUbiquitousKeyValueStoreQuotaViolationChange:
|
||||||
|
#if DEBUG
|
||||||
|
print("[RecentWorksManager] iCloud quota violated — data may not sync")
|
||||||
|
#endif
|
||||||
|
case NSUbiquitousKeyValueStoreAccountChange:
|
||||||
|
mergeFromICloud()
|
||||||
|
default:
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func mergeFromICloud() {
|
||||||
|
guard let data = iCloudStore.data(forKey: iCloudKey),
|
||||||
|
let remoteWorks = try? JSONDecoder().decode([RecentWork].self, from: data) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并策略:按 createdAt 去重(相同 assetLocalIdentifier 保留最新),保留最新 20 条
|
||||||
|
var merged = Dictionary(grouping: recentWorks + remoteWorks) { $0.assetLocalIdentifier }
|
||||||
|
.values
|
||||||
|
.compactMap { $0.max(by: { $0.createdAt < $1.createdAt }) }
|
||||||
|
|
||||||
|
merged.sort { $0.createdAt > $1.createdAt }
|
||||||
|
recentWorks = Array(merged.prefix(maxCount))
|
||||||
|
saveToStorage()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - 持久化
|
// MARK: - 持久化
|
||||||
|
|
||||||
private func loadFromStorage() {
|
private func loadFromStorage() {
|
||||||
@@ -120,6 +190,19 @@ final class RecentWorksManager: ObservableObject {
|
|||||||
do {
|
do {
|
||||||
let data = try JSONEncoder().encode(recentWorks)
|
let data = try JSONEncoder().encode(recentWorks)
|
||||||
UserDefaults.standard.set(data, forKey: userDefaultsKey)
|
UserDefaults.standard.set(data, forKey: userDefaultsKey)
|
||||||
|
|
||||||
|
// 标志位为 true 时只写本地,防止 iCloud 写入循环
|
||||||
|
guard !isHandlingICloudChange else { return }
|
||||||
|
|
||||||
|
// iCloud KVS 单 key 上限 1MB,留 100KB 余量
|
||||||
|
if data.count > 900_000 {
|
||||||
|
#if DEBUG
|
||||||
|
print("[RecentWorksManager] Data size \(data.count) exceeds iCloud safe limit, skipping iCloud write")
|
||||||
|
#endif
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
iCloudStore.set(data, forKey: iCloudKey)
|
||||||
} catch {
|
} catch {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("[RecentWorksManager] Failed to encode: \(error)")
|
print("[RecentWorksManager] Failed to encode: \(error)")
|
||||||
@@ -132,27 +215,35 @@ final class RecentWorksManager: ObservableObject {
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class ThumbnailLoader: ObservableObject {
|
final class ThumbnailLoader: ObservableObject {
|
||||||
@Published var thumbnail: UIImage?
|
@Published var thumbnail: UIImage?
|
||||||
|
@Published private(set) var isLoading = false
|
||||||
|
|
||||||
func load(assetId: String, targetSize: CGSize = CGSize(width: 200, height: 300)) {
|
func load(assetId: String, targetSize: CGSize = CGSize(width: 200, height: 300)) {
|
||||||
let result = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil)
|
guard !isLoading else { return }
|
||||||
guard let asset = result.firstObject else {
|
isLoading = true
|
||||||
thumbnail = nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let options = PHImageRequestOptions()
|
Task.detached { [weak self] in
|
||||||
options.deliveryMode = .opportunistic
|
let result = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil)
|
||||||
options.isNetworkAccessAllowed = true
|
guard let asset = result.firstObject else {
|
||||||
options.resizeMode = .fast
|
await MainActor.run { [weak self] in
|
||||||
|
self?.isLoading = false
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
PHImageManager.default().requestImage(
|
let options = PHImageRequestOptions()
|
||||||
for: asset,
|
options.deliveryMode = .opportunistic
|
||||||
targetSize: targetSize,
|
options.isNetworkAccessAllowed = true
|
||||||
contentMode: .aspectFill,
|
|
||||||
options: options
|
PHImageManager.default().requestImage(
|
||||||
) { [weak self] image, _ in
|
for: asset,
|
||||||
Task { @MainActor in
|
targetSize: targetSize,
|
||||||
self?.thumbnail = image
|
contentMode: .aspectFill,
|
||||||
|
options: options
|
||||||
|
) { [weak self] image, _ in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.thumbnail = image
|
||||||
|
self?.isLoading = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import AVKit
|
import AVKit
|
||||||
|
import PhotosUI
|
||||||
import LivePhotoCore
|
import LivePhotoCore
|
||||||
|
|
||||||
struct EditorView: View {
|
struct EditorView: View {
|
||||||
@@ -23,6 +24,8 @@ struct EditorView: View {
|
|||||||
@State private var videoDuration: Double = 0
|
@State private var videoDuration: Double = 0
|
||||||
@State private var coverImage: UIImage?
|
@State private var coverImage: UIImage?
|
||||||
@State private var isLoadingCover = false
|
@State private var isLoadingCover = false
|
||||||
|
@State private var coverExtractionTask: Task<Void, Never>?
|
||||||
|
@State private var coverExtractionToken = UUID()
|
||||||
|
|
||||||
// 比例模板相关
|
// 比例模板相关
|
||||||
@State private var selectedAspectRatio: AspectRatioTemplate = .fullScreen
|
@State private var selectedAspectRatio: AspectRatioTemplate = .fullScreen
|
||||||
@@ -31,6 +34,8 @@ struct EditorView: View {
|
|||||||
// 裁剪相关(归一化坐标)
|
// 裁剪相关(归一化坐标)
|
||||||
@State private var cropOffset: CGSize = .zero // 拖拽偏移
|
@State private var cropOffset: CGSize = .zero // 拖拽偏移
|
||||||
@State private var cropScale: CGFloat = 1.0 // 缩放比例
|
@State private var cropScale: CGFloat = 1.0 // 缩放比例
|
||||||
|
@State private var lastCropOffset: CGSize = .zero // 累积偏移
|
||||||
|
@State private var lastCropScale: CGFloat = 1.0 // 累积缩放
|
||||||
|
|
||||||
// 兼容模式
|
// 兼容模式
|
||||||
@State private var compatibilityMode: Bool = false
|
@State private var compatibilityMode: Bool = false
|
||||||
@@ -44,6 +49,25 @@ struct EditorView: View {
|
|||||||
// 视频诊断
|
// 视频诊断
|
||||||
@State private var videoDiagnosis: VideoDiagnosis?
|
@State private var videoDiagnosis: VideoDiagnosis?
|
||||||
|
|
||||||
|
// 自定义封面(从相册导入)
|
||||||
|
@State private var customCoverItem: PhotosPickerItem?
|
||||||
|
@State private var customCoverImage: UIImage?
|
||||||
|
@State private var customCoverURL: URL?
|
||||||
|
|
||||||
|
// 预设相关
|
||||||
|
@State private var showSavePresetAlert = false
|
||||||
|
@State private var presetName = ""
|
||||||
|
@State private var showPresetPicker = false
|
||||||
|
|
||||||
|
// 视图生命周期
|
||||||
|
@State private var isViewActive = true
|
||||||
|
|
||||||
|
// 触觉反馈触发
|
||||||
|
@State private var generateTapCount: Int = 0
|
||||||
|
@State private var presetSavedCount: Int = 0
|
||||||
|
@State private var coverImportCount: Int = 0
|
||||||
|
@State private var cropScaleHapticCount: Int = 0
|
||||||
|
|
||||||
/// 是否使用 iPad 分栏布局(regular 宽度 + 横屏)
|
/// 是否使用 iPad 分栏布局(regular 宽度 + 横屏)
|
||||||
private var useIPadLayout: Bool {
|
private var useIPadLayout: Bool {
|
||||||
horizontalSizeClass == .regular
|
horizontalSizeClass == .regular
|
||||||
@@ -60,19 +84,29 @@ struct EditorView: View {
|
|||||||
.navigationTitle(String(localized: "editor.title"))
|
.navigationTitle(String(localized: "editor.title"))
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
isViewActive = true
|
||||||
loadVideo()
|
loadVideo()
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
|
isViewActive = false
|
||||||
player?.pause()
|
player?.pause()
|
||||||
|
coverExtractionTask?.cancel()
|
||||||
|
coverExtractionTask = nil
|
||||||
|
cleanupCustomCoverFile()
|
||||||
}
|
}
|
||||||
|
.sensoryFeedback(.selection, trigger: selectedAspectRatio)
|
||||||
|
.sensoryFeedback(.impact(weight: .medium), trigger: generateTapCount)
|
||||||
|
.sensoryFeedback(.impact(weight: .light), trigger: cropScaleHapticCount)
|
||||||
|
.sensoryFeedback(.success, trigger: presetSavedCount)
|
||||||
|
.sensoryFeedback(.selection, trigger: coverImportCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - iPhone 布局(单列滚动)
|
// MARK: - iPhone 布局(单列滚动)
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var iPhoneLayout: some View {
|
private var iPhoneLayout: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: DesignTokens.Spacing.xl) {
|
||||||
cropPreviewSection
|
cropPreview(height: 360)
|
||||||
|
|
||||||
if let diagnosis = videoDiagnosis, !diagnosis.suggestions.isEmpty {
|
if let diagnosis = videoDiagnosis, !diagnosis.suggestions.isEmpty {
|
||||||
diagnosisSection(diagnosis: diagnosis)
|
diagnosisSection(diagnosis: diagnosis)
|
||||||
@@ -84,6 +118,7 @@ struct EditorView: View {
|
|||||||
keyFrameSection
|
keyFrameSection
|
||||||
aiEnhanceSection
|
aiEnhanceSection
|
||||||
compatibilitySection
|
compatibilitySection
|
||||||
|
presetSection
|
||||||
generateButton
|
generateButton
|
||||||
}
|
}
|
||||||
.padding(.horizontal, DesignTokens.Spacing.xl)
|
.padding(.horizontal, DesignTokens.Spacing.xl)
|
||||||
@@ -94,10 +129,10 @@ struct EditorView: View {
|
|||||||
// MARK: - iPad 布局(左右分栏)
|
// MARK: - iPad 布局(左右分栏)
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var iPadLayout: some View {
|
private var iPadLayout: some View {
|
||||||
HStack(alignment: .top, spacing: 24) {
|
HStack(alignment: .top, spacing: DesignTokens.Spacing.xxl) {
|
||||||
// 左侧:视频预览
|
// 左侧:视频预览
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: DesignTokens.Spacing.lg) {
|
||||||
iPadCropPreviewSection
|
cropPreview(height: 500, dynamicHeight: true)
|
||||||
|
|
||||||
if let diagnosis = videoDiagnosis, !diagnosis.suggestions.isEmpty {
|
if let diagnosis = videoDiagnosis, !diagnosis.suggestions.isEmpty {
|
||||||
diagnosisSection(diagnosis: diagnosis)
|
diagnosisSection(diagnosis: diagnosis)
|
||||||
@@ -109,14 +144,16 @@ struct EditorView: View {
|
|||||||
|
|
||||||
// 右侧:参数控制
|
// 右侧:参数控制
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: DesignTokens.Spacing.lg) {
|
||||||
coverFrameSection
|
coverFrameSection
|
||||||
durationSection
|
durationSection
|
||||||
keyFrameSection
|
keyFrameSection
|
||||||
aiEnhanceSection
|
aiEnhanceSection
|
||||||
compatibilitySection
|
compatibilitySection
|
||||||
|
presetSection
|
||||||
generateButton
|
generateButton
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, DesignTokens.Spacing.lg)
|
||||||
.padding(.vertical, DesignTokens.Spacing.lg)
|
.padding(.vertical, DesignTokens.Spacing.lg)
|
||||||
}
|
}
|
||||||
.frame(minWidth: 320, maxWidth: 420)
|
.frame(minWidth: 320, maxWidth: 420)
|
||||||
@@ -124,80 +161,51 @@ struct EditorView: View {
|
|||||||
.padding(DesignTokens.Spacing.xxl)
|
.padding(DesignTokens.Spacing.xxl)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - iPad 裁剪预览(更大尺寸)
|
// MARK: - 裁剪手势
|
||||||
@ViewBuilder
|
private var cropGesture: some Gesture {
|
||||||
private var iPadCropPreviewSection: some View {
|
SimultaneousGesture(
|
||||||
GeometryReader { geometry in
|
MagnificationGesture()
|
||||||
let containerWidth = geometry.size.width
|
.onChanged { value in
|
||||||
let containerHeight = min(500, geometry.size.width * 1.2)
|
cropScale = max(1.0, min(3.0, lastCropScale * value))
|
||||||
|
|
||||||
ZStack {
|
|
||||||
if let player {
|
|
||||||
VideoPlayer(player: player)
|
|
||||||
.aspectRatio(videoNaturalSize, contentMode: .fit)
|
|
||||||
.scaleEffect(cropScale)
|
|
||||||
.offset(cropOffset)
|
|
||||||
.gesture(
|
|
||||||
SimultaneousGesture(
|
|
||||||
MagnificationGesture()
|
|
||||||
.onChanged { value in
|
|
||||||
cropScale = max(1.0, min(3.0, value))
|
|
||||||
},
|
|
||||||
DragGesture()
|
|
||||||
.onChanged { value in
|
|
||||||
cropOffset = value.translation
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
ProgressView()
|
|
||||||
}
|
}
|
||||||
|
.onEnded { value in
|
||||||
if selectedAspectRatio != .original {
|
lastCropScale = max(1.0, min(3.0, lastCropScale * value))
|
||||||
CropOverlay(
|
cropScaleHapticCount += 1
|
||||||
aspectRatio: selectedAspectRatio,
|
},
|
||||||
containerSize: CGSize(width: containerWidth, height: containerHeight)
|
DragGesture()
|
||||||
|
.onChanged { value in
|
||||||
|
cropOffset = CGSize(
|
||||||
|
width: lastCropOffset.width + value.translation.width,
|
||||||
|
height: lastCropOffset.height + value.translation.height
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
.onEnded { value in
|
||||||
.frame(width: containerWidth, height: containerHeight)
|
lastCropOffset = CGSize(
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
width: lastCropOffset.width + value.translation.width,
|
||||||
.background(Color.black.clipShape(RoundedRectangle(cornerRadius: 16)))
|
height: lastCropOffset.height + value.translation.height
|
||||||
}
|
)
|
||||||
.frame(height: 500)
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 裁剪预览
|
// MARK: - 裁剪预览(共享)
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var cropPreviewSection: some View {
|
private func cropPreview(height: CGFloat, dynamicHeight: Bool = false) -> some View {
|
||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
let containerWidth = geometry.size.width
|
let containerWidth = geometry.size.width
|
||||||
let containerHeight: CGFloat = 360
|
let containerHeight = dynamicHeight ? min(height, geometry.size.width * 1.2) : height
|
||||||
|
|
||||||
ZStack {
|
ZStack {
|
||||||
// 视频预览(全画幅)
|
|
||||||
if let player {
|
if let player {
|
||||||
VideoPlayer(player: player)
|
VideoPlayer(player: player)
|
||||||
.aspectRatio(videoNaturalSize, contentMode: .fit)
|
.aspectRatio(videoNaturalSize, contentMode: .fit)
|
||||||
.scaleEffect(cropScale)
|
.scaleEffect(cropScale)
|
||||||
.offset(cropOffset)
|
.offset(cropOffset)
|
||||||
.gesture(
|
.gesture(cropGesture)
|
||||||
SimultaneousGesture(
|
|
||||||
MagnificationGesture()
|
|
||||||
.onChanged { value in
|
|
||||||
cropScale = max(1.0, min(3.0, value))
|
|
||||||
},
|
|
||||||
DragGesture()
|
|
||||||
.onChanged { value in
|
|
||||||
cropOffset = value.translation
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 裁剪框叠加层
|
|
||||||
if selectedAspectRatio != .original {
|
if selectedAspectRatio != .original {
|
||||||
CropOverlay(
|
CropOverlay(
|
||||||
aspectRatio: selectedAspectRatio,
|
aspectRatio: selectedAspectRatio,
|
||||||
@@ -206,16 +214,16 @@ struct EditorView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: containerWidth, height: containerHeight)
|
.frame(width: containerWidth, height: containerHeight)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.lg))
|
||||||
.background(Color.black.clipShape(RoundedRectangle(cornerRadius: 16)))
|
.background(Color.black.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.lg)))
|
||||||
}
|
}
|
||||||
.frame(height: 360)
|
.frame(height: height)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 比例模板选择
|
// MARK: - 比例模板选择
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var aspectRatioSection: some View {
|
private var aspectRatioSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: DesignTokens.Spacing.md) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "aspectratio")
|
Image(systemName: "aspectratio")
|
||||||
.foregroundStyle(.tint)
|
.foregroundStyle(.tint)
|
||||||
@@ -223,7 +231,7 @@ struct EditorView: View {
|
|||||||
.font(.headline)
|
.font(.headline)
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: DesignTokens.Spacing.sm) {
|
||||||
ForEach(AspectRatioTemplate.allCases, id: \.self) { template in
|
ForEach(AspectRatioTemplate.allCases, id: \.self) { template in
|
||||||
AspectRatioButton(
|
AspectRatioButton(
|
||||||
template: template,
|
template: template,
|
||||||
@@ -249,7 +257,7 @@ struct EditorView: View {
|
|||||||
// MARK: - 封面帧预览
|
// MARK: - 封面帧预览
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var coverFrameSection: some View {
|
private var coverFrameSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: DesignTokens.Spacing.md) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "photo")
|
Image(systemName: "photo")
|
||||||
.foregroundStyle(.tint)
|
.foregroundStyle(.tint)
|
||||||
@@ -262,15 +270,16 @@ struct EditorView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: DesignTokens.Spacing.md) {
|
||||||
if let coverImage {
|
let displayImage = customCoverImage ?? coverImage
|
||||||
Image(uiImage: coverImage)
|
if let displayImage {
|
||||||
|
Image(uiImage: displayImage)
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fill)
|
.aspectRatio(contentMode: .fill)
|
||||||
.frame(width: 80, height: 120)
|
.frame(width: 80, height: 120)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.sm))
|
||||||
} else {
|
} else {
|
||||||
RoundedRectangle(cornerRadius: 8)
|
RoundedRectangle(cornerRadius: DesignTokens.Radius.sm)
|
||||||
.fill(Color.softPressed)
|
.fill(Color.softPressed)
|
||||||
.frame(width: 80, height: 120)
|
.frame(width: 80, height: 120)
|
||||||
.overlay {
|
.overlay {
|
||||||
@@ -279,25 +288,54 @@ struct EditorView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) {
|
||||||
Text(String(localized: "editor.coverFrameHint1"))
|
Text(String(localized: "editor.coverFrameHint1"))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.textSecondary)
|
.foregroundColor(.textSecondary)
|
||||||
Text(String(localized: "editor.coverFrameHint2"))
|
Text(String(localized: "editor.coverFrameHint2"))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.textSecondary)
|
.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)
|
.padding(DesignTokens.Spacing.lg)
|
||||||
.background(Color.softElevated)
|
.background(Color.softElevated)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
|
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
|
||||||
|
.onChange(of: customCoverItem) { _, newItem in
|
||||||
|
loadCustomCover(from: newItem)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 时长控制
|
// MARK: - 时长控制
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var durationSection: some View {
|
private var durationSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: DesignTokens.Spacing.md) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "timer")
|
Image(systemName: "timer")
|
||||||
.foregroundStyle(.tint)
|
.foregroundStyle(.tint)
|
||||||
@@ -312,20 +350,47 @@ struct EditorView: View {
|
|||||||
|
|
||||||
SoftSlider(
|
SoftSlider(
|
||||||
value: $trimEnd,
|
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,
|
step: 0.1,
|
||||||
gradient: Color.gradientPrimary,
|
gradient: Color.gradientPrimary,
|
||||||
accessibilityLabel: String(localized: "editor.videoDuration"),
|
accessibilityLabel: String(localized: "editor.videoDuration"),
|
||||||
isDisabled: videoDuration < 1.0,
|
isDisabled: videoDuration < 1.0,
|
||||||
onEditingChanged: { _ in
|
onEditingChanged: { editing in
|
||||||
updateKeyFrameTime()
|
updateKeyFrameTime(shouldExtractCover: !editing)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(String(localized: "editor.durationHint"))
|
Text(String(localized: "editor.durationHint"))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.textSecondary)
|
.foregroundColor(.textSecondary)
|
||||||
|
|
||||||
|
// 壁纸兼容性提示
|
||||||
|
if trimEnd - trimStart > 2 {
|
||||||
|
HStack(alignment: .top, spacing: DesignTokens.Spacing.sm) {
|
||||||
|
Image(systemName: "exclamationmark.triangle")
|
||||||
|
.foregroundStyle(Color.accentOrange)
|
||||||
|
.font(.caption)
|
||||||
|
Text(String(localized: "editor.durationWallpaperWarning"))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.accentOrange)
|
||||||
|
}
|
||||||
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if trimEnd - trimStart > 3 {
|
||||||
|
HStack(alignment: .top, spacing: DesignTokens.Spacing.sm) {
|
||||||
|
Image(systemName: "square.and.arrow.up")
|
||||||
|
.foregroundStyle(Color.accentOrange)
|
||||||
|
.font(.caption)
|
||||||
|
Text(String(localized: "editor.durationShareWarning"))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.accentOrange)
|
||||||
|
}
|
||||||
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.3), value: trimEnd - trimStart > 2)
|
||||||
|
.animation(.easeInOut(duration: 0.3), value: trimEnd - trimStart > 3)
|
||||||
.padding(DesignTokens.Spacing.lg)
|
.padding(DesignTokens.Spacing.lg)
|
||||||
.background(Color.softElevated)
|
.background(Color.softElevated)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
|
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
|
||||||
@@ -334,7 +399,7 @@ struct EditorView: View {
|
|||||||
// MARK: - 封面帧时间选择
|
// MARK: - 封面帧时间选择
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var keyFrameSection: some View {
|
private var keyFrameSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: DesignTokens.Spacing.md) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "clock")
|
Image(systemName: "clock")
|
||||||
.foregroundStyle(.tint)
|
.foregroundStyle(.tint)
|
||||||
@@ -372,12 +437,12 @@ struct EditorView: View {
|
|||||||
// MARK: - AI 超分辨率开关
|
// MARK: - AI 超分辨率开关
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var aiEnhanceSection: some View {
|
private var aiEnhanceSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: DesignTokens.Spacing.md) {
|
||||||
Toggle(isOn: $aiEnhanceEnabled) {
|
Toggle(isOn: $aiEnhanceEnabled) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "wand.and.stars.inverse")
|
Image(systemName: "wand.and.stars.inverse")
|
||||||
.foregroundStyle(Color.accentPurple)
|
.foregroundStyle(Color.accentPurple)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) {
|
||||||
Text(String(localized: "editor.aiEnhance"))
|
Text(String(localized: "editor.aiEnhance"))
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
Text(String(localized: "editor.aiEnhanceDescription"))
|
Text(String(localized: "editor.aiEnhanceDescription"))
|
||||||
@@ -396,8 +461,8 @@ struct EditorView: View {
|
|||||||
|
|
||||||
// 模型下载进度
|
// 模型下载进度
|
||||||
if aiModelDownloading {
|
if aiModelDownloading {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: DesignTokens.Spacing.sm) {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.scaleEffect(0.8)
|
.scaleEffect(0.8)
|
||||||
Text(String(localized: "editor.aiModelDownloading"))
|
Text(String(localized: "editor.aiModelDownloading"))
|
||||||
@@ -408,17 +473,17 @@ struct EditorView: View {
|
|||||||
ProgressView(value: aiModelDownloadProgress)
|
ProgressView(value: aiModelDownloadProgress)
|
||||||
.tint(Color.accentPurple)
|
.tint(Color.accentPurple)
|
||||||
|
|
||||||
Text(String(format: "%.0f%%", aiModelDownloadProgress * 100))
|
Text(aiModelDownloadProgress, format: .percent.precision(.fractionLength(0)))
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundColor(.textSecondary)
|
.foregroundColor(.textSecondary)
|
||||||
}
|
}
|
||||||
.padding(.leading, 4)
|
.padding(.leading, DesignTokens.Spacing.xs)
|
||||||
}
|
}
|
||||||
|
|
||||||
if aiEnhanceEnabled && !aiModelDownloading {
|
if aiEnhanceEnabled && !aiModelDownloading {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) {
|
||||||
if aiModelNeedsDownload {
|
if aiModelNeedsDownload {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: DesignTokens.Spacing.xs) {
|
||||||
Image(systemName: "arrow.down.circle")
|
Image(systemName: "arrow.down.circle")
|
||||||
.foregroundStyle(.orange)
|
.foregroundStyle(.orange)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -426,21 +491,21 @@ struct EditorView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: DesignTokens.Spacing.xs) {
|
||||||
Image(systemName: "sparkles")
|
Image(systemName: "sparkles")
|
||||||
.foregroundStyle(Color.accentPurple)
|
.foregroundStyle(Color.accentPurple)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
Text(String(localized: "editor.aiResolutionBoost"))
|
Text(String(localized: "editor.aiResolutionBoost"))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: DesignTokens.Spacing.xs) {
|
||||||
Image(systemName: "clock")
|
Image(systemName: "clock")
|
||||||
.foregroundStyle(Color.accentPurple)
|
.foregroundStyle(Color.accentPurple)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
Text(String(localized: "editor.aiProcessingTime"))
|
Text(String(localized: "editor.aiProcessingTime"))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: DesignTokens.Spacing.xs) {
|
||||||
Image(systemName: "cpu")
|
Image(systemName: "cpu")
|
||||||
.foregroundStyle(Color.accentPurple)
|
.foregroundStyle(Color.accentPurple)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -449,11 +514,11 @@ struct EditorView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.foregroundColor(.textSecondary)
|
.foregroundColor(.textSecondary)
|
||||||
.padding(.leading, 4)
|
.padding(.leading, DesignTokens.Spacing.xs)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !AIEnhancer.isAvailable() {
|
if !AIEnhancer.isAvailable() {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: DesignTokens.Spacing.xs) {
|
||||||
Image(systemName: "exclamationmark.triangle")
|
Image(systemName: "exclamationmark.triangle")
|
||||||
.foregroundStyle(.yellow)
|
.foregroundStyle(.yellow)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -461,7 +526,7 @@ struct EditorView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.textSecondary)
|
.foregroundColor(.textSecondary)
|
||||||
}
|
}
|
||||||
.padding(.top, 4)
|
.padding(.top, DesignTokens.Spacing.xs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(DesignTokens.Spacing.lg)
|
.padding(DesignTokens.Spacing.lg)
|
||||||
@@ -476,12 +541,12 @@ struct EditorView: View {
|
|||||||
// MARK: - 兼容模式开关
|
// MARK: - 兼容模式开关
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var compatibilitySection: some View {
|
private var compatibilitySection: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: DesignTokens.Spacing.md) {
|
||||||
Toggle(isOn: $compatibilityMode) {
|
Toggle(isOn: $compatibilityMode) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "gearshape.2")
|
Image(systemName: "gearshape.2")
|
||||||
.foregroundStyle(.tint)
|
.foregroundStyle(.tint)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) {
|
||||||
Text(String(localized: "editor.compatibilityMode"))
|
Text(String(localized: "editor.compatibilityMode"))
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
Text(String(localized: "editor.compatibilityDescription"))
|
Text(String(localized: "editor.compatibilityDescription"))
|
||||||
@@ -493,29 +558,29 @@ struct EditorView: View {
|
|||||||
.tint(.accentColor)
|
.tint(.accentColor)
|
||||||
|
|
||||||
if compatibilityMode {
|
if compatibilityMode {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: DesignTokens.Spacing.xs) {
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.foregroundStyle(.green)
|
.foregroundStyle(.green)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
Text(String(localized: "editor.resolution720p"))
|
Text(String(localized: "editor.resolution720p"))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: DesignTokens.Spacing.xs) {
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.foregroundStyle(.green)
|
.foregroundStyle(.green)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
Text(String(localized: "editor.framerate30fps"))
|
Text(String(localized: "editor.framerate30fps"))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: DesignTokens.Spacing.xs) {
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.foregroundStyle(.green)
|
.foregroundStyle(.green)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
Text(String(localized: "editor.codecH264"))
|
Text(String(localized: "editor.codecH264"))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: DesignTokens.Spacing.xs) {
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.foregroundStyle(.green)
|
.foregroundStyle(.green)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -524,7 +589,7 @@ struct EditorView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.foregroundColor(.textSecondary)
|
.foregroundColor(.textSecondary)
|
||||||
.padding(.leading, 4)
|
.padding(.leading, DesignTokens.Spacing.xs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(DesignTokens.Spacing.lg)
|
.padding(DesignTokens.Spacing.lg)
|
||||||
@@ -535,7 +600,7 @@ struct EditorView: View {
|
|||||||
// MARK: - 诊断建议
|
// MARK: - 诊断建议
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func diagnosisSection(diagnosis: VideoDiagnosis) -> some View {
|
private func diagnosisSection(diagnosis: VideoDiagnosis) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: DesignTokens.Spacing.md) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
.foregroundStyle(.yellow)
|
.foregroundStyle(.yellow)
|
||||||
@@ -545,12 +610,12 @@ struct EditorView: View {
|
|||||||
|
|
||||||
ForEach(diagnosis.suggestions.indices, id: \.self) { index in
|
ForEach(diagnosis.suggestions.indices, id: \.self) { index in
|
||||||
let suggestion = diagnosis.suggestions[index]
|
let suggestion = diagnosis.suggestions[index]
|
||||||
HStack(alignment: .top, spacing: 12) {
|
HStack(alignment: .top, spacing: DesignTokens.Spacing.md) {
|
||||||
Image(systemName: suggestion.icon)
|
Image(systemName: suggestion.icon)
|
||||||
.foregroundStyle(suggestion.iconColor)
|
.foregroundStyle(suggestion.iconColor)
|
||||||
.frame(width: 24)
|
.frame(width: 24)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) {
|
||||||
Text(suggestion.title)
|
Text(suggestion.title)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
@@ -561,15 +626,19 @@ struct EditorView: View {
|
|||||||
|
|
||||||
if let actionText = suggestion.actionText {
|
if let actionText = suggestion.actionText {
|
||||||
Button {
|
Button {
|
||||||
withAnimation {
|
if let action = suggestion.action {
|
||||||
compatibilityMode = true
|
action()
|
||||||
|
} else {
|
||||||
|
withAnimation {
|
||||||
|
compatibilityMode = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Text(actionText)
|
Text(actionText)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
}
|
}
|
||||||
.padding(.top, 2)
|
.padding(.top, DesignTokens.Spacing.xs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -580,25 +649,122 @@ struct EditorView: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
|
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - 预设管理
|
||||||
|
@ViewBuilder
|
||||||
|
private var presetSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: DesignTokens.Spacing.md) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "bookmark")
|
||||||
|
.foregroundStyle(.tint)
|
||||||
|
Text(String(localized: "editor.presetTitle"))
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
showPresetPicker = true
|
||||||
|
} label: {
|
||||||
|
Label(String(localized: "editor.presetLoad"), systemImage: "tray.and.arrow.down")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.disabled(PresetManager.shared.presets.isEmpty)
|
||||||
|
.accessibilityLabel(String(localized: "editor.presetLoad"))
|
||||||
|
|
||||||
|
Button {
|
||||||
|
presetName = ""
|
||||||
|
showSavePresetAlert = true
|
||||||
|
} label: {
|
||||||
|
Label(String(localized: "editor.presetSave"), systemImage: "tray.and.arrow.up")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.accessibilityLabel(String(localized: "editor.presetSave"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(DesignTokens.Spacing.lg)
|
||||||
|
.background(Color.softElevated)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
|
||||||
|
.alert(String(localized: "editor.presetSaveTitle"), isPresented: $showSavePresetAlert) {
|
||||||
|
TextField(String(localized: "editor.presetNamePlaceholder"), text: $presetName)
|
||||||
|
Button(String(localized: "editor.presetSaveConfirm")) {
|
||||||
|
saveCurrentPreset()
|
||||||
|
}
|
||||||
|
Button(String(localized: "common.cancel"), role: .cancel) {}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showPresetPicker) {
|
||||||
|
presetPickerSheet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var presetPickerSheet: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if PresetManager.shared.presets.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
String(localized: "editor.presetEmpty"),
|
||||||
|
systemImage: "bookmark",
|
||||||
|
description: Text(String(localized: "editor.presetEmptyHint"))
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
List {
|
||||||
|
ForEach(PresetManager.shared.presets) { preset in
|
||||||
|
Button {
|
||||||
|
applyPreset(preset)
|
||||||
|
showPresetPicker = false
|
||||||
|
} label: {
|
||||||
|
VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) {
|
||||||
|
Text(preset.name)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
HStack(spacing: DesignTokens.Spacing.sm) {
|
||||||
|
Text(preset.aspectRatio.displayName)
|
||||||
|
Text("·")
|
||||||
|
Text(String(format: String(localized: "editor.durationSeconds"), preset.trimDuration))
|
||||||
|
if preset.aiEnhance {
|
||||||
|
Text("· AI")
|
||||||
|
}
|
||||||
|
if preset.compatibilityMode {
|
||||||
|
Text("· " + String(localized: "editor.compatibilityShort"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDelete { indexSet in
|
||||||
|
for index in indexSet {
|
||||||
|
PresetManager.shared.removePreset(PresetManager.shared.presets[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(String(localized: "editor.presetPickerTitle"))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button(String(localized: "common.cancel")) {
|
||||||
|
showPresetPicker = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.presentationDetents([.medium])
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - 生成按钮
|
// MARK: - 生成按钮
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var generateButton: some View {
|
private var generateButton: some View {
|
||||||
Button {
|
SoftPrimaryButton(
|
||||||
|
String(localized: "editor.generateButton"),
|
||||||
|
icon: "wand.and.stars",
|
||||||
|
gradient: Color.gradientPrimary
|
||||||
|
) {
|
||||||
startProcessing()
|
startProcessing()
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "wand.and.stars")
|
|
||||||
Text(String(localized: "editor.generateButton"))
|
|
||||||
}
|
|
||||||
.font(.headline)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding()
|
|
||||||
.background(Color.gradientPrimary)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
|
||||||
}
|
}
|
||||||
.buttonStyle(ScaleButtonStyle())
|
.accessibilityLabel(String(localized: "editor.generateButton"))
|
||||||
.padding(.top, 8)
|
.padding(.top, DesignTokens.Spacing.sm)
|
||||||
|
.padding(.bottom, DesignTokens.Spacing.sm)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 方法
|
// MARK: - 方法
|
||||||
@@ -607,7 +773,7 @@ struct EditorView: View {
|
|||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
let durationCMTime = try await asset.load(.duration)
|
let durationCMTime = try await asset.load(.duration)
|
||||||
let durationSeconds = durationCMTime.seconds
|
let durationSeconds = sanitizedDuration(durationCMTime.seconds)
|
||||||
|
|
||||||
var diagnosis = VideoDiagnosis()
|
var diagnosis = VideoDiagnosis()
|
||||||
diagnosis.duration = durationSeconds
|
diagnosis.duration = durationSeconds
|
||||||
@@ -621,9 +787,10 @@ struct EditorView: View {
|
|||||||
width: abs(transformedSize.width),
|
width: abs(transformedSize.width),
|
||||||
height: abs(transformedSize.height)
|
height: abs(transformedSize.height)
|
||||||
)
|
)
|
||||||
|
let safeSize = sanitizedVideoSize(absSize)
|
||||||
|
|
||||||
// 检测高分辨率(超过 4K)
|
// 检测高分辨率(超过 4K)
|
||||||
let maxDim = max(absSize.width, absSize.height)
|
let maxDim = max(safeSize.width, safeSize.height)
|
||||||
diagnosis.isHighRes = maxDim > 3840
|
diagnosis.isHighRes = maxDim > 3840
|
||||||
|
|
||||||
// 检测高帧率
|
// 检测高帧率
|
||||||
@@ -651,13 +818,14 @@ struct EditorView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
videoNaturalSize = absSize
|
videoNaturalSize = safeSize
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
|
guard isViewActive else { return }
|
||||||
videoDuration = durationSeconds
|
videoDuration = durationSeconds
|
||||||
trimEnd = min(1.0, durationSeconds)
|
trimEnd = max(0.1, min(1.0, durationSeconds))
|
||||||
keyFrameTime = trimEnd / 2
|
keyFrameTime = trimEnd / 2
|
||||||
player = AVPlayer(url: videoURL)
|
player = AVPlayer(url: videoURL)
|
||||||
player?.play()
|
player?.play()
|
||||||
@@ -678,26 +846,46 @@ struct EditorView: View {
|
|||||||
extractCoverFrame()
|
extractCoverFrame()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func updateKeyFrameTime(shouldExtractCover: Bool) {
|
||||||
|
// 确保 keyFrameTime 在有效范围内
|
||||||
|
keyFrameTime = max(trimStart, min(keyFrameTime, trimEnd))
|
||||||
|
if shouldExtractCover {
|
||||||
|
extractCoverFrame()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func extractCoverFrame() {
|
private func extractCoverFrame() {
|
||||||
|
// 取消上一轮封面提取,避免高频拖动造成并发任务堆积
|
||||||
|
coverExtractionTask?.cancel()
|
||||||
|
|
||||||
|
let token = UUID()
|
||||||
|
coverExtractionToken = token
|
||||||
isLoadingCover = true
|
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 {
|
do {
|
||||||
let result = try await imageGenerator.image(at: time)
|
let result = try await imageGenerator.image(at: time)
|
||||||
|
try Task.checkCancellation()
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
|
guard coverExtractionToken == token else { return }
|
||||||
coverImage = UIImage(cgImage: result.image)
|
coverImage = UIImage(cgImage: result.image)
|
||||||
isLoadingCover = false
|
isLoadingCover = false
|
||||||
}
|
}
|
||||||
|
} catch is CancellationError {
|
||||||
|
// 新请求已发起,旧任务被取消属于预期,不更新 UI
|
||||||
} catch {
|
} catch {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
|
guard coverExtractionToken == token else { return }
|
||||||
isLoadingCover = false
|
isLoadingCover = false
|
||||||
}
|
}
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
@@ -710,6 +898,8 @@ struct EditorView: View {
|
|||||||
private func resetCropState() {
|
private func resetCropState() {
|
||||||
cropOffset = .zero
|
cropOffset = .zero
|
||||||
cropScale = 1.0
|
cropScale = 1.0
|
||||||
|
lastCropOffset = .zero
|
||||||
|
lastCropScale = 1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
private func calculateCropRect() -> CropRect {
|
private func calculateCropRect() -> CropRect {
|
||||||
@@ -718,7 +908,8 @@ struct EditorView: View {
|
|||||||
return .full
|
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 cropWidth: CGFloat = 1.0
|
||||||
var cropHeight: CGFloat = 1.0
|
var cropHeight: CGFloat = 1.0
|
||||||
@@ -741,6 +932,17 @@ struct EditorView: View {
|
|||||||
return CropRect(x: cropX, y: cropY, width: cropWidth, height: cropHeight)
|
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() {
|
private func checkAndDownloadModel() {
|
||||||
guard aiEnhanceEnabled else { return }
|
guard aiEnhanceEnabled else { return }
|
||||||
|
|
||||||
@@ -783,7 +985,80 @@ struct EditorView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func saveCurrentPreset() {
|
||||||
|
let name = presetName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !name.isEmpty else { return }
|
||||||
|
PresetManager.shared.savePreset(
|
||||||
|
name: name,
|
||||||
|
aspectRatio: selectedAspectRatio,
|
||||||
|
trimDuration: trimEnd - trimStart,
|
||||||
|
aiEnhance: aiEnhanceEnabled,
|
||||||
|
compatibilityMode: compatibilityMode
|
||||||
|
)
|
||||||
|
presetSavedCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyPreset(_ preset: EditingPreset) {
|
||||||
|
withAnimation {
|
||||||
|
selectedAspectRatio = preset.aspectRatio
|
||||||
|
trimEnd = min(preset.trimDuration, videoDuration)
|
||||||
|
trimStart = 0
|
||||||
|
aiEnhanceEnabled = preset.aiEnhance
|
||||||
|
compatibilityMode = preset.compatibilityMode
|
||||||
|
updateKeyFrameTime()
|
||||||
|
resetCropState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadCustomCover(from item: PhotosPickerItem?) {
|
||||||
|
guard let item else {
|
||||||
|
customCoverImage = nil
|
||||||
|
cleanupCustomCoverFile()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Task {
|
||||||
|
guard let data = try? await item.loadTransferable(type: Data.self),
|
||||||
|
data.count < 50_000_000,
|
||||||
|
let image = UIImage(data: data) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存到临时文件供 LivePhotoCore 使用
|
||||||
|
let tempDir = FileManager.default.temporaryDirectory
|
||||||
|
let fileURL = tempDir.appendingPathComponent("custom_cover_\(UUID().uuidString).jpg")
|
||||||
|
if let jpegData = image.jpegData(compressionQuality: 0.95) {
|
||||||
|
do {
|
||||||
|
try jpegData.write(to: fileURL)
|
||||||
|
await MainActor.run {
|
||||||
|
cleanupCustomCoverFile()
|
||||||
|
customCoverImage = image
|
||||||
|
customCoverURL = fileURL
|
||||||
|
coverImportCount += 1
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
customCoverImage = image
|
||||||
|
customCoverURL = nil
|
||||||
|
coverImportCount += 1
|
||||||
|
}
|
||||||
|
#if DEBUG
|
||||||
|
print("Failed to write custom cover: \(error)")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cleanupCustomCoverFile() {
|
||||||
|
if let url = customCoverURL {
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
customCoverURL = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func startProcessing() {
|
private func startProcessing() {
|
||||||
|
generateTapCount += 1
|
||||||
Analytics.shared.log(.editorGenerateClick, parameters: [
|
Analytics.shared.log(.editorGenerateClick, parameters: [
|
||||||
"trimStart": trimStart,
|
"trimStart": trimStart,
|
||||||
"trimEnd": trimEnd,
|
"trimEnd": trimEnd,
|
||||||
@@ -801,6 +1076,7 @@ struct EditorView: View {
|
|||||||
keyFrameTime: keyFrameTime,
|
keyFrameTime: keyFrameTime,
|
||||||
cropRect: cropRect,
|
cropRect: cropRect,
|
||||||
aspectRatio: selectedAspectRatio,
|
aspectRatio: selectedAspectRatio,
|
||||||
|
coverImageURL: customCoverURL,
|
||||||
aiEnhanceConfig: aiEnhanceEnabled ? .standard : .disabled
|
aiEnhanceConfig: aiEnhanceEnabled ? .standard : .disabled
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -821,15 +1097,15 @@ struct AspectRatioButton: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: action) {
|
Button(action: action) {
|
||||||
VStack(spacing: 4) {
|
VStack(spacing: DesignTokens.Spacing.xs) {
|
||||||
// 比例图标
|
// 比例图标
|
||||||
RoundedRectangle(cornerRadius: 4)
|
RoundedRectangle(cornerRadius: DesignTokens.Spacing.xs)
|
||||||
.stroke(isSelected ? Color.accentPurple : Color.textSecondary, lineWidth: 2)
|
.stroke(isSelected ? Color.accentPurple : Color.textSecondary, lineWidth: 2)
|
||||||
.frame(width: iconWidth, height: iconHeight)
|
.frame(width: iconWidth, height: iconHeight)
|
||||||
.background(
|
.background(
|
||||||
isSelected ? Color.accentPurple.opacity(0.1) : Color.clear
|
isSelected ? Color.accentPurple.opacity(0.1) : Color.clear
|
||||||
)
|
)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Spacing.xs))
|
||||||
|
|
||||||
Text(template.displayName)
|
Text(template.displayName)
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
@@ -840,6 +1116,7 @@ struct AspectRatioButton: View {
|
|||||||
.padding(.vertical, DesignTokens.Spacing.sm)
|
.padding(.vertical, DesignTokens.Spacing.sm)
|
||||||
.background(isSelected ? Color.accentPurple.opacity(0.1) : Color.clear)
|
.background(isSelected ? Color.accentPurple.opacity(0.1) : Color.clear)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.sm))
|
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.sm))
|
||||||
|
.animation(DesignTokens.Animation.quick, value: isSelected)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityElement(children: .ignore)
|
.accessibilityElement(children: .ignore)
|
||||||
@@ -884,17 +1161,18 @@ struct CropOverlay: View {
|
|||||||
.mask(
|
.mask(
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 8)
|
RoundedRectangle(cornerRadius: DesignTokens.Radius.sm)
|
||||||
.frame(width: cropSize.width, height: cropSize.height)
|
.frame(width: cropSize.width, height: cropSize.height)
|
||||||
.blendMode(.destinationOut)
|
.blendMode(.destinationOut)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// 裁剪框边框
|
// 裁剪框边框
|
||||||
RoundedRectangle(cornerRadius: 8)
|
RoundedRectangle(cornerRadius: DesignTokens.Radius.sm)
|
||||||
.stroke(Color.white, lineWidth: 2)
|
.stroke(Color.white, lineWidth: 2)
|
||||||
.frame(width: cropSize.width, height: cropSize.height)
|
.frame(width: cropSize.width, height: cropSize.height)
|
||||||
}
|
}
|
||||||
|
.animation(DesignTokens.Animation.standard, value: aspectRatio)
|
||||||
}
|
}
|
||||||
.allowsHitTesting(false)
|
.allowsHitTesting(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,18 +16,28 @@ struct HomeView: View {
|
|||||||
@State private var selectedItem: PhotosPickerItem?
|
@State private var selectedItem: PhotosPickerItem?
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@State private var errorMessage: String?
|
@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 {
|
var body: some View {
|
||||||
ScrollView(showsIndicators: false) {
|
ScrollView(showsIndicators: false) {
|
||||||
VStack(spacing: DesignTokens.Spacing.xxl) {
|
VStack(spacing: DesignTokens.Spacing.xxl) {
|
||||||
// 顶部导入区域
|
// 顶部导入区域
|
||||||
heroSection
|
heroSection
|
||||||
|
.opacity(showHero ? 1 : 0)
|
||||||
|
.offset(y: showHero ? 0 : 20)
|
||||||
|
|
||||||
// 最近作品或提示
|
// 最近作品或提示
|
||||||
if !recentWorks.recentWorks.isEmpty {
|
if !recentWorks.recentWorks.isEmpty {
|
||||||
recentWorksSection
|
recentWorksSection
|
||||||
|
.opacity(showRecentWorks ? 1 : 0)
|
||||||
|
.offset(y: showRecentWorks ? 0 : 20)
|
||||||
} else {
|
} else {
|
||||||
emptyStateHint
|
emptyStateHint
|
||||||
|
.opacity(showRecentWorks ? 1 : 0)
|
||||||
|
.offset(y: showRecentWorks ? 0 : 20)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, DesignTokens.Spacing.xl)
|
.padding(.horizontal, DesignTokens.Spacing.xl)
|
||||||
@@ -50,7 +60,14 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
recentWorks.cleanupDeletedAssets()
|
recentWorks.cleanupDeletedAssets()
|
||||||
|
withAnimation(DesignTokens.Animation.standard) {
|
||||||
|
showHero = true
|
||||||
|
}
|
||||||
|
withAnimation(DesignTokens.Animation.standard.delay(0.2)) {
|
||||||
|
showRecentWorks = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.sensoryFeedback(.warning, trigger: deleteWorkCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Hero 区域
|
// MARK: - Hero 区域
|
||||||
@@ -193,6 +210,16 @@ struct HomeView: View {
|
|||||||
|
|
||||||
Spacer()
|
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)"))
|
Text(String(localized: "home.worksCount \(recentWorks.recentWorks.count)"))
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundColor(.textMuted)
|
.foregroundColor(.textMuted)
|
||||||
@@ -202,17 +229,32 @@ struct HomeView: View {
|
|||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: DesignTokens.Spacing.lg) {
|
HStack(spacing: DesignTokens.Spacing.lg) {
|
||||||
ForEach(recentWorks.recentWorks) { work in
|
ForEach(recentWorks.recentWorks) { work in
|
||||||
RecentWorkCard(work: work) {
|
RecentWorkCard(work: work, onTap: {
|
||||||
appState.navigateTo(.wallpaperGuide(assetId: work.assetLocalIdentifier))
|
appState.navigateTo(.wallpaperGuide(assetId: work.assetLocalIdentifier))
|
||||||
}
|
}, onDelete: {
|
||||||
|
recentWorks.removeWork(work)
|
||||||
|
deleteWorkCount += 1
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, DesignTokens.Spacing.xs)
|
.padding(.horizontal, DesignTokens.Spacing.xs)
|
||||||
.padding(.vertical, DesignTokens.Spacing.sm)
|
.padding(.vertical, DesignTokens.Spacing.sm)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.alert(
|
||||||
|
String(localized: "home.clearAllConfirmTitle"),
|
||||||
|
isPresented: $showClearAllAlert
|
||||||
|
) {
|
||||||
|
Button(String(localized: "home.clearAll"), role: .destructive) {
|
||||||
|
recentWorks.clearAll()
|
||||||
|
}
|
||||||
|
Button(String(localized: "common.cancel"), role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text(String(localized: "home.clearAllConfirmMessage"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
private func handleSelectedItem(_ item: PhotosPickerItem?) async {
|
private func handleSelectedItem(_ item: PhotosPickerItem?) async {
|
||||||
guard let item else { return }
|
guard let item else { return }
|
||||||
|
|
||||||
@@ -224,15 +266,19 @@ struct HomeView: View {
|
|||||||
guard let movie = try await item.loadTransferable(type: VideoTransferable.self) else {
|
guard let movie = try await item.loadTransferable(type: VideoTransferable.self) else {
|
||||||
errorMessage = String(localized: "home.loadFailed")
|
errorMessage = String(localized: "home.loadFailed")
|
||||||
isLoading = false
|
isLoading = false
|
||||||
|
selectedItem = nil
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
Analytics.shared.log(.importVideoSuccess)
|
Analytics.shared.log(.importVideoSuccess)
|
||||||
appState.navigateTo(.editor(videoURL: movie.url))
|
appState.navigateTo(.editor(videoURL: movie.url))
|
||||||
|
selectedItem = nil
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = String(localized: "home.loadError \(error.localizedDescription)")
|
let format = String(localized: "home.loadError")
|
||||||
|
errorMessage = String(format: format, error.localizedDescription)
|
||||||
isLoading = false
|
isLoading = false
|
||||||
|
selectedItem = nil
|
||||||
Analytics.shared.logError(.importVideoFail, error: error)
|
Analytics.shared.logError(.importVideoFail, error: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -265,25 +311,36 @@ struct QuickStartStep: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - 最近作品卡片
|
||||||
// MARK: - 视频传输类型
|
// MARK: - 视频传输类型
|
||||||
struct VideoTransferable: Transferable {
|
struct VideoTransferable: Transferable {
|
||||||
let url: URL
|
let url: URL
|
||||||
|
|
||||||
static var transferRepresentation: some TransferRepresentation {
|
static var transferRepresentation: some TransferRepresentation {
|
||||||
FileRepresentation(contentType: .movie) { video in
|
FileRepresentation(importedContentType: .movie) { received in
|
||||||
SentTransferredFile(video.url)
|
try copyToSandboxTemp(from: received.file, preferredExtension: "mov")
|
||||||
} 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: .mpeg4Movie) { received in
|
||||||
|
try copyToSandboxTemp(from: received.file, preferredExtension: "mp4")
|
||||||
|
}
|
||||||
|
|
||||||
|
FileRepresentation(importedContentType: .quickTimeMovie) { received in
|
||||||
|
try copyToSandboxTemp(from: received.file, preferredExtension: "mov")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func copyToSandboxTemp(from sourceURL: URL, preferredExtension: String) throws -> VideoTransferable {
|
||||||
|
let tempDir = FileManager.default.temporaryDirectory
|
||||||
|
let ext = sourceURL.pathExtension.isEmpty ? preferredExtension : sourceURL.pathExtension
|
||||||
|
let filename = "import_\(UUID().uuidString)." + ext
|
||||||
|
let destURL = tempDir.appendingPathComponent(filename)
|
||||||
|
|
||||||
|
if FileManager.default.fileExists(atPath: destURL.path) {
|
||||||
|
try FileManager.default.removeItem(at: destURL)
|
||||||
|
}
|
||||||
|
try FileManager.default.copyItem(at: sourceURL, to: destURL)
|
||||||
|
return VideoTransferable(url: destURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,6 +348,7 @@ struct VideoTransferable: Transferable {
|
|||||||
struct RecentWorkCard: View {
|
struct RecentWorkCard: View {
|
||||||
let work: RecentWork
|
let work: RecentWork
|
||||||
let onTap: () -> Void
|
let onTap: () -> Void
|
||||||
|
var onDelete: (() -> Void)?
|
||||||
|
|
||||||
@StateObject private var thumbnailLoader = ThumbnailLoader()
|
@StateObject private var thumbnailLoader = ThumbnailLoader()
|
||||||
@State private var isPressed = false
|
@State private var isPressed = false
|
||||||
@@ -304,13 +362,13 @@ struct RecentWorkCard: View {
|
|||||||
Image(uiImage: thumbnail)
|
Image(uiImage: thumbnail)
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fill)
|
.aspectRatio(contentMode: .fill)
|
||||||
.frame(width: 110, height: 150)
|
.frame(width: 130, height: 178)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
|
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
|
||||||
.transition(.opacity.combined(with: .scale(scale: 0.95)))
|
.transition(.opacity.combined(with: .scale(scale: 0.95)))
|
||||||
} else {
|
} else {
|
||||||
RoundedRectangle(cornerRadius: DesignTokens.Radius.md)
|
RoundedRectangle(cornerRadius: DesignTokens.Radius.md)
|
||||||
.fill(Color.softPressed)
|
.fill(Color.softPressed)
|
||||||
.frame(width: 110, height: 150)
|
.frame(width: 130, height: 178)
|
||||||
.overlay {
|
.overlay {
|
||||||
Image(systemName: "livephoto")
|
Image(systemName: "livephoto")
|
||||||
.font(.system(size: 24))
|
.font(.system(size: 24))
|
||||||
@@ -329,7 +387,7 @@ struct RecentWorkCard: View {
|
|||||||
}
|
}
|
||||||
.padding(DesignTokens.Spacing.sm)
|
.padding(DesignTokens.Spacing.sm)
|
||||||
}
|
}
|
||||||
.frame(width: 110, height: 150)
|
.frame(width: 130, height: 178)
|
||||||
|
|
||||||
// 信息
|
// 信息
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
@@ -350,9 +408,15 @@ struct RecentWorkCard: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.scaleEffect(isPressed ? 0.97 : 1.0)
|
.scaleEffect(isPressed ? 0.97 : 1.0)
|
||||||
.animation(DesignTokens.Animation.spring, value: isPressed)
|
.animation(DesignTokens.Animation.spring, value: isPressed)
|
||||||
.onLongPressGesture(minimumDuration: .infinity, pressing: { pressing in
|
.contextMenu {
|
||||||
isPressed = pressing
|
if let onDelete {
|
||||||
}, perform: {})
|
Button(role: .destructive) {
|
||||||
|
onDelete()
|
||||||
|
} label: {
|
||||||
|
Label(String(localized: "home.deleteWork"), systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
thumbnailLoader.load(assetId: work.assetLocalIdentifier)
|
thumbnailLoader.load(assetId: work.assetLocalIdentifier)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ struct ProcessingView: View {
|
|||||||
@State private var hasStarted = false
|
@State private var hasStarted = false
|
||||||
@State private var pulseAnimation = false
|
@State private var pulseAnimation = false
|
||||||
|
|
||||||
|
/// 处理阶段总数(对应 LivePhotoBuildProgress.Stage 的 case 数量)
|
||||||
|
private let totalStages = 7
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
// 背景
|
// 背景
|
||||||
@@ -54,7 +57,7 @@ struct ProcessingView: View {
|
|||||||
await startProcessing()
|
await startProcessing()
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
withAnimation(.easeInOut(duration: 1.5).repeatForever(autoreverses: true)) {
|
withAnimation(.easeInOut(duration: 2.0).repeatForever(autoreverses: true)) {
|
||||||
pulseAnimation = true
|
pulseAnimation = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,16 +91,16 @@ struct ProcessingView: View {
|
|||||||
// 脉冲动画背景
|
// 脉冲动画背景
|
||||||
Circle()
|
Circle()
|
||||||
.fill(Color.accentPurple.opacity(0.1))
|
.fill(Color.accentPurple.opacity(0.1))
|
||||||
.frame(width: pulseAnimation ? 180 : 160, height: pulseAnimation ? 180 : 160)
|
.frame(width: pulseAnimation ? 175 : 160, height: pulseAnimation ? 175 : 160)
|
||||||
.animation(.easeInOut(duration: 1.5).repeatForever(autoreverses: true), value: pulseAnimation)
|
|
||||||
|
|
||||||
// 进度环
|
// 进度环
|
||||||
SoftProgressRing(
|
SoftProgressRing(
|
||||||
progress: appState.processingProgress?.fraction ?? 0,
|
progress: overallProgress,
|
||||||
size: 140,
|
size: 140,
|
||||||
lineWidth: 10,
|
lineWidth: 10,
|
||||||
gradient: stageGradient
|
gradient: stageGradient
|
||||||
)
|
)
|
||||||
|
.animation(.easeInOut(duration: 0.5), value: overallProgress)
|
||||||
|
|
||||||
// 动态图标
|
// 动态图标
|
||||||
VStack(spacing: DesignTokens.Spacing.xs) {
|
VStack(spacing: DesignTokens.Spacing.xs) {
|
||||||
@@ -106,11 +109,12 @@ struct ProcessingView: View {
|
|||||||
.foregroundStyle(stageGradient)
|
.foregroundStyle(stageGradient)
|
||||||
.contentTransition(.symbolEffect(.replace))
|
.contentTransition(.symbolEffect(.replace))
|
||||||
|
|
||||||
if let progress = appState.processingProgress {
|
if appState.processingProgress != nil {
|
||||||
Text(String(format: "%.0f%%", progress.fraction * 100))
|
Text(String(format: "%.0f%%", overallProgress * 100))
|
||||||
.font(.headline.bold())
|
.font(.headline.bold())
|
||||||
.foregroundColor(.textPrimary)
|
.foregroundColor(.textPrimary)
|
||||||
.contentTransition(.numericText())
|
.contentTransition(.numericText())
|
||||||
|
.animation(.easeInOut(duration: 0.5), value: overallProgress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,7 +135,7 @@ struct ProcessingView: View {
|
|||||||
|
|
||||||
// 阶段指示器
|
// 阶段指示器
|
||||||
HStack(spacing: DesignTokens.Spacing.sm) {
|
HStack(spacing: DesignTokens.Spacing.sm) {
|
||||||
ForEach(0..<7) { index in
|
ForEach(0..<totalStages, id: \.self) { index in
|
||||||
Circle()
|
Circle()
|
||||||
.fill(index <= currentStageIndex ? Color.accentPurple : Color.softPressed)
|
.fill(index <= currentStageIndex ? Color.accentPurple : Color.softPressed)
|
||||||
.frame(width: 8, height: 8)
|
.frame(width: 8, height: 8)
|
||||||
@@ -221,6 +225,13 @@ struct ProcessingView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 全局进度 = (阶段序号 + 阶段内fraction) / 总阶段数
|
||||||
|
private var overallProgress: Double {
|
||||||
|
let totalStages = Double(self.totalStages)
|
||||||
|
let stageFraction = appState.processingProgress?.fraction ?? 0
|
||||||
|
return (Double(currentStageIndex) + stageFraction) / totalStages
|
||||||
|
}
|
||||||
|
|
||||||
private var stageIcon: String {
|
private var stageIcon: String {
|
||||||
guard let stage = appState.processingProgress?.stage else {
|
guard let stage = appState.processingProgress?.stage else {
|
||||||
return "hourglass"
|
return "hourglass"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import PhotosUI
|
||||||
import LivePhotoCore
|
import LivePhotoCore
|
||||||
|
|
||||||
struct ResultView: View {
|
struct ResultView: View {
|
||||||
@@ -17,6 +18,7 @@ struct ResultView: View {
|
|||||||
@State private var showContent = false
|
@State private var showContent = false
|
||||||
@State private var showButtons = false
|
@State private var showButtons = false
|
||||||
@State private var celebrationParticles = false
|
@State private var celebrationParticles = false
|
||||||
|
@State private var livePhoto: PHLivePhoto?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -29,11 +31,15 @@ struct ResultView: View {
|
|||||||
.accessibilityHidden(true)
|
.accessibilityHidden(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(spacing: DesignTokens.Spacing.xxxl) {
|
VStack(spacing: DesignTokens.Spacing.xl) {
|
||||||
Spacer()
|
if isSuccess && livePhoto != nil {
|
||||||
|
// Live Photo 预览卡片
|
||||||
// 结果图标
|
livePhotoPreview
|
||||||
resultIcon
|
} else {
|
||||||
|
Spacer()
|
||||||
|
// 结果图标
|
||||||
|
resultIcon
|
||||||
|
}
|
||||||
|
|
||||||
// 结果信息
|
// 结果信息
|
||||||
resultInfo
|
resultInfo
|
||||||
@@ -49,9 +55,50 @@ struct ResultView: View {
|
|||||||
.navigationTitle(String(localized: "result.title"))
|
.navigationTitle(String(localized: "result.title"))
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.navigationBarBackButtonHidden(true)
|
.navigationBarBackButtonHidden(true)
|
||||||
.onAppear {
|
.task {
|
||||||
animateIn()
|
// 入场动画(structured concurrency,视图销毁时自动取消)
|
||||||
|
withAnimation { showIcon = true }
|
||||||
|
if isSuccess {
|
||||||
|
try? await Task.sleep(for: .seconds(0.2))
|
||||||
|
celebrationParticles = true
|
||||||
|
}
|
||||||
|
try? await Task.sleep(for: .seconds(0.3))
|
||||||
|
showContent = true
|
||||||
|
try? await Task.sleep(for: .seconds(0.2))
|
||||||
|
showButtons = true
|
||||||
}
|
}
|
||||||
|
.task {
|
||||||
|
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: - 结果图标
|
// MARK: - 结果图标
|
||||||
@@ -165,26 +212,92 @@ struct ResultView: View {
|
|||||||
!workflowResult.savedAssetId.isEmpty
|
!workflowResult.savedAssetId.isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 动画
|
// MARK: - 加载 Live Photo
|
||||||
|
|
||||||
private func animateIn() {
|
private func loadLivePhoto() async {
|
||||||
// 串行动画
|
let imageURL = workflowResult.pairedImageURL
|
||||||
withAnimation {
|
let videoURL = workflowResult.pairedVideoURL
|
||||||
showIcon = true
|
|
||||||
|
guard FileManager.default.fileExists(atPath: imageURL.path),
|
||||||
|
FileManager.default.fileExists(atPath: videoURL.path) else {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if isSuccess {
|
let photo = await withCheckedContinuation { (continuation: CheckedContinuation<PHLivePhoto?, Never>) in
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
let lock = NSLock()
|
||||||
celebrationParticles = true
|
var hasResumed = false
|
||||||
|
|
||||||
|
PHLivePhoto.request(
|
||||||
|
withResourceFileURLs: [imageURL, videoURL],
|
||||||
|
placeholderImage: nil,
|
||||||
|
targetSize: .zero,
|
||||||
|
contentMode: .aspectFit
|
||||||
|
) { result, info in
|
||||||
|
let isDegraded = (info[PHLivePhotoInfoIsDegradedKey] as? Bool) ?? false
|
||||||
|
let isCancelled = (info[PHLivePhotoInfoCancelledKey] as? Bool) ?? false
|
||||||
|
let hasError = info[PHLivePhotoInfoErrorKey] != nil
|
||||||
|
|
||||||
|
// 非降级的正常结果,或者已取消/出错时,都需要 resume
|
||||||
|
guard !isDegraded || isCancelled || hasError else { return }
|
||||||
|
|
||||||
|
lock.lock()
|
||||||
|
let shouldResume = !hasResumed
|
||||||
|
hasResumed = true
|
||||||
|
lock.unlock()
|
||||||
|
|
||||||
|
if shouldResume {
|
||||||
|
continuation.resume(returning: isCancelled || hasError ? nil : result)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
if let photo {
|
||||||
showContent = true
|
withAnimation(.easeIn(duration: 0.3)) {
|
||||||
|
livePhoto = photo
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
// MARK: - Live Photo UIKit 包装器
|
||||||
showButtons = true
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user