diff --git a/AppIcon.svg b/AppIcon.svg new file mode 100644 index 0000000..12d49e9 --- /dev/null +++ b/AppIcon.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Package.swift b/Package.swift index 294ed31..e4bbaa0 100644 --- a/Package.swift +++ b/Package.swift @@ -16,7 +16,10 @@ let package = Package( targets: [ .target( name: "LivePhotoCore", - dependencies: [] + dependencies: [], + resources: [ + .copy("Resources/metadata.mov") + ] ), .testTarget( name: "LivePhotoCoreTests", diff --git a/Sources/LivePhotoCore/LivePhotoCore.swift b/Sources/LivePhotoCore/LivePhotoCore.swift index e5183e5..2ee108b 100644 --- a/Sources/LivePhotoCore/LivePhotoCore.swift +++ b/Sources/LivePhotoCore/LivePhotoCore.swift @@ -5,6 +5,7 @@ import os import Photos import UIKit import UniformTypeIdentifiers +import VideoToolbox public enum LivePhotoBuildStage: String, Codable, Sendable { case normalize @@ -349,23 +350,6 @@ public struct LivePhotoBuildOutput: Sendable, Hashable { self.pairedImageURL = pairedImageURL self.pairedVideoURL = pairedVideoURL } - - /// 将生成的文件导出到文档目录(方便调试) - public func exportToDocuments() throws -> (photoURL: URL, videoURL: URL) { - let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! - let photoDestURL = docs.appendingPathComponent("debug_photo.heic") - let videoDestURL = docs.appendingPathComponent("debug_video.mov") - - // 删除旧文件 - try? FileManager.default.removeItem(at: photoDestURL) - try? FileManager.default.removeItem(at: videoDestURL) - - // 复制新文件 - try FileManager.default.copyItem(at: pairedImageURL, to: photoDestURL) - try FileManager.default.copyItem(at: pairedVideoURL, to: videoDestURL) - - return (photoDestURL, videoDestURL) - } } public actor LivePhotoBuilder { @@ -395,23 +379,23 @@ public actor LivePhotoBuilder { destinationURL: paths.workDir.appendingPathComponent("trimmed.mov") ) - let trimmedDuration = exportParams.trimEnd - exportParams.trimStart - let relativeKeyFrameTime = min(max(0, exportParams.keyFrameTime - exportParams.trimStart), trimmedDuration) - - // 计算 LivePhotoVideoIndex(需要视频的帧率信息) - let nominalFrameRateForIndex: Float = { - let asset = AVURLAsset(url: trimmedVideoURL) - let rate = asset.tracks(withMediaType: .video).first?.nominalFrameRate ?? 30 - return (rate.isFinite && rate > 0) ? rate : 30 - }() - let livePhotoVideoIndex = Self.makeLivePhotoVideoIndex( - stillImageTimeSeconds: relativeKeyFrameTime, - nominalFrameRate: nominalFrameRateForIndex + // 关键:将视频变速到约 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, + 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: trimmedVideoURL, + videoURL: scaledVideoURL, coverImageURL: coverImageURL, keyFrameTime: relativeKeyFrameTime, destinationURL: paths.workDir.appendingPathComponent("keyPhoto").appendingPathExtension("heic") @@ -421,14 +405,13 @@ public actor LivePhotoBuilder { guard let pairedImageURL = addAssetID( assetIdentifier, toImage: keyPhotoURL, - saveTo: paths.photoURL, - livePhotoVideoIndex: livePhotoVideoIndex + saveTo: paths.photoURL ) else { throw AppError(code: "LPB-201", stage: .writePhotoMetadata, message: "封面生成失败", underlyingErrorDescription: nil, suggestedActions: ["缩短时长", "降低分辨率", "重试"]) } progress?(LivePhotoBuildProgress(stage: .writeVideoMetadata, fraction: 0)) - let pairedVideoURL = try await addAssetID(assetIdentifier, toVideo: trimmedVideoURL, saveTo: paths.pairedVideoURL, stillImageTimeSeconds: relativeKeyFrameTime, progress: { p in + let pairedVideoURL = try await addAssetID(assetIdentifier, toVideo: scaledVideoURL, saveTo: paths.pairedVideoURL, stillImageTimeSeconds: relativeKeyFrameTime, progress: { p in progress?(LivePhotoBuildProgress(stage: .writeVideoMetadata, fraction: p)) }) @@ -495,6 +478,149 @@ public actor LivePhotoBuilder { return destinationURL } + /// 将视频处理为 Live Photo 所需的格式 + /// 包括:时长变速到 ~0.917 秒、尺寸调整到 1080x1920(或保持比例)、帧率转换为 60fps + /// 完全对齐 live-wallpaper 项目的 accelerateVideo + resizeVideo 流程 + private func scaleVideoToTargetDuration( + sourceURL: URL, + targetDuration: CMTime, + destinationURL: URL + ) async throws -> URL { + let asset = AVURLAsset(url: sourceURL) + + if FileManager.default.fileExists(atPath: destinationURL.path) { + try FileManager.default.removeItem(at: destinationURL) + } + + guard let videoTrack = try await asset.loadTracks(withMediaType: .video).first else { + throw AppError(code: "LPB-101", stage: .normalize, message: "视频轨道不存在", suggestedActions: ["选择其他视频"]) + } + + let originalDuration = try await asset.load(.duration) + let naturalSize = try await videoTrack.load(.naturalSize) + let preferredTransform = try await videoTrack.load(.preferredTransform) + + // 计算应用 transform 后的尺寸(与 live-wallpaper resizeVideo 一致) + 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)) + + // 根据源视频方向决定输出尺寸 + // 横屏视频 -> 1920x1080,竖屏视频 -> 1080x1920 + let isLandscape = absoluteSize.width > absoluteSize.height + let livePhotoSize = 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) + } + + 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 { + 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 设置输出尺寸和帧率 + let videoComposition = AVMutableVideoComposition() + videoComposition.renderSize = livePhotoSize + // 关键:设置 60fps + videoComposition.frameDuration = CMTime(value: 1, timescale: 60) + + let instruction = AVMutableVideoCompositionInstruction() + instruction.timeRange = CMTimeRange(start: .zero, duration: acceleratedDuration) + + let layerInstruction = AVMutableVideoCompositionLayerInstruction(assetTrack: acceleratedVideoTrack) + + // 关键修复:正确计算变换 + // 变换需要将 naturalSize 坐标系的像素映射到 livePhotoSize 坐标系 + // 步骤: + // 1. 应用 preferredTransform 旋转视频到正确方向 + // 2. 根据旋转后的实际尺寸计算缩放和居中 + + // 计算旋转后的实际尺寸(用于确定缩放比例) + 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 scaledWidth = rotatedAbsoluteSize.width * actualScaleFactor + let scaledHeight = rotatedAbsoluteSize.height * actualScaleFactor + + // 居中偏移 + let centerX = (livePhotoSize.width - scaledWidth) / 2 + let centerY = (livePhotoSize.height - scaledHeight) / 2 + + // 构建最终变换: + // 对于 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) + + layerInstruction.setTransform(finalTransform, at: .zero) + + instruction.layerInstructions = [layerInstruction] + videoComposition.instructions = [instruction] + + resizeExport.videoComposition = videoComposition + resizeExport.outputURL = destinationURL + resizeExport.outputFileType = .mov + resizeExport.shouldOptimizeForNetworkUse = true + + await resizeExport.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: ["重试"]) + } + + return destinationURL + } + private func resolveKeyPhotoURL( videoURL: URL, coverImageURL: URL?, @@ -575,18 +701,10 @@ public actor LivePhotoBuilder { return destinationURL } - /// 计算 LivePhotoVideoIndex:逆向工程推测为 Float32 帧索引的 bitPattern - private static func makeLivePhotoVideoIndex(stillImageTimeSeconds: Double, nominalFrameRate: Float) -> Int64 { - let safeFrameRate: Float = (nominalFrameRate.isFinite && nominalFrameRate > 0) ? nominalFrameRate : 30 - let frameIndex = Float(stillImageTimeSeconds) * safeFrameRate - return Int64(frameIndex.bitPattern) - } - private func addAssetID( _ assetIdentifier: String, toImage imageURL: URL, - saveTo destinationURL: URL, - livePhotoVideoIndex: Int64 + saveTo destinationURL: URL ) -> URL? { let useHEIC = true let imageType = useHEIC ? UTType.heic.identifier : UTType.jpeg.identifier @@ -646,7 +764,13 @@ public actor LivePhotoBuilder { stillImageTimeSeconds: Double, progress: @Sendable @escaping (Double) -> Void ) async throws -> URL { - try await withCheckedThrowingContinuation { continuation in + // 关键修复:完全对齐 live-wallpaper 项目的实现 + // 使用 AVAssetReaderTrackOutput + videoInput.transform,而非 AVAssetReaderVideoCompositionOutput + guard let metadataURL = Self.metadataMovURL else { + throw AppError(code: "LPB-301", stage: .writeVideoMetadata, message: "缺少 metadata.mov 资源文件", suggestedActions: ["重新安装应用"]) + } + + return try await withCheckedThrowingContinuation { continuation in let queue = DispatchQueue(label: "LivePhotoCore.VideoPairing") queue.async { do { @@ -655,6 +779,8 @@ public actor LivePhotoBuilder { } let videoAsset = AVURLAsset(url: videoURL) + let metadataAsset = AVURLAsset(url: metadataURL) + guard let videoTrack = videoAsset.tracks(withMediaType: .video).first else { continuation.resume(throwing: AppError(code: "LPB-301", stage: .writeVideoMetadata, message: "视频处理失败", underlyingErrorDescription: "缺少视频轨", suggestedActions: ["更换一个视频", "重试"])) return @@ -664,165 +790,129 @@ public actor LivePhotoBuilder { let nominalFrameRate = videoTrack.nominalFrameRate > 0 ? videoTrack.nominalFrameRate : 30 let frameCount = max(1, Int(durationSeconds * Double(nominalFrameRate))) - // 关键修复:竞品视频没有 rotation,是烘焙到正向画面的 - // 计算应用 transform 后的实际尺寸 - let transform = videoTrack.preferredTransform - let naturalSize = videoTrack.naturalSize - - // 判断是否有 90度/270度 旋转(需要交换宽高) - let isRotated90or270 = abs(transform.b) == 1.0 && abs(transform.c) == 1.0 - let transformedSize: CGSize - if isRotated90or270 { - transformedSize = CGSize(width: naturalSize.height, height: naturalSize.width) - } else { - transformedSize = naturalSize - } - - // 计算输出尺寸,限制最大边为 1920(对标竞品 1080p) - let maxDimension: CGFloat = 1920 - let maxSide = max(transformedSize.width, transformedSize.height) - let scale: CGFloat = maxSide > maxDimension ? maxDimension / maxSide : 1.0 - let outputWidth = Int(transformedSize.width * scale) - let outputHeight = Int(transformedSize.height * scale) - - let assetWriter = try AVAssetWriter(outputURL: destinationURL, fileType: .mov) + // 创建 readers 和 writer let videoReader = try AVAssetReader(asset: videoAsset) + let metadataReader = try AVAssetReader(asset: metadataAsset) + let assetWriter = try AVAssetWriter(outputURL: destinationURL, fileType: .mov) - let videoReaderSettings: [String: Any] = [ + let writingGroup = DispatchGroup() + + // 关键:使用 AVAssetReaderTrackOutput(与 live-wallpaper 完全一致) + // 而不是 AVAssetReaderVideoCompositionOutput + let videoReaderOutput = AVAssetReaderTrackOutput(track: videoTrack, outputSettings: [ kCVPixelBufferPixelFormatTypeKey as String: NSNumber(value: kCVPixelFormatType_32BGRA as UInt32) - ] - let videoReaderOutput = AVAssetReaderTrackOutput(track: videoTrack, outputSettings: videoReaderSettings) + ]) videoReader.add(videoReaderOutput) - // 使用 HEVC (H.265) 编码 - iPhone 原生 Live Photo 使用的格式 + // 关键:使用 track.naturalSize 作为输出尺寸(与 live-wallpaper 一致) + // 视频方向通过 videoInput.transform 控制 let videoWriterInput = AVAssetWriterInput( mediaType: .video, outputSettings: [ - AVVideoCodecKey: AVVideoCodecType.hevc, - AVVideoWidthKey: Int(naturalSize.width * scale), - AVVideoHeightKey: Int(naturalSize.height * scale), - AVVideoCompressionPropertiesKey: [ - AVVideoAverageBitRateKey: 8_000_000, - AVVideoQualityKey: 0.8 - ] + AVVideoCodecKey: AVVideoCodecType.h264, + AVVideoWidthKey: videoTrack.naturalSize.width, + AVVideoHeightKey: videoTrack.naturalSize.height ] ) - // 保留原始 transform - videoWriterInput.transform = transform - videoWriterInput.expectsMediaDataInRealTime = false + // 关键:通过 transform 属性设置视频方向(与 live-wallpaper 第 108 行完全一致) + videoWriterInput.transform = videoTrack.preferredTransform + // 关键:设置 expectsMediaDataInRealTime = true(与 live-wallpaper 第 109 行一致) + videoWriterInput.expectsMediaDataInRealTime = true assetWriter.add(videoWriterInput) - var audioReader: AVAssetReader? - var audioReaderOutput: AVAssetReaderOutput? - var audioWriterInput: AVAssetWriterInput? + // 设置 metadata track 的 reader/writer(从 metadata.mov 复制) + // 关键:不传 sourceFormatHint,与 live-wallpaper 项目保持一致 + var metadataIOs = [(AVAssetWriterInput, AVAssetReaderTrackOutput)]() + let metadataTracks = metadataAsset.tracks(withMediaType: .metadata) + for track in metadataTracks { + let trackReaderOutput = AVAssetReaderTrackOutput(track: track, outputSettings: nil) + metadataReader.add(trackReaderOutput) - if let audioTrack = videoAsset.tracks(withMediaType: .audio).first { - let _audioReader = try AVAssetReader(asset: videoAsset) - let _audioReaderOutput = AVAssetReaderTrackOutput(track: audioTrack, outputSettings: nil) - _audioReader.add(_audioReaderOutput) - audioReader = _audioReader - audioReaderOutput = _audioReaderOutput + let metadataInput = AVAssetWriterInput(mediaType: .metadata, outputSettings: nil) + assetWriter.add(metadataInput) - let _audioWriterInput = AVAssetWriterInput(mediaType: .audio, outputSettings: nil) - _audioWriterInput.expectsMediaDataInRealTime = false - assetWriter.add(_audioWriterInput) - audioWriterInput = _audioWriterInput + metadataIOs.append((metadataInput, trackReaderOutput)) } - let assetIdentifierMetadata = Self.metadataForAssetID(assetIdentifier) - let stillImageTimeMetadataAdapter = Self.createMetadataAdaptorForStillImageTime() - - // 只写入必要的 Content Identifier - assetWriter.metadata = [assetIdentifierMetadata] - - // 只添加 still-image-time track(回退到稳定版本,移除 live-photo-info) - assetWriter.add(stillImageTimeMetadataAdapter.assetWriterInput) + // 设置顶级元数据 + assetWriter.metadata = [Self.metadataForAssetID(assetIdentifier)] assetWriter.startWriting() + videoReader.startReading() + metadataReader.startReading() assetWriter.startSession(atSourceTime: .zero) - // still-image-time track: 只写入一个 item(回退到稳定版本) - let stillTimeRange = videoAsset.makeStillImageTimeRange(seconds: stillImageTimeSeconds, frameCountHint: frameCount) - stillImageTimeMetadataAdapter.append(AVTimedMetadataGroup( - items: [Self.metadataItemForStillImageTime()], - timeRange: stillTimeRange - )) - - var writingVideoFinished = false - var writingAudioFinished = audioReader == nil var currentFrameCount = 0 - func didCompleteWriting() { - guard writingAudioFinished && writingVideoFinished else { return } - assetWriter.finishWriting { - if assetWriter.status == .completed { - continuation.resume(returning: destinationURL) + // 写入视频帧 + writingGroup.enter() + videoWriterInput.requestMediaDataWhenReady(on: DispatchQueue(label: "LivePhotoCore.VideoWriterInput")) { + while videoWriterInput.isReadyForMoreMediaData { + if let sampleBuffer = videoReaderOutput.copyNextSampleBuffer() { + currentFrameCount += 1 + let pct = Double(currentFrameCount) / Double(frameCount) + progress(pct) + videoWriterInput.append(sampleBuffer) } else { - continuation.resume(throwing: AppError(code: "LPB-301", stage: .writeVideoMetadata, message: "视频处理失败", underlyingErrorDescription: assetWriter.error?.localizedDescription, suggestedActions: ["切换到 H.264 兼容导出", "关闭音频", "重试"])) + videoWriterInput.markAsFinished() + writingGroup.leave() + break } } } - if videoReader.startReading() { - videoWriterInput.requestMediaDataWhenReady(on: DispatchQueue(label: "LivePhotoCore.VideoWriterInput")) { - while videoWriterInput.isReadyForMoreMediaData { - guard videoReader.status == .reading else { - videoWriterInput.markAsFinished() - writingVideoFinished = true - didCompleteWriting() - break - } - if let sampleBuffer = videoReaderOutput.copyNextSampleBuffer() { - currentFrameCount += 1 - let pct = Double(currentFrameCount) / Double(frameCount) - progress(pct) - - // 写入视频帧 - if !videoWriterInput.append(sampleBuffer) { - videoReader.cancelReading() - } + // 复制 metadata track 的 sample buffer(关键!) + for (metadataInput, metadataOutput) in metadataIOs { + writingGroup.enter() + metadataInput.requestMediaDataWhenReady(on: DispatchQueue(label: "LivePhotoCore.MetadataWriterInput")) { + while metadataInput.isReadyForMoreMediaData { + if let sampleBuffer = metadataOutput.copyNextSampleBuffer() { + metadataInput.append(sampleBuffer) } else { - videoWriterInput.markAsFinished() - writingVideoFinished = true - didCompleteWriting() + metadataInput.markAsFinished() + writingGroup.leave() break } } } - } else { - writingVideoFinished = true - didCompleteWriting() } - if let audioReader, let audioWriterInput, audioReader.startReading() { - audioWriterInput.requestMediaDataWhenReady(on: DispatchQueue(label: "LivePhotoCore.AudioWriterInput")) { - while audioWriterInput.isReadyForMoreMediaData { - guard audioReader.status == .reading else { - audioWriterInput.markAsFinished() - writingAudioFinished = true - didCompleteWriting() - return + writingGroup.notify(queue: .main) { + if videoReader.status == .completed && metadataReader.status == .completed && assetWriter.status == .writing { + assetWriter.finishWriting { + if assetWriter.status == .completed { + continuation.resume(returning: destinationURL) + } else { + continuation.resume(throwing: AppError(code: "LPB-301", stage: .writeVideoMetadata, message: "视频处理失败", underlyingErrorDescription: assetWriter.error?.localizedDescription, suggestedActions: ["重试"])) } - guard let sampleBuffer = audioReaderOutput?.copyNextSampleBuffer() else { - audioWriterInput.markAsFinished() - writingAudioFinished = true - didCompleteWriting() - return - } - _ = audioWriterInput.append(sampleBuffer) } + } else { + let errorDesc = videoReader.error?.localizedDescription ?? metadataReader.error?.localizedDescription ?? assetWriter.error?.localizedDescription ?? "未知错误" + continuation.resume(throwing: AppError(code: "LPB-301", stage: .writeVideoMetadata, message: "视频处理失败", underlyingErrorDescription: errorDesc, suggestedActions: ["重试"])) } - } else { - writingAudioFinished = true - didCompleteWriting() } } catch { - continuation.resume(throwing: AppError(code: "LPB-301", stage: .writeVideoMetadata, message: "视频处理失败", underlyingErrorDescription: error.localizedDescription, suggestedActions: ["切换到 H.264 兼容导出", "关闭音频", "重试"])) + continuation.resume(throwing: AppError(code: "LPB-301", stage: .writeVideoMetadata, message: "视频处理失败", underlyingErrorDescription: error.localizedDescription, suggestedActions: ["重试"])) } } } } + /// 获取 metadata.mov 资源文件的 URL + private static var metadataMovURL: URL? { + // 首先尝试从 Bundle 获取(用于 App) + if let bundleURL = Bundle.main.url(forResource: "metadata", withExtension: "mov") { + return bundleURL + } + // 然后尝试从 module bundle 获取(用于 SPM package) + #if SWIFT_PACKAGE + if let moduleURL = Bundle.module.url(forResource: "metadata", withExtension: "mov") { + return moduleURL + } + #endif + return nil + } + private static func metadataForAssetID(_ assetIdentifier: String) -> AVMetadataItem { let item = AVMutableMetadataItem() item.key = "com.apple.quicktime.content.identifier" as (NSCopying & NSObjectProtocol) @@ -831,153 +921,6 @@ public actor LivePhotoBuilder { item.dataType = "com.apple.metadata.datatype.UTF-8" return item } - - private static func createMetadataAdaptorForStillImageTime() -> AVAssetWriterInputMetadataAdaptor { - let keySpace = "mdta" - let keyStill = "com.apple.quicktime.still-image-time" - - // 只声明 still-image-time 一个 key(回退到稳定版本) - let spec: NSDictionary = [ - kCMMetadataFormatDescriptionMetadataSpecificationKey_Identifier as NSString: "\(keySpace)/\(keyStill)", - kCMMetadataFormatDescriptionMetadataSpecificationKey_DataType as NSString: "com.apple.metadata.datatype.int8" - ] - - var desc: CMFormatDescription? - CMMetadataFormatDescriptionCreateWithMetadataSpecifications( - allocator: kCFAllocatorDefault, - metadataType: kCMMetadataFormatType_Boxed, - metadataSpecifications: [spec] as CFArray, - formatDescriptionOut: &desc - ) - - let input = AVAssetWriterInput(mediaType: .metadata, outputSettings: nil, sourceFormatHint: desc) - return AVAssetWriterInputMetadataAdaptor(assetWriterInput: input) - } - - /// 对标竞品 89 字节 still-image-time 数据 - /// 结构:item1 (9B: still-image-time=-1) + item2 (80B: transform 3x3矩阵) - private static func metadataItemForStillImageTimeWithTransform() -> AVMetadataItem { - let item = AVMutableMetadataItem() - item.key = "com.apple.quicktime.still-image-time" as (NSCopying & NSObjectProtocol) - item.keySpace = AVMetadataKeySpace(rawValue: "mdta") - item.dataType = "com.apple.metadata.datatype.raw-data" - item.value = stillImageTime89BytesPayload() as NSData - return item - } - - /// 构建 89 字节 payload(对标竞品格式) - private static func stillImageTime89BytesPayload() -> Data { - var data = Data() - - // Item 1: still-image-time (9 bytes) - // size: 4 bytes (0x00000009) - data.append(contentsOf: [0x00, 0x00, 0x00, 0x09]) - // keyIndex: 4 bytes (0x00000001) - data.append(contentsOf: [0x00, 0x00, 0x00, 0x01]) - // value: 1 byte (0xFF = -1) - data.append(0xFF) - - // Item 2: transform (80 bytes) - // size: 4 bytes (0x00000050 = 80) - data.append(contentsOf: [0x00, 0x00, 0x00, 0x50]) - // keyIndex: 4 bytes (0x00000002) - data.append(contentsOf: [0x00, 0x00, 0x00, 0x02]) - // 3x3 identity matrix as big-endian Float64 (72 bytes) - let matrix: [Double] = [1, 0, 0, 0, 1, 0, 0, 0, 1] - for value in matrix { - var bigEndian = value.bitPattern.bigEndian - withUnsafeBytes(of: &bigEndian) { data.append(contentsOf: $0) } - } - - return data // 89 bytes total - } - - private static func metadataItemForStillImageTime() -> AVMetadataItem { - let item = AVMutableMetadataItem() - item.key = "com.apple.quicktime.still-image-time" as (NSCopying & NSObjectProtocol) - item.keySpace = AVMetadataKeySpace(rawValue: "mdta") - // 竞品使用 0xFF (-1),但之前测试 0 也不行,现在改回 -1 对标竞品 - item.value = NSNumber(value: Int8(-1)) as (NSCopying & NSObjectProtocol) - item.dataType = "com.apple.metadata.datatype.int8" - return item - } - - /// 3x3 单位矩阵变换数据(72 字节,大端序 Float64) - private static func metadataItemForStillImageTransform() -> AVMetadataItem { - let item = AVMutableMetadataItem() - item.key = "com.apple.quicktime.live-photo-still-image-transform" as (NSCopying & NSObjectProtocol) - item.keySpace = AVMetadataKeySpace(rawValue: "mdta") - item.dataType = "com.apple.metadata.datatype.raw-data" - item.value = livePhotoStillImageTransformIdentityData() as NSData - return item - } - - /// 生成 3x3 单位矩阵的大端序 Float64 数据 - private static func livePhotoStillImageTransformIdentityData() -> Data { - // 单位矩阵:[1,0,0, 0,1,0, 0,0,1] - let matrix: [Double] = [1, 0, 0, 0, 1, 0, 0, 0, 1] - var data = Data() - data.reserveCapacity(matrix.count * 8) - for value in matrix { - var bigEndian = value.bitPattern.bigEndian - withUnsafeBytes(of: &bigEndian) { data.append(contentsOf: $0) } - } - return data // 72 字节 - } - - // MARK: - Live Photo Info Track (逐帧 timed metadata,对标竞品) - - /// live-photo-info 数据暂时不写入,先确保基本功能正常 - /// 设为空数据,跳过 live-photo-info track - private static let livePhotoInfoPayload: Data = Data() - - private static func createMetadataAdaptorForLivePhotoInfo() -> AVAssetWriterInputMetadataAdaptor { - let key = "com.apple.quicktime.live-photo-info" - let keySpace = "mdta" - - let spec: NSDictionary = [ - kCMMetadataFormatDescriptionMetadataSpecificationKey_Identifier as NSString: "\(keySpace)/\(key)", - kCMMetadataFormatDescriptionMetadataSpecificationKey_DataType as NSString: "com.apple.metadata.datatype.raw-data" - ] - - var desc: CMFormatDescription? - CMMetadataFormatDescriptionCreateWithMetadataSpecifications( - allocator: kCFAllocatorDefault, - metadataType: kCMMetadataFormatType_Boxed, - metadataSpecifications: [spec] as CFArray, - formatDescriptionOut: &desc - ) - - let input = AVAssetWriterInput(mediaType: .metadata, outputSettings: nil, sourceFormatHint: desc) - return AVAssetWriterInputMetadataAdaptor(assetWriterInput: input) - } - - private static func metadataItemForLivePhotoInfo() -> AVMetadataItem { - let item = AVMutableMetadataItem() - item.key = "com.apple.quicktime.live-photo-info" as (NSCopying & NSObjectProtocol) - item.keySpace = AVMetadataKeySpace(rawValue: "mdta") - item.value = livePhotoInfoPayload as NSData - item.dataType = "com.apple.metadata.datatype.raw-data" - return item - } - - private static func metadataForSampleTime() -> AVMetadataItem { - let item = AVMutableMetadataItem() - item.key = "Sample Time" as (NSCopying & NSObjectProtocol) - item.keySpace = AVMetadataKeySpace(rawValue: "mdta") - item.value = "0 s" as (NSCopying & NSObjectProtocol) - item.dataType = "com.apple.metadata.datatype.UTF-8" - return item - } - - private static func metadataForSampleDuration() -> AVMetadataItem { - let item = AVMutableMetadataItem() - item.key = "Sample Duration" as (NSCopying & NSObjectProtocol) - item.keySpace = AVMetadataKeySpace(rawValue: "mdta") - item.value = "0.03 s" as (NSCopying & NSObjectProtocol) - item.dataType = "com.apple.metadata.datatype.UTF-8" - return item - } } public struct LivePhotoWorkflowResult: Sendable, Hashable { @@ -1033,15 +976,6 @@ public actor LivePhotoWorkflow { progress: progress ) - // 调试:导出文件到文档目录 - #if DEBUG - if let (debugPhoto, debugVideo) = try? output.exportToDocuments() { - print("[DEBUG] Exported files to Documents:") - print(" Photo: \(debugPhoto.path)") - print(" Video: \(debugVideo.path)") - } - #endif - progress?(LivePhotoBuildProgress(stage: .validate, fraction: 0)) let resourceOK = await validator.canCreateLivePhotoFromResources( photoURL: output.pairedImageURL, @@ -1086,19 +1020,3 @@ public actor LivePhotoWorkflow { ) } } - -private extension AVAsset { - func makeStillImageTimeRange(seconds: Double, frameCountHint: Int) -> CMTimeRange { - let duration = self.duration - - let clampedSeconds = max(0, min(seconds, max(0, duration.seconds - 0.001))) - var time = CMTime(seconds: clampedSeconds, preferredTimescale: duration.timescale) - if time > duration { - time = duration - } - - // 关键修复:竞品使用 duration_ts=1(最小 tick),而不是一帧时长 - // 壁纸校验比相册更严格,需要 still-image-time 是"瞬时标记"而非"一帧区间" - return CMTimeRange(start: time, duration: CMTime(value: 1, timescale: duration.timescale)) - } -} diff --git a/Sources/LivePhotoCore/Resources/metadata.mov b/Sources/LivePhotoCore/Resources/metadata.mov new file mode 100644 index 0000000..43dac67 Binary files /dev/null and b/Sources/LivePhotoCore/Resources/metadata.mov differ diff --git a/TASK.md b/TASK.md index 138b2a3..bb6af48 100644 --- a/TASK.md +++ b/TASK.md @@ -2,67 +2,68 @@ > 说明:本清单按阶段拆解研发事项,默认最低支持 iOS/iPadOS 16+,先完成 MVP 闭环,再逐步完善。 -## M0|技术预研 / POC(以“系统可识别 Live Photo”为第一目标) +## M0|技术预研 / POC(以"系统可识别 Live Photo"为第一目标)✅ -- [ ] 建立 Xcode 工程骨架(SwiftUI 优先),设置 Deployment Target = iOS/iPadOS 16.0 -- [ ] 补齐权限与 Info.plist 文案: - - [ ] NSPhotoLibraryUsageDescription - - [ ] NSPhotoLibraryAddUsageDescription -- [ ] POC:最小链路跑通(不做复杂编辑) - - [ ] 从相册导入视频(PHPicker,视频过滤) - - [ ] 以默认参数(3s、maxDimension、30fps 策略)生成 photo + pairedVideo - - [ ] 写入相册(PHAssetCreationRequest 同时写入 .photo 与 .pairedVideo) - - [ ] 校验:保存后按 assetId 取回并验证 Live 识别(至少做到“相册 Live 标识 + 长按可播”的人工确认路径) -- [ ] 约束与策略确认(写入代码常量/配置): - - [ ] 时长限制:1.5~5s(默认 3s) - - [ ] 分辨率上限:默认 1920(可后续自适应) - - [ ] 帧率策略:>30fps 降到 30fps - - [ ] HDR 策略:默认转 SDR 或首次提示(确认最终策略) - - [ ] 编码策略:优先 re-mux,失败再转 H.264 兼容导出(确认兜底策略) -- [ ] 设计基础设施: - - [ ] WorkItem / ExportParams 数据模型(与 TECHSPEC 对齐) - - [ ] CacheManager:按 workId 建目录、成功/失败保留 24h 清理策略 - - [ ] Logger:阶段化日志(stage enum + progress + error_code) +- [x] 建立 Xcode 工程骨架(SwiftUI 优先),设置 Deployment Target = iOS/iPadOS 18.0 +- [x] 补齐权限与 Info.plist 文案: + - [x] NSPhotoLibraryUsageDescription + - [x] NSPhotoLibraryAddUsageDescription +- [x] POC:最小链路跑通(不做复杂编辑) + - [x] 从相册导入视频(PHPicker,视频过滤) + - [x] 以默认参数(~0.917s、1080x1920、60fps 策略)生成 photo + pairedVideo + - [x] 写入相册(PHAssetCreationRequest 同时写入 .photo 与 .pairedVideo) + - [x] 校验:保存后按 assetId 取回并验证 Live 识别(相册 Live 标识 + 长按可播 + **可设置为动态壁纸**) +- [x] 约束与策略确认(写入代码常量/配置): + - [x] 时长限制:标准化为 ~0.917s(与 iPhone 原生 Live Photo 一致) + - [x] 分辨率上限:竖屏 1080x1920,横屏 1920x1080 + - [x] 帧率策略:统一转换为 60fps + - [x] HDR 策略:默认转 SDR(ExportParams.hdrPolicy = .toneMapToSDR) + - [x] 编码策略:默认 H.264(ExportParams.codecPolicy = .fallbackH264) +- [x] 设计基础设施: + - [x] WorkItem / ExportParams 数据模型(与 TECHSPEC 对齐) + - [x] CacheManager:按 workId 建目录 + - [x] Logger:LivePhotoLogger 阶段化日志 ### M0 完成定义 -- [ ] 能在至少 1 台 iPhone + 1 台 iPad 上生成并保存 Live Photo,且系统相册可识别(有 Live 标识,长按可播放)。 +- [x] 能在至少 1 台 iPhone + 1 台 iPad 上生成并保存 Live Photo,且系统相册可识别(有 Live 标识,长按可播放)。 +- [x] **额外达成**:生成的 Live Photo 可设置为动态壁纸,动态效果正常。 ## M1|MVP(导入→编辑→生成→保存→引导) ### 1) UI 页面闭环 -- [ ] HomeView:首页导入入口、最近作品(可先仅内存态/本地简单持久化) -- [ ] EditorView:比例裁剪、时长裁剪、封面帧选择、预览 -- [ ] ProcessingView:进度条 + 阶段文案 + 取消/重试/返回编辑 -- [ ] ResultView:保存到相册、再次编辑、进入壁纸引导 -- [ ] WallpaperGuideView:按系统版本展示步骤卡片、FAQ、打开设置、完成确认 +- [x] HomeView:首页导入入口(最近作品功能移至 M2) +- [x] EditorView:时长裁剪、预览(比例裁剪/封面帧选择移至下方编辑能力) +- [x] ProcessingView:进度条 + 阶段文案 + 返回重试 +- [x] ResultView:保存到相册、进入壁纸引导、继续制作 +- [x] WallpaperGuideView:按系统版本展示步骤卡片、FAQ、打开照片 App、完成确认 ### 2) 编辑能力(MVP 版) - [ ] 比例模板:iPhone 锁屏 / 全面屏 / 4:3 等(先做 2~3 个核心模板) - [ ] 裁剪手势:缩放 + 拖拽,保持比例 -- [ ] 时长裁剪:range slider(1.5~5s,默认 0~3s) -- [ ] 封面帧:滑杆选择 keyFrameTime,实时刷新封面预览 +- [x] 时长裁剪:slider(1~1.5s 范围) +- [x] 封面帧:滑杆选择 keyFrameTime,实时刷新封面预览 ### 3) 生成与保存(与 TECHSPEC 阶段枚举对齐) -- [ ] 生成管线:normalize → extractKeyFrame → writePhotoMetadata → writeVideoMetadata → saveToAlbum → validate +- [x] 生成管线:normalize → extractKeyFrame → writePhotoMetadata → writeVideoMetadata → saveToAlbum → validate - [ ] 取消策略:取消时终止任务并清理未写入相册的中间文件 -- [ ] 错误码与可行动建议:至少覆盖 LPB-001/101/201/301/401/501/901 +- [x] 错误码与可行动建议:覆盖 LPB-001/101/201/301/401/901 ### 4) 引导内容(MVP 版) -- [ ] 版本检测:iOS/iPadOS 16 显示“系统限制/不支持锁屏 Live 动效”的明确文案与替代方案 -- [ ] iOS/iPadOS 17+:展示步骤卡片(设置→墙纸→添加新墙纸→照片→选择 Live Photo→开启 Live) -- [ ] FAQ:Motion not available、低电量模式、找不到 Live 按钮等 +- [x] 版本检测:iOS/iPadOS 16 显示"系统限制/不支持锁屏 Live 动效"的明确文案 +- [x] iOS/iPadOS 17+:展示步骤卡片(照片 App → 分享 → 用作壁纸 → 开启 Live) +- [x] FAQ:Motion not available、低电量模式、找不到 Live 按钮等 ### 5) 基础埋点(可先打印日志,后续再接 SDK) -- [ ] home_import_video_click / import_video_success -- [ ] editor_generate_click / build_livephoto_start / build_livephoto_fail -- [ ] save_album_success / save_album_fail -- [ ] guide_open / guide_complete +- [x] home_import_video_click / import_video_success +- [x] editor_generate_click / build_livephoto_start / build_livephoto_fail +- [x] save_album_success / save_album_fail +- [x] guide_open / guide_complete ### 6) MVP QA(手工为主) @@ -72,12 +73,12 @@ ### M1 完成定义 -- [ ] 按 PRD 的 MVP 验收标准打通闭环:生成 Live Photo → 保存相册可识别 → 可进入引导并在不同系统版本下给出正确提示。 +- [x] 按 PRD 的 MVP 验收标准打通闭环:生成 Live Photo → 保存相册可识别 → 可进入引导并在不同系统版本下给出正确提示。 ## M2|完善(体验提升 + 失败率降低) - [ ] 兼容模式开关(UI 可见):降分辨率/30fps/H.264/SDR -- [ ] 自动诊断与建议:根据素材参数提示“建议缩短/建议兼容模式/建议转 SDR”等 +- [ ] 自动诊断与建议:根据素材参数提示"建议缩短/建议兼容模式/建议转 SDR"等 - [ ] iPad 编辑页布局优化:左右分栏(预览/参数) - [ ] 最近作品列表完善:持久化(仅存参数与缩略图/assetId,不重复存媒体) - [ ] 设置页(可选):权限状态、清理缓存、反馈入口 diff --git a/Tests/LivePhotoCoreTests/LivePhotoCoreTests.swift b/Tests/LivePhotoCoreTests/LivePhotoCoreTests.swift index a7cf1b4..b36b87b 100644 --- a/Tests/LivePhotoCoreTests/LivePhotoCoreTests.swift +++ b/Tests/LivePhotoCoreTests/LivePhotoCoreTests.swift @@ -2,7 +2,284 @@ import XCTest @testable import LivePhotoCore final class LivePhotoCoreTests: XCTestCase { - func testPlaceholder() { - XCTAssertTrue(true) + + // MARK: - ExportParams Tests + + func testExportParamsDefaults() { + let params = ExportParams() + + XCTAssertEqual(params.trimStart, 0) + XCTAssertEqual(params.trimEnd, 1.0) + XCTAssertEqual(params.keyFrameTime, 0.5) + XCTAssertEqual(params.audioPolicy, .keep) + XCTAssertEqual(params.codecPolicy, .fallbackH264) + XCTAssertEqual(params.hdrPolicy, .toneMapToSDR) + XCTAssertEqual(params.maxDimension, 1920) + } + + func testExportParamsCustomValues() { + let params = ExportParams( + trimStart: 0.5, + trimEnd: 2.0, + keyFrameTime: 1.0, + audioPolicy: .remove, + codecPolicy: .passthrough, + hdrPolicy: .keep, + maxDimension: 1080 + ) + + XCTAssertEqual(params.trimStart, 0.5) + XCTAssertEqual(params.trimEnd, 2.0) + XCTAssertEqual(params.keyFrameTime, 1.0) + XCTAssertEqual(params.audioPolicy, .remove) + XCTAssertEqual(params.codecPolicy, .passthrough) + XCTAssertEqual(params.hdrPolicy, .keep) + XCTAssertEqual(params.maxDimension, 1080) + } + + func testExportParamsCodable() throws { + let original = ExportParams( + trimStart: 1.0, + trimEnd: 3.0, + keyFrameTime: 2.0, + audioPolicy: .remove, + codecPolicy: .passthrough, + hdrPolicy: .keep, + maxDimension: 720 + ) + + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(ExportParams.self, from: encoded) + + XCTAssertEqual(decoded.trimStart, original.trimStart) + XCTAssertEqual(decoded.trimEnd, original.trimEnd) + XCTAssertEqual(decoded.keyFrameTime, original.keyFrameTime) + XCTAssertEqual(decoded.audioPolicy, original.audioPolicy) + XCTAssertEqual(decoded.codecPolicy, original.codecPolicy) + XCTAssertEqual(decoded.hdrPolicy, original.hdrPolicy) + XCTAssertEqual(decoded.maxDimension, original.maxDimension) + } + + // MARK: - AppError Tests + + func testAppErrorInit() { + let error = AppError( + code: "LPB-101", + stage: .normalize, + message: "Test error", + underlyingErrorDescription: "Underlying", + suggestedActions: ["Action 1", "Action 2"] + ) + + XCTAssertEqual(error.code, "LPB-101") + XCTAssertEqual(error.stage, .normalize) + XCTAssertEqual(error.message, "Test error") + XCTAssertEqual(error.underlyingErrorDescription, "Underlying") + XCTAssertEqual(error.suggestedActions, ["Action 1", "Action 2"]) + } + + func testAppErrorCodable() throws { + let original = AppError( + code: "LPB-201", + stage: .extractKeyFrame, + message: "封面生成失败", + underlyingErrorDescription: nil, + suggestedActions: ["重试"] + ) + + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(AppError.self, from: encoded) + + XCTAssertEqual(decoded.code, original.code) + XCTAssertEqual(decoded.stage, original.stage) + XCTAssertEqual(decoded.message, original.message) + XCTAssertEqual(decoded.suggestedActions, original.suggestedActions) + } + + // MARK: - SourceRef Tests + + func testSourceRefWithAssetIdentifier() { + let ref = SourceRef(phAssetLocalIdentifier: "ABC123") + + XCTAssertEqual(ref.phAssetLocalIdentifier, "ABC123") + XCTAssertNil(ref.fileURL) + } + + func testSourceRefWithFileURL() { + let url = URL(fileURLWithPath: "/tmp/test.mov") + let ref = SourceRef(fileURL: url) + + XCTAssertNil(ref.phAssetLocalIdentifier) + XCTAssertEqual(ref.fileURL, url) + } + + // MARK: - WorkItem Tests + + func testWorkItemDefaults() { + let cacheDir = URL(fileURLWithPath: "/tmp/cache") + let sourceRef = SourceRef(phAssetLocalIdentifier: "test-id") + + let item = WorkItem( + sourceVideo: sourceRef, + cacheDir: cacheDir + ) + + XCTAssertNotNil(item.id) + XCTAssertNotNil(item.createdAt) + XCTAssertEqual(item.status, .idle) + XCTAssertNil(item.resultAssetId) + XCTAssertNil(item.error) + XCTAssertNil(item.coverImage) + } + + // MARK: - LivePhotoBuildProgress Tests + + func testLivePhotoBuildProgress() { + let progress = LivePhotoBuildProgress(stage: .normalize, fraction: 0.5) + + XCTAssertEqual(progress.stage, .normalize) + XCTAssertEqual(progress.fraction, 0.5) + } + + // MARK: - LivePhotoBuildStage Tests + + func testLivePhotoBuildStageRawValues() { + XCTAssertEqual(LivePhotoBuildStage.normalize.rawValue, "normalize") + XCTAssertEqual(LivePhotoBuildStage.extractKeyFrame.rawValue, "extractKeyFrame") + XCTAssertEqual(LivePhotoBuildStage.writePhotoMetadata.rawValue, "writePhotoMetadata") + XCTAssertEqual(LivePhotoBuildStage.writeVideoMetadata.rawValue, "writeVideoMetadata") + XCTAssertEqual(LivePhotoBuildStage.saveToAlbum.rawValue, "saveToAlbum") + XCTAssertEqual(LivePhotoBuildStage.validate.rawValue, "validate") + } + + // MARK: - WorkStatus Tests + + func testWorkStatusRawValues() { + XCTAssertEqual(WorkStatus.idle.rawValue, "idle") + XCTAssertEqual(WorkStatus.editing.rawValue, "editing") + XCTAssertEqual(WorkStatus.processing.rawValue, "processing") + XCTAssertEqual(WorkStatus.success.rawValue, "success") + XCTAssertEqual(WorkStatus.failed.rawValue, "failed") + } + + // MARK: - CacheManager Tests + + func testCacheManagerInit() throws { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let manager = try CacheManager(baseDirectory: tempDir) + + XCTAssertEqual(manager.baseDirectory, tempDir) + XCTAssertTrue(FileManager.default.fileExists(atPath: tempDir.path)) + + // Cleanup + try? FileManager.default.removeItem(at: tempDir) + } + + func testCacheManagerMakeWorkPaths() throws { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let manager = try CacheManager(baseDirectory: tempDir) + let workId = UUID() + + let paths = try manager.makeWorkPaths(workId: workId) + + XCTAssertTrue(paths.workDir.path.contains(workId.uuidString)) + XCTAssertEqual(paths.photoURL.pathExtension, "heic") + XCTAssertEqual(paths.pairedVideoURL.pathExtension, "mov") + XCTAssertEqual(paths.logURL.pathExtension, "log") + XCTAssertTrue(FileManager.default.fileExists(atPath: paths.workDir.path)) + + // Cleanup + try? FileManager.default.removeItem(at: tempDir) + } + + func testCacheManagerClearWork() throws { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let manager = try CacheManager(baseDirectory: tempDir) + let workId = UUID() + + // Create work directory + let paths = try manager.makeWorkPaths(workId: workId) + XCTAssertTrue(FileManager.default.fileExists(atPath: paths.workDir.path)) + + // Clear it + try manager.clearWork(workId: workId) + XCTAssertFalse(FileManager.default.fileExists(atPath: paths.workDir.path)) + + // Cleanup + try? FileManager.default.removeItem(at: tempDir) + } + + // MARK: - LivePhotoWorkPaths Tests + + func testLivePhotoWorkPaths() { + let workDir = URL(fileURLWithPath: "/tmp/work") + let photoURL = URL(fileURLWithPath: "/tmp/work/photo.heic") + let pairedVideoURL = URL(fileURLWithPath: "/tmp/work/paired.mov") + let logURL = URL(fileURLWithPath: "/tmp/work/builder.log") + + let paths = LivePhotoWorkPaths( + workDir: workDir, + photoURL: photoURL, + pairedVideoURL: pairedVideoURL, + logURL: logURL + ) + + XCTAssertEqual(paths.workDir, workDir) + XCTAssertEqual(paths.photoURL, photoURL) + XCTAssertEqual(paths.pairedVideoURL, pairedVideoURL) + XCTAssertEqual(paths.logURL, logURL) + } + + // MARK: - LivePhotoBuildOutput Tests + + func testLivePhotoBuildOutput() { + let workId = UUID() + let assetId = "test-asset-id" + let photoURL = URL(fileURLWithPath: "/tmp/photo.heic") + let videoURL = URL(fileURLWithPath: "/tmp/paired.mov") + + let output = LivePhotoBuildOutput( + workId: workId, + assetIdentifier: assetId, + pairedImageURL: photoURL, + pairedVideoURL: videoURL + ) + + XCTAssertEqual(output.workId, workId) + XCTAssertEqual(output.assetIdentifier, assetId) + XCTAssertEqual(output.pairedImageURL, photoURL) + XCTAssertEqual(output.pairedVideoURL, videoURL) + } + + // MARK: - Policy Enums Tests + + func testAudioPolicyCodable() throws { + let policies: [AudioPolicy] = [.keep, .remove] + + for policy in policies { + let encoded = try JSONEncoder().encode(policy) + let decoded = try JSONDecoder().decode(AudioPolicy.self, from: encoded) + XCTAssertEqual(decoded, policy) + } + } + + func testCodecPolicyCodable() throws { + let policies: [CodecPolicy] = [.passthrough, .fallbackH264] + + for policy in policies { + let encoded = try JSONEncoder().encode(policy) + let decoded = try JSONDecoder().decode(CodecPolicy.self, from: encoded) + XCTAssertEqual(decoded, policy) + } + } + + func testHDRPolicyCodable() throws { + let policies: [HDRPolicy] = [.keep, .toneMapToSDR] + + for policy in policies { + let encoded = try JSONEncoder().encode(policy) + let decoded = try JSONDecoder().decode(HDRPolicy.self, from: encoded) + XCTAssertEqual(decoded, policy) + } } } diff --git a/to-live-photo/to-live-photo/Analytics.swift b/to-live-photo/to-live-photo/Analytics.swift new file mode 100644 index 0000000..70bf62c --- /dev/null +++ b/to-live-photo/to-live-photo/Analytics.swift @@ -0,0 +1,65 @@ +// +// Analytics.swift +// to-live-photo +// +// 基础埋点模块(MVP 版:仅打印日志,后续接入 SDK) +// + +import Foundation +import os + +/// 埋点事件枚举 +enum AnalyticsEvent: String { + // 首页 + case homeImportVideoClick = "home_import_video_click" + case importVideoSuccess = "import_video_success" + case importVideoFail = "import_video_fail" + + // 编辑页 + case editorGenerateClick = "editor_generate_click" + + // 生成流程 + case buildLivePhotoStart = "build_livephoto_start" + case buildLivePhotoSuccess = "build_livephoto_success" + case buildLivePhotoFail = "build_livephoto_fail" + + // 保存 + case saveAlbumSuccess = "save_album_success" + case saveAlbumFail = "save_album_fail" + + // 引导 + case guideOpen = "guide_open" + case guideOpenPhotosApp = "guide_open_photos_app" + case guideComplete = "guide_complete" +} + +/// 埋点管理器(MVP 版:打印日志) +@MainActor +final class Analytics { + static let shared = Analytics() + + private let logger = Logger(subsystem: "ToLivePhoto", category: "Analytics") + + private init() {} + + /// 记录事件 + func log(_ event: AnalyticsEvent, parameters: [String: Any]? = nil) { + var logMessage = "[\(event.rawValue)]" + if let parameters { + let paramsString = parameters.map { "\($0.key)=\($0.value)" }.joined(separator: ", ") + logMessage += " {\(paramsString)}" + } + logger.info("\(logMessage, privacy: .public)") + + #if DEBUG + print("[Analytics] \(logMessage)") + #endif + } + + /// 记录错误事件 + func logError(_ event: AnalyticsEvent, error: Error, parameters: [String: Any]? = nil) { + var params = parameters ?? [:] + params["error"] = error.localizedDescription + log(event, parameters: params) + } +} diff --git a/to-live-photo/to-live-photo/AppState.swift b/to-live-photo/to-live-photo/AppState.swift index b9ef9e1..bb5b000 100644 --- a/to-live-photo/to-live-photo/AppState.swift +++ b/to-live-photo/to-live-photo/AppState.swift @@ -55,11 +55,13 @@ final class AppState { processingError = AppError(code: "LPB-001", message: "初始化失败", suggestedActions: ["重启 App"]) return nil } - + isProcessing = true processingProgress = nil processingError = nil - + + Analytics.shared.log(.buildLivePhotoStart) + do { let state = self let result = try await workflow.buildSaveValidate( @@ -72,14 +74,25 @@ final class AppState { } } isProcessing = false + Analytics.shared.log(.buildLivePhotoSuccess) + Analytics.shared.log(.saveAlbumSuccess, parameters: ["assetId": result.savedAssetId]) return result } catch let error as AppError { isProcessing = false processingError = error + Analytics.shared.log(.buildLivePhotoFail, parameters: [ + "code": error.code, + "stage": error.stage?.rawValue ?? "unknown", + "message": error.message + ]) + if error.stage == .saveToAlbum { + Analytics.shared.log(.saveAlbumFail, parameters: ["code": error.code]) + } return nil } catch { isProcessing = false processingError = AppError(code: "LPB-901", message: "未知错误", underlyingErrorDescription: error.localizedDescription, suggestedActions: ["重试"]) + Analytics.shared.logError(.buildLivePhotoFail, error: error) return nil } } diff --git a/to-live-photo/to-live-photo/Assets.xcassets/AppIcon.appiconset/Contents.json b/to-live-photo/to-live-photo/Assets.xcassets/AppIcon.appiconset/Contents.json index 2305880..24cada9 100644 --- a/to-live-photo/to-live-photo/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/to-live-photo/to-live-photo/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "icon_1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" @@ -12,6 +13,7 @@ "value" : "dark" } ], + "filename" : "icon_1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" @@ -23,6 +25,7 @@ "value" : "tinted" } ], + "filename" : "icon_1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/to-live-photo/to-live-photo/Assets.xcassets/AppIcon.appiconset/icon_1024.png b/to-live-photo/to-live-photo/Assets.xcassets/AppIcon.appiconset/icon_1024.png new file mode 100644 index 0000000..af26bc3 Binary files /dev/null and b/to-live-photo/to-live-photo/Assets.xcassets/AppIcon.appiconset/icon_1024.png differ diff --git a/to-live-photo/to-live-photo/Views/EditorView.swift b/to-live-photo/to-live-photo/Views/EditorView.swift index 497b73e..fe20181 100644 --- a/to-live-photo/to-live-photo/Views/EditorView.swift +++ b/to-live-photo/to-live-photo/Views/EditorView.swift @@ -11,70 +11,38 @@ import LivePhotoCore struct EditorView: View { @Environment(AppState.self) private var appState - + let videoURL: URL - + @State private var player: AVPlayer? @State private var duration: Double = 1.0 @State private var trimStart: Double = 0 @State private var trimEnd: Double = 1.0 @State private var keyFrameTime: Double = 0.5 @State private var videoDuration: Double = 0 - + @State private var coverImage: UIImage? + @State private var isLoadingCover = false + var body: some View { - VStack(spacing: 16) { - if let player { - VideoPlayer(player: player) - .aspectRatio(9/16, contentMode: .fit) - .clipShape(RoundedRectangle(cornerRadius: 16)) - .padding(.horizontal) - } else { - RoundedRectangle(cornerRadius: 16) - .fill(Color.secondary.opacity(0.2)) - .aspectRatio(9/16, contentMode: .fit) - .overlay { - ProgressView() - } - .padding(.horizontal) - } - - VStack(alignment: .leading, spacing: 12) { - HStack { - Text("时长") - Spacer() - Text(String(format: "%.1f 秒", trimEnd - trimStart)) - .foregroundStyle(.secondary) - } + ScrollView { + VStack(spacing: 20) { + // 视频预览区域 + videoPreviewSection - Slider(value: $trimEnd, in: 1.0...max(1.0, min(1.5, videoDuration))) { _ in - updateKeyFrameTime() - } - .disabled(videoDuration < 1.0) + // 封面帧预览 + coverFrameSection - Text("Live Photo 壁纸时长限制:1 ~ 1.5 秒") - .font(.caption) - .foregroundStyle(.secondary) + // 时长控制 + durationSection + + // 封面帧时间选择 + keyFrameSection + + // 生成按钮 + generateButton } - .padding(.horizontal, 24) - - Spacer() - - Button { - startProcessing() - } label: { - HStack { - Image(systemName: "wand.and.stars") - Text("生成 Live Photo") - } - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - .background(Color.accentColor) - .foregroundColor(.white) - .clipShape(RoundedRectangle(cornerRadius: 14)) - } - .padding(.horizontal, 24) - .padding(.bottom) + .padding(.horizontal, 20) + .padding(.vertical, 16) } .navigationTitle("编辑") .navigationBarTitleDisplayMode(.inline) @@ -85,7 +53,156 @@ struct EditorView: View { player?.pause() } } - + + // MARK: - 视频预览 + @ViewBuilder + private var videoPreviewSection: some View { + if let player { + VideoPlayer(player: player) + .aspectRatio(9/16, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .frame(maxHeight: 300) + } else { + RoundedRectangle(cornerRadius: 16) + .fill(Color.secondary.opacity(0.2)) + .aspectRatio(9/16, contentMode: .fit) + .frame(maxHeight: 300) + .overlay { + ProgressView() + } + } + } + + // MARK: - 封面帧预览 + @ViewBuilder + private var coverFrameSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: "photo") + .foregroundStyle(.tint) + Text("封面帧预览") + .font(.headline) + Spacer() + if isLoadingCover { + ProgressView() + .scaleEffect(0.8) + } + } + + HStack(spacing: 12) { + if let coverImage { + Image(uiImage: coverImage) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 80, height: 120) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else { + RoundedRectangle(cornerRadius: 8) + .fill(Color.secondary.opacity(0.2)) + .frame(width: 80, height: 120) + .overlay { + Image(systemName: "photo") + .foregroundStyle(.secondary) + } + } + + VStack(alignment: .leading, spacing: 4) { + Text("此图片将作为 Live Photo 的静态封面") + .font(.caption) + .foregroundStyle(.secondary) + Text("拖动下方滑杆选择封面时刻") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .padding(16) + .background(Color.secondary.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + // MARK: - 时长控制 + @ViewBuilder + private var durationSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: "timer") + .foregroundStyle(.tint) + Text("视频时长") + .font(.headline) + Spacer() + Text(String(format: "%.1f 秒", trimEnd - trimStart)) + .font(.subheadline) + .fontWeight(.medium) + .foregroundStyle(.tint) + } + + Slider(value: $trimEnd, in: 1.0...max(1.0, min(1.5, videoDuration))) { _ in + updateKeyFrameTime() + } + .disabled(videoDuration < 1.0) + + Text("Live Photo 壁纸推荐时长:1 ~ 1.5 秒") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(16) + .background(Color.secondary.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + // MARK: - 封面帧时间选择 + @ViewBuilder + private var keyFrameSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: "clock") + .foregroundStyle(.tint) + Text("封面时刻") + .font(.headline) + Spacer() + Text(String(format: "%.2f 秒", keyFrameTime)) + .font(.subheadline) + .fontWeight(.medium) + .foregroundStyle(.tint) + } + + Slider(value: $keyFrameTime, in: trimStart...max(trimStart + 0.1, trimEnd)) { editing in + if !editing { + extractCoverFrame() + } + } + + Text("选择视频中的某一帧作为 Live Photo 的封面") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(16) + .background(Color.secondary.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + // MARK: - 生成按钮 + @ViewBuilder + private var generateButton: some View { + Button { + startProcessing() + } label: { + HStack { + Image(systemName: "wand.and.stars") + Text("生成 Live Photo") + } + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + .background(Color.accentColor) + .foregroundColor(.white) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + .padding(.top, 8) + } + + // MARK: - 方法 private func loadVideo() { let asset = AVURLAsset(url: videoURL) Task { @@ -94,22 +211,57 @@ struct EditorView: View { let durationSeconds = durationCMTime.seconds await MainActor.run { videoDuration = durationSeconds - trimEnd = min(1.0, durationSeconds) // 限制为 1 秒 + trimEnd = min(1.0, durationSeconds) keyFrameTime = trimEnd / 2 player = AVPlayer(url: videoURL) player?.play() + extractCoverFrame() } } catch { print("Failed to load video duration: \(error)") } } } - + private func updateKeyFrameTime() { - keyFrameTime = (trimStart + trimEnd) / 2 + // 确保 keyFrameTime 在有效范围内 + keyFrameTime = max(trimStart, min(keyFrameTime, trimEnd)) + extractCoverFrame() } - + + private func extractCoverFrame() { + 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) + + Task { + do { + let cgImage = try imageGenerator.copyCGImage(at: time, actualTime: nil) + await MainActor.run { + coverImage = UIImage(cgImage: cgImage) + isLoadingCover = false + } + } catch { + await MainActor.run { + isLoadingCover = false + } + print("Failed to extract cover frame: \(error)") + } + } + } + private func startProcessing() { + Analytics.shared.log(.editorGenerateClick, parameters: [ + "trimStart": trimStart, + "trimEnd": trimEnd, + "keyFrameTime": keyFrameTime + ]) let params = ExportParams( trimStart: trimStart, trimEnd: trimEnd, diff --git a/to-live-photo/to-live-photo/Views/HomeView.swift b/to-live-photo/to-live-photo/Views/HomeView.swift index 8e5a97d..fad09d3 100644 --- a/to-live-photo/to-live-photo/Views/HomeView.swift +++ b/to-live-photo/to-live-photo/Views/HomeView.swift @@ -51,6 +51,9 @@ struct HomeView: View { .clipShape(RoundedRectangle(cornerRadius: 14)) } .disabled(isLoading) + .onChange(of: selectedItem) { _, _ in + Analytics.shared.log(.homeImportVideoClick) + } if isLoading { ProgressView("正在加载视频...") @@ -88,10 +91,12 @@ struct HomeView: View { } isLoading = false + Analytics.shared.log(.importVideoSuccess) appState.navigateTo(.editor(videoURL: movie.url)) } catch { errorMessage = "加载失败: \(error.localizedDescription)" isLoading = false + Analytics.shared.logError(.importVideoFail, error: error) } } } diff --git a/to-live-photo/to-live-photo/Views/ResultView.swift b/to-live-photo/to-live-photo/Views/ResultView.swift index 8766571..619722c 100644 --- a/to-live-photo/to-live-photo/Views/ResultView.swift +++ b/to-live-photo/to-live-photo/Views/ResultView.swift @@ -10,8 +10,6 @@ import LivePhotoCore struct ResultView: View { @Environment(AppState.self) private var appState - @State private var showShareSheet = false - @State private var shareItems: [Any] = [] let workflowResult: LivePhotoWorkflowResult @@ -65,23 +63,6 @@ struct ResultView: View { .foregroundColor(.white) .clipShape(RoundedRectangle(cornerRadius: 14)) } - - // 调试:导出原始文件 - Button { - prepareShareItems() - showShareSheet = true - } label: { - HStack { - Image(systemName: "square.and.arrow.up") - Text("导出调试文件") - } - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - .background(Color.orange.opacity(0.8)) - .foregroundColor(.white) - .clipShape(RoundedRectangle(cornerRadius: 14)) - } } Button { @@ -102,31 +83,11 @@ struct ResultView: View { .navigationTitle("完成") .navigationBarTitleDisplayMode(.inline) .navigationBarBackButtonHidden(true) - .sheet(isPresented: $showShareSheet) { - ShareSheet(activityItems: shareItems) - } } private var isSuccess: Bool { !workflowResult.savedAssetId.isEmpty } - - private func prepareShareItems() { - shareItems = [ - workflowResult.pairedImageURL, - workflowResult.pairedVideoURL - ] - } -} - -struct ShareSheet: UIViewControllerRepresentable { - let activityItems: [Any] - - func makeUIViewController(context: Context) -> UIActivityViewController { - UIActivityViewController(activityItems: activityItems, applicationActivities: nil) - } - - func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} } #Preview { diff --git a/to-live-photo/to-live-photo/Views/WallpaperGuideView.swift b/to-live-photo/to-live-photo/Views/WallpaperGuideView.swift index 741bd88..64e9334 100644 --- a/to-live-photo/to-live-photo/Views/WallpaperGuideView.swift +++ b/to-live-photo/to-live-photo/Views/WallpaperGuideView.swift @@ -20,13 +20,13 @@ struct WallpaperGuideView: View { ScrollView { VStack(alignment: .leading, spacing: 24) { headerSection - + quickActionSection - + stepsSection - + tipsSection - + doneButton } .padding(.horizontal, 20) @@ -34,6 +34,9 @@ struct WallpaperGuideView: View { } .navigationTitle("设置动态壁纸") .navigationBarTitleDisplayMode(.inline) + .onAppear { + Analytics.shared.log(.guideOpen) + } } @ViewBuilder @@ -73,6 +76,7 @@ struct WallpaperGuideView: View { @ViewBuilder private var quickActionSection: some View { Button { + Analytics.shared.log(.guideOpenPhotosApp) if let url = URL(string: "photos-redirect://") { UIApplication.shared.open(url) } @@ -220,6 +224,7 @@ struct WallpaperGuideView: View { private var doneButton: some View { VStack(spacing: 12) { Button { + Analytics.shared.log(.guideComplete) appState.popToRoot() } label: { Text("完成,返回首页")