Compare commits

..

7 Commits

Author SHA1 Message Date
empty
c826689ee4 fix: 代码审查 P2 建议项修复(22 项体验优化)
EditorView (8 项):
- 时长警告图标区分:分享警告改用 square.and.arrow.up
- coverExtractionTask 竞态防护:新增 isViewActive 守卫
- sensoryFeedback 优化:缩放触觉仅在手势结束时触发
- iPad 右侧面板增加水平内边距
- 预设列表/兼容模式/AI 区域硬编码间距替换为 DesignTokens
- 诊断按钮 padding 替换为 DesignTokens
- generateButton 补充 accessibilityLabel

PresetManager + RecentWorksManager (5 项):
- iCloud 合并回写 + 防循环标志位
- iCloud 配额防护(>900KB 跳过写入)
- QuotaViolation/AccountChange 事件处理
- EditingPreset 自定义 Codable(decodeIfPresent + 默认值)
- RecentWork aspectRatio 枚举化
- 清理 saveToLocalOnly 死代码

ResultView + ProcessingView + HomeView (5 项):
- ResultView animateIn 改用 structured concurrency
- ProcessingView 阶段数提取为常量
- ProcessingView 脉冲动画去重
- HomeView 删除触觉升级为 .warning
- HomeView Cancel 按钮本地化

LivePhotoCore + AppState (4 项):
- coverImageURL 参数去重,内部从 exportParams 读取
- progress 回调钳位 min(pct, 1.0)
- CropRect 新增 clamped() 归一化校验
- AppState 初始化错误增加 os.Logger 日志

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-08 00:34:44 +08:00
empty
f3bcaf4651 fix: 代码审查 P0+P1 问题修复(2 个崩溃 + 14 个体验问题)
P0 致命问题:
- LivePhotoCore: requestLivePhoto continuation 多次 resume 崩溃
  使用 ResumeOnce + highQualityFormat + 跳过降级版本
- ResultView: PHLivePhoto.request continuation 泄漏/多次 resume
  NSLock + hasResumed 守卫 + 处理 cancel/error 边缘情况

P1 并发安全:
- PresetManager/RecentWorksManager: 移除 NotificationCenter 闭包
  中的 guard let self(Swift 6 并发安全)
- RecentWorksManager: cleanupDeletedAssets 移到 Task.detached
- RecentWorksManager: ThumbnailLoader.load 移到后台 + 防重复

P1 EditorView 修复:
- AI 百分比改用 .percent FormatStyle(本地化)
- 预设选择器 if-else 互斥 + Group 包裹
- 诊断按钮优先使用 suggestion.action 闭包
- PhotosPicker 导入添加 50MB 大小限制
- 封面文件写入 try? 改为 do-catch

P1 其他修复:
- HomeView: selectedItem 在所有退出路径重置
- LivePhotoCore: trimmedSeconds 改用实际裁剪后视频时长
- LivePhotoCore: keyFrameTime 越界显式 clamp + DEBUG 警告
- LivePhotoCore: ExportParams Codable 向后兼容

本地化:
- Localizable.xcstrings 9 批翻译修复(~295 条)
  ar/es/fr/ja/ko 英文占位替换为正确翻译

修改文件:
- Sources/LivePhotoCore/LivePhotoCore.swift
- to-live-photo/Views/ResultView.swift
- to-live-photo/Views/HomeView.swift
- to-live-photo/Views/EditorView.swift
- to-live-photo/PresetManager.swift
- to-live-photo/RecentWorksManager.swift
- to-live-photo/Localizable.xcstrings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-08 00:19:54 +08:00
empty
ec2e0a3ce5 feat: v1.1 P1 功能实现 + 体验优化(4 项新功能 + 9 项修复)
## 新功能

### 视频时长放宽(Feature 5)
- LivePhotoCore: targetDuration 从固定 0.917 秒改为动态计算(0.5-5.0 秒)
- LivePhotoCore: keyFrameTime 按比例映射到目标视频时长
- EditorView: slider 上限从 1.5 秒提升至 5.0 秒
- EditorView: 超过 2 秒显示壁纸兼容性提示,超过 3 秒显示分享截断提示

### 自定义封面帧(Feature 4)
- ExportParams: 新增 coverImageURL 字段
- EditorView: 封面帧区域增加 PhotosPicker 从相册导入照片作为封面
- AppState: coverImageURL 传递到 LivePhotoWorkflow
- EditorView: onDisappear 时清理临时封面文件,避免磁盘泄漏

### 收藏/模板预设(Feature 6)
- 新增 PresetManager.swift: EditingPreset 模型 + 预设管理器
- 支持保存/加载/删除预设,最多 10 个
- UserDefaults + iCloud KV Store 双重持久化
- EditorView: 预设保存/加载 UI + 空状态 ContentUnavailableView

### iCloud 同步(Feature 7)
- RecentWorksManager: 新增 NSUbiquitousKeyValueStore 同步
- PresetManager: 同步支持 iCloud KV Store
- 合并策略:按 ID 去重,保留最新记录,限制条数上限

## Bug 修复

### P0 级
- ResultView: Live Photo 预览比例从固定值改为动态计算 aspectRatio
- EditorView: 修复本地化字符串拼接问题(使用 String(localized:) 替代硬编码)

### P1 级
- EditorView: 预设列表空状态显示 ContentUnavailableView
- ProcessingView: 进度环与百分比文本动画同步(统一 overallProgress 计算)

### P2 级
- EditorView: 添加触觉反馈(比例切换、生成按钮、预设保存、封面导入)
- HomeView: 删除作品触觉反馈 + 入场动画优化
- ProcessingView: 脉冲动画背景尺寸微调(160-175pt)
- LaunchScreen: 品牌启动画面(App Icon + 标题 + 副标题)

## 本地化

- 新增约 25 个本地化 key,覆盖 8 种语言
  (zh-Hans, zh-Hant, en, ja, ko, es, fr, ar)
- 包含:预设管理、封面导入、时长提示、删除确认、
  空状态、诊断建议等全部新增 UI 文案

## 改动文件(16 个)

- Sources/LivePhotoCore/LivePhotoCore.swift — 动态时长 + coverImageURL
- to-live-photo/AppState.swift — coverImageURL 传递
- to-live-photo/PresetManager.swift — 新增预设管理器
- to-live-photo/RecentWorksManager.swift — iCloud 同步
- to-live-photo/DesignSystem.swift — 新增设计令牌
- to-live-photo/Localizable.xcstrings — 25+ 本地化 key
- to-live-photo/Views/EditorView.swift — 4 项新功能 UI
- to-live-photo/Views/HomeView.swift — 删除作品 + 触觉反馈
- to-live-photo/Views/ProcessingView.swift — 进度环动画修复
- to-live-photo/Views/ResultView.swift — 预览比例修复 + Live Photo 预览
- to-live-photo/Base.lproj/LaunchScreen.storyboard — 品牌启动画面
- to-live-photo/Assets.xcassets/Launch*.colorset — 启动画面颜色资源

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-08 00:04:10 +08:00
empty
846d8ea8d7 feat: ProcessingView 脉冲动画微调
- 动画时长从 1.5s 调整为 2.0s(更平缓自然)
- 脉冲尺寸差从 160↔180 调整为 160↔175(更微妙克制)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-07 21:14:48 +08:00
empty
4588c7c1eb feat: 首页入场动画 + 增大最近作品卡片尺寸
入场动画:
- heroSection 添加 opacity + offset 交错入场动画
- recentWorksSection/emptyStateHint 延迟 0.2s 后动画进入
- 使用 DesignTokens.Animation.standard 保持一致性

卡片尺寸:
- RecentWorkCard 从 110x150 增大到 130x178
- 保持相近比例,提升视觉冲击力和触控目标

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-07 21:13:56 +08:00
empty
a75aeed767 refactor: 替换硬编码值为 DesignTokens + 添加触觉反馈和动画
硬编码值替换:
- spacing: 20/24/16/12/8/4 → DesignTokens.Spacing.xl/xxl/lg/md/sm/xs
- cornerRadius: 16/8/4 → DesignTokens.Radius.lg/sm/xs
- padding(.top, 8) / padding(.leading, 4) → DesignTokens.Spacing.sm/xs

生成按钮替换:
- 手动实现的渐变按钮替换为 SoftPrimaryButton 组件
- 添加底部安全间距 padding(.bottom, DesignTokens.Spacing.sm)

触觉反馈(iOS 17+ sensoryFeedback):
- 比例选择切换:.selection
- 生成按钮点击:.impact(weight: .medium)
- 裁剪手势结束:.impact(weight: .light)

动画优化:
- AspectRatioButton 添加选中状态过渡动画
- CropOverlay 添加比例切换平滑过渡动画

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-07 21:12:37 +08:00
empty
1556dfd167 fix: 修复裁剪手势状态丢失 + 合并重复预览代码
- 为 MagnificationGesture/DragGesture 添加 .onEnded 处理器,
  新增 lastCropOffset/lastCropScale 累积变量,修复抬手后
  缩放/偏移从初始值重新开始的问题
