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:
empty
2025-12-14 20:34:20 +08:00
parent 299415a530
commit a8b334ef39
14 changed files with 930 additions and 475 deletions

View 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)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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("完成,返回首页")