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:
@@ -64,6 +64,63 @@ public enum HDRPolicy: String, Codable, Sendable {
|
||||
case toneMapToSDR
|
||||
}
|
||||
|
||||
/// 裁剪区域(归一化坐标,0~1)
|
||||
public struct CropRect: Codable, Sendable, Hashable {
|
||||
public var x: CGFloat // 左上角 x(0~1)
|
||||
public var y: CGFloat // 左上角 y(0~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,
|
||||
|
||||
Reference in New Issue
Block a user