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:
empty
2025-12-16 10:24:31 +08:00
parent 64cdb82459
commit 5aba93e967
46 changed files with 5279 additions and 421 deletions

View File

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