feat: 新增相册自动修图与OTG状态预览
支持相册修图配置、模板选择和传输模式选择;上传后自动提交修图并展示状态角标及精修预览。补充接口文档与相关测试。
This commit is contained in:
@@ -20,6 +20,22 @@ struct TravelAlbumPhoneAlbumImportItem: Sendable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// OTG 自动修图预览的数据校验错误,避免进入缺少素材或结果的空白预览页。
|
||||
enum TravelAlbumOTGPreviewError: LocalizedError, Equatable {
|
||||
case materialUnavailable
|
||||
case resultNotReady
|
||||
|
||||
/// 预览入口可直接展示给用户的错误说明。
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .materialUnavailable:
|
||||
return "素材信息暂不可用,请返回相册刷新后重试"
|
||||
case .resultNotReady:
|
||||
return "修图结果暂未同步,请稍后重试"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 有线相机传输页 ViewModel,编排 ImageCaptureCore 连接、照片导入、本地缓存与上传登记。
|
||||
@MainActor
|
||||
final class WiredCameraTransferViewModel {
|
||||
@@ -63,7 +79,12 @@ final class WiredCameraTransferViewModel {
|
||||
private(set) var sonyMTPHint: String?
|
||||
private(set) var isContentCatalogReady = false
|
||||
|
||||
let retouchOption = "不修图"
|
||||
private(set) var autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration {
|
||||
didSet { notifyStateChanged() }
|
||||
}
|
||||
private(set) var isUpdatingAutoRetouchConfiguration = false {
|
||||
didSet { notifyStateChanged() }
|
||||
}
|
||||
private(set) var photoFormatOption: TravelAlbumOTGPhotoFormatOption = .jpg
|
||||
private(set) var transferMode: TravelAlbumOTGTransferMode = .liveUpload {
|
||||
didSet { notifyStateChanged() }
|
||||
@@ -87,6 +108,8 @@ final class WiredCameraTransferViewModel {
|
||||
private var queuedAutoUploadPhotoIds: Set<String> = []
|
||||
private var suppressSelectedTimeSlotNotification = false
|
||||
private var serverStatusSyncTask: Task<Void, Never>?
|
||||
private var configurationSyncTask: Task<Void, Never>?
|
||||
private var autoRetouchPollingTask: Task<Void, Never>?
|
||||
|
||||
@MainActor
|
||||
init(
|
||||
@@ -94,6 +117,8 @@ final class WiredCameraTransferViewModel {
|
||||
albumTitle: String,
|
||||
headerPhone: String,
|
||||
scenicSpotLabel: String? = nil,
|
||||
initialTransferMode: TravelAlbumOTGTransferMode? = nil,
|
||||
initialAutoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled,
|
||||
connectionManager: (any WiredCameraConnectionManaging)? = nil,
|
||||
storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(),
|
||||
uploader: (any TravelAlbumOTGUploading)? = nil,
|
||||
@@ -112,7 +137,11 @@ final class WiredCameraTransferViewModel {
|
||||
self.api = api ?? NetworkServices.shared.travelAlbumAPI
|
||||
self.appStore = appStore
|
||||
self.userDefaults = userDefaults
|
||||
self.transferMode = Self.persistedTransferMode(in: userDefaults)
|
||||
self.autoRetouchConfiguration = initialAutoRetouchConfiguration
|
||||
self.transferMode = initialTransferMode ?? Self.persistedTransferMode(in: userDefaults)
|
||||
if let initialTransferMode {
|
||||
userDefaults.set(initialTransferMode.rawValue, forKey: Self.transferModeDefaultsKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// 相机状态文案。
|
||||
@@ -213,6 +242,40 @@ final class WiredCameraTransferViewModel {
|
||||
transferMode.title
|
||||
}
|
||||
|
||||
/// 自动修图设置 Chip 的展示文案。
|
||||
var retouchOption: String { autoRetouchConfiguration.displayTitle }
|
||||
|
||||
/// 自动修图设置页复用当前注入的相册服务。
|
||||
var autoRetouchAPI: any TravelAlbumServing { api }
|
||||
|
||||
/// 获取已完成自动修图照片的最新原图与精修图,仅供只读预览,不改变上传或修图状态。
|
||||
func loadAutoRetouchPreviewProject(photoId: String) async throws -> TravelAlbumPreviewProject {
|
||||
try Task.checkCancellation()
|
||||
guard let record = persistedRecordsById[photoId],
|
||||
record.autoRetouchState == .completed,
|
||||
record.serverMaterialId > 0 else {
|
||||
throw TravelAlbumOTGPreviewError.materialUnavailable
|
||||
}
|
||||
let material = try await api.materialInfo(
|
||||
userEquityTravelId: albumId,
|
||||
materialId: record.serverMaterialId
|
||||
)
|
||||
try Task.checkCancellation()
|
||||
let project = TravelAlbumPreviewProject(material: material)
|
||||
guard material.id == record.serverMaterialId,
|
||||
let original = project.asset(for: .original), !original.displayURL.isEmpty else {
|
||||
throw TravelAlbumOTGPreviewError.materialUnavailable
|
||||
}
|
||||
guard let retouched = project.asset(for: .retouched), !retouched.displayURL.isEmpty else {
|
||||
throw TravelAlbumOTGPreviewError.resultNotReady
|
||||
}
|
||||
return TravelAlbumPreviewProject(
|
||||
originalMaterialId: project.originalMaterialId,
|
||||
aiRetouchBatchId: project.aiRetouchBatchId,
|
||||
assets: [original, retouched]
|
||||
)
|
||||
}
|
||||
|
||||
/// 指定上传弹窗选项。
|
||||
var specifyUploadOptions: [TravelAlbumOTGSpecifyUploadOption] {
|
||||
TravelAlbumOTGSpecifyUploadOption.allCases
|
||||
@@ -230,6 +293,9 @@ final class WiredCameraTransferViewModel {
|
||||
syncFromConnectionManager()
|
||||
loadPersistedPhotos()
|
||||
syncServerUploadStatuses()
|
||||
syncAutoRetouchConfiguration()
|
||||
resumePendingAutoRetouches()
|
||||
updateAutoRetouchPolling()
|
||||
connectionManager.start()
|
||||
}
|
||||
|
||||
@@ -237,10 +303,29 @@ final class WiredCameraTransferViewModel {
|
||||
func stop() {
|
||||
serverStatusSyncTask?.cancel()
|
||||
serverStatusSyncTask = nil
|
||||
configurationSyncTask?.cancel()
|
||||
configurationSyncTask = nil
|
||||
autoRetouchPollingTask?.cancel()
|
||||
autoRetouchPollingTask = nil
|
||||
connectionManager.unbindDelegate()
|
||||
connectionManager.suspendLiveTransfer()
|
||||
}
|
||||
|
||||
/// App 进入后台时停止配置请求和任务轮询,保留待恢复状态。
|
||||
func applicationDidEnterBackground() {
|
||||
configurationSyncTask?.cancel()
|
||||
configurationSyncTask = nil
|
||||
autoRetouchPollingTask?.cancel()
|
||||
autoRetouchPollingTask = nil
|
||||
}
|
||||
|
||||
/// App 回到前台时同步跨设备配置并恢复自动修图任务。
|
||||
func applicationDidBecomeActive() {
|
||||
syncAutoRetouchConfiguration()
|
||||
resumePendingAutoRetouches()
|
||||
updateAutoRetouchPolling()
|
||||
}
|
||||
|
||||
/// 主动断开相机连接。
|
||||
func disconnect() {
|
||||
connectionManager.disconnect()
|
||||
@@ -301,6 +386,50 @@ final class WiredCameraTransferViewModel {
|
||||
userDefaults.set(mode.rawValue, forKey: Self.transferModeDefaultsKey)
|
||||
}
|
||||
|
||||
/// 保存相册级自动修图配置;服务端成功后才更新页面状态。
|
||||
func updateAutoRetouchConfiguration(_ configuration: TravelAlbumAutoRetouchConfiguration) async {
|
||||
guard !isUpdatingAutoRetouchConfiguration else { return }
|
||||
guard configuration.isValid else {
|
||||
showMessage("请选择有效的 AI 修图模板")
|
||||
return
|
||||
}
|
||||
isUpdatingAutoRetouchConfiguration = true
|
||||
defer { isUpdatingAutoRetouchConfiguration = false }
|
||||
do {
|
||||
autoRetouchConfiguration = try await api.updateAutoRetouchConfiguration(
|
||||
TravelAlbumAutoRetouchConfigurationRequest(
|
||||
userEquityTravelId: albumId,
|
||||
enabled: configuration.enabled,
|
||||
refinedTemplateId: configuration.refinedTemplateId
|
||||
)
|
||||
)
|
||||
showMessage(autoRetouchConfiguration.enabled ? "已开启 AI 自动修图" : "已关闭 AI 自动修图")
|
||||
} catch {
|
||||
showMessage(error.localizedDescription.isEmpty ? "自动修图设置保存失败" : error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 重试单张已上传照片的自动修图,不重复上传原图。
|
||||
func retryAutoRetouch(photoId: String) {
|
||||
guard var record = persistedRecordsById[photoId],
|
||||
record.status == .uploaded,
|
||||
record.serverMaterialId > 0,
|
||||
record.autoRetouchState == .failed,
|
||||
record.autoRetouchTemplateId != nil else {
|
||||
showMessage("当前照片无法重新修图")
|
||||
return
|
||||
}
|
||||
if record.autoRetouchBatchId > 0 {
|
||||
record.autoRetouchAttempt += 1
|
||||
record.autoRetouchBatchId = 0
|
||||
record.autoRetouchClientRequestId = makeAutoRetouchClientRequestId(record: record)
|
||||
}
|
||||
record.autoRetouchState = .pendingSubmission
|
||||
record.autoRetouchErrorMessage = nil
|
||||
persist(record)
|
||||
Task { await submitAutoRetouch(photoId: photoId) }
|
||||
}
|
||||
|
||||
/// 切换上传格式。
|
||||
func selectPhotoFormat(_ option: TravelAlbumOTGPhotoFormatOption) {
|
||||
guard photoFormatOption != option else { return }
|
||||
@@ -523,6 +652,166 @@ final class WiredCameraTransferViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
private func syncAutoRetouchConfiguration() {
|
||||
configurationSyncTask?.cancel()
|
||||
configurationSyncTask = Task { [weak self] in
|
||||
await self?.refreshAutoRetouchConfiguration()
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshAutoRetouchConfiguration() async {
|
||||
do {
|
||||
let album = try await api.info(id: albumId)
|
||||
try Task.checkCancellation()
|
||||
autoRetouchConfiguration = album.autoRetouchConfiguration
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
OTGLog.error(.connection, "sync auto retouch configuration failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private func resumePendingAutoRetouches() {
|
||||
let photoIds = persistedRecordsById.values.compactMap { record in
|
||||
record.status == .uploaded && record.autoRetouchState == .pendingSubmission
|
||||
? record.id
|
||||
: nil
|
||||
}
|
||||
guard !photoIds.isEmpty else { return }
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
for photoId in photoIds {
|
||||
await submitAutoRetouch(photoId: photoId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func submitAutoRetouch(photoId: String) async {
|
||||
guard var record = persistedRecordsById[photoId],
|
||||
record.status == .uploaded,
|
||||
record.serverMaterialId > 0,
|
||||
let templateId = record.autoRetouchTemplateId,
|
||||
record.autoRetouchState == .pendingSubmission || record.autoRetouchState == .failed else {
|
||||
return
|
||||
}
|
||||
if record.autoRetouchClientRequestId.isEmpty {
|
||||
record.autoRetouchClientRequestId = makeAutoRetouchClientRequestId(record: record)
|
||||
}
|
||||
record.autoRetouchState = .submitting
|
||||
record.autoRetouchErrorMessage = nil
|
||||
persist(record)
|
||||
|
||||
do {
|
||||
let submission = try await api.submitAIRetouch(
|
||||
TravelAlbumAIRetouchRequest(
|
||||
userEquityTravelId: albumId,
|
||||
materialIds: [record.serverMaterialId],
|
||||
refinedTemplateId: templateId,
|
||||
atmosphereTemplateId: nil,
|
||||
coverTemplateId: nil,
|
||||
clientRequestId: record.autoRetouchClientRequestId
|
||||
)
|
||||
)
|
||||
guard var latest = persistedRecordsById[photoId] else { return }
|
||||
latest.autoRetouchBatchId = submission.aiRetouchBatchId
|
||||
latest.autoRetouchState = autoRetouchState(for: submission.status)
|
||||
latest.autoRetouchErrorMessage = latest.autoRetouchState == .failed ? submission.status.title : nil
|
||||
persist(latest)
|
||||
updateAutoRetouchPolling()
|
||||
} catch is CancellationError {
|
||||
guard var latest = persistedRecordsById[photoId], latest.autoRetouchState == .submitting else { return }
|
||||
latest.autoRetouchState = .pendingSubmission
|
||||
persist(latest)
|
||||
} catch {
|
||||
guard var latest = persistedRecordsById[photoId] else { return }
|
||||
if isAmbiguousAutoRetouchSubmissionFailure(error) {
|
||||
latest.autoRetouchState = .pendingSubmission
|
||||
latest.autoRetouchErrorMessage = "网络中断,恢复后将自动继续修图"
|
||||
persist(latest)
|
||||
showMessage(latest.autoRetouchErrorMessage ?? "网络恢复后将自动继续修图")
|
||||
return
|
||||
}
|
||||
latest.autoRetouchState = .failed
|
||||
latest.autoRetouchErrorMessage = error.localizedDescription.isEmpty
|
||||
? "AI 修图任务提交失败"
|
||||
: error.localizedDescription
|
||||
persist(latest)
|
||||
showMessage(latest.autoRetouchErrorMessage ?? "AI 修图任务提交失败")
|
||||
}
|
||||
}
|
||||
|
||||
private func updateAutoRetouchPolling() {
|
||||
guard autoRetouchPollingTask == nil,
|
||||
persistedRecordsById.values.contains(where: {
|
||||
$0.autoRetouchState == .processing && $0.autoRetouchBatchId > 0
|
||||
}) else { return }
|
||||
autoRetouchPollingTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
while !Task.isCancelled {
|
||||
await refreshAutoRetouchJobs()
|
||||
guard persistedRecordsById.values.contains(where: {
|
||||
$0.autoRetouchState == .processing && $0.autoRetouchBatchId > 0
|
||||
}) else { break }
|
||||
try? await Task.sleep(for: .seconds(8))
|
||||
}
|
||||
autoRetouchPollingTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshAutoRetouchJobs() async {
|
||||
let batchIds = Set(persistedRecordsById.values.compactMap { record in
|
||||
record.autoRetouchState == .processing && record.autoRetouchBatchId > 0
|
||||
? record.autoRetouchBatchId
|
||||
: nil
|
||||
})
|
||||
for batchId in batchIds {
|
||||
do {
|
||||
let detail = try await api.aiRetouchJobInfo(batchId: batchId)
|
||||
try Task.checkCancellation()
|
||||
let state = autoRetouchState(for: detail.status)
|
||||
guard state != .processing else { continue }
|
||||
let matchingRecords = persistedRecordsById.values.filter { $0.autoRetouchBatchId == batchId }
|
||||
for var record in matchingRecords {
|
||||
record.autoRetouchState = state
|
||||
record.autoRetouchErrorMessage = state == .failed ? detail.status.title : nil
|
||||
persist(record)
|
||||
}
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
OTGLog.error(.connection, "poll auto retouch job failed: \(batchId) \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func autoRetouchState(for status: TravelAlbumAIJobStatus) -> TravelAlbumAutoRetouchState {
|
||||
switch status {
|
||||
case .queued, .processing, .unknown:
|
||||
return .processing
|
||||
case .succeeded, .partiallySucceeded:
|
||||
return .completed
|
||||
case .failed, .canceled:
|
||||
return .failed
|
||||
}
|
||||
}
|
||||
|
||||
private func makeAutoRetouchClientRequestId(record: TravelAlbumOTGPhotoRecord) -> String {
|
||||
let templateId = record.autoRetouchTemplateId ?? 0
|
||||
return "auto-\(albumId)-\(record.serverMaterialId)-tpl\(templateId)-a\(record.autoRetouchAttempt)"
|
||||
}
|
||||
|
||||
private func isAmbiguousAutoRetouchSubmissionFailure(_ error: Error) -> Bool {
|
||||
guard let apiError = error as? APIError else { return false }
|
||||
switch apiError {
|
||||
case .networkFailed, .invalidResponse, .emptyData, .decodeFailed:
|
||||
return true
|
||||
case .httpStatus(let status, _):
|
||||
return status == 408 || status >= 500
|
||||
case .invalidURL, .serverCode:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func applyMergedPhotos() {
|
||||
let persistedItems = persistedRecordsById.values
|
||||
.map { $0.toPhotoItem(storage: storage, albumId: albumId) }
|
||||
@@ -588,6 +877,7 @@ final class WiredCameraTransferViewModel {
|
||||
}
|
||||
|
||||
private func uploadPhoto(id: String) async {
|
||||
let retouchSnapshot = autoRetouchConfiguration
|
||||
do {
|
||||
let record = try await localRecordForUpload(id: id)
|
||||
updateRecord(id: id, status: .uploading, progress: max(record.progress, 1), error: nil)
|
||||
@@ -597,13 +887,44 @@ final class WiredCameraTransferViewModel {
|
||||
) { [weak self] progress in
|
||||
self?.updateRecord(id: id, status: .uploading, progress: progress, error: nil)
|
||||
}
|
||||
updateRecord(id: id, status: .uploaded, progress: 100, error: nil, remoteUrl: material.fileUrl)
|
||||
completeOriginalUpload(id: id, material: material, retouchSnapshot: retouchSnapshot)
|
||||
if retouchSnapshot.enabled {
|
||||
await submitAutoRetouch(photoId: id)
|
||||
}
|
||||
} catch {
|
||||
updateRecord(id: id, status: .failed, progress: 0, error: error.localizedDescription)
|
||||
showMessage(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func completeOriginalUpload(
|
||||
id: String,
|
||||
material: TravelAlbumMaterial,
|
||||
retouchSnapshot: TravelAlbumAutoRetouchConfiguration
|
||||
) {
|
||||
guard var record = persistedRecordsById[id] else {
|
||||
updateRecord(id: id, status: .uploaded, progress: 100, error: nil, remoteUrl: material.fileUrl)
|
||||
return
|
||||
}
|
||||
record.status = .uploaded
|
||||
record.progress = 100
|
||||
record.errorMessage = nil
|
||||
record.remoteUrl = material.fileUrl
|
||||
record.serverMaterialId = material.id
|
||||
record.autoRetouchTemplateId = retouchSnapshot.enabled ? retouchSnapshot.refinedTemplateId : nil
|
||||
record.autoRetouchBatchId = 0
|
||||
record.autoRetouchAttempt = 0
|
||||
record.autoRetouchErrorMessage = nil
|
||||
if retouchSnapshot.enabled, retouchSnapshot.refinedTemplateId != nil {
|
||||
record.autoRetouchState = .pendingSubmission
|
||||
record.autoRetouchClientRequestId = makeAutoRetouchClientRequestId(record: record)
|
||||
} else {
|
||||
record.autoRetouchState = .none
|
||||
record.autoRetouchClientRequestId = ""
|
||||
}
|
||||
persist(record)
|
||||
}
|
||||
|
||||
private func localRecordForUpload(id: String) async throws -> TravelAlbumOTGPhotoRecord {
|
||||
if let record = persistedRecordsById[id],
|
||||
!record.localPath.isEmpty,
|
||||
@@ -653,6 +974,14 @@ final class WiredCameraTransferViewModel {
|
||||
applyMergedPhotos()
|
||||
}
|
||||
|
||||
private func persist(_ record: TravelAlbumOTGPhotoRecord) {
|
||||
var updated = record
|
||||
updated.updatedAt = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
persistedRecordsById[updated.id] = updated
|
||||
storage.upsert(updated, albumId: albumId)
|
||||
applyMergedPhotos()
|
||||
}
|
||||
|
||||
private func notifyStateChanged() {
|
||||
if Thread.isMainThread {
|
||||
onStateChange?()
|
||||
|
||||
Reference in New Issue
Block a user