feat: 新增相册自动修图与OTG状态预览
支持相册修图配置、模板选择和传输模式选择;上传后自动提交修图并展示状态角标及精修预览。补充接口文档与相关测试。
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
# AI 自动修图后端接口改造(精简版)
|
||||
|
||||
## 一、需要修改的接口
|
||||
|
||||
### 1. 创建相册
|
||||
|
||||
`POST /api/yf-handset-app/photog/travel-album/create`
|
||||
|
||||
请求新增:
|
||||
|
||||
```json
|
||||
{
|
||||
"auto_retouch_config": {
|
||||
"enabled": true,
|
||||
"refined_template_id": 12
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `enabled = true` 时,`refined_template_id` 必填且模板必须有效。
|
||||
- `enabled = false` 时,服务端将 `refined_template_id` 规范化为 `null`。
|
||||
- 响应返回完整 `auto_retouch_config`。
|
||||
|
||||
### 2. 相册详情和列表
|
||||
|
||||
- `GET /api/yf-handset-app/photog/travel-album/info`
|
||||
- `GET /api/yf-handset-app/photog/travel-album/list`
|
||||
|
||||
每个相册新增响应字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"auto_retouch_config": {
|
||||
"enabled": true,
|
||||
"refined_template_id": 12
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 历史相册没有配置时按关闭状态返回。
|
||||
|
||||
## 二、需要新增的接口
|
||||
|
||||
### 更新相册自动修图配置
|
||||
|
||||
`POST /api/yf-handset-app/photog/travel-album/auto-retouch-config`
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"user_equity_travel_id": 88,
|
||||
"enabled": true,
|
||||
"refined_template_id": 12
|
||||
}
|
||||
```
|
||||
|
||||
成功响应 `data`:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"refined_template_id": 12
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 返回服务端规范化后的完整配置。
|
||||
- 多设备同时修改时采用最后一次写入生效。
|
||||
|
||||
## 三、无需新增但需要确保可用的接口
|
||||
|
||||
- `POST .../upload-material`:成功后必须返回稳定的服务端素材 `id`。
|
||||
- `GET .../ai-retouch-templates`:直接使用现有 `refined_templates`,无需新增模板描述字段。
|
||||
- `POST .../ai-retouch`:请求和响应保持现状,不增加 `client_request_id`。
|
||||
- `GET .../ai-retouch-job-info`:支持按任务批次查询 `queued / processing / succeeded / partially_succeeded / failed / canceled` 状态。
|
||||
|
||||
客户端会在 `upload-material` 成功后提交 AI 任务,并在前台每 8 秒查询任务状态。AI 额度不足、模板失效或任务失败都不能回滚原图上传结果。
|
||||
|
||||
由于 `ai-retouch` 不提供幂等能力,客户端在请求超时、断网等结果不确定的情况下不得自动重复提交;只能先同步素材或任务状态。若无法确认是否已创建任务,需提示用户“提交状态未知”,避免直接重试导致重复任务或重复扣额。
|
||||
|
||||
## 四、后端验收重点
|
||||
|
||||
1. 创建、详情和列表中的配置字段保持一致。
|
||||
2. 配置关闭时服务端将 `refined_template_id` 规范化为 `null`。
|
||||
3. 原图登记成功后可以使用现有 `ai-retouch` 接口创建单素材精修任务。
|
||||
4. AI 任务失败或额度不足时,原图上传结果保持成功状态。
|
||||
@@ -0,0 +1,52 @@
|
||||
# AI 自动修图接口补充
|
||||
|
||||
本文仅描述相册级自动修图新增契约;现有手动批量 AI 修图接口保持不变。
|
||||
|
||||
## 相册配置
|
||||
|
||||
`create` 请求新增:
|
||||
|
||||
```json
|
||||
{
|
||||
"auto_retouch_config": {
|
||||
"enabled": true,
|
||||
"refined_template_id": 12
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`create`、`info`、`list` 响应返回服务端规范化后的完整配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"auto_retouch_config": {
|
||||
"enabled": true,
|
||||
"refined_template_id": 12
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
旧相册缺少 `auto_retouch_config` 时,客户端按关闭处理。
|
||||
|
||||
## 更新配置
|
||||
|
||||
```http
|
||||
POST /api/yf-handset-app/photog/travel-album/auto-retouch-config
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"user_equity_travel_id": 88,
|
||||
"enabled": true,
|
||||
"refined_template_id": 12
|
||||
}
|
||||
```
|
||||
|
||||
响应 `data` 为服务端规范化后的完整配置。多设备同时修改时采用最后一次写入生效。
|
||||
|
||||
## AI 任务提交
|
||||
|
||||
`POST ai-retouch` 请求和响应保持现状,不增加 `client_request_id`。请求结果不确定时客户端不得自动重复提交,应先同步素材或任务状态;无法确认时提示用户“提交状态未知”。
|
||||
|
||||
自动修图只使用现有模板接口的 `refined_templates`,不展示模板描述,也不自动生成氛围感或封面。
|
||||
@@ -50,6 +50,11 @@ protocol TravelAlbumServing {
|
||||
/// 拉取当前景区可用的 AI 修图模板。
|
||||
func aiRetouchTemplates(scenicId: Int) async throws -> TravelAlbumAIRetouchTemplatesResponse
|
||||
|
||||
/// 更新相册级自动 AI 修图配置。
|
||||
func updateAutoRetouchConfiguration(
|
||||
_ request: TravelAlbumAutoRetouchConfigurationRequest
|
||||
) async throws -> TravelAlbumAutoRetouchConfiguration
|
||||
|
||||
/// 提交相册素材 AI 修图任务。
|
||||
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws -> TravelAlbumAIJobSubmission
|
||||
|
||||
@@ -207,6 +212,15 @@ final class TravelAlbumAPI: TravelAlbumServing {
|
||||
)
|
||||
}
|
||||
|
||||
/// 更新自动修图配置并返回服务端最新版本。
|
||||
func updateAutoRetouchConfiguration(
|
||||
_ request: TravelAlbumAutoRetouchConfigurationRequest
|
||||
) async throws -> TravelAlbumAutoRetouchConfiguration {
|
||||
try await client.send(
|
||||
APIRequest(method: .post, path: "\(basePath)/auto-retouch-config", body: request)
|
||||
)
|
||||
}
|
||||
|
||||
/// 提交相册素材 AI 修图任务并返回任务摘要。
|
||||
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws -> TravelAlbumAIJobSubmission {
|
||||
try await client.send(APIRequest(method: .post, path: "\(basePath)/ai-retouch", body: request))
|
||||
|
||||
@@ -5,6 +5,36 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 相册级自动 AI 修图配置,由服务端同步并在每次上传开始时固定快照。
|
||||
struct TravelAlbumAutoRetouchConfiguration: Codable, Sendable, Equatable, Hashable {
|
||||
let enabled: Bool
|
||||
let refinedTemplateId: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case enabled
|
||||
case refinedTemplateId = "refined_template_id"
|
||||
}
|
||||
|
||||
/// 默认关闭自动修图,用于兼容尚未返回配置字段的旧接口。
|
||||
static let disabled = TravelAlbumAutoRetouchConfiguration(
|
||||
enabled: false,
|
||||
refinedTemplateId: nil
|
||||
)
|
||||
|
||||
/// 创建经过规范化的自动修图配置。
|
||||
init(enabled: Bool, refinedTemplateId: Int?) {
|
||||
let validTemplateId = refinedTemplateId.flatMap { $0 > 0 ? $0 : nil }
|
||||
self.enabled = enabled
|
||||
self.refinedTemplateId = enabled ? validTemplateId : nil
|
||||
}
|
||||
|
||||
/// 上传页紧凑设置项的展示文案。
|
||||
var displayTitle: String { enabled ? "AI修图" : "不修图" }
|
||||
|
||||
/// 当前配置是否可以用于提交自动修图任务。
|
||||
var isValid: Bool { !enabled || refinedTemplateId != nil }
|
||||
}
|
||||
|
||||
/// 旅拍相册用户信息,对齐 Android `TravelAlbumUserEntity`。
|
||||
struct TravelAlbumUser: Decodable, Sendable, Equatable, Hashable {
|
||||
let id: Int
|
||||
@@ -33,6 +63,7 @@ struct TravelAlbum: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
let user: TravelAlbumUser?
|
||||
let autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
@@ -50,6 +81,31 @@ struct TravelAlbum: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
case user
|
||||
case autoRetouchConfiguration = "auto_retouch_config"
|
||||
}
|
||||
|
||||
/// 解码相册详情;旧响应缺少自动修图配置时按关闭状态兼容。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(Int.self, forKey: .id)
|
||||
storeUserId = try container.decode(Int.self, forKey: .storeUserId)
|
||||
name = try container.decode(String.self, forKey: .name)
|
||||
type = try container.decode(Int.self, forKey: .type)
|
||||
orderNumber = try container.decode(String.self, forKey: .orderNumber)
|
||||
materialNum = try container.decode(Int.self, forKey: .materialNum)
|
||||
materialPrice = try container.decode(Int.self, forKey: .materialPrice)
|
||||
materialPackagePrice = try container.decode(Int.self, forKey: .materialPackagePrice)
|
||||
photoPrice = try container.decode(Int.self, forKey: .photoPrice)
|
||||
coverUrl = try container.decode(String.self, forKey: .coverUrl)
|
||||
userId = try container.decode(Int.self, forKey: .userId)
|
||||
status = try container.decode(Int.self, forKey: .status)
|
||||
createdAt = try container.decode(String.self, forKey: .createdAt)
|
||||
updatedAt = try container.decode(String.self, forKey: .updatedAt)
|
||||
user = try container.decodeIfPresent(TravelAlbumUser.self, forKey: .user)
|
||||
autoRetouchConfiguration = try container.decodeIfPresent(
|
||||
TravelAlbumAutoRetouchConfiguration.self,
|
||||
forKey: .autoRetouchConfiguration
|
||||
) ?? .disabled
|
||||
}
|
||||
|
||||
init(
|
||||
@@ -67,7 +123,8 @@ struct TravelAlbum: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
||||
status: Int = 0,
|
||||
createdAt: String = "",
|
||||
updatedAt: String = "",
|
||||
user: TravelAlbumUser? = nil
|
||||
user: TravelAlbumUser? = nil,
|
||||
autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled
|
||||
) {
|
||||
self.id = id
|
||||
self.storeUserId = storeUserId
|
||||
@@ -84,6 +141,7 @@ struct TravelAlbum: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
self.user = user
|
||||
self.autoRetouchConfiguration = autoRetouchConfiguration
|
||||
}
|
||||
|
||||
/// 展示用手机号。
|
||||
@@ -287,6 +345,7 @@ struct TravelAlbumCreateRequest: Encodable, Sendable, Equatable {
|
||||
let materialPrice: Double?
|
||||
let materialPackagePrice: Double?
|
||||
let photoPrice: Double?
|
||||
let autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
@@ -296,6 +355,67 @@ struct TravelAlbumCreateRequest: Encodable, Sendable, Equatable {
|
||||
case materialPrice = "material_price"
|
||||
case materialPackagePrice = "material_package_price"
|
||||
case photoPrice = "photo_price"
|
||||
case autoRetouchConfiguration = "auto_retouch_config"
|
||||
}
|
||||
|
||||
private enum AutoRetouchConfigurationCodingKeys: String, CodingKey {
|
||||
case enabled
|
||||
case refinedTemplateId = "refined_template_id"
|
||||
}
|
||||
|
||||
/// 创建相册请求;自动修图配置默认关闭以兼容现有调用方。
|
||||
init(
|
||||
name: String,
|
||||
type: Int,
|
||||
orderNumber: String?,
|
||||
materialNum: Int?,
|
||||
materialPrice: Double?,
|
||||
materialPackagePrice: Double?,
|
||||
photoPrice: Double?,
|
||||
autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled
|
||||
) {
|
||||
self.name = name
|
||||
self.type = type
|
||||
self.orderNumber = orderNumber
|
||||
self.materialNum = materialNum
|
||||
self.materialPrice = materialPrice
|
||||
self.materialPackagePrice = materialPackagePrice
|
||||
self.photoPrice = photoPrice
|
||||
self.autoRetouchConfiguration = autoRetouchConfiguration
|
||||
}
|
||||
|
||||
/// 编码创建参数;相册配置版本由服务端生成,不随创建请求上送。
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(name, forKey: .name)
|
||||
try container.encode(type, forKey: .type)
|
||||
try container.encodeIfPresent(orderNumber, forKey: .orderNumber)
|
||||
try container.encodeIfPresent(materialNum, forKey: .materialNum)
|
||||
try container.encodeIfPresent(materialPrice, forKey: .materialPrice)
|
||||
try container.encodeIfPresent(materialPackagePrice, forKey: .materialPackagePrice)
|
||||
try container.encodeIfPresent(photoPrice, forKey: .photoPrice)
|
||||
var configuration = container.nestedContainer(
|
||||
keyedBy: AutoRetouchConfigurationCodingKeys.self,
|
||||
forKey: .autoRetouchConfiguration
|
||||
)
|
||||
try configuration.encode(autoRetouchConfiguration.enabled, forKey: .enabled)
|
||||
try configuration.encodeIfPresent(
|
||||
autoRetouchConfiguration.refinedTemplateId,
|
||||
forKey: .refinedTemplateId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新相册自动修图配置的请求参数。
|
||||
struct TravelAlbumAutoRetouchConfigurationRequest: Encodable, Sendable, Equatable {
|
||||
let userEquityTravelId: Int
|
||||
let enabled: Bool
|
||||
let refinedTemplateId: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case userEquityTravelId = "user_equity_travel_id"
|
||||
case enabled
|
||||
case refinedTemplateId = "refined_template_id"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,6 +457,28 @@ struct TravelAlbumListResponse<Item: Decodable & Sendable & Equatable>: Decodabl
|
||||
/// 旅拍相册创建响应。
|
||||
struct TravelAlbumCreateResponse: Decodable, Sendable, Equatable {
|
||||
let id: Int
|
||||
let autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case autoRetouchConfiguration = "auto_retouch_config"
|
||||
}
|
||||
|
||||
/// 创建相册响应;后端暂未回传配置时按关闭状态兼容。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(Int.self, forKey: .id)
|
||||
autoRetouchConfiguration = try container.decodeIfPresent(
|
||||
TravelAlbumAutoRetouchConfiguration.self,
|
||||
forKey: .autoRetouchConfiguration
|
||||
) ?? .disabled
|
||||
}
|
||||
|
||||
/// 创建相册响应测试数据。
|
||||
init(id: Int, autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled) {
|
||||
self.id = id
|
||||
self.autoRetouchConfiguration = autoRetouchConfiguration
|
||||
}
|
||||
}
|
||||
|
||||
/// 旅拍相册小程序码响应。
|
||||
@@ -496,6 +638,7 @@ struct TravelAlbumAIRetouchRequest: Encodable, Sendable, Equatable {
|
||||
let refinedTemplateId: Int
|
||||
let atmosphereTemplateId: Int?
|
||||
let coverTemplateId: Int?
|
||||
let clientRequestId: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case userEquityTravelId = "user_equity_travel_id"
|
||||
@@ -503,6 +646,24 @@ struct TravelAlbumAIRetouchRequest: Encodable, Sendable, Equatable {
|
||||
case refinedTemplateId = "refined_template_id"
|
||||
case atmosphereTemplateId = "atmosphere_template_id"
|
||||
case coverTemplateId = "cover_template_id"
|
||||
case clientRequestId = "client_request_id"
|
||||
}
|
||||
|
||||
/// 创建首次 AI 修图请求;手动修图可不传幂等标识,自动修图必须传稳定标识。
|
||||
init(
|
||||
userEquityTravelId: Int,
|
||||
materialIds: [Int],
|
||||
refinedTemplateId: Int,
|
||||
atmosphereTemplateId: Int?,
|
||||
coverTemplateId: Int?,
|
||||
clientRequestId: String? = nil
|
||||
) {
|
||||
self.userEquityTravelId = userEquityTravelId
|
||||
self.materialIds = materialIds
|
||||
self.refinedTemplateId = refinedTemplateId
|
||||
self.atmosphereTemplateId = atmosphereTemplateId
|
||||
self.coverTemplateId = coverTemplateId
|
||||
self.clientRequestId = clientRequestId
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,13 @@ struct TravelAlbumOTGPhotoItem: Hashable, Sendable {
|
||||
var errorMessage: String?
|
||||
let localPath: String
|
||||
var remoteUrl: String
|
||||
var serverMaterialId: Int = 0
|
||||
var autoRetouchState: TravelAlbumAutoRetouchState = .none
|
||||
var autoRetouchTemplateId: Int? = nil
|
||||
var autoRetouchClientRequestId: String = ""
|
||||
var autoRetouchBatchId: Int = 0
|
||||
var autoRetouchAttempt: Int = 0
|
||||
var autoRetouchErrorMessage: String? = nil
|
||||
|
||||
/// 是否未上传完成。
|
||||
var isNotUploaded: Bool {
|
||||
@@ -31,6 +38,16 @@ struct TravelAlbumOTGPhotoItem: Hashable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// OTG 照片自动修图阶段,与原图上传状态独立保存。
|
||||
enum TravelAlbumAutoRetouchState: String, Codable, Sendable, Equatable, Hashable {
|
||||
case none = "NONE"
|
||||
case pendingSubmission = "PENDING_SUBMISSION"
|
||||
case submitting = "SUBMITTING"
|
||||
case processing = "PROCESSING"
|
||||
case completed = "COMPLETED"
|
||||
case failed = "FAILED"
|
||||
}
|
||||
|
||||
/// OTG 传输页半小时维度时间槽。
|
||||
struct TravelAlbumOTGTimeSlot: Hashable, Sendable {
|
||||
let id: String
|
||||
@@ -85,6 +102,14 @@ enum TravelAlbumOTGTransferMode: String, CaseIterable, Sendable {
|
||||
self == .liveUpload
|
||||
}
|
||||
|
||||
/// 模式选择 Sheet 的辅助说明。
|
||||
var detailText: String {
|
||||
switch self {
|
||||
case .liveUpload: return "相机拍摄后,照片自动传输并上传到当前相册"
|
||||
case .postTransfer: return "拍摄完成后,再选择照片批量传输"
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据展示标题解析传输模式。
|
||||
static func option(title: String) -> TravelAlbumOTGTransferMode? {
|
||||
allCases.first { $0.title == title }
|
||||
@@ -343,7 +368,14 @@ extension TravelAlbumOTGPhotoRecord {
|
||||
progress: progress,
|
||||
errorMessage: errorMessage,
|
||||
localPath: localPath,
|
||||
remoteUrl: remoteUrl
|
||||
remoteUrl: remoteUrl,
|
||||
serverMaterialId: serverMaterialId,
|
||||
autoRetouchState: autoRetouchState,
|
||||
autoRetouchTemplateId: autoRetouchTemplateId,
|
||||
autoRetouchClientRequestId: autoRetouchClientRequestId,
|
||||
autoRetouchBatchId: autoRetouchBatchId,
|
||||
autoRetouchAttempt: autoRetouchAttempt,
|
||||
autoRetouchErrorMessage: autoRetouchErrorMessage
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,13 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
|
||||
let albumId: Int
|
||||
let userId: String
|
||||
var remoteUrl: String
|
||||
var serverMaterialId: Int
|
||||
var autoRetouchState: TravelAlbumAutoRetouchState
|
||||
var autoRetouchTemplateId: Int?
|
||||
var autoRetouchClientRequestId: String
|
||||
var autoRetouchBatchId: Int
|
||||
var autoRetouchAttempt: Int
|
||||
var autoRetouchErrorMessage: String?
|
||||
var updatedAt: Int64
|
||||
|
||||
/// 创建 OTG 本地照片记录。
|
||||
@@ -74,6 +81,13 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
|
||||
albumId: Int,
|
||||
userId: String,
|
||||
remoteUrl: String = "",
|
||||
serverMaterialId: Int = 0,
|
||||
autoRetouchState: TravelAlbumAutoRetouchState = .none,
|
||||
autoRetouchTemplateId: Int? = nil,
|
||||
autoRetouchClientRequestId: String = "",
|
||||
autoRetouchBatchId: Int = 0,
|
||||
autoRetouchAttempt: Int = 0,
|
||||
autoRetouchErrorMessage: String? = nil,
|
||||
updatedAt: Int64 = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
) {
|
||||
self.id = id
|
||||
@@ -90,12 +104,22 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
|
||||
self.albumId = albumId
|
||||
self.userId = userId
|
||||
self.remoteUrl = remoteUrl
|
||||
self.serverMaterialId = max(0, serverMaterialId)
|
||||
self.autoRetouchState = autoRetouchState
|
||||
self.autoRetouchTemplateId = autoRetouchTemplateId
|
||||
self.autoRetouchClientRequestId = autoRetouchClientRequestId
|
||||
self.autoRetouchBatchId = max(0, autoRetouchBatchId)
|
||||
self.autoRetouchAttempt = max(0, autoRetouchAttempt)
|
||||
self.autoRetouchErrorMessage = autoRetouchErrorMessage
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, sourceId, clientPhotoId, fileName, localPath, thumbnailPath, capturedAt
|
||||
case fileSizeBytes, status, progress, errorMessage, albumId, userId, remoteUrl, updatedAt
|
||||
case serverMaterialId, autoRetouchState, autoRetouchTemplateId
|
||||
case autoRetouchClientRequestId, autoRetouchBatchId, autoRetouchAttempt
|
||||
case autoRetouchErrorMessage
|
||||
}
|
||||
|
||||
/// 解码本地索引;旧版本缺少 `clientPhotoId` 时先保留为空,由 Store 一次性迁移并回写。
|
||||
@@ -115,16 +139,34 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
|
||||
albumId = try container.decode(Int.self, forKey: .albumId)
|
||||
userId = try container.decode(String.self, forKey: .userId)
|
||||
remoteUrl = try container.decodeIfPresent(String.self, forKey: .remoteUrl) ?? ""
|
||||
serverMaterialId = try container.decodeIfPresent(Int.self, forKey: .serverMaterialId) ?? 0
|
||||
autoRetouchState = try container.decodeIfPresent(
|
||||
TravelAlbumAutoRetouchState.self,
|
||||
forKey: .autoRetouchState
|
||||
) ?? .none
|
||||
autoRetouchTemplateId = try container.decodeIfPresent(Int.self, forKey: .autoRetouchTemplateId)
|
||||
autoRetouchClientRequestId = try container.decodeIfPresent(
|
||||
String.self,
|
||||
forKey: .autoRetouchClientRequestId
|
||||
) ?? ""
|
||||
autoRetouchBatchId = try container.decodeIfPresent(Int.self, forKey: .autoRetouchBatchId) ?? 0
|
||||
autoRetouchAttempt = try container.decodeIfPresent(Int.self, forKey: .autoRetouchAttempt) ?? 0
|
||||
autoRetouchErrorMessage = try container.decodeIfPresent(String.self, forKey: .autoRetouchErrorMessage)
|
||||
updatedAt = try container.decodeIfPresent(Int64.self, forKey: .updatedAt) ?? 0
|
||||
}
|
||||
|
||||
/// 把中断中的传输恢复为待上传,避免重进页面卡在上传中。
|
||||
func normalizedAfterInterruptedTransfer() -> TravelAlbumOTGPhotoRecord {
|
||||
guard status == .transferring || status == .uploading else { return self }
|
||||
var copy = self
|
||||
copy.status = .pending
|
||||
copy.progress = 0
|
||||
copy.errorMessage = nil
|
||||
if status == .transferring || status == .uploading {
|
||||
copy.status = .pending
|
||||
copy.progress = 0
|
||||
copy.errorMessage = nil
|
||||
}
|
||||
if autoRetouchState == .submitting {
|
||||
copy.autoRetouchState = .pendingSubmission
|
||||
}
|
||||
guard copy != self else { return self }
|
||||
copy.updatedAt = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
return copy
|
||||
}
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import Foundation
|
||||
|
||||
/// 自动修图设置状态,负责加载真实精修模板、单选和配置校验,不直接创建修图任务。
|
||||
final class TravelAlbumAutoRetouchSettingViewModel {
|
||||
/// 设置页当前层级。
|
||||
enum Stage: Sendable, Equatable {
|
||||
case mode
|
||||
case templates
|
||||
}
|
||||
|
||||
let scenicId: Int
|
||||
let startsWithModeSelection: Bool
|
||||
private(set) var stage: Stage
|
||||
private(set) var templates: [TravelAlbumAIRetouchTemplate] = []
|
||||
private(set) var selectedTemplateId: Int?
|
||||
private(set) var isEnabled: Bool
|
||||
private(set) var isLoading = false
|
||||
private(set) var errorMessage: String?
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
|
||||
/// 创建自动修图配置状态。
|
||||
init(
|
||||
scenicId: Int,
|
||||
configuration: TravelAlbumAutoRetouchConfiguration,
|
||||
startsWithModeSelection: Bool
|
||||
) {
|
||||
self.scenicId = scenicId
|
||||
self.startsWithModeSelection = startsWithModeSelection
|
||||
self.stage = startsWithModeSelection ? .mode : .templates
|
||||
self.isEnabled = configuration.enabled
|
||||
self.selectedTemplateId = configuration.refinedTemplateId
|
||||
}
|
||||
|
||||
/// 当前可提交配置;启用状态必须已经选择服务端模板。
|
||||
var pendingConfiguration: TravelAlbumAutoRetouchConfiguration? {
|
||||
if !isEnabled { return .disabled }
|
||||
guard let selectedTemplateId,
|
||||
templates.contains(where: { $0.id == selectedTemplateId }) else {
|
||||
return nil
|
||||
}
|
||||
return TravelAlbumAutoRetouchConfiguration(
|
||||
enabled: true,
|
||||
refinedTemplateId: selectedTemplateId
|
||||
)
|
||||
}
|
||||
|
||||
/// 拉取当前景区的真实精修模板。
|
||||
func loadTemplates(api: any TravelAlbumServing) async {
|
||||
guard scenicId > 0 else {
|
||||
errorMessage = "请先选择景区"
|
||||
notify()
|
||||
return
|
||||
}
|
||||
guard !isLoading else { return }
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
notify()
|
||||
defer {
|
||||
isLoading = false
|
||||
notify()
|
||||
}
|
||||
do {
|
||||
let response = try await api.aiRetouchTemplates(scenicId: scenicId)
|
||||
templates = response.refinedTemplates
|
||||
if !templates.contains(where: { $0.id == selectedTemplateId }) {
|
||||
selectedTemplateId = nil
|
||||
}
|
||||
if templates.isEmpty { errorMessage = "暂无可用的原图精修模板" }
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
templates = []
|
||||
errorMessage = error.localizedDescription.isEmpty ? "模板加载失败" : error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 选择一级修图方式。
|
||||
func selectMode(enabled: Bool) {
|
||||
isEnabled = enabled
|
||||
if enabled {
|
||||
stage = .templates
|
||||
} else {
|
||||
selectedTemplateId = nil
|
||||
}
|
||||
notify()
|
||||
}
|
||||
|
||||
/// 选择一个真实精修模板。
|
||||
func selectTemplate(id: Int) {
|
||||
guard templates.contains(where: { $0.id == id }) else { return }
|
||||
isEnabled = true
|
||||
selectedTemplateId = id
|
||||
notify()
|
||||
}
|
||||
|
||||
/// 从模板列表返回修图方式层级。
|
||||
func returnToModeSelection() {
|
||||
guard startsWithModeSelection else { return }
|
||||
stage = .mode
|
||||
notify()
|
||||
}
|
||||
|
||||
private func notify() { onStateChange?() }
|
||||
}
|
||||
@@ -121,6 +121,7 @@ final class TravelAlbumEntryViewModel {
|
||||
freeCount: String,
|
||||
singlePrice: String,
|
||||
packagePrice: String,
|
||||
autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled,
|
||||
order: TravelAlbumAvailableOrder?,
|
||||
api: any TravelAlbumServing
|
||||
) async {
|
||||
@@ -143,6 +144,10 @@ final class TravelAlbumEntryViewModel {
|
||||
onShowMessage?("请输入有效的单张照片价格")
|
||||
return
|
||||
}
|
||||
if autoRetouchConfiguration.enabled && !autoRetouchConfiguration.isValid {
|
||||
onShowMessage?("请选择有效的 AI 修图模板")
|
||||
return
|
||||
}
|
||||
|
||||
let albumName: String
|
||||
switch mode {
|
||||
@@ -162,7 +167,8 @@ final class TravelAlbumEntryViewModel {
|
||||
materialNum: Int(freeCount) ?? 0,
|
||||
materialPrice: materialPrice,
|
||||
materialPackagePrice: Double(packagePrice) ?? 0,
|
||||
photoPrice: 0
|
||||
photoPrice: 0,
|
||||
autoRetouchConfiguration: autoRetouchConfiguration
|
||||
)
|
||||
case .preOrder:
|
||||
request = TravelAlbumCreateRequest(
|
||||
@@ -172,7 +178,8 @@ final class TravelAlbumEntryViewModel {
|
||||
materialNum: nil,
|
||||
materialPrice: nil,
|
||||
materialPackagePrice: nil,
|
||||
photoPrice: nil
|
||||
photoPrice: nil,
|
||||
autoRetouchConfiguration: autoRetouchConfiguration
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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