feat: AI 模型支持 On-Demand Resources 按需下载

- 新增 ODRManager 管理模型资源下载
- EditorView 添加下载进度 UI
- Package.swift 移除内嵌模型资源
- 减小应用包体积约 64MB

🤖 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
2026-01-03 22:23:59 +08:00
parent 6e60bea509
commit 64e962b6a4
3 changed files with 287 additions and 5 deletions

View File

@@ -18,9 +18,8 @@ let package = Package(
name: "LivePhotoCore", name: "LivePhotoCore",
dependencies: [], dependencies: [],
resources: [ resources: [
.copy("Resources/metadata.mov"), .copy("Resources/metadata.mov")
// AI Real-ESRGAN x4plus // AI On-Demand Resources
.process("Resources/RealESRGAN_x4plus.mlmodel")
] ]
), ),
.testTarget( .testTarget(

View File

@@ -0,0 +1,201 @@
//
// ODRManager.swift
// LivePhotoCore
//
// On-Demand Resources manager for AI model download.
//
import Foundation
import os
// MARK: - Download State
/// Model download state
public enum ModelDownloadState: Sendable, Equatable {
case notDownloaded
case downloading(progress: Double)
case downloaded
case failed(String)
public static func == (lhs: ModelDownloadState, rhs: ModelDownloadState) -> Bool {
switch (lhs, rhs) {
case (.notDownloaded, .notDownloaded): return true
case (.downloaded, .downloaded): return true
case let (.downloading(p1), .downloading(p2)): return p1 == p2
case let (.failed(e1), .failed(e2)): return e1 == e2
default: return false
}
}
}
// MARK: - ODR Manager
/// On-Demand Resources manager for AI model
public actor ODRManager {
public static let shared = ODRManager()
private static let modelTag = "ai-model"
private static let modelName = "RealESRGAN_x4plus"
private var resourceRequest: NSBundleResourceRequest?
private var cachedModelURL: URL?
private let logger = Logger(subsystem: "LivePhotoCore", category: "ODRManager")
private init() {}
// MARK: - Public API
/// Check if model is available locally (either in ODR cache or bundle)
public func isModelAvailable() async -> Bool {
// First check if we have a cached URL
if let url = cachedModelURL, FileManager.default.fileExists(atPath: url.path) {
return true
}
// Check bundle (development/fallback)
if getBundleModelURL() != nil {
return true
}
// Check ODR conditionally (only available in app context)
return await checkODRAvailability()
}
/// Get current download state
public func getDownloadState() async -> ModelDownloadState {
if await isModelAvailable() {
return .downloaded
}
if resourceRequest != nil {
return .downloading(progress: 0)
}
return .notDownloaded
}
/// Download model with progress callback
/// - Parameter progress: Progress callback (0.0 to 1.0)
public func downloadModel(progress: @escaping @Sendable (Double) -> Void) async throws {
// Check if already available
if await isModelAvailable() {
logger.info("Model already available, skipping download")
progress(1.0)
return
}
logger.info("Starting ODR download for model: \(Self.modelTag)")
// Create resource request
let request = NSBundleResourceRequest(tags: [Self.modelTag])
self.resourceRequest = request
// Set up progress observation
let observation = request.progress.observe(\.fractionCompleted) { progressObj, _ in
Task { @MainActor in
progress(progressObj.fractionCompleted)
}
}
defer {
observation.invalidate()
}
do {
// Begin accessing resources
try await request.beginAccessingResources()
logger.info("ODR download completed successfully")
// Find and cache the model URL
if let url = findModelInBundle(request.bundle) {
cachedModelURL = url
logger.info("Model cached at: \(url.path)")
}
progress(1.0)
} catch {
logger.error("ODR download failed: \(error.localizedDescription)")
self.resourceRequest = nil
throw AIEnhanceError.modelLoadFailed("Download failed: \(error.localizedDescription)")
}
}
/// Get model URL (after download or from bundle)
public func getModelURL() -> URL? {
// Return cached URL if available
if let url = cachedModelURL {
return url
}
// Check bundle fallback
if let url = getBundleModelURL() {
return url
}
// Try to find in ODR bundle
if let request = resourceRequest, let url = findModelInBundle(request.bundle) {
cachedModelURL = url
return url
}
return nil
}
/// Release ODR resources when not in use
public func releaseResources() {
resourceRequest?.endAccessingResources()
resourceRequest = nil
cachedModelURL = nil
logger.info("ODR resources released")
}
// MARK: - Private Helpers
private func checkODRAvailability() async -> Bool {
// Use conditionallyBeginAccessingResources to check without triggering download
let request = NSBundleResourceRequest(tags: [Self.modelTag])
return await withCheckedContinuation { continuation in
request.conditionallyBeginAccessingResources { available in
if available {
// Model is already downloaded via ODR
self.logger.debug("ODR model is available locally")
}
continuation.resume(returning: available)
}
}
}
private func getBundleModelURL() -> URL? {
// Try main bundle first
if let url = Bundle.main.url(forResource: Self.modelName, withExtension: "mlmodelc") {
return url
}
if let url = Bundle.main.url(forResource: Self.modelName, withExtension: "mlpackage") {
return url
}
// Try SPM bundle (development)
#if SWIFT_PACKAGE
if let url = Bundle.module.url(forResource: Self.modelName, withExtension: "mlmodelc") {
return url
}
if let url = Bundle.module.url(forResource: Self.modelName, withExtension: "mlpackage") {
return url
}
#endif
return nil
}
private func findModelInBundle(_ bundle: Bundle) -> URL? {
if let url = bundle.url(forResource: Self.modelName, withExtension: "mlmodelc") {
return url
}
if let url = bundle.url(forResource: Self.modelName, withExtension: "mlpackage") {
return url
}
return nil
}
}

View File

@@ -37,6 +37,9 @@ struct EditorView: View {
// AI // AI
@State private var aiEnhanceEnabled: Bool = false @State private var aiEnhanceEnabled: Bool = false
@State private var aiModelNeedsDownload: Bool = false
@State private var aiModelDownloading: Bool = false
@State private var aiModelDownloadProgress: Double = 0
// //
@State private var videoDiagnosis: VideoDiagnosis? @State private var videoDiagnosis: VideoDiagnosis?
@@ -370,10 +373,45 @@ struct EditorView: View {
} }
} }
.tint(.purple) .tint(.purple)
.disabled(!AIEnhancer.isAvailable()) .disabled(!AIEnhancer.isAvailable() || aiModelDownloading)
.onChange(of: aiEnhanceEnabled) { _, newValue in
if newValue {
checkAndDownloadModel()
}
}
if aiEnhanceEnabled { //
if aiModelDownloading {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
ProgressView()
.scaleEffect(0.8)
Text("正在下载 AI 模型...")
.font(.caption)
.foregroundStyle(.secondary)
}
ProgressView(value: aiModelDownloadProgress)
.tint(.purple)
Text(String(format: "%.0f%%", aiModelDownloadProgress * 100))
.font(.caption2)
.foregroundStyle(.secondary)
}
.padding(.leading, 4)
}
if aiEnhanceEnabled && !aiModelDownloading {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
if aiModelNeedsDownload {
HStack(spacing: 4) {
Image(systemName: "arrow.down.circle")
.foregroundStyle(.orange)
.font(.caption)
Text("首次使用需下载 AI 模型(约 64MB")
.font(.caption)
}
}
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: "sparkles") Image(systemName: "sparkles")
.foregroundStyle(.purple) .foregroundStyle(.purple)
@@ -415,6 +453,10 @@ struct EditorView: View {
.padding(16) .padding(16)
.background(Color.purple.opacity(0.1)) .background(Color.purple.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 12)) .clipShape(RoundedRectangle(cornerRadius: 12))
.task {
//
aiModelNeedsDownload = await AIEnhancer.needsDownload()
}
} }
// MARK: - // MARK: -
@@ -681,6 +723,46 @@ struct EditorView: View {
return CropRect(x: cropX, y: cropY, width: cropWidth, height: cropHeight) return CropRect(x: cropX, y: cropY, width: cropWidth, height: cropHeight)
} }
private func checkAndDownloadModel() {
guard aiEnhanceEnabled else { return }
Task {
//
let needsDownload = await AIEnhancer.needsDownload()
await MainActor.run {
aiModelNeedsDownload = needsDownload
}
if needsDownload {
await MainActor.run {
aiModelDownloading = true
aiModelDownloadProgress = 0
}
do {
try await AIEnhancer.downloadModel { progress in
Task { @MainActor in
aiModelDownloadProgress = progress
}
}
await MainActor.run {
aiModelDownloading = false
aiModelNeedsDownload = false
}
} catch {
await MainActor.run {
aiModelDownloading = false
// AI
aiEnhanceEnabled = false
}
print("Failed to download AI model: \(error)")
}
}
}
}
private func startProcessing() { private func startProcessing() {
Analytics.shared.log(.editorGenerateClick, parameters: [ Analytics.shared.log(.editorGenerateClick, parameters: [
"trimStart": trimStart, "trimStart": trimStart,