feat(M1): 完成比例模板、裁剪手势和取消策略

主要改动:
- EditorView: 添加5种比例模板选择(原比例/锁屏/全屏/4:3/1:1)
- EditorView: 实现裁剪预览(半透明遮罩+裁剪框)和缩放拖拽手势
- LivePhotoCore: ExportParams 新增 CropRect 和 AspectRatioTemplate
- LivePhotoCore: scaleVideoToTargetDuration 支持裁剪和比例输出
- AppState: 添加任务取消机制(cancelProcessing)
- ProcessingView: 添加取消按钮,支持取消状态显示
- CacheManager: 添加 removeWorkDir 静默清理方法
- Analytics: 添加 buildLivePhotoCancel 事件

M1 编辑能力全部完成:
 比例模板:锁屏/全屏/4:3/1:1/原比例
 裁剪手势:缩放+拖拽
 取消策略:终止任务+清理中间文件

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
empty
2025-12-14 20:51:08 +08:00
parent a8b334ef39
commit 64cdb82459
6 changed files with 516 additions and 104 deletions

View File

@@ -64,6 +64,63 @@ public enum HDRPolicy: String, Codable, Sendable {
case toneMapToSDR
}
/// 0~1
public struct CropRect: Codable, Sendable, Hashable {
public var x: CGFloat // x0~1
public var y: CGFloat // y0~1
public var width: CGFloat // 0~1
public var height: CGFloat // 0~1
public init(x: CGFloat = 0, y: CGFloat = 0, width: CGFloat = 1, height: CGFloat = 1) {
self.x = x
self.y = y
self.width = width
self.height = height
}
///
public static let full = CropRect()
///
public func toPixelRect(videoSize: CGSize) -> CGRect {
CGRect(
x: x * videoSize.width,
y: y * videoSize.height,
width: width * videoSize.width,
height: height * videoSize.height
)
}
}
///
public enum AspectRatioTemplate: String, Codable, Sendable, CaseIterable {
case original = "original" //
case lockScreen = "lock_screen" // iPhone 9:19.5
case fullScreen = "full_screen" // 9:16
case classic = "classic" // 4:3
case square = "square" // 1:1
public var displayName: String {
switch self {
case .original: return "原比例"
case .lockScreen: return "锁屏"
case .fullScreen: return "全屏"
case .classic: return "4:3"
case .square: return "1:1"
}
}
public var ratio: CGFloat? {
switch self {
case .original: return nil
case .lockScreen: return 9.0 / 19.5
case .fullScreen: return 9.0 / 16.0
case .classic: return 3.0 / 4.0
case .square: return 1.0
}
}
}
public struct ExportParams: Codable, Sendable, Hashable {
public var trimStart: Double
public var trimEnd: Double
@@ -72,6 +129,8 @@ public struct ExportParams: Codable, Sendable, Hashable {
public var codecPolicy: CodecPolicy
public var hdrPolicy: HDRPolicy
public var maxDimension: Int
public var cropRect: CropRect
public var aspectRatio: AspectRatioTemplate
public init(
trimStart: Double = 0,
@@ -80,7 +139,9 @@ public struct ExportParams: Codable, Sendable, Hashable {
audioPolicy: AudioPolicy = .keep,
codecPolicy: CodecPolicy = .fallbackH264,
hdrPolicy: HDRPolicy = .toneMapToSDR,
maxDimension: Int = 1920
maxDimension: Int = 1920,
cropRect: CropRect = .full,
aspectRatio: AspectRatioTemplate = .original
) {
self.trimStart = trimStart
self.trimEnd = trimEnd
@@ -89,6 +150,8 @@ public struct ExportParams: Codable, Sendable, Hashable {
self.codecPolicy = codecPolicy
self.hdrPolicy = hdrPolicy
self.maxDimension = maxDimension
self.cropRect = cropRect
self.aspectRatio = aspectRatio
}
}
@@ -192,6 +255,12 @@ public struct CacheManager: Sendable {
try FileManager.default.removeItem(at: workDir)
}
}
///
public func removeWorkDir(workId: UUID) {
let workDir = baseDirectory.appendingPathComponent(workId.uuidString, isDirectory: true)
try? FileManager.default.removeItem(at: workDir)
}
}
public struct LivePhotoLogger: Sendable {
@@ -387,6 +456,8 @@ public actor LivePhotoBuilder {
let scaledVideoURL = try await scaleVideoToTargetDuration(
sourceURL: trimmedVideoURL,
targetDuration: targetDuration,
cropRect: exportParams.cropRect,
aspectRatio: exportParams.aspectRatio,
destinationURL: paths.workDir.appendingPathComponent("scaled.mov")
)
@@ -479,11 +550,13 @@ public actor LivePhotoBuilder {
}
/// Live Photo
/// ~0.917 1080x1920 60fps
/// ~0.917 1080x1920 60fps
/// live-wallpaper accelerateVideo + resizeVideo
private func scaleVideoToTargetDuration(
sourceURL: URL,
targetDuration: CMTime,
cropRect: CropRect,
aspectRatio: AspectRatioTemplate,
destinationURL: URL
) async throws -> URL {
let asset = AVURLAsset(url: sourceURL)
@@ -505,10 +578,19 @@ public actor LivePhotoBuilder {
let transformedSize = originalSize.applying(preferredTransform)
let absoluteSize = CGSize(width: abs(transformedSize.width), height: abs(transformedSize.height))
//
// -> 1920x1080 -> 1080x1920
let isLandscape = absoluteSize.width > absoluteSize.height
let livePhotoSize = isLandscape ? CGSize(width: 1920, height: 1080) : CGSize(width: 1080, height: 1920)
//
let outputSize: CGSize
if let targetRatio = aspectRatio.ratio {
//
// 1080
let width: CGFloat = 1080
let height = width / targetRatio
outputSize = CGSize(width: width, height: min(height, 1920))
} else {
//
let isLandscape = absoluteSize.width > absoluteSize.height
outputSize = isLandscape ? CGSize(width: 1920, height: 1080) : CGSize(width: 1080, height: 1920)
}
// 1 live-wallpaper accelerateVideo
let acceleratedURL = destinationURL.deletingLastPathComponent().appendingPathComponent("accelerated.mov")
@@ -556,7 +638,7 @@ public actor LivePhotoBuilder {
// 使 AVMutableVideoComposition
let videoComposition = AVMutableVideoComposition()
videoComposition.renderSize = livePhotoSize
videoComposition.renderSize = outputSize
// 60fps
videoComposition.frameDuration = CMTime(value: 1, timescale: 60)
@@ -565,27 +647,37 @@ public actor LivePhotoBuilder {
let layerInstruction = AVMutableVideoCompositionLayerInstruction(assetTrack: acceleratedVideoTrack)
//
// naturalSize livePhotoSize
//
// naturalSize outputSize
//
// 1. preferredTransform
// 2.
// 2.
// 3.
//
let rotatedSize = acceleratedNaturalSize.applying(acceleratedTransform)
let rotatedAbsoluteSize = CGSize(width: abs(rotatedSize.width), height: abs(rotatedSize.height))
//
let actualWidthRatio = livePhotoSize.width / rotatedAbsoluteSize.width
let actualHeightRatio = livePhotoSize.height / rotatedAbsoluteSize.height
let actualScaleFactor = min(actualWidthRatio, actualHeightRatio)
//
let croppedSourceWidth = rotatedAbsoluteSize.width * cropRect.width
let croppedSourceHeight = rotatedAbsoluteSize.height * cropRect.height
//
let actualWidthRatio = outputSize.width / croppedSourceWidth
let actualHeightRatio = outputSize.height / croppedSourceHeight
let actualScaleFactor = max(actualWidthRatio, actualHeightRatio) // 使 max
let scaledWidth = rotatedAbsoluteSize.width * actualScaleFactor
let scaledHeight = rotatedAbsoluteSize.height * actualScaleFactor
//
let centerX = (livePhotoSize.width - scaledWidth) / 2
let centerY = (livePhotoSize.height - scaledHeight) / 2
//
let cropCenterX = (cropRect.x + cropRect.width / 2) * scaledWidth
let cropCenterY = (cropRect.y + cropRect.height / 2) * scaledHeight
let outputCenterX = outputSize.width / 2
let outputCenterY = outputSize.height / 2
let centerX = outputCenterX - cropCenterX
let centerY = outputCenterY - cropCenterY
//
// preferredTransform+
@@ -955,21 +1047,30 @@ public actor LivePhotoWorkflow {
private let builder: LivePhotoBuilder
private let albumWriter: AlbumWriter
private let validator: LivePhotoValidator
private let cacheManager: CacheManager
public init(cacheManager: CacheManager? = nil, logger: LivePhotoLogger = LivePhotoLogger()) throws {
let cm = try cacheManager ?? CacheManager()
self.cacheManager = cm
self.builder = try LivePhotoBuilder(cacheManager: cm, logger: logger)
self.albumWriter = AlbumWriter()
self.validator = LivePhotoValidator()
}
/// workId
public func cleanupWork(workId: UUID) async {
cacheManager.removeWorkDir(workId: workId)
}
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,