1114 lines
41 KiB
Swift
1114 lines
41 KiB
Swift
//
|
|
// WiredCameraTransferViewModel.swift
|
|
// suixinkan
|
|
//
|
|
|
|
import Darwin
|
|
import Foundation
|
|
|
|
/// 手机相册导入到 OTG 本地缓存的图片数据。
|
|
struct TravelAlbumPhoneAlbumImportItem: Sendable, Equatable {
|
|
let data: Data
|
|
let fileName: String
|
|
let createdAt: Date
|
|
|
|
/// 创建手机相册导入图片。
|
|
init(data: Data, fileName: String, createdAt: Date = Date()) {
|
|
self.data = data
|
|
self.fileName = fileName
|
|
self.createdAt = createdAt
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
private static let transferModeDefaultsKey = "travelAlbum.otg.transferMode"
|
|
|
|
let albumId: Int
|
|
let albumTitle: String
|
|
let headerPhone: String
|
|
let scenicSpotLabel: String?
|
|
|
|
private(set) var connectionState: ConnectionState = .idle {
|
|
didSet { notifyStateChanged() }
|
|
}
|
|
private(set) var photos: [TravelAlbumOTGPhotoItem] = [] {
|
|
didSet {
|
|
normalizeSelectedTimeSlot()
|
|
notifyStateChanged()
|
|
}
|
|
}
|
|
private(set) var selectedTab: TravelAlbumOTGTransferTab = .all {
|
|
didSet {
|
|
normalizeSelectedTimeSlot()
|
|
notifyStateChanged()
|
|
}
|
|
}
|
|
private(set) var selectedPhotoIds: Set<String> = [] {
|
|
didSet { notifyStateChanged() }
|
|
}
|
|
private(set) var selectUploadMode = false {
|
|
didSet { notifyStateChanged() }
|
|
}
|
|
private(set) var sidebarExpanded = true {
|
|
didSet { notifyStateChanged() }
|
|
}
|
|
private(set) var selectedTimeSlotId: String? {
|
|
didSet {
|
|
guard !suppressSelectedTimeSlotNotification else { return }
|
|
notifyStateChanged()
|
|
}
|
|
}
|
|
private(set) var sonyMTPHint: String?
|
|
private(set) var isContentCatalogReady = false
|
|
|
|
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() }
|
|
}
|
|
|
|
var onStateChange: (() -> Void)?
|
|
var onShowMessage: ((String) -> Void)?
|
|
var onLiveShotSaved: ((String) -> Void)?
|
|
|
|
private let connectionManager: any WiredCameraConnectionManaging
|
|
private let storage: TravelAlbumOTGPhotoStore
|
|
private let repository: TravelAlbumOTGPhotoRepository
|
|
private let uploader: any TravelAlbumOTGUploading
|
|
private let api: any TravelAlbumServing
|
|
private let appStore: AppStore
|
|
private let userDefaults: UserDefaults
|
|
|
|
private var currentDriver: CameraDriver?
|
|
private var persistedRecordsById: [String: TravelAlbumOTGPhotoRecord] = [:]
|
|
private var isUploading = false
|
|
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(
|
|
albumId: Int,
|
|
albumTitle: String,
|
|
headerPhone: String,
|
|
scenicSpotLabel: String? = nil,
|
|
initialTransferMode: TravelAlbumOTGTransferMode? = nil,
|
|
initialAutoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled,
|
|
connectionManager: (any WiredCameraConnectionManaging)? = nil,
|
|
storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(),
|
|
uploader: (any TravelAlbumOTGUploading)? = nil,
|
|
api: (any TravelAlbumServing)? = nil,
|
|
appStore: AppStore = .shared,
|
|
userDefaults: UserDefaults = .standard
|
|
) {
|
|
self.albumId = albumId
|
|
self.albumTitle = albumTitle.isEmpty ? "有线传输" : albumTitle
|
|
self.headerPhone = headerPhone
|
|
self.scenicSpotLabel = scenicSpotLabel
|
|
self.connectionManager = connectionManager ?? ConnectionManager.shared
|
|
self.storage = storage
|
|
self.repository = TravelAlbumOTGPhotoRepository(storage: storage)
|
|
self.uploader = uploader ?? TravelAlbumOTGUploader()
|
|
self.api = api ?? NetworkServices.shared.travelAlbumAPI
|
|
self.appStore = appStore
|
|
self.userDefaults = userDefaults
|
|
self.autoRetouchConfiguration = initialAutoRetouchConfiguration
|
|
self.transferMode = initialTransferMode ?? Self.persistedTransferMode(in: userDefaults)
|
|
if let initialTransferMode {
|
|
userDefaults.set(initialTransferMode.rawValue, forKey: Self.transferModeDefaultsKey)
|
|
}
|
|
}
|
|
|
|
/// 相机状态文案。
|
|
var cameraStatusText: String {
|
|
switch connectionState {
|
|
case .idle, .disconnected:
|
|
return "未连接 USB"
|
|
case .searching:
|
|
return "正在搜索"
|
|
case .connecting:
|
|
return "正在连接"
|
|
case .connected(_, let name):
|
|
return "\(name)已连接"
|
|
case .failed:
|
|
return "连接失败"
|
|
}
|
|
}
|
|
|
|
/// 操作按钮文案。
|
|
var actionButtonText: String {
|
|
switch connectionState {
|
|
case .idle, .disconnected, .failed:
|
|
return "重新连接"
|
|
case .searching, .connecting:
|
|
return "等待连接"
|
|
case .connected:
|
|
return "刷新"
|
|
}
|
|
}
|
|
|
|
/// 当前手机型号名称。
|
|
var deviceModelName: String {
|
|
Self.currentPhoneModelName()
|
|
}
|
|
|
|
/// 当前手机可用存储空间。
|
|
var availableStorageText: String {
|
|
Self.availableStorageText()
|
|
}
|
|
|
|
/// 是否已连接相机。
|
|
var isCameraConnected: Bool {
|
|
if case .connected = connectionState {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
/// Tab 统计。
|
|
var tabCounts: [Int] {
|
|
[
|
|
photos.count,
|
|
photos.filter { $0.status == .uploaded }.count,
|
|
photos.filter { $0.status == .failed }.count,
|
|
]
|
|
}
|
|
|
|
/// 过滤后的照片列表。
|
|
func filteredPhotos() -> [TravelAlbumOTGPhotoItem] {
|
|
switch selectedTab {
|
|
case .all:
|
|
return photos
|
|
case .uploaded:
|
|
return photos.filter { $0.status == .uploaded }
|
|
case .failed:
|
|
return photos.filter { $0.status == .failed }
|
|
}
|
|
}
|
|
|
|
/// 过滤后照片的半小时时间分段。
|
|
var photoSections: [TravelAlbumOTGPhotoSection] {
|
|
filteredPhotos().buildPhotoSections()
|
|
}
|
|
|
|
/// 过滤后照片的左侧时间导航分组。
|
|
var sidebarGroups: [TravelAlbumOTGDateGroup] {
|
|
filteredPhotos().buildSidebarGroups()
|
|
}
|
|
|
|
/// 当前可见列表中可被选择上传的照片 ID。
|
|
var selectableVisiblePhotoIds: Set<String> {
|
|
Set(filteredPhotos().filter(\.canSelectForUpload).map(\.id))
|
|
}
|
|
|
|
/// 当前可见列表中是否已全选可上传照片。
|
|
var allVisibleSelectablePhotosSelected: Bool {
|
|
let ids = selectableVisiblePhotoIds
|
|
return !ids.isEmpty && ids.isSubset(of: selectedPhotoIds)
|
|
}
|
|
|
|
/// 当前可见列表中已选择的可上传照片数量。
|
|
var selectedVisibleUploadCount: Int {
|
|
selectedPhotoIds.intersection(selectableVisiblePhotoIds).count
|
|
}
|
|
|
|
/// 传输模式展示文案。
|
|
var transferModeOption: String {
|
|
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
|
|
}
|
|
|
|
/// 是否可打开指定上传选项。
|
|
var canOpenSpecifyUploadChooser: Bool {
|
|
!selectUploadMode
|
|
}
|
|
|
|
/// 绑定连接管理器并启动发现。
|
|
func start() {
|
|
connectionManager.configureLiveTransfer(albumID: albumId)
|
|
connectionManager.delegate = self
|
|
syncFromConnectionManager()
|
|
loadPersistedPhotos()
|
|
syncServerUploadStatuses()
|
|
syncAutoRetouchConfiguration()
|
|
resumePendingAutoRetouches()
|
|
updateAutoRetouchPolling()
|
|
connectionManager.start()
|
|
}
|
|
|
|
/// 页面消失时解绑 UI delegate,并暂停边拍边传轮询,保留底层设备缓存。
|
|
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()
|
|
}
|
|
|
|
/// 刷新相机文件列表或重新连接。
|
|
func refreshCameraFiles() {
|
|
switch connectionState {
|
|
case .connected:
|
|
loadPersistedPhotos()
|
|
case .idle, .disconnected, .failed:
|
|
connectionManager.start()
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
/// 重新加载当前相册本地 OTG 照片。
|
|
func reloadLocalPhotos() {
|
|
loadPersistedPhotos()
|
|
}
|
|
|
|
/// 切换 Tab。
|
|
func selectTab(_ tab: TravelAlbumOTGTransferTab) {
|
|
selectedTab = tab
|
|
selectedPhotoIds = selectedPhotoIds.intersection(Set(filteredPhotos().map(\.id)))
|
|
}
|
|
|
|
/// 展开或收起左侧时间快速导航。
|
|
func toggleSidebar() {
|
|
sidebarExpanded.toggle()
|
|
}
|
|
|
|
/// 选择左侧时间槽,并联动主列表滚动。
|
|
func selectTimeSlot(id: String) {
|
|
guard photoSections.contains(where: { $0.slotId == id }) else { return }
|
|
selectedTimeSlotId = id
|
|
}
|
|
|
|
/// 同步主列表当前可见时间槽,只更新左侧时间轴选中态,不触发整页重新绑定。
|
|
func syncVisibleTimeSlot(id: String) {
|
|
guard selectedTimeSlotId != id,
|
|
photoSections.contains(where: { $0.slotId == id }) else {
|
|
return
|
|
}
|
|
suppressSelectedTimeSlotNotification = true
|
|
selectedTimeSlotId = id
|
|
suppressSelectedTimeSlotNotification = false
|
|
}
|
|
|
|
/// 切换传输模式。
|
|
func selectTransferMode(_ option: String) {
|
|
guard let mode = TravelAlbumOTGTransferMode.option(title: option),
|
|
mode != transferMode else {
|
|
return
|
|
}
|
|
transferMode = mode
|
|
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 }
|
|
photoFormatOption = option
|
|
applyMergedPhotos()
|
|
}
|
|
|
|
/// 批量上传按钮:首次进入选择模式,再次上传选中照片。
|
|
func onBatchUploadButtonClick() {
|
|
if !selectUploadMode {
|
|
selectUploadMode = true
|
|
selectedPhotoIds = []
|
|
return
|
|
}
|
|
if selectedPhotoIds.isEmpty {
|
|
selectUploadMode = false
|
|
return
|
|
}
|
|
uploadSelectedTransferPhotos()
|
|
}
|
|
|
|
/// 选择指定上传选项后开始上传。
|
|
func onSpecifyUploadOptionSelected(_ option: TravelAlbumOTGSpecifyUploadOption) {
|
|
guard canOpenSpecifyUploadChooser else { return }
|
|
switch option {
|
|
case .allPending:
|
|
uploadAllNotUploadedPhotos()
|
|
case .todayCaptured:
|
|
uploadAllTodayCapturedPhotos()
|
|
}
|
|
}
|
|
|
|
/// 创建相机历史照片选择导入页 ViewModel。
|
|
func makeCameraImportViewModel() -> TravelAlbumCameraImportViewModel? {
|
|
guard isContentCatalogReady else {
|
|
showMessage("相机照片目录尚未就绪,请稍候再试")
|
|
return nil
|
|
}
|
|
guard let currentDriver else {
|
|
showMessage("请先连接相机")
|
|
return nil
|
|
}
|
|
if case .connected = connectionState {
|
|
return TravelAlbumCameraImportViewModel(
|
|
albumId: albumId,
|
|
albumTitle: albumTitle,
|
|
driver: currentDriver,
|
|
storage: storage,
|
|
appStore: appStore
|
|
)
|
|
}
|
|
showMessage("请先连接相机")
|
|
return nil
|
|
}
|
|
|
|
/// 历史导入期间暂停边拍边传轮询,避免 PTP 轮询与 ImageCaptureCore 下载互相抢占。
|
|
func pauseLiveTransferForHistoricalImport() {
|
|
connectionManager.suspendLiveTransfer()
|
|
}
|
|
|
|
/// 处理本次新传入 App 的照片,按当前传输模式决定是否自动上传。
|
|
func handleNewlyImportedPhotoIds(_ photoIds: [String]) {
|
|
let ids = Set(photoIds)
|
|
loadPersistedPhotos()
|
|
guard !ids.isEmpty, transferMode.shouldAutoUploadNewImports else { return }
|
|
startUploadByIds(
|
|
ids,
|
|
emptyMessage: "暂无可自动上传的照片",
|
|
queueIfBusy: true
|
|
)
|
|
}
|
|
|
|
/// 将手机相册选择的图片导入当前 OTG 相册本地缓存。
|
|
func importPhoneAlbumImages(_ items: [TravelAlbumPhoneAlbumImportItem]) -> (importedCount: Int, failedCount: Int, importedPhotoIds: [String]) {
|
|
guard !items.isEmpty else { return (0, 0, []) }
|
|
var importedPhotoIds: [String] = []
|
|
var failedCount = 0
|
|
|
|
for item in items {
|
|
do {
|
|
let fileURL = try storage.writeImage(item.data, filename: item.fileName, albumId: albumId)
|
|
let record = TravelAlbumOTGPhotoRecord(
|
|
id: "phone_album_\(UUID().uuidString)",
|
|
fileName: fileURL.lastPathComponent,
|
|
localPath: storage.relativePath(for: fileURL, albumId: albumId),
|
|
capturedAt: TravelAlbumOTGPhotoStore.transferTimeText(item.createdAt),
|
|
fileSizeBytes: Int64(item.data.count),
|
|
status: .pending,
|
|
albumId: albumId,
|
|
userId: appStore.session.userId
|
|
)
|
|
storage.upsert(record, albumId: albumId)
|
|
importedPhotoIds.append(record.id)
|
|
} catch {
|
|
failedCount += 1
|
|
OTGLog.error(.connection, "phone album import failed: \(item.fileName) \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
handleNewlyImportedPhotoIds(importedPhotoIds)
|
|
return (importedPhotoIds.count, failedCount, importedPhotoIds)
|
|
}
|
|
|
|
/// 选择或取消选择照片。
|
|
func toggleTransferPhotoSelection(photoId: String) {
|
|
guard selectUploadMode,
|
|
photos.first(where: { $0.id == photoId })?.canSelectForUpload == true else {
|
|
return
|
|
}
|
|
if selectedPhotoIds.contains(photoId) {
|
|
selectedPhotoIds.remove(photoId)
|
|
} else {
|
|
selectedPhotoIds.insert(photoId)
|
|
}
|
|
}
|
|
|
|
/// 切换当前可见照片的全选状态。
|
|
func toggleVisibleSelectablePhotos() {
|
|
guard selectUploadMode else { return }
|
|
let ids = selectableVisiblePhotoIds
|
|
guard !ids.isEmpty else { return }
|
|
if ids.isSubset(of: selectedPhotoIds) {
|
|
selectedPhotoIds.subtract(ids)
|
|
} else {
|
|
selectedPhotoIds.formUnion(ids)
|
|
}
|
|
}
|
|
|
|
/// 上传选中照片。
|
|
func uploadSelectedTransferPhotos() {
|
|
let ids = selectedPhotoIds
|
|
guard !ids.isEmpty else {
|
|
showMessage("请先选择照片")
|
|
return
|
|
}
|
|
selectUploadMode = false
|
|
selectedPhotoIds = []
|
|
startUploadByIds(ids, emptyMessage: "所选照片不可上传")
|
|
}
|
|
|
|
private func uploadAllNotUploadedPhotos() {
|
|
let ids = Set(photos.filter(\.isNotUploaded).map(\.id))
|
|
startUploadByIds(ids, emptyMessage: "暂无未上传的照片")
|
|
}
|
|
|
|
private func uploadAllTodayCapturedPhotos() {
|
|
let ids = Set(photos.filter { photo in
|
|
guard photo.isNotUploaded,
|
|
let capturedDate = photo.capturedDate() else {
|
|
return false
|
|
}
|
|
return Calendar.current.isDateInToday(capturedDate)
|
|
}.map(\.id))
|
|
startUploadByIds(ids, emptyMessage: "暂无今日拍摄且未上传的照片")
|
|
}
|
|
|
|
/// 重试上传单张照片。
|
|
func retryPhoto(photoId: String) {
|
|
startUploadByIds([photoId], emptyMessage: "本地文件不存在,无法重传")
|
|
}
|
|
|
|
/// 删除单张本地照片。
|
|
func deletePhoto(photoId: String) {
|
|
storage.remove(albumId: albumId, photoId: photoId)
|
|
persistedRecordsById.removeValue(forKey: photoId)
|
|
selectedPhotoIds.remove(photoId)
|
|
applyMergedPhotos()
|
|
showMessage("已删除")
|
|
}
|
|
|
|
/// 清空当前相册本地 OTG 缓存。
|
|
func clearLocalAlbumCache() {
|
|
storage.clearAlbum(albumId: albumId)
|
|
persistedRecordsById = [:]
|
|
photos = []
|
|
}
|
|
|
|
private func syncFromConnectionManager() {
|
|
currentDriver = connectionManager.currentDriver
|
|
isContentCatalogReady = connectionManager.isContentCatalogReady
|
|
connectionState = connectionManager.state
|
|
}
|
|
|
|
private func loadPersistedPhotos() {
|
|
let records = storage.load(albumId: albumId)
|
|
persistedRecordsById = Dictionary(uniqueKeysWithValues: records.map { ($0.id, $0) })
|
|
applyMergedPhotos()
|
|
}
|
|
|
|
private func syncServerUploadStatuses() {
|
|
serverStatusSyncTask?.cancel()
|
|
guard albumId > 0 else { return }
|
|
let baseline = persistedRecordsById
|
|
serverStatusSyncTask = Task { [weak self] in
|
|
guard let self else { return }
|
|
do {
|
|
let response = try await api.materialClientPhotoIds(userEquityTravelId: albumId)
|
|
try Task.checkCancellation()
|
|
let activeIds = Set(persistedRecordsById.values.compactMap { record in
|
|
record.status == .transferring || record.status == .uploading ? record.id : nil
|
|
})
|
|
let reconciled = TravelAlbumOTGServerStatusPolicy.reconcile(
|
|
records: Array(persistedRecordsById.values),
|
|
baselineRecordsById: baseline,
|
|
activePhotoIds: activeIds,
|
|
serverClientPhotoIds: response.clientPhotoIds,
|
|
now: Int64(Date().timeIntervalSince1970 * 1000)
|
|
)
|
|
let reconciledById = Dictionary(uniqueKeysWithValues: reconciled.map { ($0.id, $0) })
|
|
guard reconciledById != persistedRecordsById else { return }
|
|
persistedRecordsById = reconciledById
|
|
storage.save(reconciled, albumId: albumId)
|
|
applyMergedPhotos()
|
|
selectedPhotoIds = selectedPhotoIds.intersection(selectableVisiblePhotoIds)
|
|
} catch is CancellationError {
|
|
return
|
|
} catch {
|
|
OTGLog.error(.connection, "sync server upload statuses failed: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
|
|
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) }
|
|
photos = persistedItems.sorted { $0.capturedAt > $1.capturedAt }
|
|
}
|
|
|
|
private func normalizeSelectedTimeSlot() {
|
|
let sections = filteredPhotos().buildPhotoSections()
|
|
guard let firstSlotId = sections.first?.slotId else {
|
|
selectedTimeSlotId = nil
|
|
return
|
|
}
|
|
if selectedTimeSlotId == nil || !sections.contains(where: { $0.slotId == selectedTimeSlotId }) {
|
|
selectedTimeSlotId = firstSlotId
|
|
}
|
|
}
|
|
|
|
private func startUploadByIds(
|
|
_ ids: Set<String>,
|
|
emptyMessage: String,
|
|
queueIfBusy: Bool = false
|
|
) {
|
|
guard !isUploading else {
|
|
if queueIfBusy {
|
|
queuedAutoUploadPhotoIds.formUnion(ids)
|
|
} else {
|
|
showMessage("正在上传,请稍候")
|
|
}
|
|
return
|
|
}
|
|
let targets = resolveUploadTargets(ids)
|
|
guard !targets.isEmpty else {
|
|
showMessage(emptyMessage)
|
|
return
|
|
}
|
|
isUploading = true
|
|
Task {
|
|
for id in targets {
|
|
await uploadPhoto(id: id)
|
|
}
|
|
isUploading = false
|
|
notifyStateChanged()
|
|
uploadQueuedAutoImportsIfNeeded()
|
|
}
|
|
}
|
|
|
|
private func uploadQueuedAutoImportsIfNeeded() {
|
|
let ids = queuedAutoUploadPhotoIds
|
|
queuedAutoUploadPhotoIds = []
|
|
guard !ids.isEmpty else { return }
|
|
startUploadByIds(
|
|
ids,
|
|
emptyMessage: "暂无可自动上传的照片",
|
|
queueIfBusy: true
|
|
)
|
|
}
|
|
|
|
private func resolveUploadTargets(_ ids: Set<String>) -> [String] {
|
|
ids.filter { id in
|
|
guard let record = persistedRecordsById[id] else { return false }
|
|
return record.status == .pending || record.status == .failed
|
|
}
|
|
}
|
|
|
|
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)
|
|
let material = try await uploader.upload(
|
|
record: record,
|
|
scenicId: appStore.session.currentScenicId
|
|
) { [weak self] progress in
|
|
self?.updateRecord(id: id, status: .uploading, progress: progress, error: nil)
|
|
}
|
|
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,
|
|
storage.fileExists(relativePath: record.localPath, albumId: albumId) {
|
|
return storage.recordWithResolvedFilePaths(record, albumId: albumId)
|
|
}
|
|
throw TravelAlbumOTGUploadError.localFileMissing
|
|
}
|
|
|
|
private func updateRecord(
|
|
id: String,
|
|
status: TravelAlbumOTGUploadStatus,
|
|
progress: Int,
|
|
error: String?,
|
|
remoteUrl: String? = nil
|
|
) {
|
|
var record = persistedRecordsById[id]
|
|
if record == nil, let item = photos.first(where: { $0.id == id }) {
|
|
record = TravelAlbumOTGPhotoRecord(
|
|
id: item.id,
|
|
sourceId: item.sourceId,
|
|
fileName: item.fileName,
|
|
localPath: item.localPath,
|
|
thumbnailPath: item.thumbnailURL?.isFileURL == true
|
|
? storage.relativePath(for: item.thumbnailURL ?? URL(fileURLWithPath: ""), albumId: albumId)
|
|
: "",
|
|
capturedAt: item.capturedAt,
|
|
fileSizeBytes: item.fileSizeBytes,
|
|
status: status,
|
|
progress: progress,
|
|
errorMessage: error,
|
|
albumId: albumId,
|
|
userId: appStore.session.userId,
|
|
remoteUrl: remoteUrl ?? item.remoteUrl
|
|
)
|
|
}
|
|
guard var record else { return }
|
|
record.status = status
|
|
record.progress = min(max(progress, 0), 100)
|
|
record.errorMessage = error
|
|
if let remoteUrl {
|
|
record.remoteUrl = remoteUrl
|
|
}
|
|
record.updatedAt = Int64(Date().timeIntervalSince1970 * 1000)
|
|
persistedRecordsById[id] = record
|
|
storage.upsert(record, albumId: albumId)
|
|
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?()
|
|
} else {
|
|
DispatchQueue.main.async { [weak self] in self?.onStateChange?() }
|
|
}
|
|
}
|
|
|
|
private static func persistedTransferMode(in userDefaults: UserDefaults) -> TravelAlbumOTGTransferMode {
|
|
guard let rawValue = userDefaults.string(forKey: transferModeDefaultsKey),
|
|
let mode = TravelAlbumOTGTransferMode(rawValue: rawValue) else {
|
|
return .liveUpload
|
|
}
|
|
return mode
|
|
}
|
|
|
|
private static func currentPhoneModelName() -> String {
|
|
let identifier = machineIdentifier()
|
|
if identifier == "i386" || identifier == "x86_64" || identifier == "arm64" {
|
|
return "iPhone 模拟器"
|
|
}
|
|
if let modelName = iPhoneModelNames[identifier] {
|
|
return modelName
|
|
}
|
|
return identifier.hasPrefix("iPhone") ? identifier : "iPhone"
|
|
}
|
|
|
|
private static func machineIdentifier() -> String {
|
|
var systemInfo = utsname()
|
|
uname(&systemInfo)
|
|
return withUnsafePointer(to: &systemInfo.machine) { pointer in
|
|
pointer.withMemoryRebound(to: CChar.self, capacity: 1) {
|
|
String(validatingUTF8: $0) ?? "iPhone"
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func availableStorageText() -> String {
|
|
guard let bytes = availableStorageBytes() else {
|
|
return "-- GB 可用"
|
|
}
|
|
let gigabytes = Double(bytes) / 1_073_741_824.0
|
|
if gigabytes >= 100 {
|
|
return "\(Int(gigabytes.rounded(.down))) GB 可用"
|
|
}
|
|
return String(format: "%.1f GB 可用", gigabytes)
|
|
}
|
|
|
|
private static func availableStorageBytes() -> Int64? {
|
|
let homeURL = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
|
|
if let values = try? homeURL.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]),
|
|
let capacity = values.volumeAvailableCapacityForImportantUsage {
|
|
return capacity
|
|
}
|
|
if let attributes = try? FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory()),
|
|
let freeSize = attributes[.systemFreeSize] as? NSNumber {
|
|
return freeSize.int64Value
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static let iPhoneModelNames: [String: String] = [
|
|
"iPhone12,1": "iPhone 11",
|
|
"iPhone12,3": "iPhone 11 Pro",
|
|
"iPhone12,5": "iPhone 11 Pro Max",
|
|
"iPhone12,8": "iPhone SE 2",
|
|
"iPhone13,1": "iPhone 12 mini",
|
|
"iPhone13,2": "iPhone 12",
|
|
"iPhone13,3": "iPhone 12 Pro",
|
|
"iPhone13,4": "iPhone 12 Pro Max",
|
|
"iPhone14,2": "iPhone 13 Pro",
|
|
"iPhone14,3": "iPhone 13 Pro Max",
|
|
"iPhone14,4": "iPhone 13 mini",
|
|
"iPhone14,5": "iPhone 13",
|
|
"iPhone14,6": "iPhone SE 3",
|
|
"iPhone14,7": "iPhone 14",
|
|
"iPhone14,8": "iPhone 14 Plus",
|
|
"iPhone15,2": "iPhone 14 Pro",
|
|
"iPhone15,3": "iPhone 14 Pro Max",
|
|
"iPhone15,4": "iPhone 15",
|
|
"iPhone15,5": "iPhone 15 Plus",
|
|
"iPhone16,1": "iPhone 15 Pro",
|
|
"iPhone16,2": "iPhone 15 Pro Max",
|
|
"iPhone17,1": "iPhone 16 Pro",
|
|
"iPhone17,2": "iPhone 16 Pro Max",
|
|
"iPhone17,3": "iPhone 16",
|
|
"iPhone17,4": "iPhone 16 Plus",
|
|
"iPhone17,5": "iPhone 16e",
|
|
]
|
|
|
|
private func showMessage(_ message: String) {
|
|
if Thread.isMainThread {
|
|
onShowMessage?(message)
|
|
} else {
|
|
DispatchQueue.main.async { [weak self] in self?.onShowMessage?(message) }
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
extension WiredCameraTransferViewModel: ConnectionManagerDelegate {
|
|
func connectionManager(_ manager: ConnectionManager, didUpdate state: ConnectionState) {
|
|
connectionState = state
|
|
}
|
|
|
|
func connectionManager(_ manager: ConnectionManager, didCreate driver: any CameraDriver) {
|
|
currentDriver = driver
|
|
}
|
|
|
|
func connectionManager(_ manager: ConnectionManager, contentCatalogDidBecomeReady driver: any CameraDriver) {
|
|
currentDriver = driver
|
|
isContentCatalogReady = true
|
|
notifyStateChanged()
|
|
}
|
|
|
|
func connectionManager(_ manager: ConnectionManager, didFail error: any Error) {
|
|
showMessage(error.localizedDescription)
|
|
}
|
|
|
|
func connectionManagerDidSuggestReplug(_ manager: ConnectionManager) {
|
|
showMessage("长时间未找到相机,请尝试重新插拔连接线")
|
|
}
|
|
|
|
func connectionManager(_ manager: ConnectionManager, didSaveLiveShot record: TravelAlbumOTGPhotoRecord) {
|
|
guard record.albumId == albumId else { return }
|
|
handleNewlyImportedPhotoIds([record.id])
|
|
onLiveShotSaved?(record.fileName)
|
|
}
|
|
}
|