feat: M2-M4 完成,添加 AI 增强、设计系统、App Store 准备
新增功能: - AI 超分辨率模块 (Real-ESRGAN Core ML) - Soft UI 设计系统 (DesignSystem.swift) - 设置页、隐私政策页、引导页 - 最近作品管理器 App Store 准备: - 完善截图 (iPhone 6.7"/6.5", iPad 12.9") - App Store 元数据文档 - 修复应用图标 alpha 通道 - 更新显示名称为 Live Photo Studio 工程配置: - 配置 Git LFS 跟踪 mlmodel 文件 - 添加 Claude skill 开发指南 - 更新 .gitignore 规则 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import VideoToolbox
|
||||
public enum LivePhotoBuildStage: String, Codable, Sendable {
|
||||
case normalize
|
||||
case extractKeyFrame
|
||||
case aiEnhance
|
||||
case writePhotoMetadata
|
||||
case writeVideoMetadata
|
||||
case saveToAlbum
|
||||
@@ -131,6 +132,9 @@ public struct ExportParams: Codable, Sendable, Hashable {
|
||||
public var maxDimension: Int
|
||||
public var cropRect: CropRect
|
||||
public var aspectRatio: AspectRatioTemplate
|
||||
public var compatibilityMode: Bool
|
||||
public var targetFrameRate: Int
|
||||
public var aiEnhanceConfig: AIEnhanceConfig
|
||||
|
||||
public init(
|
||||
trimStart: Double = 0,
|
||||
@@ -141,7 +145,10 @@ public struct ExportParams: Codable, Sendable, Hashable {
|
||||
hdrPolicy: HDRPolicy = .toneMapToSDR,
|
||||
maxDimension: Int = 1920,
|
||||
cropRect: CropRect = .full,
|
||||
aspectRatio: AspectRatioTemplate = .original
|
||||
aspectRatio: AspectRatioTemplate = .original,
|
||||
compatibilityMode: Bool = false,
|
||||
targetFrameRate: Int = 60,
|
||||
aiEnhanceConfig: AIEnhanceConfig = .disabled
|
||||
) {
|
||||
self.trimStart = trimStart
|
||||
self.trimEnd = trimEnd
|
||||
@@ -152,6 +159,20 @@ public struct ExportParams: Codable, Sendable, Hashable {
|
||||
self.maxDimension = maxDimension
|
||||
self.cropRect = cropRect
|
||||
self.aspectRatio = aspectRatio
|
||||
self.compatibilityMode = compatibilityMode
|
||||
self.targetFrameRate = targetFrameRate
|
||||
self.aiEnhanceConfig = aiEnhanceConfig
|
||||
}
|
||||
|
||||
/// 应用兼容模式的便捷方法
|
||||
public func withCompatibilityMode() -> ExportParams {
|
||||
var params = self
|
||||
params.compatibilityMode = true
|
||||
params.maxDimension = 720
|
||||
params.targetFrameRate = 30
|
||||
params.codecPolicy = .fallbackH264
|
||||
params.hdrPolicy = .toneMapToSDR
|
||||
return params
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,58 +461,82 @@ public actor LivePhotoBuilder {
|
||||
let assetIdentifier = UUID().uuidString
|
||||
let paths = try cacheManager.makeWorkPaths(workId: workId)
|
||||
|
||||
progress?(LivePhotoBuildProgress(stage: .normalize, fraction: 0))
|
||||
let trimmedVideoURL = try await trimVideo(
|
||||
sourceURL: sourceVideoURL,
|
||||
trimStart: exportParams.trimStart,
|
||||
trimEnd: exportParams.trimEnd,
|
||||
destinationURL: paths.workDir.appendingPathComponent("trimmed.mov")
|
||||
)
|
||||
// 临时文件路径(用于清理)
|
||||
let trimmedURL = paths.workDir.appendingPathComponent("trimmed.mov")
|
||||
let scaledURL = paths.workDir.appendingPathComponent("scaled.mov")
|
||||
let keyPhotoTempURL = paths.workDir.appendingPathComponent("keyPhoto").appendingPathExtension("heic")
|
||||
|
||||
// 关键:将视频变速到约 1 秒,与 metadata.mov 的时间标记匹配
|
||||
// live-wallpaper 项目使用 CMTimeMake(550, 600) = 0.917 秒
|
||||
// 我们使用 1 秒以完全匹配 metadata.mov 的时长
|
||||
let targetDuration = CMTimeMake(value: 550, timescale: 600) // ~0.917 秒,与 live-wallpaper 一致
|
||||
progress?(LivePhotoBuildProgress(stage: .normalize, fraction: 0.5))
|
||||
let scaledVideoURL = try await scaleVideoToTargetDuration(
|
||||
sourceURL: trimmedVideoURL,
|
||||
targetDuration: targetDuration,
|
||||
cropRect: exportParams.cropRect,
|
||||
aspectRatio: exportParams.aspectRatio,
|
||||
destinationURL: paths.workDir.appendingPathComponent("scaled.mov")
|
||||
)
|
||||
|
||||
// 计算关键帧时间:目标视频的中间位置(0.5 秒处,与 metadata.mov 的 still-image-time 匹配)
|
||||
let relativeKeyFrameTime = 0.5 // 固定为 0.5 秒,与 metadata.mov 匹配
|
||||
|
||||
progress?(LivePhotoBuildProgress(stage: .extractKeyFrame, fraction: 0))
|
||||
let keyPhotoURL = try await resolveKeyPhotoURL(
|
||||
videoURL: scaledVideoURL,
|
||||
coverImageURL: coverImageURL,
|
||||
keyFrameTime: relativeKeyFrameTime,
|
||||
destinationURL: paths.workDir.appendingPathComponent("keyPhoto").appendingPathExtension("heic")
|
||||
)
|
||||
|
||||
progress?(LivePhotoBuildProgress(stage: .writePhotoMetadata, fraction: 0))
|
||||
guard let pairedImageURL = addAssetID(
|
||||
assetIdentifier,
|
||||
toImage: keyPhotoURL,
|
||||
saveTo: paths.photoURL
|
||||
) else {
|
||||
throw AppError(code: "LPB-201", stage: .writePhotoMetadata, message: "封面生成失败", underlyingErrorDescription: nil, suggestedActions: ["缩短时长", "降低分辨率", "重试"])
|
||||
// 内部函数:清理临时文件
|
||||
func cleanupTempFiles() {
|
||||
try? FileManager.default.removeItem(at: trimmedURL)
|
||||
try? FileManager.default.removeItem(at: scaledURL)
|
||||
try? FileManager.default.removeItem(at: keyPhotoTempURL)
|
||||
}
|
||||
|
||||
progress?(LivePhotoBuildProgress(stage: .writeVideoMetadata, fraction: 0))
|
||||
let pairedVideoURL = try await addAssetID(assetIdentifier, toVideo: scaledVideoURL, saveTo: paths.pairedVideoURL, stillImageTimeSeconds: relativeKeyFrameTime, progress: { p in
|
||||
progress?(LivePhotoBuildProgress(stage: .writeVideoMetadata, fraction: p))
|
||||
})
|
||||
do {
|
||||
progress?(LivePhotoBuildProgress(stage: .normalize, fraction: 0))
|
||||
let trimmedVideoURL = try await trimVideo(
|
||||
sourceURL: sourceVideoURL,
|
||||
trimStart: exportParams.trimStart,
|
||||
trimEnd: exportParams.trimEnd,
|
||||
destinationURL: trimmedURL
|
||||
)
|
||||
|
||||
logger.info("Generated Live Photo files:")
|
||||
logger.info(" Photo: \(pairedImageURL.path)")
|
||||
logger.info(" Video: \(pairedVideoURL.path)")
|
||||
logger.info(" AssetIdentifier: \(assetIdentifier)")
|
||||
// 关键:将视频变速到约 1 秒,与 metadata.mov 的时间标记匹配
|
||||
let targetDuration = CMTimeMake(value: 550, timescale: 600) // ~0.917 秒,与 live-wallpaper 一致
|
||||
progress?(LivePhotoBuildProgress(stage: .normalize, fraction: 0.5))
|
||||
let scaledVideoURL = try await scaleVideoToTargetDuration(
|
||||
sourceURL: trimmedVideoURL,
|
||||
targetDuration: targetDuration,
|
||||
cropRect: exportParams.cropRect,
|
||||
aspectRatio: exportParams.aspectRatio,
|
||||
maxDimension: exportParams.maxDimension,
|
||||
targetFrameRate: exportParams.targetFrameRate,
|
||||
destinationURL: scaledURL
|
||||
)
|
||||
|
||||
return LivePhotoBuildOutput(workId: workId, assetIdentifier: assetIdentifier, pairedImageURL: pairedImageURL, pairedVideoURL: pairedVideoURL)
|
||||
// 计算关键帧时间:目标视频的中间位置(0.5 秒处,与 metadata.mov 的 still-image-time 匹配)
|
||||
let relativeKeyFrameTime = 0.5 // 固定为 0.5 秒,与 metadata.mov 匹配
|
||||
|
||||
progress?(LivePhotoBuildProgress(stage: .extractKeyFrame, fraction: 0))
|
||||
let keyPhotoURL = try await resolveKeyPhotoURL(
|
||||
videoURL: scaledVideoURL,
|
||||
coverImageURL: coverImageURL,
|
||||
keyFrameTime: relativeKeyFrameTime,
|
||||
destinationURL: keyPhotoTempURL,
|
||||
aiEnhanceConfig: exportParams.aiEnhanceConfig,
|
||||
progress: progress
|
||||
)
|
||||
|
||||
progress?(LivePhotoBuildProgress(stage: .writePhotoMetadata, fraction: 0))
|
||||
guard let pairedImageURL = addAssetID(
|
||||
assetIdentifier,
|
||||
toImage: keyPhotoURL,
|
||||
saveTo: paths.photoURL
|
||||
) else {
|
||||
cleanupTempFiles()
|
||||
throw AppError(code: "LPB-201", stage: .writePhotoMetadata, message: "封面生成失败", underlyingErrorDescription: nil, suggestedActions: ["缩短时长", "降低分辨率", "重试"])
|
||||
}
|
||||
|
||||
progress?(LivePhotoBuildProgress(stage: .writeVideoMetadata, fraction: 0))
|
||||
let pairedVideoURL = try await addAssetID(assetIdentifier, toVideo: scaledVideoURL, saveTo: paths.pairedVideoURL, stillImageTimeSeconds: relativeKeyFrameTime, progress: { p in
|
||||
progress?(LivePhotoBuildProgress(stage: .writeVideoMetadata, fraction: p))
|
||||
})
|
||||
|
||||
// 清理临时文件(成功后)
|
||||
cleanupTempFiles()
|
||||
|
||||
logger.info("Generated Live Photo files:")
|
||||
logger.info(" Photo: \(pairedImageURL.path)")
|
||||
logger.info(" Video: \(pairedVideoURL.path)")
|
||||
logger.info(" AssetIdentifier: \(assetIdentifier)")
|
||||
|
||||
return LivePhotoBuildOutput(workId: workId, assetIdentifier: assetIdentifier, pairedImageURL: pairedImageURL, pairedVideoURL: pairedVideoURL)
|
||||
} catch {
|
||||
// 清理临时文件(失败后)
|
||||
cleanupTempFiles()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func trimVideo(sourceURL: URL, trimStart: Double, trimEnd: Double, destinationURL: URL) async throws -> URL {
|
||||
@@ -550,13 +595,15 @@ public actor LivePhotoBuilder {
|
||||
}
|
||||
|
||||
/// 将视频处理为 Live Photo 所需的格式
|
||||
/// 包括:时长变速到 ~0.917 秒、裁剪、尺寸调整到 1080x1920(或保持比例)、帧率转换为 60fps
|
||||
/// 完全对齐 live-wallpaper 项目的 accelerateVideo + resizeVideo 流程
|
||||
/// 包括:时长变速到 ~0.917 秒、裁剪、尺寸调整、帧率转换
|
||||
/// 优化:单次导出完成变速+裁剪+缩放(减少一次编码,降低内存峰值)
|
||||
private func scaleVideoToTargetDuration(
|
||||
sourceURL: URL,
|
||||
targetDuration: CMTime,
|
||||
cropRect: CropRect,
|
||||
aspectRatio: AspectRatioTemplate,
|
||||
maxDimension: Int,
|
||||
targetFrameRate: Int,
|
||||
destinationURL: URL
|
||||
) async throws -> URL {
|
||||
let asset = AVURLAsset(url: sourceURL)
|
||||
@@ -573,99 +620,64 @@ public actor LivePhotoBuilder {
|
||||
let naturalSize = try await videoTrack.load(.naturalSize)
|
||||
let preferredTransform = try await videoTrack.load(.preferredTransform)
|
||||
|
||||
// 计算应用 transform 后的尺寸(与 live-wallpaper resizeVideo 一致)
|
||||
// 计算应用 transform 后的尺寸
|
||||
let originalSize = CGSize(width: naturalSize.width, height: naturalSize.height)
|
||||
let transformedSize = originalSize.applying(preferredTransform)
|
||||
let absoluteSize = CGSize(width: abs(transformedSize.width), height: abs(transformedSize.height))
|
||||
|
||||
// 根据 maxDimension 计算基准宽度
|
||||
let baseWidth: CGFloat = maxDimension == 720 ? 720 : 1080
|
||||
let maxHeight: CGFloat = maxDimension == 720 ? 1280 : 1920
|
||||
|
||||
// 根据裁剪和比例计算输出尺寸
|
||||
let outputSize: CGSize
|
||||
if let targetRatio = aspectRatio.ratio {
|
||||
// 根据目标比例决定输出尺寸
|
||||
// 竖屏优先:宽度 1080,高度根据比例计算
|
||||
let width: CGFloat = 1080
|
||||
let width: CGFloat = baseWidth
|
||||
let height = width / targetRatio
|
||||
outputSize = CGSize(width: width, height: min(height, 1920))
|
||||
outputSize = CGSize(width: width, height: min(height, maxHeight))
|
||||
} 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")
|
||||
if FileManager.default.fileExists(atPath: acceleratedURL.path) {
|
||||
try FileManager.default.removeItem(at: acceleratedURL)
|
||||
outputSize = isLandscape ? CGSize(width: maxHeight, height: baseWidth) : CGSize(width: baseWidth, height: maxHeight)
|
||||
}
|
||||
|
||||
// 优化:单次导出完成变速+裁剪+缩放
|
||||
// 使用 AVMutableComposition 进行时间缩放,AVMutableVideoComposition 进行空间变换
|
||||
let composition = AVMutableComposition()
|
||||
guard let compositionVideoTrack = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) else {
|
||||
throw AppError(code: "LPB-101", stage: .normalize, message: "无法创建视频轨道", suggestedActions: ["重试"])
|
||||
}
|
||||
|
||||
try compositionVideoTrack.insertTimeRange(CMTimeRange(start: .zero, duration: originalDuration), of: videoTrack, at: .zero)
|
||||
// 变速:将原始时长缩放到目标时长(与 live-wallpaper accelerateVideo 第 287-288 行一致)
|
||||
// 变速:将原始时长缩放到目标时长
|
||||
compositionVideoTrack.scaleTimeRange(CMTimeRange(start: .zero, duration: originalDuration), toDuration: targetDuration)
|
||||
compositionVideoTrack.preferredTransform = preferredTransform
|
||||
|
||||
guard let accelerateExport = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetHighestQuality) else {
|
||||
guard let exportSession = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetHighestQuality) else {
|
||||
throw AppError(code: "LPB-101", stage: .normalize, message: "无法创建导出会话", suggestedActions: ["重试"])
|
||||
}
|
||||
|
||||
accelerateExport.outputURL = acceleratedURL
|
||||
accelerateExport.outputFileType = .mov
|
||||
|
||||
await accelerateExport.export()
|
||||
|
||||
guard accelerateExport.status == .completed else {
|
||||
throw AppError(code: "LPB-101", stage: .normalize, message: "视频变速失败", underlyingErrorDescription: accelerateExport.error?.localizedDescription, suggestedActions: ["重试"])
|
||||
}
|
||||
|
||||
// 步骤2:调整尺寸和帧率(对应 live-wallpaper 的 resizeVideo)
|
||||
let acceleratedAsset = AVURLAsset(url: acceleratedURL)
|
||||
guard let acceleratedVideoTrack = try await acceleratedAsset.loadTracks(withMediaType: .video).first else {
|
||||
return acceleratedURL
|
||||
}
|
||||
|
||||
let acceleratedDuration = try await acceleratedAsset.load(.duration)
|
||||
// 加载加速后视频轨道的属性
|
||||
let acceleratedNaturalSize = try await acceleratedVideoTrack.load(.naturalSize)
|
||||
let acceleratedTransform = try await acceleratedVideoTrack.load(.preferredTransform)
|
||||
|
||||
guard let resizeExport = AVAssetExportSession(asset: acceleratedAsset, presetName: AVAssetExportPresetHighestQuality) else {
|
||||
return acceleratedURL
|
||||
}
|
||||
|
||||
// 关键:使用 AVMutableVideoComposition 设置输出尺寸和帧率
|
||||
// 使用 AVMutableVideoComposition 设置输出尺寸和帧率
|
||||
let videoComposition = AVMutableVideoComposition()
|
||||
videoComposition.renderSize = outputSize
|
||||
// 关键:设置 60fps
|
||||
videoComposition.frameDuration = CMTime(value: 1, timescale: 60)
|
||||
videoComposition.frameDuration = CMTime(value: 1, timescale: CMTimeScale(targetFrameRate))
|
||||
|
||||
let instruction = AVMutableVideoCompositionInstruction()
|
||||
instruction.timeRange = CMTimeRange(start: .zero, duration: acceleratedDuration)
|
||||
instruction.timeRange = CMTimeRange(start: .zero, duration: targetDuration)
|
||||
|
||||
let layerInstruction = AVMutableVideoCompositionLayerInstruction(assetTrack: acceleratedVideoTrack)
|
||||
let layerInstruction = AVMutableVideoCompositionLayerInstruction(assetTrack: compositionVideoTrack)
|
||||
|
||||
// 关键修复:正确计算变换(支持裁剪)
|
||||
// 变换需要将 naturalSize 坐标系的像素映射到 outputSize 坐标系
|
||||
// 步骤:
|
||||
// 1. 应用 preferredTransform 旋转视频到正确方向
|
||||
// 2. 应用裁剪区域
|
||||
// 3. 根据旋转后的实际尺寸计算缩放和居中
|
||||
|
||||
// 计算旋转后的实际尺寸(用于确定缩放比例)
|
||||
let rotatedSize = acceleratedNaturalSize.applying(acceleratedTransform)
|
||||
// 计算旋转后的实际尺寸
|
||||
let rotatedSize = naturalSize.applying(preferredTransform)
|
||||
let rotatedAbsoluteSize = CGSize(width: abs(rotatedSize.width), height: abs(rotatedSize.height))
|
||||
|
||||
// 计算裁剪后的源区域尺寸
|
||||
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 actualScaleFactor = max(actualWidthRatio, actualHeightRatio)
|
||||
|
||||
let scaledWidth = rotatedAbsoluteSize.width * actualScaleFactor
|
||||
let scaledHeight = rotatedAbsoluteSize.height * actualScaleFactor
|
||||
@@ -679,35 +691,25 @@ public actor LivePhotoBuilder {
|
||||
let centerX = outputCenterX - cropCenterX
|
||||
let centerY = outputCenterY - cropCenterY
|
||||
|
||||
// 构建最终变换:
|
||||
// 对于 preferredTransform,它通常包含旋转+平移,平移部分是为了将旋转后的内容移到正坐标
|
||||
// 变换组合顺序(从右到左应用):
|
||||
// 1. 先应用 preferredTransform(旋转+平移到正坐标)
|
||||
// 2. 再缩放
|
||||
// 3. 最后平移到目标中心
|
||||
//
|
||||
// 使用 concatenating: A.concatenating(B) 表示先应用 A,再应用 B
|
||||
// 构建最终变换
|
||||
let scaleTransform = CGAffineTransform(scaleX: actualScaleFactor, y: actualScaleFactor)
|
||||
let translateToCenter = CGAffineTransform(translationX: centerX, y: centerY)
|
||||
let finalTransform = acceleratedTransform.concatenating(scaleTransform).concatenating(translateToCenter)
|
||||
let finalTransform = preferredTransform.concatenating(scaleTransform).concatenating(translateToCenter)
|
||||
|
||||
layerInstruction.setTransform(finalTransform, at: .zero)
|
||||
|
||||
instruction.layerInstructions = [layerInstruction]
|
||||
videoComposition.instructions = [instruction]
|
||||
|
||||
resizeExport.videoComposition = videoComposition
|
||||
resizeExport.outputURL = destinationURL
|
||||
resizeExport.outputFileType = .mov
|
||||
resizeExport.shouldOptimizeForNetworkUse = true
|
||||
exportSession.videoComposition = videoComposition
|
||||
exportSession.outputURL = destinationURL
|
||||
exportSession.outputFileType = .mov
|
||||
exportSession.shouldOptimizeForNetworkUse = true
|
||||
|
||||
await resizeExport.export()
|
||||
await exportSession.export()
|
||||
|
||||
// 清理临时文件
|
||||
try? FileManager.default.removeItem(at: acceleratedURL)
|
||||
|
||||
guard resizeExport.status == .completed else {
|
||||
throw AppError(code: "LPB-101", stage: .normalize, message: "视频尺寸调整失败", underlyingErrorDescription: resizeExport.error?.localizedDescription, suggestedActions: ["重试"])
|
||||
guard exportSession.status == .completed else {
|
||||
throw AppError(code: "LPB-101", stage: .normalize, message: "视频处理失败", underlyingErrorDescription: exportSession.error?.localizedDescription, suggestedActions: ["重试"])
|
||||
}
|
||||
|
||||
return destinationURL
|
||||
@@ -717,7 +719,9 @@ public actor LivePhotoBuilder {
|
||||
videoURL: URL,
|
||||
coverImageURL: URL?,
|
||||
keyFrameTime: Double,
|
||||
destinationURL: URL
|
||||
destinationURL: URL,
|
||||
aiEnhanceConfig: AIEnhanceConfig = .disabled,
|
||||
progress: (@Sendable (LivePhotoBuildProgress) -> Void)? = nil
|
||||
) async throws -> URL {
|
||||
// 最大分辨率限制(对标竞品 1080p)
|
||||
let maxDimension = 1920
|
||||
@@ -736,60 +740,75 @@ public actor LivePhotoBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
// 内部函数:缩放图像
|
||||
func scaleImage(_ image: CGImage, maxDim: Int) -> CGImage {
|
||||
let width = image.width
|
||||
let height = image.height
|
||||
let maxSide = max(width, height)
|
||||
if maxSide <= maxDim { return image }
|
||||
|
||||
let scale = CGFloat(maxDim) / CGFloat(maxSide)
|
||||
let newWidth = Int(CGFloat(width) * scale)
|
||||
let newHeight = Int(CGFloat(height) * scale)
|
||||
|
||||
guard let context = CGContext(
|
||||
data: nil, width: newWidth, height: newHeight,
|
||||
bitsPerComponent: 8, bytesPerRow: 0,
|
||||
space: CGColorSpaceCreateDeviceRGB(),
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
) else { return image }
|
||||
|
||||
context.interpolationQuality = .high
|
||||
context.draw(image, in: CGRect(x: 0, y: 0, width: newWidth, height: newHeight))
|
||||
return context.makeImage() ?? image
|
||||
// 内部函数:使用 CGImageSource 高效缩放图像(内存优化)
|
||||
func scaleImageFromSource(_ source: CGImageSource, maxDim: Int) -> CGImage? {
|
||||
let options: [CFString: Any] = [
|
||||
kCGImageSourceThumbnailMaxPixelSize: maxDim,
|
||||
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||
kCGImageSourceCreateThumbnailWithTransform: true
|
||||
]
|
||||
return CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary)
|
||||
}
|
||||
|
||||
var finalImage: CGImage
|
||||
|
||||
// 如果用户提供了封面图
|
||||
if let coverImageURL {
|
||||
guard let src = CGImageSourceCreateWithURL(coverImageURL as CFURL, nil),
|
||||
let img = CGImageSourceCreateImageAtIndex(src, 0, nil) else {
|
||||
guard let src = CGImageSourceCreateWithURL(coverImageURL as CFURL, nil) else {
|
||||
throw AppError(code: "LPB-201", stage: .extractKeyFrame, message: "封面读取失败", underlyingErrorDescription: nil, suggestedActions: ["更换封面图", "重试"])
|
||||
}
|
||||
let scaledImg = scaleImage(img, maxDim: maxDimension)
|
||||
try writeHEIC(scaledImg, to: destinationURL)
|
||||
return destinationURL
|
||||
|
||||
// 使用 CGImageSource 高效缩放,无需加载完整图像到内存
|
||||
if let scaledImg = scaleImageFromSource(src, maxDim: maxDimension) {
|
||||
finalImage = scaledImg
|
||||
} else if let img = CGImageSourceCreateImageAtIndex(src, 0, nil) {
|
||||
// 回退:直接使用原图
|
||||
finalImage = img
|
||||
} else {
|
||||
throw AppError(code: "LPB-201", stage: .extractKeyFrame, message: "封面读取失败", underlyingErrorDescription: nil, suggestedActions: ["更换封面图", "重试"])
|
||||
}
|
||||
} else {
|
||||
// 从视频抽帧
|
||||
let asset = AVURLAsset(url: videoURL)
|
||||
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
||||
imageGenerator.appliesPreferredTrackTransform = true
|
||||
imageGenerator.requestedTimeToleranceAfter = CMTime(value: 1, timescale: 100)
|
||||
imageGenerator.requestedTimeToleranceBefore = CMTime(value: 1, timescale: 100)
|
||||
// 设置最大尺寸,让 AVAssetImageGenerator 自动缩放
|
||||
imageGenerator.maximumSize = CGSize(width: maxDimension, height: maxDimension)
|
||||
|
||||
let safeSeconds = max(0, min(keyFrameTime, max(0, asset.duration.seconds - 0.1)))
|
||||
let time = CMTime(seconds: safeSeconds, preferredTimescale: asset.duration.timescale)
|
||||
|
||||
do {
|
||||
finalImage = try imageGenerator.copyCGImage(at: time, actualTime: nil)
|
||||
} catch {
|
||||
throw AppError(code: "LPB-201", stage: .extractKeyFrame, message: "抽帧失败", underlyingErrorDescription: error.localizedDescription, suggestedActions: ["缩短时长", "降低分辨率", "重试"])
|
||||
}
|
||||
}
|
||||
|
||||
// 从视频抽帧
|
||||
let asset = AVURLAsset(url: videoURL)
|
||||
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
||||
imageGenerator.appliesPreferredTrackTransform = true
|
||||
imageGenerator.requestedTimeToleranceAfter = CMTime(value: 1, timescale: 100)
|
||||
imageGenerator.requestedTimeToleranceBefore = CMTime(value: 1, timescale: 100)
|
||||
// 设置最大尺寸,让 AVAssetImageGenerator 自动缩放
|
||||
imageGenerator.maximumSize = CGSize(width: maxDimension, height: maxDimension)
|
||||
// AI 超分辨率增强(如果启用)
|
||||
if aiEnhanceConfig.enabled && AIEnhancer.isAvailable() {
|
||||
progress?(LivePhotoBuildProgress(stage: .aiEnhance, fraction: 0))
|
||||
logger.info("Starting AI enhancement for cover image: \(finalImage.width)x\(finalImage.height)")
|
||||
|
||||
let safeSeconds = max(0, min(keyFrameTime, max(0, asset.duration.seconds - 0.1)))
|
||||
let time = CMTime(seconds: safeSeconds, preferredTimescale: asset.duration.timescale)
|
||||
do {
|
||||
let enhancer = AIEnhancer(config: aiEnhanceConfig)
|
||||
try await enhancer.preloadModel()
|
||||
|
||||
let cgImage: CGImage
|
||||
do {
|
||||
cgImage = try imageGenerator.copyCGImage(at: time, actualTime: nil)
|
||||
} catch {
|
||||
throw AppError(code: "LPB-201", stage: .extractKeyFrame, message: "抽帧失败", underlyingErrorDescription: error.localizedDescription, suggestedActions: ["缩短时长", "降低分辨率", "重试"])
|
||||
let result = try await enhancer.enhance(image: finalImage) { p in
|
||||
progress?(LivePhotoBuildProgress(stage: .aiEnhance, fraction: p))
|
||||
}
|
||||
|
||||
finalImage = result.enhancedImage
|
||||
logger.info("AI enhancement complete: \(Int(result.originalSize.width))x\(Int(result.originalSize.height)) -> \(Int(result.enhancedSize.width))x\(Int(result.enhancedSize.height)) in \(Int(result.processingTimeMs))ms")
|
||||
} catch {
|
||||
// AI 增强失败时静默降级,使用原图
|
||||
logger.error("AI enhancement failed, using original image: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
try writeHEIC(cgImage, to: destinationURL)
|
||||
try writeHEIC(finalImage, to: destinationURL)
|
||||
return destinationURL
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user