feat(M1): 完成 MVP 核心功能,添加埋点和应用图标
主要改动: - 移除调试导出功能(exportToDocuments 及相关 UI) - EditorView 添加封面帧预览和关键帧时间选择 - 新增 Analytics.swift 基础埋点模块(使用 os.Logger) - 创建 Live Photo 风格应用图标(SVG → PNG) - 优化 LivePhotoCore:简化代码结构,修复宽高比问题 - 添加单元测试资源文件 metadata.mov - 更新 TASK.md 进度追踪 M1 MVP 闭环已完成: ✅ 5个核心页面(Home/Editor/Processing/Result/WallpaperGuide) ✅ 时长裁剪 + 封面帧选择 ✅ 完整生成管线 + 相册保存 + 系统验证 ✅ 壁纸设置引导(iOS 16/17+ 差异化文案) ✅ 基础埋点事件追踪 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
65
to-live-photo/to-live-photo/Analytics.swift
Normal file
65
to-live-photo/to-live-photo/Analytics.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 248 KiB |
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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("完成,返回首页")
|
||||
|
||||
Reference in New Issue
Block a user