- 提取 cropPreview(height:dynamicHeight:) 共享方法和
  cropGesture 计算属性,消除 iPhone/iPad 约 60 行重复代码
- resetCropState() 同步重置累积变量

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-07 21:07:59 +08:00
16 changed files with 12326 additions and 10259 deletions

View File

@@ -18,11 +18,17 @@ to-live-photo/to-live-photo/
## 构建命令
```bash
# 模拟器构建
xcodebuild -scheme to-live-photo -destination 'platform=iOS Simulator,name=iPhone 16 Pro' build
# 模拟器构建(必须指定 -project
xcodebuild -project to-live-photo/to-live-photo.xcodeproj \
-scheme to-live-photo \
-destination 'platform=iOS Simulator,name=iPhone 16 Pro' build
# Archive
xcodebuild -scheme to-live-photo -configuration Release -destination 'generic/platform=iOS' -archivePath build/to-live-photo.xcarchive archive
xcodebuild -project to-live-photo/to-live-photo.xcodeproj \
-scheme to-live-photo \
-configuration Release \
-destination 'generic/platform=iOS' \
-archivePath build/to-live-photo.xcarchive archive
```
## Git 规范
@@ -89,3 +95,10 @@ xcodebuild -scheme to-live-photo -configuration Release -destination 'generic/pl
- 新增/修改功能 → 同步 `USER_GUIDE.md` 相关章节
- 新增测试场景 → 同步 `TEST_MATRIX.md`
- 归档文档 → 不更新,保持历史原貌
## Skill 引用
本项目配套 skill: `live-photo-studio`(位于 `~/.claude/skills/live-photo-studio/SKILL.md`
包含:设计系统令牌速查、构建踩坑记录、导航架构、关键类型、代码规范等项目专属知识。
在本项目中进行 UI 开发、调试构建问题时,可调用 `/live-photo-studio` 获取上下文。

View File

@@ -82,13 +82,23 @@ public struct CropRect: Codable, Sendable, Hashable {
///
public static let full = CropRect()
/// [0, 1] CropRect x+width <= 1, y+height <= 1
public func clamped() -> CropRect {
let clampedX = min(max(x, 0), 1)
let clampedY = min(max(y, 0), 1)
let clampedW = min(max(width, 0), 1 - clampedX)
let clampedH = min(max(height, 0), 1 - clampedY)
return CropRect(x: clampedX, y: clampedY, width: clampedW, height: clampedH)
}
///
public func toPixelRect(videoSize: CGSize) -> CGRect {
CGRect(
x: x * videoSize.width,
y: y * videoSize.height,
width: width * videoSize.width,
height: height * videoSize.height
let safe = clamped()
return CGRect(
x: safe.x * videoSize.width,
y: safe.y * videoSize.height,
width: safe.width * videoSize.width,
height: safe.height * videoSize.height
)
}
}
@@ -134,6 +144,7 @@ public struct ExportParams: Codable, Sendable, Hashable {
public var aspectRatio: AspectRatioTemplate
public var compatibilityMode: Bool
public var targetFrameRate: Int
public var coverImageURL: URL?
public var aiEnhanceConfig: AIEnhanceConfig
public init(
@@ -148,6 +159,7 @@ public struct ExportParams: Codable, Sendable, Hashable {
aspectRatio: AspectRatioTemplate = .original,
compatibilityMode: Bool = false,
targetFrameRate: Int = 60,
coverImageURL: URL? = nil,
aiEnhanceConfig: AIEnhanceConfig = .disabled
) {
self.trimStart = trimStart
@@ -161,6 +173,7 @@ public struct ExportParams: Codable, Sendable, Hashable {
self.aspectRatio = aspectRatio
self.compatibilityMode = compatibilityMode
self.targetFrameRate = targetFrameRate
self.coverImageURL = coverImageURL
self.aiEnhanceConfig = aiEnhanceConfig
}
@@ -174,6 +187,25 @@ public struct ExportParams: Codable, Sendable, Hashable {
params.hdrPolicy = .toneMapToSDR
return params
}
// MARK: - Codable
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
trimStart = try container.decode(Double.self, forKey: .trimStart)
trimEnd = try container.decode(Double.self, forKey: .trimEnd)
keyFrameTime = try container.decode(Double.self, forKey: .keyFrameTime)
audioPolicy = try container.decode(AudioPolicy.self, forKey: .audioPolicy)
codecPolicy = try container.decode(CodecPolicy.self, forKey: .codecPolicy)
hdrPolicy = try container.decode(HDRPolicy.self, forKey: .hdrPolicy)
maxDimension = try container.decode(Int.self, forKey: .maxDimension)
cropRect = try container.decode(CropRect.self, forKey: .cropRect)
aspectRatio = try container.decode(AspectRatioTemplate.self, forKey: .aspectRatio)
compatibilityMode = try container.decode(Bool.self, forKey: .compatibilityMode)
targetFrameRate = try container.decode(Int.self, forKey: .targetFrameRate)
coverImageURL = try container.decodeIfPresent(URL.self, forKey: .coverImageURL)
aiEnhanceConfig = try container.decodeIfPresent(AIEnhanceConfig.self, forKey: .aiEnhanceConfig) ?? .disabled
}
}
public struct AppError: Error, Codable, Sendable, Hashable {
@@ -396,13 +428,22 @@ public actor LivePhotoValidator {
}
return await withCheckedContinuation { continuation in
let resumeOnce = ResumeOnce()
let options = PHLivePhotoRequestOptions()
options.deliveryMode = .highQualityFormat
PHImageManager.default().requestLivePhoto(
for: asset,
targetSize: CGSize(width: 1, height: 1),
contentMode: .aspectFit,
options: nil
) { livePhoto, _ in
continuation.resume(returning: livePhoto)
options: options
) { livePhoto, info in
//
if let isDegraded = info?[PHImageResultIsDegradedKey] as? Bool, isDegraded {
return
}
if resumeOnce.tryConsume() {
continuation.resume(returning: livePhoto)
}
}
}
}
@@ -490,10 +531,10 @@ public actor LivePhotoBuilder {
public func buildResources(
workId: UUID = UUID(),
sourceVideoURL: URL,
coverImageURL: URL? = nil,
exportParams: ExportParams = ExportParams(),
progress: (@Sendable (LivePhotoBuildProgress) -> Void)? = nil
) async throws -> LivePhotoBuildOutput {
let coverImageURL = exportParams.coverImageURL
let assetIdentifier = UUID().uuidString
let paths = try cacheManager.makeWorkPaths(workId: workId)
@@ -518,8 +559,11 @@ public actor LivePhotoBuilder {
destinationURL: trimmedURL
)
// 1 metadata.mov
let targetDuration = CMTimeMake(value: 550, timescale: 600) // ~0.917 live-wallpaper
// trimEnd
let trimmedAsset = AVURLAsset(url: trimmedVideoURL)
let actualTrimmedDuration = try await trimmedAsset.load(.duration).seconds
let trimmedSeconds = min(max(actualTrimmedDuration, 0.5), 5.0)
let targetDuration = CMTime(seconds: trimmedSeconds, preferredTimescale: 600)
progress?(LivePhotoBuildProgress(stage: .normalize, fraction: 0.5))
let scaledVideoURL = try await scaleVideoToTargetDuration(
sourceURL: trimmedVideoURL,
@@ -531,8 +575,20 @@ public actor LivePhotoBuilder {
destinationURL: scaledURL
)
// 0.5 metadata.mov still-image-time
let relativeKeyFrameTime = 0.5 // 0.5 metadata.mov
// clamp keyFrameTime [trimStart, trimEnd]
let clampedKeyFrameTime: Double
if exportParams.keyFrameTime < exportParams.trimStart || exportParams.keyFrameTime > exportParams.trimEnd {
#if DEBUG
print("[LivePhotoBuilder] WARNING: keyFrameTime \(exportParams.keyFrameTime) out of range [\(exportParams.trimStart), \(exportParams.trimEnd)], clamping")
#endif
clampedKeyFrameTime = min(max(exportParams.keyFrameTime, exportParams.trimStart), exportParams.trimEnd)
} else {
clampedKeyFrameTime = exportParams.keyFrameTime
}
//
let keyFrameRatio = (clampedKeyFrameTime - exportParams.trimStart) / max(0.001, exportParams.trimEnd - exportParams.trimStart)
let relativeKeyFrameTime = max(0, min(trimmedSeconds, keyFrameRatio * trimmedSeconds))
progress?(LivePhotoBuildProgress(stage: .extractKeyFrame, fraction: 0))
let keyPhotoURL = try await resolveKeyPhotoURL(
@@ -1024,7 +1080,7 @@ public actor LivePhotoBuilder {
if let sampleBuffer = videoReaderOutput.copyNextSampleBuffer() {
currentFrameCount += 1
let pct = Double(currentFrameCount) / Double(frameCount)
progress(pct)
progress(min(pct, 1.0))
videoWriterInput.append(sampleBuffer)
} else {
videoWriterInput.markAsFinished()
@@ -1146,14 +1202,12 @@ public actor LivePhotoWorkflow {
public func buildSaveValidate(
workId: UUID = UUID(),
sourceVideoURL: URL,
coverImageURL: URL? = nil,
exportParams: ExportParams = ExportParams(),
progress: (@Sendable (LivePhotoBuildProgress) -> Void)? = nil
) async throws -> LivePhotoWorkflowResult {
let output = try await builder.buildResources(
workId: workId,
sourceVideoURL: sourceVideoURL,
coverImageURL: coverImageURL,
exportParams: exportParams,
progress: progress
)

View File

@@ -422,7 +422,7 @@
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.photography";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
@@ -457,7 +457,7 @@
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.photography";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 18.0;

View File

@@ -5,6 +5,7 @@
// App +
//
import os
import SwiftUI
import PhotosUI
import LivePhotoCore
@@ -34,11 +35,13 @@ final class AppState {
private var workflow: LivePhotoWorkflow?
private var currentProcessingTask: Task<LivePhotoWorkflowResult?, Never>?
private var currentWorkId: UUID?
private let logger = Logger(subsystem: "ToLivePhoto", category: "AppState")
init() {
do {
workflow = try LivePhotoWorkflow()
} catch {
logger.error("Failed to init LivePhotoWorkflow: \(error.localizedDescription, privacy: .public)")
#if DEBUG
print("Failed to init LivePhotoWorkflow: \(error)")
#endif
@@ -117,7 +120,6 @@ final class AppState {
let result = try await workflow.buildSaveValidate(
workId: workId,
sourceVideoURL: videoURL,
coverImageURL: nil,
exportParams: exportParams
) { progress in
Task { @MainActor in

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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>

View File

@@ -554,7 +554,9 @@ struct SoftSlider: View {
var body: some View {
GeometryReader { geometry in
let width = geometry.size.width
let progress = (value - range.lowerBound) / (range.upperBound - range.lowerBound)
let effectiveRange = sanitizedRange(range)
let span = effectiveRange.upperBound - effectiveRange.lowerBound
let progress = span > 0 ? (value - effectiveRange.lowerBound) / span : 0
let thumbX = width * progress
ZStack(alignment: .leading) {
@@ -581,7 +583,7 @@ struct SoftSlider: View {
guard !isDisabled else { return }
let newProgress = gesture.location.x / width
let clampedProgress = max(0, min(1, newProgress))
value = range.lowerBound + (range.upperBound - range.lowerBound) * clampedProgress
value = effectiveRange.lowerBound + (effectiveRange.upperBound - effectiveRange.lowerBound) * clampedProgress
onEditingChanged?(true)
}
.onEnded { _ in
@@ -599,14 +601,23 @@ struct SoftSlider: View {
guard !isDisabled else { return }
switch direction {
case .increment:
value = min(range.upperBound, value + step)
value = min(sanitizedRange(range).upperBound, value + step)
case .decrement:
value = max(range.lowerBound, value - step)
value = max(sanitizedRange(range).lowerBound, value - step)
@unknown default:
break
}
}
}
private func sanitizedRange(_ input: ClosedRange<Double>) -> ClosedRange<Double> {
let lower = input.lowerBound.isFinite && !input.lowerBound.isNaN ? input.lowerBound : 0
let upper = input.upperBound.isFinite && !input.upperBound.isNaN ? input.upperBound : lower
if upper <= lower {
return lower...(lower + 0.0001)
}
return lower...upper
}
}
// MARK: -

File diff suppressed because it is too large Load Diff

View 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
}
}
}

View File

@@ -7,6 +7,7 @@
import Combine
import Foundation
import LivePhotoCore
import UIKit
import Photos
@@ -15,22 +16,24 @@ struct RecentWork: Codable, Identifiable, Hashable {
let id: UUID
let createdAt: Date
let assetLocalIdentifier: String // PHAsset localIdentifier
let aspectRatioRaw: String // AspectRatioTemplate.rawValue
let aspectRatioRaw: String // AspectRatioTemplate.rawValue
let compatibilityMode: Bool
/// 访rawValue nil
var aspectRatio: AspectRatioTemplate? {
AspectRatioTemplate(rawValue: aspectRatioRaw)
}
/// 使 displayNamefallback raw string
var aspectRatioDisplayName: String {
switch aspectRatioRaw {
case "original": return String(localized: "aspectRatio.original")
case "lock_screen": return String(localized: "aspectRatio.lockScreen")
case "full_screen": return String(localized: "aspectRatio.fullScreen")
case "classic": return String(localized: "aspectRatio.classic")
case "square": return String(localized: "aspectRatio.square")
default: return aspectRatioRaw
if let template = aspectRatio {
return template.displayName
}
return aspectRatioRaw
}
}
///
/// iCloud
@MainActor
final class RecentWorksManager: ObservableObject {
static let shared = RecentWorksManager()
@@ -39,9 +42,14 @@ final class RecentWorksManager: ObservableObject {
private let maxCount = 20 // 20
private let userDefaultsKey = "recent_works_v1"
private let iCloudKey = "recent_works_v1"
private let iCloudStore = NSUbiquitousKeyValueStore.default
/// iCloud
private var isHandlingICloudChange = false
private init() {
loadFromStorage()
setupICloudSync()
}
///
@@ -85,20 +93,82 @@ final class RecentWorksManager: ObservableObject {
let identifiers = recentWorks.map { $0.assetLocalIdentifier }
guard !identifiers.isEmpty else { return }
let fetchResult = PHAsset.fetchAssets(withLocalIdentifiers: identifiers, options: nil)
var existingIds = Set<String>()
fetchResult.enumerateObjects { asset, _, _ in
existingIds.insert(asset.localIdentifier)
Task.detached { [identifiers] in
let fetchResult = PHAsset.fetchAssets(withLocalIdentifiers: identifiers, options: nil)
var existingIds = Set<String>()
fetchResult.enumerateObjects { asset, _, _ in
existingIds.insert(asset.localIdentifier)
}
await MainActor.run { [weak self, existingIds] in
guard let self else { return }
let before = self.recentWorks.count
self.recentWorks.removeAll { !existingIds.contains($0.assetLocalIdentifier) }
if self.recentWorks.count != before {
self.saveToStorage()
}
}
}
}
// MARK: - iCloud
private func setupICloudSync() {
NotificationCenter.default.addObserver(
forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
object: iCloudStore,
queue: .main
) { [weak self] notification in
let userInfo = notification.userInfo
let reason = userInfo?[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int
let keys = userInfo?[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String]
Task { @MainActor [weak self] in
self?.handleICloudChange(reason: reason, changedKeys: keys)
}
}
iCloudStore.synchronize()
}
private func handleICloudChange(reason: Int?, changedKeys: [String]?) {
guard let reason, let changedKeys,
changedKeys.contains(iCloudKey) else {
return
}
let originalCount = recentWorks.count
recentWorks.removeAll { !existingIds.contains($0.assetLocalIdentifier) }
isHandlingICloudChange = true
defer { isHandlingICloudChange = false }
if recentWorks.count != originalCount {
saveToStorage()
switch reason {
case NSUbiquitousKeyValueStoreServerChange,
NSUbiquitousKeyValueStoreInitialSyncChange:
mergeFromICloud()
case NSUbiquitousKeyValueStoreQuotaViolationChange:
#if DEBUG
print("[RecentWorksManager] iCloud quota violated — data may not sync")
#endif
case NSUbiquitousKeyValueStoreAccountChange:
mergeFromICloud()
default:
break
}
}
private func mergeFromICloud() {
guard let data = iCloudStore.data(forKey: iCloudKey),
let remoteWorks = try? JSONDecoder().decode([RecentWork].self, from: data) else {
return
}
// createdAt assetLocalIdentifier 20
var merged = Dictionary(grouping: recentWorks + remoteWorks) { $0.assetLocalIdentifier }
.values
.compactMap { $0.max(by: { $0.createdAt < $1.createdAt }) }
merged.sort { $0.createdAt > $1.createdAt }
recentWorks = Array(merged.prefix(maxCount))
saveToStorage()
}
// MARK: -
private func loadFromStorage() {
@@ -120,6 +190,19 @@ final class RecentWorksManager: ObservableObject {
do {
let data = try JSONEncoder().encode(recentWorks)
UserDefaults.standard.set(data, forKey: userDefaultsKey)
// true iCloud
guard !isHandlingICloudChange else { return }
// iCloud KVS key 1MB 100KB
if data.count > 900_000 {
#if DEBUG
print("[RecentWorksManager] Data size \(data.count) exceeds iCloud safe limit, skipping iCloud write")
#endif
return
}
iCloudStore.set(data, forKey: iCloudKey)
} catch {
#if DEBUG
print("[RecentWorksManager] Failed to encode: \(error)")
@@ -132,27 +215,35 @@ final class RecentWorksManager: ObservableObject {
@MainActor
final class ThumbnailLoader: ObservableObject {
@Published var thumbnail: UIImage?
@Published private(set) var isLoading = false
func load(assetId: String, targetSize: CGSize = CGSize(width: 200, height: 300)) {
let result = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil)
guard let asset = result.firstObject else {
thumbnail = nil
return
}
guard !isLoading else { return }
isLoading = true
let options = PHImageRequestOptions()
options.deliveryMode = .opportunistic
options.isNetworkAccessAllowed = true
options.resizeMode = .fast
Task.detached { [weak self] in
let result = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil)
guard let asset = result.firstObject else {
await MainActor.run { [weak self] in
self?.isLoading = false
}
return
}
PHImageManager.default().requestImage(
for: asset,
targetSize: targetSize,
contentMode: .aspectFill,
options: options
) { [weak self] image, _ in
Task { @MainActor in
self?.thumbnail = image
let options = PHImageRequestOptions()
options.deliveryMode = .opportunistic
options.isNetworkAccessAllowed = true
PHImageManager.default().requestImage(
for: asset,
targetSize: targetSize,
contentMode: .aspectFill,
options: options
) { [weak self] image, _ in
Task { @MainActor [weak self] in
self?.thumbnail = image
self?.isLoading = false
}
}
}
}

View File

@@ -7,6 +7,7 @@
import SwiftUI
import AVKit
import PhotosUI
import LivePhotoCore
struct EditorView: View {
@@ -23,6 +24,8 @@ struct EditorView: View {
@State private var videoDuration: Double = 0
@State private var coverImage: UIImage?
@State private var isLoadingCover = false
@State private var coverExtractionTask: Task<Void, Never>?
@State private var coverExtractionToken = UUID()
//
@State private var selectedAspectRatio: AspectRatioTemplate = .fullScreen
@@ -31,6 +34,8 @@ struct EditorView: View {
//
@State private var cropOffset: CGSize = .zero //
@State private var cropScale: CGFloat = 1.0 //
@State private var lastCropOffset: CGSize = .zero //
@State private var lastCropScale: CGFloat = 1.0 //
//
@State private var compatibilityMode: Bool = false
@@ -44,6 +49,25 @@ struct EditorView: View {
//
@State private var videoDiagnosis: VideoDiagnosis?
//
@State private var customCoverItem: PhotosPickerItem?
@State private var customCoverImage: UIImage?
@State private var customCoverURL: URL?
//
@State private var showSavePresetAlert = false
@State private var presetName = ""
@State private var showPresetPicker = false
//
@State private var isViewActive = true
//
@State private var generateTapCount: Int = 0
@State private var presetSavedCount: Int = 0
@State private var coverImportCount: Int = 0
@State private var cropScaleHapticCount: Int = 0
/// 使 iPad regular +
private var useIPadLayout: Bool {
horizontalSizeClass == .regular
@@ -60,19 +84,29 @@ struct EditorView: View {
.navigationTitle(String(localized: "editor.title"))
.navigationBarTitleDisplayMode(.inline)
.onAppear {
isViewActive = true
loadVideo()
}
.onDisappear {
isViewActive = false
player?.pause()
coverExtractionTask?.cancel()
coverExtractionTask = nil
cleanupCustomCoverFile()
}
.sensoryFeedback(.selection, trigger: selectedAspectRatio)
.sensoryFeedback(.impact(weight: .medium), trigger: generateTapCount)
.sensoryFeedback(.impact(weight: .light), trigger: cropScaleHapticCount)
.sensoryFeedback(.success, trigger: presetSavedCount)
.sensoryFeedback(.selection, trigger: coverImportCount)
}
// MARK: - iPhone
@ViewBuilder
private var iPhoneLayout: some View {
ScrollView {
VStack(spacing: 20) {
cropPreviewSection
VStack(spacing: DesignTokens.Spacing.xl) {
cropPreview(height: 360)
if let diagnosis = videoDiagnosis, !diagnosis.suggestions.isEmpty {
diagnosisSection(diagnosis: diagnosis)
@@ -84,6 +118,7 @@ struct EditorView: View {
keyFrameSection
aiEnhanceSection
compatibilitySection
presetSection
generateButton
}
.padding(.horizontal, DesignTokens.Spacing.xl)
@@ -94,10 +129,10 @@ struct EditorView: View {
// MARK: - iPad
@ViewBuilder
private var iPadLayout: some View {
HStack(alignment: .top, spacing: 24) {
HStack(alignment: .top, spacing: DesignTokens.Spacing.xxl) {
//
VStack(spacing: 16) {
iPadCropPreviewSection
VStack(spacing: DesignTokens.Spacing.lg) {
cropPreview(height: 500, dynamicHeight: true)
if let diagnosis = videoDiagnosis, !diagnosis.suggestions.isEmpty {
diagnosisSection(diagnosis: diagnosis)
@@ -109,14 +144,16 @@ struct EditorView: View {
//
ScrollView {
VStack(spacing: 16) {
VStack(spacing: DesignTokens.Spacing.lg) {
coverFrameSection
durationSection
keyFrameSection
aiEnhanceSection
compatibilitySection
presetSection
generateButton
}
.padding(.horizontal, DesignTokens.Spacing.lg)
.padding(.vertical, DesignTokens.Spacing.lg)
}
.frame(minWidth: 320, maxWidth: 420)
@@ -124,80 +161,51 @@ struct EditorView: View {
.padding(DesignTokens.Spacing.xxl)
}
// MARK: - iPad
@ViewBuilder
private var iPadCropPreviewSection: some View {
GeometryReader { geometry in
let containerWidth = geometry.size.width
let containerHeight = min(500, geometry.size.width * 1.2)
ZStack {
if let player {
VideoPlayer(player: player)
.aspectRatio(videoNaturalSize, contentMode: .fit)
.scaleEffect(cropScale)
.offset(cropOffset)
.gesture(
SimultaneousGesture(
MagnificationGesture()
.onChanged { value in
cropScale = max(1.0, min(3.0, value))
},
DragGesture()
.onChanged { value in
cropOffset = value.translation
}
)
)
} else {
ProgressView()
// MARK: -
private var cropGesture: some Gesture {
SimultaneousGesture(
MagnificationGesture()
.onChanged { value in
cropScale = max(1.0, min(3.0, lastCropScale * value))
}
if selectedAspectRatio != .original {
CropOverlay(
aspectRatio: selectedAspectRatio,
containerSize: CGSize(width: containerWidth, height: containerHeight)
.onEnded { value in
lastCropScale = max(1.0, min(3.0, lastCropScale * value))
cropScaleHapticCount += 1
},
DragGesture()
.onChanged { value in
cropOffset = CGSize(
width: lastCropOffset.width + value.translation.width,
height: lastCropOffset.height + value.translation.height
)
}
}
.frame(width: containerWidth, height: containerHeight)
.clipShape(RoundedRectangle(cornerRadius: 16))
.background(Color.black.clipShape(RoundedRectangle(cornerRadius: 16)))
}
.frame(height: 500)
.onEnded { value in
lastCropOffset = CGSize(
width: lastCropOffset.width + value.translation.width,
height: lastCropOffset.height + value.translation.height
)
}
)
}
// MARK: -
// MARK: -
@ViewBuilder
private var cropPreviewSection: some View {
private func cropPreview(height: CGFloat, dynamicHeight: Bool = false) -> some View {
GeometryReader { geometry in
let containerWidth = geometry.size.width
let containerHeight: CGFloat = 360
let containerHeight = dynamicHeight ? min(height, geometry.size.width * 1.2) : height
ZStack {
//
if let player {
VideoPlayer(player: player)
.aspectRatio(videoNaturalSize, contentMode: .fit)
.scaleEffect(cropScale)
.offset(cropOffset)
.gesture(
SimultaneousGesture(
MagnificationGesture()
.onChanged { value in
cropScale = max(1.0, min(3.0, value))
},
DragGesture()
.onChanged { value in
cropOffset = value.translation
}
)
)
.gesture(cropGesture)
} else {
ProgressView()
}
//
if selectedAspectRatio != .original {
CropOverlay(
aspectRatio: selectedAspectRatio,
@@ -206,16 +214,16 @@ struct EditorView: View {
}
}
.frame(width: containerWidth, height: containerHeight)
.clipShape(RoundedRectangle(cornerRadius: 16))
.background(Color.black.clipShape(RoundedRectangle(cornerRadius: 16)))
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.lg))
.background(Color.black.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.lg)))
}
.frame(height: 360)
.frame(height: height)
}
// MARK: -
@ViewBuilder
private var aspectRatioSection: some View {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: DesignTokens.Spacing.md) {
HStack {
Image(systemName: "aspectratio")
.foregroundStyle(.tint)
@@ -223,7 +231,7 @@ struct EditorView: View {
.font(.headline)
}
HStack(spacing: 8) {
HStack(spacing: DesignTokens.Spacing.sm) {
ForEach(AspectRatioTemplate.allCases, id: \.self) { template in
AspectRatioButton(
template: template,
@@ -249,7 +257,7 @@ struct EditorView: View {
// MARK: -
@ViewBuilder
private var coverFrameSection: some View {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: DesignTokens.Spacing.md) {
HStack {
Image(systemName: "photo")
.foregroundStyle(.tint)
@@ -262,15 +270,16 @@ struct EditorView: View {
}
}
HStack(spacing: 12) {
if let coverImage {
Image(uiImage: coverImage)
HStack(spacing: DesignTokens.Spacing.md) {
let displayImage = customCoverImage ?? coverImage
if let displayImage {
Image(uiImage: displayImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 80, height: 120)
.clipShape(RoundedRectangle(cornerRadius: 8))
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.sm))
} else {
RoundedRectangle(cornerRadius: 8)
RoundedRectangle(cornerRadius: DesignTokens.Radius.sm)
.fill(Color.softPressed)
.frame(width: 80, height: 120)
.overlay {
@@ -279,25 +288,54 @@ struct EditorView: View {
}
}
VStack(alignment: .leading, spacing: 4) {
VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) {
Text(String(localized: "editor.coverFrameHint1"))
.font(.caption)
.foregroundColor(.textSecondary)
Text(String(localized: "editor.coverFrameHint2"))
.font(.caption)
.foregroundColor(.textSecondary)
HStack(spacing: DesignTokens.Spacing.sm) {
PhotosPicker(
selection: $customCoverItem,
matching: .images
) {
Label(String(localized: "editor.coverFromAlbum"), systemImage: "photo.on.rectangle")
.font(.caption)
}
.buttonStyle(.borderedProminent)
.buttonBorderShape(.capsule)
.controlSize(.small)
.accessibilityLabel(String(localized: "editor.coverFromAlbum"))
if customCoverImage != nil {
Button {
customCoverItem = nil
customCoverImage = nil
cleanupCustomCoverFile()
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.accessibilityLabel(String(localized: "editor.coverReset"))
}
}
}
}
}
.padding(DesignTokens.Spacing.lg)
.background(Color.softElevated)
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
.onChange(of: customCoverItem) { _, newItem in
loadCustomCover(from: newItem)
}
}
// MARK: -
@ViewBuilder
private var durationSection: some View {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: DesignTokens.Spacing.md) {
HStack {
Image(systemName: "timer")
.foregroundStyle(.tint)
@@ -312,20 +350,47 @@ struct EditorView: View {
SoftSlider(
value: $trimEnd,
in: 1.0...max(1.0, min(1.5, videoDuration)),
in: 1.0...max(1.0, min(5.0, videoDuration)),
step: 0.1,
gradient: Color.gradientPrimary,
accessibilityLabel: String(localized: "editor.videoDuration"),
isDisabled: videoDuration < 1.0,
onEditingChanged: { _ in
updateKeyFrameTime()
onEditingChanged: { editing in
updateKeyFrameTime(shouldExtractCover: !editing)
}
)
Text(String(localized: "editor.durationHint"))
.font(.caption)
.foregroundColor(.textSecondary)
//
if trimEnd - trimStart > 2 {
HStack(alignment: .top, spacing: DesignTokens.Spacing.sm) {
Image(systemName: "exclamationmark.triangle")
.foregroundStyle(Color.accentOrange)
.font(.caption)
Text(String(localized: "editor.durationWallpaperWarning"))
.font(.caption)
.foregroundColor(.accentOrange)
}
.transition(.opacity.combined(with: .move(edge: .top)))
}
if trimEnd - trimStart > 3 {
HStack(alignment: .top, spacing: DesignTokens.Spacing.sm) {
Image(systemName: "square.and.arrow.up")
.foregroundStyle(Color.accentOrange)
.font(.caption)
Text(String(localized: "editor.durationShareWarning"))
.font(.caption)
.foregroundColor(.accentOrange)
}
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
.animation(.easeInOut(duration: 0.3), value: trimEnd - trimStart > 2)
.animation(.easeInOut(duration: 0.3), value: trimEnd - trimStart > 3)
.padding(DesignTokens.Spacing.lg)
.background(Color.softElevated)
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
@@ -334,7 +399,7 @@ struct EditorView: View {
// MARK: -
@ViewBuilder
private var keyFrameSection: some View {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: DesignTokens.Spacing.md) {
HStack {
Image(systemName: "clock")
.foregroundStyle(.tint)
@@ -372,12 +437,12 @@ struct EditorView: View {
// MARK: - AI
@ViewBuilder
private var aiEnhanceSection: some View {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: DesignTokens.Spacing.md) {
Toggle(isOn: $aiEnhanceEnabled) {
HStack {
Image(systemName: "wand.and.stars.inverse")
.foregroundStyle(Color.accentPurple)
VStack(alignment: .leading, spacing: 2) {
VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) {
Text(String(localized: "editor.aiEnhance"))
.font(.headline)
Text(String(localized: "editor.aiEnhanceDescription"))
@@ -396,8 +461,8 @@ struct EditorView: View {
//
if aiModelDownloading {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) {
HStack(spacing: DesignTokens.Spacing.sm) {
ProgressView()
.scaleEffect(0.8)
Text(String(localized: "editor.aiModelDownloading"))
@@ -408,17 +473,17 @@ struct EditorView: View {
ProgressView(value: aiModelDownloadProgress)
.tint(Color.accentPurple)
Text(String(format: "%.0f%%", aiModelDownloadProgress * 100))
Text(aiModelDownloadProgress, format: .percent.precision(.fractionLength(0)))
.font(.caption2)
.foregroundColor(.textSecondary)
}
.padding(.leading, 4)
.padding(.leading, DesignTokens.Spacing.xs)
}
if aiEnhanceEnabled && !aiModelDownloading {
VStack(alignment: .leading, spacing: 6) {
VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) {
if aiModelNeedsDownload {
HStack(spacing: 4) {
HStack(spacing: DesignTokens.Spacing.xs) {
Image(systemName: "arrow.down.circle")
.foregroundStyle(.orange)
.font(.caption)
@@ -426,21 +491,21 @@ struct EditorView: View {
.font(.caption)
}
}
HStack(spacing: 4) {
HStack(spacing: DesignTokens.Spacing.xs) {
Image(systemName: "sparkles")
.foregroundStyle(Color.accentPurple)
.font(.caption)
Text(String(localized: "editor.aiResolutionBoost"))
.font(.caption)
}
HStack(spacing: 4) {
HStack(spacing: DesignTokens.Spacing.xs) {
Image(systemName: "clock")
.foregroundStyle(Color.accentPurple)
.font(.caption)
Text(String(localized: "editor.aiProcessingTime"))
.font(.caption)
}
HStack(spacing: 4) {
HStack(spacing: DesignTokens.Spacing.xs) {
Image(systemName: "cpu")
.foregroundStyle(Color.accentPurple)
.font(.caption)
@@ -449,11 +514,11 @@ struct EditorView: View {
}
}
.foregroundColor(.textSecondary)
.padding(.leading, 4)
.padding(.leading, DesignTokens.Spacing.xs)
}
if !AIEnhancer.isAvailable() {
HStack(spacing: 4) {
HStack(spacing: DesignTokens.Spacing.xs) {
Image(systemName: "exclamationmark.triangle")
.foregroundStyle(.yellow)
.font(.caption)
@@ -461,7 +526,7 @@ struct EditorView: View {
.font(.caption)
.foregroundColor(.textSecondary)
}
.padding(.top, 4)
.padding(.top, DesignTokens.Spacing.xs)
}
}
.padding(DesignTokens.Spacing.lg)
@@ -476,12 +541,12 @@ struct EditorView: View {
// MARK: -
@ViewBuilder
private var compatibilitySection: some View {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: DesignTokens.Spacing.md) {
Toggle(isOn: $compatibilityMode) {
HStack {
Image(systemName: "gearshape.2")
.foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 2) {
VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) {
Text(String(localized: "editor.compatibilityMode"))
.font(.headline)
Text(String(localized: "editor.compatibilityDescription"))
@@ -493,29 +558,29 @@ struct EditorView: View {
.tint(.accentColor)
if compatibilityMode {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 4) {
VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) {
HStack(spacing: DesignTokens.Spacing.xs) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
.font(.caption)
Text(String(localized: "editor.resolution720p"))
.font(.caption)
}
HStack(spacing: 4) {
HStack(spacing: DesignTokens.Spacing.xs) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
.font(.caption)
Text(String(localized: "editor.framerate30fps"))
.font(.caption)
}
HStack(spacing: 4) {
HStack(spacing: DesignTokens.Spacing.xs) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
.font(.caption)
Text(String(localized: "editor.codecH264"))
.font(.caption)
}
HStack(spacing: 4) {
HStack(spacing: DesignTokens.Spacing.xs) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
.font(.caption)
@@ -524,7 +589,7 @@ struct EditorView: View {
}
}
.foregroundColor(.textSecondary)
.padding(.leading, 4)
.padding(.leading, DesignTokens.Spacing.xs)
}
}
.padding(DesignTokens.Spacing.lg)
@@ -535,7 +600,7 @@ struct EditorView: View {
// MARK: -
@ViewBuilder
private func diagnosisSection(diagnosis: VideoDiagnosis) -> some View {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: DesignTokens.Spacing.md) {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.yellow)
@@ -545,12 +610,12 @@ struct EditorView: View {
ForEach(diagnosis.suggestions.indices, id: \.self) { index in
let suggestion = diagnosis.suggestions[index]
HStack(alignment: .top, spacing: 12) {
HStack(alignment: .top, spacing: DesignTokens.Spacing.md) {
Image(systemName: suggestion.icon)
.foregroundStyle(suggestion.iconColor)
.frame(width: 24)
VStack(alignment: .leading, spacing: 4) {
VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) {
Text(suggestion.title)
.font(.subheadline)
.fontWeight(.medium)
@@ -561,15 +626,19 @@ struct EditorView: View {
if let actionText = suggestion.actionText {
Button {
withAnimation {
compatibilityMode = true
if let action = suggestion.action {
action()
} else {
withAnimation {
compatibilityMode = true
}
}
} label: {
Text(actionText)
.font(.caption)
.fontWeight(.medium)
}
.padding(.top, 2)
.padding(.top, DesignTokens.Spacing.xs)
}
}
}
@@ -580,25 +649,122 @@ struct EditorView: View {
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
}
// MARK: -
@ViewBuilder
private var presetSection: some View {
VStack(alignment: .leading, spacing: DesignTokens.Spacing.md) {
HStack {
Image(systemName: "bookmark")
.foregroundStyle(.tint)
Text(String(localized: "editor.presetTitle"))
.font(.headline)
Spacer()
Button {
showPresetPicker = true
} label: {
Label(String(localized: "editor.presetLoad"), systemImage: "tray.and.arrow.down")
.font(.caption)
}
.disabled(PresetManager.shared.presets.isEmpty)
.accessibilityLabel(String(localized: "editor.presetLoad"))
Button {
presetName = ""
showSavePresetAlert = true
} label: {
Label(String(localized: "editor.presetSave"), systemImage: "tray.and.arrow.up")
.font(.caption)
}
.accessibilityLabel(String(localized: "editor.presetSave"))
}
}
.padding(DesignTokens.Spacing.lg)
.background(Color.softElevated)
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
.alert(String(localized: "editor.presetSaveTitle"), isPresented: $showSavePresetAlert) {
TextField(String(localized: "editor.presetNamePlaceholder"), text: $presetName)
Button(String(localized: "editor.presetSaveConfirm")) {
saveCurrentPreset()
}
Button(String(localized: "common.cancel"), role: .cancel) {}
}
.sheet(isPresented: $showPresetPicker) {
presetPickerSheet
}
}
@ViewBuilder
private var presetPickerSheet: some View {
NavigationStack {
Group {
if PresetManager.shared.presets.isEmpty {
ContentUnavailableView(
String(localized: "editor.presetEmpty"),
systemImage: "bookmark",
description: Text(String(localized: "editor.presetEmptyHint"))
)
} else {
List {
ForEach(PresetManager.shared.presets) { preset in
Button {
applyPreset(preset)
showPresetPicker = false
} label: {
VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) {
Text(preset.name)
.font(.body)
.foregroundStyle(.primary)
HStack(spacing: DesignTokens.Spacing.sm) {
Text(preset.aspectRatio.displayName)
Text("·")
Text(String(format: String(localized: "editor.durationSeconds"), preset.trimDuration))
if preset.aiEnhance {
Text("· AI")
}
if preset.compatibilityMode {
Text("· " + String(localized: "editor.compatibilityShort"))
}
}
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.onDelete { indexSet in
for index in indexSet {
PresetManager.shared.removePreset(PresetManager.shared.presets[index])
}
}
}
}
}
.navigationTitle(String(localized: "editor.presetPickerTitle"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "common.cancel")) {
showPresetPicker = false
}
}
}
}
.presentationDetents([.medium])
}
// MARK: -
@ViewBuilder
private var generateButton: some View {
Button {
SoftPrimaryButton(
String(localized: "editor.generateButton"),
icon: "wand.and.stars",
gradient: Color.gradientPrimary
) {
startProcessing()
} label: {
HStack {
Image(systemName: "wand.and.stars")
Text(String(localized: "editor.generateButton"))
}
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(Color.gradientPrimary)
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.buttonStyle(ScaleButtonStyle())
.padding(.top, 8)
.accessibilityLabel(String(localized: "editor.generateButton"))
.padding(.top, DesignTokens.Spacing.sm)
.padding(.bottom, DesignTokens.Spacing.sm)
}
// MARK: -
@@ -607,7 +773,7 @@ struct EditorView: View {
Task {
do {
let durationCMTime = try await asset.load(.duration)
let durationSeconds = durationCMTime.seconds
let durationSeconds = sanitizedDuration(durationCMTime.seconds)
var diagnosis = VideoDiagnosis()
diagnosis.duration = durationSeconds
@@ -621,9 +787,10 @@ struct EditorView: View {
width: abs(transformedSize.width),
height: abs(transformedSize.height)
)
let safeSize = sanitizedVideoSize(absSize)
// 4K
let maxDim = max(absSize.width, absSize.height)
let maxDim = max(safeSize.width, safeSize.height)
diagnosis.isHighRes = maxDim > 3840
//
@@ -651,13 +818,14 @@ struct EditorView: View {
}
await MainActor.run {
videoNaturalSize = absSize
videoNaturalSize = safeSize
}
}
await MainActor.run {
guard isViewActive else { return }
videoDuration = durationSeconds
trimEnd = min(1.0, durationSeconds)
trimEnd = max(0.1, min(1.0, durationSeconds))
keyFrameTime = trimEnd / 2
player = AVPlayer(url: videoURL)
player?.play()
@@ -678,26 +846,46 @@ struct EditorView: View {
extractCoverFrame()
}
private func updateKeyFrameTime(shouldExtractCover: Bool) {
// keyFrameTime
keyFrameTime = max(trimStart, min(keyFrameTime, trimEnd))
if shouldExtractCover {
extractCoverFrame()
}
}
private func extractCoverFrame() {
//
coverExtractionTask?.cancel()
let token = UUID()
coverExtractionToken = token
isLoadingCover = true
let asset = AVURLAsset(url: videoURL)
let imageGenerator = AVAssetImageGenerator(asset: asset)
imageGenerator.appliesPreferredTrackTransform = true
imageGenerator.maximumSize = CGSize(width: 200, height: 300)
imageGenerator.requestedTimeToleranceAfter = CMTime(value: 1, timescale: 100)
imageGenerator.requestedTimeToleranceBefore = CMTime(value: 1, timescale: 100)
let time = CMTime(seconds: keyFrameTime, preferredTimescale: 600)
let requestTime = keyFrameTime
coverExtractionTask = Task {
let asset = AVURLAsset(url: videoURL)
let imageGenerator = AVAssetImageGenerator(asset: asset)
imageGenerator.appliesPreferredTrackTransform = true
imageGenerator.maximumSize = CGSize(width: 200, height: 300)
imageGenerator.requestedTimeToleranceAfter = CMTime(value: 1, timescale: 100)
imageGenerator.requestedTimeToleranceBefore = CMTime(value: 1, timescale: 100)
let time = CMTime(seconds: requestTime, preferredTimescale: 600)
Task {
do {
let result = try await imageGenerator.image(at: time)
try Task.checkCancellation()
await MainActor.run {
guard coverExtractionToken == token else { return }
coverImage = UIImage(cgImage: result.image)
isLoadingCover = false
}
} catch is CancellationError {
// UI
} catch {
await MainActor.run {
guard coverExtractionToken == token else { return }
isLoadingCover = false
}
#if DEBUG
@@ -710,6 +898,8 @@ struct EditorView: View {
private func resetCropState() {
cropOffset = .zero
cropScale = 1.0
lastCropOffset = .zero
lastCropScale = 1.0
}
private func calculateCropRect() -> CropRect {
@@ -718,7 +908,8 @@ struct EditorView: View {
return .full
}
let videoRatio = videoNaturalSize.width / videoNaturalSize.height
let safeHeight = max(videoNaturalSize.height, 1)
let videoRatio = videoNaturalSize.width / safeHeight
var cropWidth: CGFloat = 1.0
var cropHeight: CGFloat = 1.0
@@ -741,6 +932,17 @@ struct EditorView: View {
return CropRect(x: cropX, y: cropY, width: cropWidth, height: cropHeight)
}
private func sanitizedDuration(_ value: Double) -> Double {
guard value.isFinite, !value.isNaN else { return 0 }
return max(0, value)
}
private func sanitizedVideoSize(_ size: CGSize) -> CGSize {
let safeWidth = size.width.isFinite && !size.width.isNaN ? max(1, size.width) : 1080
let safeHeight = size.height.isFinite && !size.height.isNaN ? max(1, size.height) : 1920
return CGSize(width: safeWidth, height: safeHeight)
}
private func checkAndDownloadModel() {
guard aiEnhanceEnabled else { return }
@@ -783,7 +985,80 @@ struct EditorView: View {
}
}
private func saveCurrentPreset() {
let name = presetName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty else { return }
PresetManager.shared.savePreset(
name: name,
aspectRatio: selectedAspectRatio,
trimDuration: trimEnd - trimStart,
aiEnhance: aiEnhanceEnabled,
compatibilityMode: compatibilityMode
)
presetSavedCount += 1
}
private func applyPreset(_ preset: EditingPreset) {
withAnimation {
selectedAspectRatio = preset.aspectRatio
trimEnd = min(preset.trimDuration, videoDuration)
trimStart = 0
aiEnhanceEnabled = preset.aiEnhance
compatibilityMode = preset.compatibilityMode
updateKeyFrameTime()
resetCropState()
}
}
private func loadCustomCover(from item: PhotosPickerItem?) {
guard let item else {
customCoverImage = nil
cleanupCustomCoverFile()
return
}
Task {
guard let data = try? await item.loadTransferable(type: Data.self),
data.count < 50_000_000,
let image = UIImage(data: data) else {
return
}
// LivePhotoCore 使
let tempDir = FileManager.default.temporaryDirectory
let fileURL = tempDir.appendingPathComponent("custom_cover_\(UUID().uuidString).jpg")
if let jpegData = image.jpegData(compressionQuality: 0.95) {
do {
try jpegData.write(to: fileURL)
await MainActor.run {
cleanupCustomCoverFile()
customCoverImage = image
customCoverURL = fileURL
coverImportCount += 1
}
} catch {
await MainActor.run {
customCoverImage = image
customCoverURL = nil
coverImportCount += 1
}
#if DEBUG
print("Failed to write custom cover: \(error)")
#endif
}
}
}
}
private func cleanupCustomCoverFile() {
if let url = customCoverURL {
try? FileManager.default.removeItem(at: url)
customCoverURL = nil
}
}
private func startProcessing() {
generateTapCount += 1
Analytics.shared.log(.editorGenerateClick, parameters: [
"trimStart": trimStart,
"trimEnd": trimEnd,
@@ -801,6 +1076,7 @@ struct EditorView: View {
keyFrameTime: keyFrameTime,
cropRect: cropRect,
aspectRatio: selectedAspectRatio,
coverImageURL: customCoverURL,
aiEnhanceConfig: aiEnhanceEnabled ? .standard : .disabled
)
@@ -821,15 +1097,15 @@ struct AspectRatioButton: View {
var body: some View {
Button(action: action) {
VStack(spacing: 4) {
VStack(spacing: DesignTokens.Spacing.xs) {
//
RoundedRectangle(cornerRadius: 4)
RoundedRectangle(cornerRadius: DesignTokens.Spacing.xs)
.stroke(isSelected ? Color.accentPurple : Color.textSecondary, lineWidth: 2)
.frame(width: iconWidth, height: iconHeight)
.background(
isSelected ? Color.accentPurple.opacity(0.1) : Color.clear
)
.clipShape(RoundedRectangle(cornerRadius: 4))
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Spacing.xs))
Text(template.displayName)
.font(.caption2)
@@ -840,6 +1116,7 @@ struct AspectRatioButton: View {
.padding(.vertical, DesignTokens.Spacing.sm)
.background(isSelected ? Color.accentPurple.opacity(0.1) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.sm))
.animation(DesignTokens.Animation.quick, value: isSelected)
}
.buttonStyle(.plain)
.accessibilityElement(children: .ignore)
@@ -884,17 +1161,18 @@ struct CropOverlay: View {
.mask(
Rectangle()
.overlay(
RoundedRectangle(cornerRadius: 8)
RoundedRectangle(cornerRadius: DesignTokens.Radius.sm)
.frame(width: cropSize.width, height: cropSize.height)
.blendMode(.destinationOut)
)
)
//
RoundedRectangle(cornerRadius: 8)
RoundedRectangle(cornerRadius: DesignTokens.Radius.sm)
.stroke(Color.white, lineWidth: 2)
.frame(width: cropSize.width, height: cropSize.height)
}
.animation(DesignTokens.Animation.standard, value: aspectRatio)
}
.allowsHitTesting(false)
}

View File

@@ -16,18 +16,28 @@ struct HomeView: View {
@State private var selectedItem: PhotosPickerItem?
@State private var isLoading = false
@State private var errorMessage: String?
@State private var showHero = false
@State private var showRecentWorks = false
@State private var showClearAllAlert = false
@State private var deleteWorkCount: Int = 0
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: DesignTokens.Spacing.xxl) {
//
heroSection
.opacity(showHero ? 1 : 0)
.offset(y: showHero ? 0 : 20)
//
if !recentWorks.recentWorks.isEmpty {
recentWorksSection
.opacity(showRecentWorks ? 1 : 0)
.offset(y: showRecentWorks ? 0 : 20)
} else {
emptyStateHint
.opacity(showRecentWorks ? 1 : 0)
.offset(y: showRecentWorks ? 0 : 20)
}
}
.padding(.horizontal, DesignTokens.Spacing.xl)
@@ -50,7 +60,14 @@ struct HomeView: View {
}
.onAppear {
recentWorks.cleanupDeletedAssets()
withAnimation(DesignTokens.Animation.standard) {
showHero = true
}
withAnimation(DesignTokens.Animation.standard.delay(0.2)) {
showRecentWorks = true
}
}
.sensoryFeedback(.warning, trigger: deleteWorkCount)
}
// MARK: - Hero
@@ -193,6 +210,16 @@ struct HomeView: View {
Spacer()
//
Button {
showClearAllAlert = true
} label: {
Text(String(localized: "home.clearAll"))
.font(.footnote)
.foregroundColor(.accentPink)
}
.accessibilityLabel(String(localized: "home.clearAll"))
Text(String(localized: "home.worksCount \(recentWorks.recentWorks.count)"))
.font(.footnote)
.foregroundColor(.textMuted)
@@ -202,17 +229,32 @@ struct HomeView: View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: DesignTokens.Spacing.lg) {
ForEach(recentWorks.recentWorks) { work in
RecentWorkCard(work: work) {
RecentWorkCard(work: work, onTap: {
appState.navigateTo(.wallpaperGuide(assetId: work.assetLocalIdentifier))
}
}, onDelete: {
recentWorks.removeWork(work)
deleteWorkCount += 1
})
}
}
.padding(.horizontal, DesignTokens.Spacing.xs)
.padding(.vertical, DesignTokens.Spacing.sm)
}
}
.alert(
String(localized: "home.clearAllConfirmTitle"),
isPresented: $showClearAllAlert
) {
Button(String(localized: "home.clearAll"), role: .destructive) {
recentWorks.clearAll()
}
Button(String(localized: "common.cancel"), role: .cancel) {}
} message: {
Text(String(localized: "home.clearAllConfirmMessage"))
}
}
@MainActor
private func handleSelectedItem(_ item: PhotosPickerItem?) async {
guard let item else { return }
@@ -224,15 +266,19 @@ struct HomeView: View {
guard let movie = try await item.loadTransferable(type: VideoTransferable.self) else {
errorMessage = String(localized: "home.loadFailed")
isLoading = false
selectedItem = nil
return
}
isLoading = false
Analytics.shared.log(.importVideoSuccess)
appState.navigateTo(.editor(videoURL: movie.url))
selectedItem = nil
} catch {
errorMessage = String(localized: "home.loadError \(error.localizedDescription)")
let format = String(localized: "home.loadError")
errorMessage = String(format: format, error.localizedDescription)
isLoading = false
selectedItem = nil
Analytics.shared.logError(.importVideoFail, error: error)
}
}
@@ -265,25 +311,36 @@ struct QuickStartStep: View {
}
}
// MARK: -
// MARK: -
struct VideoTransferable: Transferable {
let url: URL
static var transferRepresentation: some TransferRepresentation {
FileRepresentation(contentType: .movie) { video in
SentTransferredFile(video.url)
} importing: { received in
let tempDir = FileManager.default.temporaryDirectory
let filename = "import_\(UUID().uuidString).mov"
let destURL = tempDir.appendingPathComponent(filename)
if FileManager.default.fileExists(atPath: destURL.path) {
try FileManager.default.removeItem(at: destURL)
}
try FileManager.default.copyItem(at: received.file, to: destURL)
return VideoTransferable(url: destURL)
FileRepresentation(importedContentType: .movie) { received in
try copyToSandboxTemp(from: received.file, preferredExtension: "mov")
}
FileRepresentation(importedContentType: .mpeg4Movie) { received in
try copyToSandboxTemp(from: received.file, preferredExtension: "mp4")
}
FileRepresentation(importedContentType: .quickTimeMovie) { received in
try copyToSandboxTemp(from: received.file, preferredExtension: "mov")
}
}
private static func copyToSandboxTemp(from sourceURL: URL, preferredExtension: String) throws -> VideoTransferable {
let tempDir = FileManager.default.temporaryDirectory
let ext = sourceURL.pathExtension.isEmpty ? preferredExtension : sourceURL.pathExtension
let filename = "import_\(UUID().uuidString)." + ext
let destURL = tempDir.appendingPathComponent(filename)
if FileManager.default.fileExists(atPath: destURL.path) {
try FileManager.default.removeItem(at: destURL)
}
try FileManager.default.copyItem(at: sourceURL, to: destURL)
return VideoTransferable(url: destURL)
}
}
@@ -291,6 +348,7 @@ struct VideoTransferable: Transferable {
struct RecentWorkCard: View {
let work: RecentWork
let onTap: () -> Void
var onDelete: (() -> Void)?
@StateObject private var thumbnailLoader = ThumbnailLoader()
@State private var isPressed = false
@@ -304,13 +362,13 @@ struct RecentWorkCard: View {
Image(uiImage: thumbnail)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 110, height: 150)
.frame(width: 130, height: 178)
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
.transition(.opacity.combined(with: .scale(scale: 0.95)))
} else {
RoundedRectangle(cornerRadius: DesignTokens.Radius.md)
.fill(Color.softPressed)
.frame(width: 110, height: 150)
.frame(width: 130, height: 178)
.overlay {
Image(systemName: "livephoto")
.font(.system(size: 24))
@@ -329,7 +387,7 @@ struct RecentWorkCard: View {
}
.padding(DesignTokens.Spacing.sm)
}
.frame(width: 110, height: 150)
.frame(width: 130, height: 178)
//
VStack(alignment: .leading, spacing: 2) {
@@ -350,9 +408,15 @@ struct RecentWorkCard: View {
.buttonStyle(.plain)
.scaleEffect(isPressed ? 0.97 : 1.0)
.animation(DesignTokens.Animation.spring, value: isPressed)
.onLongPressGesture(minimumDuration: .infinity, pressing: { pressing in
isPressed = pressing
}, perform: {})
.contextMenu {
if let onDelete {
Button(role: .destructive) {
onDelete()
} label: {
Label(String(localized: "home.deleteWork"), systemImage: "trash")
}
}
}
.onAppear {
thumbnailLoader.load(assetId: work.assetLocalIdentifier)
}

View File

@@ -17,6 +17,9 @@ struct ProcessingView: View {
@State private var hasStarted = false
@State private var pulseAnimation = false
/// LivePhotoBuildProgress.Stage case
private let totalStages = 7
var body: some View {
ZStack {
//
@@ -54,7 +57,7 @@ struct ProcessingView: View {
await startProcessing()
}
.onAppear {
withAnimation(.easeInOut(duration: 1.5).repeatForever(autoreverses: true)) {
withAnimation(.easeInOut(duration: 2.0).repeatForever(autoreverses: true)) {
pulseAnimation = true
}
}
@@ -88,16 +91,16 @@ struct ProcessingView: View {
//
Circle()
.fill(Color.accentPurple.opacity(0.1))
.frame(width: pulseAnimation ? 180 : 160, height: pulseAnimation ? 180 : 160)
.animation(.easeInOut(duration: 1.5).repeatForever(autoreverses: true), value: pulseAnimation)
.frame(width: pulseAnimation ? 175 : 160, height: pulseAnimation ? 175 : 160)
//
SoftProgressRing(
progress: appState.processingProgress?.fraction ?? 0,
progress: overallProgress,
size: 140,
lineWidth: 10,
gradient: stageGradient
)
.animation(.easeInOut(duration: 0.5), value: overallProgress)
//
VStack(spacing: DesignTokens.Spacing.xs) {
@@ -106,11 +109,12 @@ struct ProcessingView: View {
.foregroundStyle(stageGradient)
.contentTransition(.symbolEffect(.replace))
if let progress = appState.processingProgress {
Text(String(format: "%.0f%%", progress.fraction * 100))
if appState.processingProgress != nil {
Text(String(format: "%.0f%%", overallProgress * 100))
.font(.headline.bold())
.foregroundColor(.textPrimary)
.contentTransition(.numericText())
.animation(.easeInOut(duration: 0.5), value: overallProgress)
}
}
}
@@ -131,7 +135,7 @@ struct ProcessingView: View {
//
HStack(spacing: DesignTokens.Spacing.sm) {
ForEach(0..<7) { index in
ForEach(0..<totalStages, id: \.self) { index in
Circle()
.fill(index <= currentStageIndex ? Color.accentPurple : Color.softPressed)
.frame(width: 8, height: 8)
@@ -221,6 +225,13 @@ struct ProcessingView: View {
}
}
/// = ( + fraction) /
private var overallProgress: Double {
let totalStages = Double(self.totalStages)
let stageFraction = appState.processingProgress?.fraction ?? 0
return (Double(currentStageIndex) + stageFraction) / totalStages
}
private var stageIcon: String {
guard let stage = appState.processingProgress?.stage else {
return "hourglass"

View File

@@ -6,6 +6,7 @@
//
import SwiftUI
import PhotosUI
import LivePhotoCore
struct ResultView: View {
@@ -17,6 +18,7 @@ struct ResultView: View {
@State private var showContent = false
@State private var showButtons = false
@State private var celebrationParticles = false
@State private var livePhoto: PHLivePhoto?
var body: some View {
ZStack {
@@ -29,11 +31,15 @@ struct ResultView: View {
.accessibilityHidden(true)
}
VStack(spacing: DesignTokens.Spacing.xxxl) {
Spacer()
//
resultIcon
VStack(spacing: DesignTokens.Spacing.xl) {
if isSuccess && livePhoto != nil {
// Live Photo
livePhotoPreview
} else {
Spacer()
//
resultIcon
}
//
resultInfo
@@ -49,9 +55,50 @@ struct ResultView: View {
.navigationTitle(String(localized: "result.title"))
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true)
.onAppear {
animateIn()
.task {
// structured concurrency
withAnimation { showIcon = true }
if isSuccess {
try? await Task.sleep(for: .seconds(0.2))
celebrationParticles = true
}
try? await Task.sleep(for: .seconds(0.3))
showContent = true
try? await Task.sleep(for: .seconds(0.2))
showButtons = true
}
.task {
guard isSuccess else { return }
await loadLivePhoto()
}
}
// MARK: - Live Photo
@ViewBuilder
private var livePhotoPreview: some View {
VStack(spacing: DesignTokens.Spacing.md) {
Spacer()
if let livePhoto {
let photoSize = livePhoto.size
let ratio = photoSize.width / max(photoSize.height, 1)
SoftCard(padding: DesignTokens.Spacing.md) {
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: -
@@ -165,26 +212,92 @@ struct ResultView: View {
!workflowResult.savedAssetId.isEmpty
}
// MARK: -
// MARK: - Live Photo
private func animateIn() {
//
withAnimation {
showIcon = true
private func loadLivePhoto() async {
let imageURL = workflowResult.pairedImageURL
let videoURL = workflowResult.pairedVideoURL
guard FileManager.default.fileExists(atPath: imageURL.path),
FileManager.default.fileExists(atPath: videoURL.path) else {
return
}
if isSuccess {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
celebrationParticles = true
let photo = await withCheckedContinuation { (continuation: CheckedContinuation<PHLivePhoto?, Never>) in
let lock = NSLock()
var hasResumed = false
PHLivePhoto.request(
withResourceFileURLs: [imageURL, videoURL],
placeholderImage: nil,
targetSize: .zero,
contentMode: .aspectFit
) { result, info in
let isDegraded = (info[PHLivePhotoInfoIsDegradedKey] as? Bool) ?? false
let isCancelled = (info[PHLivePhotoInfoCancelledKey] as? Bool) ?? false
let hasError = info[PHLivePhotoInfoErrorKey] != nil
// / resume
guard !isDegraded || isCancelled || hasError else { return }
lock.lock()
let shouldResume = !hasResumed
hasResumed = true
lock.unlock()
if shouldResume {
continuation.resume(returning: isCancelled || hasError ? nil : result)
}
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
showContent = true
if let photo {
withAnimation(.easeIn(duration: 0.3)) {
livePhoto = photo
}
}
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
showButtons = true
// MARK: - Live Photo UIKit
struct LivePhotoPreviewView: UIViewRepresentable {
let livePhoto: PHLivePhoto
func makeUIView(context: Context) -> PHLivePhotoView {
let view = PHLivePhotoView()
view.contentMode = .scaleAspectFit
view.livePhoto = livePhoto
view.accessibilityLabel = String(localized: "result.livePhotoPreview.accessibilityLabel")
let longPress = UILongPressGestureRecognizer(
target: context.coordinator,
action: #selector(Coordinator.handleLongPress(_:))
)
longPress.minimumPressDuration = 0.15
view.addGestureRecognizer(longPress)
return view
}
func updateUIView(_ uiView: PHLivePhotoView, context: Context) {
uiView.livePhoto = livePhoto
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
final class Coordinator: NSObject {
@objc func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
guard let livePhotoView = gesture.view as? PHLivePhotoView else { return }
switch gesture.state {
case .began:
livePhotoView.startPlayback(with: .full)
case .ended, .cancelled:
livePhotoView.stopPlayback()
default:
break
}
}
}
}