新增ai修图
This commit is contained in:
@ -116,6 +116,8 @@ struct TravelAlbumAvailableOrder: Decodable, Sendable, Equatable, Hashable {
|
||||
|
||||
/// 旅拍相册素材实体,对齐 Android `TravelAlbumMaterialEntity`。
|
||||
struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
||||
private static let bundledImageURLPrefix = "asset://"
|
||||
|
||||
let id: Int
|
||||
let userEquityTravelId: Int
|
||||
let status: Int
|
||||
@ -175,6 +177,18 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
|
||||
/// 将工程内图片资源转换为素材可识别的本地地址,仅供无接口数据的演示素材使用。
|
||||
static func bundledImageURL(assetName: String) -> String {
|
||||
bundledImageURLPrefix + assetName
|
||||
}
|
||||
|
||||
/// 返回本地演示素材对应的图片资源名;真实网络素材返回 `nil`。
|
||||
var bundledImageAssetName: String? {
|
||||
guard fileUrl.hasPrefix(Self.bundledImageURLPrefix) else { return nil }
|
||||
let assetName = String(fileUrl.dropFirst(Self.bundledImageURLPrefix.count))
|
||||
return assetName.isEmpty ? nil : assetName
|
||||
}
|
||||
}
|
||||
|
||||
/// 旅拍相册创建请求体,对齐 Android `TravelAlbumCreateRequest`。
|
||||
|
||||
@ -0,0 +1,316 @@
|
||||
//
|
||||
// TravelAlbumPhotoPreviewModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 单张照片的 AI 修图状态,控制处理中禁用、结果对比以及再次修图覆盖。
|
||||
struct TravelAlbumAIEditResultState: Equatable, Sendable {
|
||||
/// 相册缩略图需要展示的 AI 修图状态。
|
||||
enum ThumbnailStatus: Equatable, Sendable {
|
||||
case none
|
||||
case processing
|
||||
case edited
|
||||
case cover
|
||||
case failed
|
||||
}
|
||||
|
||||
/// 当前预览展示的版本。
|
||||
enum DisplayMode: Equatable, Sendable {
|
||||
case original
|
||||
case edited
|
||||
case atmosphere
|
||||
case cover
|
||||
}
|
||||
|
||||
private(set) var isProcessing = false
|
||||
private(set) var didFailLastAttempt = false
|
||||
private(set) var appliedPresetID: String?
|
||||
private(set) var appliedAtmosphereOptionID: String?
|
||||
private(set) var appliedCoverTemplateID: String?
|
||||
private(set) var displayMode: DisplayMode = .original
|
||||
|
||||
/// 已经存在可查看的修图结果。
|
||||
var hasEditedResult: Bool {
|
||||
appliedPresetID != nil
|
||||
}
|
||||
|
||||
/// 处理期间禁止再次发起 AI 修图。
|
||||
var canStartEditing: Bool {
|
||||
!isProcessing
|
||||
}
|
||||
|
||||
/// 当前结果是否为封面模板生成的封面图。
|
||||
var isCover: Bool {
|
||||
appliedCoverTemplateID != nil
|
||||
}
|
||||
|
||||
/// 当前是否已经生成可独立查看的氛围感修图结果。
|
||||
var hasAtmosphereResult: Bool {
|
||||
appliedAtmosphereOptionID != nil
|
||||
}
|
||||
|
||||
/// 根据修图任务状态生成缩略图角标,处理中和失败优先于历史成功结果。
|
||||
var thumbnailStatus: ThumbnailStatus {
|
||||
if isProcessing {
|
||||
return .processing
|
||||
}
|
||||
if didFailLastAttempt {
|
||||
return .failed
|
||||
}
|
||||
if isCover {
|
||||
return .cover
|
||||
}
|
||||
return hasEditedResult ? .edited : .none
|
||||
}
|
||||
|
||||
/// 开始修图;已有结果保留到新结果完成,避免处理中画面回退。
|
||||
mutating func startProcessing() {
|
||||
guard !isProcessing else { return }
|
||||
isProcessing = true
|
||||
didFailLastAttempt = false
|
||||
}
|
||||
|
||||
/// 完成修图并覆盖旧预设结果,默认展示新的修图后版本。
|
||||
mutating func complete(
|
||||
presetID: String,
|
||||
atmosphereOptionID: String? = nil,
|
||||
coverTemplateID: String? = nil
|
||||
) {
|
||||
isProcessing = false
|
||||
didFailLastAttempt = false
|
||||
appliedPresetID = presetID
|
||||
appliedAtmosphereOptionID = atmosphereOptionID
|
||||
appliedCoverTemplateID = coverTemplateID
|
||||
displayMode = coverTemplateID == nil ? .edited : .cover
|
||||
}
|
||||
|
||||
/// 处理失败时恢复入口;已有成功结果继续保留。
|
||||
mutating func failProcessing() {
|
||||
isProcessing = false
|
||||
didFailLastAttempt = true
|
||||
}
|
||||
|
||||
/// 选择还原原图后清除历史修图结果和失败状态。
|
||||
mutating func restoreOriginal() {
|
||||
isProcessing = false
|
||||
didFailLastAttempt = false
|
||||
appliedPresetID = nil
|
||||
appliedAtmosphereOptionID = nil
|
||||
appliedCoverTemplateID = nil
|
||||
displayMode = .original
|
||||
}
|
||||
|
||||
/// 在已有结果时切换原图或修图后预览。
|
||||
mutating func selectDisplayMode(_ mode: DisplayMode) {
|
||||
guard hasEditedResult else {
|
||||
displayMode = .original
|
||||
return
|
||||
}
|
||||
if mode == .atmosphere, !hasAtmosphereResult {
|
||||
displayMode = .edited
|
||||
return
|
||||
}
|
||||
if mode == .cover, !isCover {
|
||||
displayMode = .edited
|
||||
return
|
||||
}
|
||||
displayMode = mode
|
||||
}
|
||||
}
|
||||
|
||||
/// 相册管理底部操作栏状态,用于在上传与多选操作之间切换。
|
||||
struct TravelAlbumSelectionToolbarState: Equatable, Sendable {
|
||||
let isSelectionMode: Bool
|
||||
let selectedCount: Int
|
||||
|
||||
/// 普通状态显示上传照片。
|
||||
var showsUpload: Bool {
|
||||
!isSelectionMode
|
||||
}
|
||||
|
||||
/// 多选状态显示 AI 修图和删除。
|
||||
var showsSelectionActions: Bool {
|
||||
isSelectionMode
|
||||
}
|
||||
|
||||
/// 至少选中一张照片后才允许执行批量操作。
|
||||
var selectionActionsEnabled: Bool {
|
||||
isSelectionMode && selectedCount > 0
|
||||
}
|
||||
}
|
||||
|
||||
/// 相册照片预览状态,维护当前照片位置并处理左右切换。
|
||||
struct TravelAlbumPhotoPreviewState: Equatable, Sendable {
|
||||
private(set) var materials: [TravelAlbumMaterial]
|
||||
private(set) var currentIndex: Int
|
||||
|
||||
init(materials: [TravelAlbumMaterial], initialIndex: Int) {
|
||||
self.materials = materials
|
||||
currentIndex = min(max(initialIndex, 0), max(materials.count - 1, 0))
|
||||
}
|
||||
|
||||
/// 当前正在预览的照片。
|
||||
var currentMaterial: TravelAlbumMaterial? {
|
||||
materials.indices.contains(currentIndex) ? materials[currentIndex] : nil
|
||||
}
|
||||
|
||||
/// 切换至下一张;已经是最后一张时保持不变。
|
||||
@discardableResult
|
||||
mutating func moveNext() -> Bool {
|
||||
guard currentIndex + 1 < materials.count else { return false }
|
||||
currentIndex += 1
|
||||
return true
|
||||
}
|
||||
|
||||
/// 切换至上一张;已经是第一张时保持不变。
|
||||
@discardableResult
|
||||
mutating func movePrevious() -> Bool {
|
||||
guard currentIndex > 0 else { return false }
|
||||
currentIndex -= 1
|
||||
return true
|
||||
}
|
||||
|
||||
/// 删除当前照片,并自动定位到相邻照片。
|
||||
@discardableResult
|
||||
mutating func removeCurrent() -> Bool {
|
||||
guard materials.indices.contains(currentIndex) else { return false }
|
||||
materials.remove(at: currentIndex)
|
||||
if currentIndex >= materials.count {
|
||||
currentIndex = max(materials.count - 1, 0)
|
||||
}
|
||||
return !materials.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
/// 相册照片 AI 修图预设,当前为本地演示数据,后续可替换为服务端下发的预设列表。
|
||||
struct TravelAlbumEditPreset: Hashable, Sendable, Identifiable {
|
||||
/// 图像效果类型,用于在预览页生成对应效果图。
|
||||
enum Effect: String, Hashable, Sendable {
|
||||
case original
|
||||
case portrait
|
||||
case vintage
|
||||
case brocade
|
||||
case distantMountain
|
||||
case mist
|
||||
case summer
|
||||
case rich
|
||||
}
|
||||
|
||||
let id: String
|
||||
let title: String
|
||||
let effect: Effect
|
||||
|
||||
/// AI 修图底部弹窗展示的默认预设,暂无接口时用于完整演示选择流程。
|
||||
static let defaultOptions: [TravelAlbumEditPreset] = [
|
||||
TravelAlbumEditPreset(id: "original", title: "还原为原图", effect: .original),
|
||||
TravelAlbumEditPreset(id: "portrait", title: "写真-简约肖像", effect: .portrait),
|
||||
TravelAlbumEditPreset(id: "vintage", title: "写真-清冷古风", effect: .vintage),
|
||||
TravelAlbumEditPreset(id: "brocade", title: "旅拍-锦绣", effect: .brocade),
|
||||
TravelAlbumEditPreset(id: "distant_mountain", title: "旅拍-远山", effect: .distantMountain),
|
||||
TravelAlbumEditPreset(id: "mist", title: "旅拍-薄雾", effect: .mist),
|
||||
TravelAlbumEditPreset(id: "summer", title: "油画-夏日", effect: .summer),
|
||||
TravelAlbumEditPreset(id: "rich", title: "油画-浓郁", effect: .rich),
|
||||
]
|
||||
}
|
||||
|
||||
/// 可选的氛围感修图样式;选中后在原图精修之外额外生成一张独立结果。
|
||||
struct TravelAlbumAtmosphereEditOption: Hashable, Sendable, Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let effect: TravelAlbumEditPreset.Effect
|
||||
|
||||
/// 暂无接口时用于演示横向滚动单选的氛围感样式。
|
||||
static let defaultOptions: [TravelAlbumAtmosphereEditOption] = [
|
||||
TravelAlbumAtmosphereEditOption(
|
||||
id: "warm_sun",
|
||||
title: "日落暖阳",
|
||||
subtitle: "温暖柔和",
|
||||
effect: .summer
|
||||
),
|
||||
TravelAlbumAtmosphereEditOption(
|
||||
id: "retro_film",
|
||||
title: "复古胶片",
|
||||
subtitle: "怀旧颗粒感",
|
||||
effect: .vintage
|
||||
),
|
||||
TravelAlbumAtmosphereEditOption(
|
||||
id: "clear_blue",
|
||||
title: "清透蓝调",
|
||||
subtitle: "通透轻盈",
|
||||
effect: .mist
|
||||
),
|
||||
TravelAlbumAtmosphereEditOption(
|
||||
id: "forest_mist",
|
||||
title: "森系薄雾",
|
||||
subtitle: "低饱和自然感",
|
||||
effect: .distantMountain
|
||||
),
|
||||
TravelAlbumAtmosphereEditOption(
|
||||
id: "vivid_story",
|
||||
title: "浓郁故事",
|
||||
subtitle: "高饱和电影感",
|
||||
effect: .rich
|
||||
),
|
||||
]
|
||||
|
||||
/// 兼容现有结果测试与默认演示数据的首个氛围感样式。
|
||||
static let defaultOption = defaultOptions[0]
|
||||
}
|
||||
|
||||
/// 多图 AI 修图使用的封面风格模板,当前为本地演示数据,后续可替换为服务端配置。
|
||||
struct TravelAlbumCoverTemplate: Hashable, Sendable, Identifiable {
|
||||
/// 封面预览的排版样式,用于生成可辨识的本地效果图。
|
||||
enum PreviewStyle: String, Hashable, Sendable {
|
||||
case travelMemoir
|
||||
case minimal
|
||||
case scenicStory
|
||||
case film
|
||||
}
|
||||
|
||||
/// 触发展示封面模板选择的最少照片数量。
|
||||
static let minimumPhotoCount = 4
|
||||
|
||||
let id: String
|
||||
let title: String
|
||||
let previewStyle: PreviewStyle
|
||||
/// 当前消耗的精修额度;免费阶段为 0,后续收费可由服务端改为正数。
|
||||
let quotaCost: Int
|
||||
|
||||
/// 当前模板是否为赠送且不占用精修张数。
|
||||
var isFreeGift: Bool {
|
||||
quotaCost == 0
|
||||
}
|
||||
|
||||
/// 判断本次多选是否需要展示封面模板模块。
|
||||
static func shouldShow(for selectedPhotoCount: Int) -> Bool {
|
||||
selectedPhotoCount >= minimumPhotoCount
|
||||
}
|
||||
|
||||
/// 暂无接口时用于完整演示单选流程的封面模板。
|
||||
static let defaultOptions: [TravelAlbumCoverTemplate] = [
|
||||
TravelAlbumCoverTemplate(id: "travel_memoir", title: "旅行纪念册", previewStyle: .travelMemoir, quotaCost: 0),
|
||||
TravelAlbumCoverTemplate(id: "minimal", title: "简约留白", previewStyle: .minimal, quotaCost: 0),
|
||||
TravelAlbumCoverTemplate(id: "scenic_story", title: "景区故事", previewStyle: .scenicStory, quotaCost: 0),
|
||||
TravelAlbumCoverTemplate(id: "film", title: "电影胶片", previewStyle: .film, quotaCost: 0),
|
||||
]
|
||||
}
|
||||
|
||||
/// AI 修图弹窗最终提交的样式组合,统一承载原图精修、氛围感和封面模板。
|
||||
struct TravelAlbumAIEditSelection: Equatable, Sendable {
|
||||
let refinedPreset: TravelAlbumEditPreset
|
||||
let atmosphereOption: TravelAlbumAtmosphereEditOption?
|
||||
let coverTemplate: TravelAlbumCoverTemplate?
|
||||
|
||||
/// 每张普通照片需要生成的修图结果数量,不包含原图。
|
||||
var resultCountPerPhoto: Int {
|
||||
atmosphereOption == nil ? 1 : 2
|
||||
}
|
||||
|
||||
/// 弹窗当前展示并提交的样式类别数量。
|
||||
var selectedStyleCount: Int {
|
||||
1 + (atmosphereOption == nil ? 0 : 1) + (coverTemplate == nil ? 0 : 1)
|
||||
}
|
||||
}
|
||||
@ -52,6 +52,12 @@ struct TravelAlbumOTGPhotoSection: Hashable, Sendable {
|
||||
let photos: [TravelAlbumOTGPhotoItem]
|
||||
}
|
||||
|
||||
/// 有线传输照片预览上下文,保存可预览照片顺序和点击照片位置。
|
||||
struct TravelAlbumOTGPhotoPreviewContext: Equatable, Sendable {
|
||||
let photos: [TravelAlbumOTGPhotoItem]
|
||||
let initialIndex: Int
|
||||
}
|
||||
|
||||
/// OTG 传输页 Tab。
|
||||
enum TravelAlbumOTGTransferTab: Int, CaseIterable, Sendable {
|
||||
case all
|
||||
@ -80,6 +86,16 @@ enum TravelAlbumOTGTransferMode: String, CaseIterable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 模式选择弹窗中的业务说明。
|
||||
var detailText: String {
|
||||
switch self {
|
||||
case .liveUpload:
|
||||
return "相机拍摄后,照片自动传输并上传到当前相册"
|
||||
case .postTransfer:
|
||||
return "拍摄完成后,再选择照片批量传输"
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否在新照片导入 App 后自动上传。
|
||||
var shouldAutoUploadNewImports: Bool {
|
||||
self == .liveUpload
|
||||
@ -113,6 +129,18 @@ extension TravelAlbumOTGPhotoItem {
|
||||
}
|
||||
|
||||
extension Array where Element == TravelAlbumOTGPhotoItem {
|
||||
/// 按当前列表顺序生成预览上下文,不可预览照片不会占用左右滑动页码。
|
||||
func previewContext(startingWith photoID: String) -> TravelAlbumOTGPhotoPreviewContext? {
|
||||
let previewablePhotos = filter { $0.thumbnailURL != nil }
|
||||
guard let initialIndex = previewablePhotos.firstIndex(where: { $0.id == photoID }) else {
|
||||
return nil
|
||||
}
|
||||
return TravelAlbumOTGPhotoPreviewContext(
|
||||
photos: previewablePhotos,
|
||||
initialIndex: initialIndex
|
||||
)
|
||||
}
|
||||
|
||||
/// 按 Android OTG 页规则构造半小时照片分段。
|
||||
func buildPhotoSections() -> [TravelAlbumOTGPhotoSection] {
|
||||
let pairs = compactMap { photo -> (slotStart: Date, photo: TravelAlbumOTGPhotoItem)? in
|
||||
|
||||
@ -0,0 +1,308 @@
|
||||
//
|
||||
// TravelAlbumAIEditResultStore.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import CoreImage
|
||||
import UIKit
|
||||
|
||||
/// AI 修图结果的内存数据源,在相册管理与照片预览之间共享本地演示结果。
|
||||
@MainActor
|
||||
final class TravelAlbumAIEditResultStore {
|
||||
/// 单张照片的修图记录。
|
||||
struct Record {
|
||||
var state: TravelAlbumAIEditResultState
|
||||
var editedImage: UIImage?
|
||||
var atmosphereImage: UIImage?
|
||||
var coverImage: UIImage?
|
||||
}
|
||||
|
||||
static let shared = TravelAlbumAIEditResultStore()
|
||||
static let didChangeNotification = Notification.Name("TravelAlbumAIEditResultStore.didChange")
|
||||
static let materialIDUserInfoKey = "materialID"
|
||||
|
||||
private var recordsByMaterialID: [Int: Record] = [:]
|
||||
|
||||
private init() {}
|
||||
|
||||
/// 获取指定照片的修图状态。
|
||||
func state(for materialID: Int) -> TravelAlbumAIEditResultState {
|
||||
recordsByMaterialID[materialID]?.state ?? TravelAlbumAIEditResultState()
|
||||
}
|
||||
|
||||
/// 获取指定照片最新的修图结果。
|
||||
func editedImage(for materialID: Int) -> UIImage? {
|
||||
recordsByMaterialID[materialID]?.editedImage
|
||||
}
|
||||
|
||||
/// 获取指定照片最新的氛围感修图结果。
|
||||
func atmosphereImage(for materialID: Int) -> UIImage? {
|
||||
recordsByMaterialID[materialID]?.atmosphereImage
|
||||
}
|
||||
|
||||
/// 获取指定照片最新的封面模板结果。
|
||||
func coverImage(for materialID: Int) -> UIImage? {
|
||||
recordsByMaterialID[materialID]?.coverImage
|
||||
}
|
||||
|
||||
/// 将照片标记为修图中,已有结果继续保留到新结果完成。
|
||||
func startProcessing(materialIDs: [Int]) {
|
||||
materialIDs.forEach { materialID in
|
||||
var record = recordsByMaterialID[materialID]
|
||||
?? Record(
|
||||
state: TravelAlbumAIEditResultState(),
|
||||
editedImage: nil,
|
||||
atmosphereImage: nil,
|
||||
coverImage: nil
|
||||
)
|
||||
record.state.startProcessing()
|
||||
recordsByMaterialID[materialID] = record
|
||||
notifyChange(materialID: materialID)
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存最新结果并覆盖同一照片之前的修图结果。
|
||||
func complete(
|
||||
materialID: Int,
|
||||
presetID: String,
|
||||
atmosphereOptionID: String? = nil,
|
||||
coverTemplateID: String? = nil,
|
||||
editedImage: UIImage,
|
||||
atmosphereImage: UIImage? = nil,
|
||||
coverImage: UIImage? = nil
|
||||
) {
|
||||
var record = recordsByMaterialID[materialID]
|
||||
?? Record(
|
||||
state: TravelAlbumAIEditResultState(),
|
||||
editedImage: nil,
|
||||
atmosphereImage: nil,
|
||||
coverImage: nil
|
||||
)
|
||||
record.state.complete(
|
||||
presetID: presetID,
|
||||
atmosphereOptionID: atmosphereOptionID,
|
||||
coverTemplateID: coverTemplateID
|
||||
)
|
||||
record.editedImage = editedImage
|
||||
record.atmosphereImage = atmosphereImage
|
||||
record.coverImage = coverImage
|
||||
recordsByMaterialID[materialID] = record
|
||||
notifyChange(materialID: materialID)
|
||||
}
|
||||
|
||||
/// 修图失败时恢复按钮可用状态,并保留之前成功的结果。
|
||||
func failProcessing(materialID: Int) {
|
||||
guard var record = recordsByMaterialID[materialID] else { return }
|
||||
record.state.failProcessing()
|
||||
recordsByMaterialID[materialID] = record
|
||||
notifyChange(materialID: materialID)
|
||||
}
|
||||
|
||||
/// 还原为原图时清除该照片的本地修图结果。
|
||||
func restoreOriginal(materialID: Int) {
|
||||
guard var record = recordsByMaterialID[materialID] else { return }
|
||||
record.state.restoreOriginal()
|
||||
record.editedImage = nil
|
||||
record.atmosphereImage = nil
|
||||
record.coverImage = nil
|
||||
recordsByMaterialID[materialID] = record
|
||||
notifyChange(materialID: materialID)
|
||||
}
|
||||
|
||||
/// 切换指定照片当前展示的原图或修图后版本。
|
||||
func selectDisplayMode(_ mode: TravelAlbumAIEditResultState.DisplayMode, materialID: Int) {
|
||||
guard var record = recordsByMaterialID[materialID] else { return }
|
||||
record.state.selectDisplayMode(mode)
|
||||
recordsByMaterialID[materialID] = record
|
||||
notifyChange(materialID: materialID)
|
||||
}
|
||||
|
||||
/// 删除照片时同步清理其本地修图结果。
|
||||
func remove(materialID: Int) {
|
||||
recordsByMaterialID.removeValue(forKey: materialID)
|
||||
notifyChange(materialID: materialID)
|
||||
}
|
||||
|
||||
private func notifyChange(materialID: Int) {
|
||||
NotificationCenter.default.post(
|
||||
name: Self.didChangeNotification,
|
||||
object: self,
|
||||
userInfo: [Self.materialIDUserInfoKey: materialID]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 本地 AI 修图效果生成器;后续接入服务端时可替换为结果图片下载。
|
||||
@MainActor
|
||||
enum TravelAlbumAIEditImageProcessor {
|
||||
private static let context = CIContext()
|
||||
|
||||
/// 根据预设生成演示用修图图片。
|
||||
static func render(
|
||||
effect: TravelAlbumEditPreset.Effect,
|
||||
source: UIImage
|
||||
) -> UIImage {
|
||||
guard effect != .original, let ciImage = CIImage(image: source) else { return source }
|
||||
let output: CIImage
|
||||
switch effect {
|
||||
case .original:
|
||||
output = ciImage
|
||||
case .portrait:
|
||||
output = ciImage.applyingFilter("CIColorControls", parameters: [
|
||||
kCIInputSaturationKey: 0.86,
|
||||
kCIInputBrightnessKey: 0.07,
|
||||
kCIInputContrastKey: 1.08,
|
||||
])
|
||||
case .vintage:
|
||||
output = ciImage.applyingFilter("CISepiaTone", parameters: [kCIInputIntensityKey: 0.45])
|
||||
case .brocade:
|
||||
output = ciImage.applyingFilter("CIColorControls", parameters: [
|
||||
kCIInputSaturationKey: 1.22,
|
||||
kCIInputBrightnessKey: 0.04,
|
||||
kCIInputContrastKey: 1.1,
|
||||
])
|
||||
case .distantMountain:
|
||||
output = ciImage.applyingFilter("CIColorControls", parameters: [
|
||||
kCIInputSaturationKey: 0.72,
|
||||
kCIInputBrightnessKey: 0.1,
|
||||
kCIInputContrastKey: 0.9,
|
||||
])
|
||||
case .mist:
|
||||
output = ciImage.applyingFilter("CIColorControls", parameters: [
|
||||
kCIInputSaturationKey: 0.7,
|
||||
kCIInputBrightnessKey: 0.14,
|
||||
kCIInputContrastKey: 0.82,
|
||||
])
|
||||
case .summer:
|
||||
output = ciImage.applyingFilter("CISepiaTone", parameters: [kCIInputIntensityKey: 0.24])
|
||||
case .rich:
|
||||
output = ciImage.applyingFilter("CIColorControls", parameters: [
|
||||
kCIInputSaturationKey: 1.38,
|
||||
kCIInputBrightnessKey: -0.02,
|
||||
kCIInputContrastKey: 1.2,
|
||||
])
|
||||
}
|
||||
guard let cgImage = context.createCGImage(output, from: output.extent) else { return source }
|
||||
return UIImage(cgImage: cgImage, scale: source.scale, orientation: source.imageOrientation)
|
||||
}
|
||||
}
|
||||
|
||||
/// 本地封面模板效果生成器,让封面结果在缩略图和预览页中与普通照片明显区分。
|
||||
@MainActor
|
||||
enum TravelAlbumCoverTemplateImageProcessor {
|
||||
/// 将照片渲染为指定封面排版;无原图时使用稳定的演示背景。
|
||||
static func render(
|
||||
template: TravelAlbumCoverTemplate,
|
||||
source: UIImage?,
|
||||
targetSize: CGSize = CGSize(width: 1200, height: 900)
|
||||
) -> UIImage {
|
||||
UIGraphicsImageRenderer(size: targetSize).image { context in
|
||||
let bounds = CGRect(origin: .zero, size: targetSize)
|
||||
UIColor(hex: 0xCAD8E8).setFill()
|
||||
context.cgContext.fill(bounds)
|
||||
if let source {
|
||||
source.draw(in: aspectFillRect(imageSize: source.size, bounds: bounds))
|
||||
}
|
||||
|
||||
let unit = min(targetSize.width / 232, targetSize.height / 208)
|
||||
switch template.previewStyle {
|
||||
case .travelMemoir:
|
||||
UIColor.black.withAlphaComponent(0.24).setFill()
|
||||
context.cgContext.fill(bounds)
|
||||
UIColor.white.setStroke()
|
||||
let oval = UIBezierPath(ovalIn: CGRect(
|
||||
x: bounds.midX - 50 * unit,
|
||||
y: 28 * unit,
|
||||
width: 100 * unit,
|
||||
height: 100 * unit
|
||||
))
|
||||
oval.lineWidth = 9 * unit
|
||||
oval.stroke()
|
||||
drawCaption(
|
||||
"TRAVEL\nMEMOIR",
|
||||
in: CGRect(x: 30 * unit, y: bounds.height - 62 * unit, width: bounds.width - 60 * unit, height: 46 * unit),
|
||||
alignment: .center,
|
||||
fontSize: 17 * unit
|
||||
)
|
||||
case .minimal:
|
||||
UIColor.white.withAlphaComponent(0.9).setFill()
|
||||
context.cgContext.fill(CGRect(x: bounds.width * 0.55, y: 0, width: bounds.width * 0.45, height: bounds.height))
|
||||
drawCaption(
|
||||
"LESS\nIS MORE",
|
||||
in: CGRect(x: bounds.width * 0.62, y: bounds.midY - 27 * unit, width: bounds.width * 0.3, height: 54 * unit),
|
||||
alignment: .left,
|
||||
color: .black,
|
||||
fontSize: 17 * unit
|
||||
)
|
||||
case .scenicStory:
|
||||
UIColor.black.withAlphaComponent(0.2).setFill()
|
||||
context.cgContext.fill(bounds)
|
||||
UIColor.white.setStroke()
|
||||
let inset = 20 * unit
|
||||
let frame = UIBezierPath(rect: bounds.insetBy(dx: inset, dy: inset))
|
||||
frame.lineWidth = 4 * unit
|
||||
frame.stroke()
|
||||
drawCaption(
|
||||
"SCENIC STORY",
|
||||
in: CGRect(x: 34 * unit, y: bounds.height - 58 * unit, width: bounds.width - 68 * unit, height: 24 * unit),
|
||||
alignment: .center,
|
||||
fontSize: 17 * unit
|
||||
)
|
||||
case .film:
|
||||
let stripHeight = 24 * unit
|
||||
UIColor.black.withAlphaComponent(0.72).setFill()
|
||||
context.cgContext.fill(CGRect(x: 0, y: 0, width: bounds.width, height: stripHeight))
|
||||
context.cgContext.fill(CGRect(x: 0, y: bounds.height - stripHeight, width: bounds.width, height: stripHeight))
|
||||
UIColor.white.withAlphaComponent(0.9).setFill()
|
||||
var x = 10 * unit
|
||||
while x <= bounds.width - 16 * unit {
|
||||
context.cgContext.fill(CGRect(x: x, y: 6 * unit, width: 14 * unit, height: 10 * unit))
|
||||
context.cgContext.fill(CGRect(
|
||||
x: x,
|
||||
y: bounds.height - 16 * unit,
|
||||
width: 14 * unit,
|
||||
height: 10 * unit
|
||||
))
|
||||
x += 30 * unit
|
||||
}
|
||||
drawCaption(
|
||||
"THE JOURNEY",
|
||||
in: CGRect(x: 28 * unit, y: bounds.height - 60 * unit, width: bounds.width - 56 * unit, height: 24 * unit),
|
||||
alignment: .center,
|
||||
fontSize: 17 * unit
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func aspectFillRect(imageSize: CGSize, bounds: CGRect) -> CGRect {
|
||||
guard imageSize.width > 0, imageSize.height > 0 else { return bounds }
|
||||
let scale = max(bounds.width / imageSize.width, bounds.height / imageSize.height)
|
||||
let drawSize = CGSize(width: imageSize.width * scale, height: imageSize.height * scale)
|
||||
return CGRect(
|
||||
x: bounds.midX - drawSize.width / 2,
|
||||
y: bounds.midY - drawSize.height / 2,
|
||||
width: drawSize.width,
|
||||
height: drawSize.height
|
||||
)
|
||||
}
|
||||
|
||||
private static func drawCaption(
|
||||
_ text: String,
|
||||
in rect: CGRect,
|
||||
alignment: NSTextAlignment,
|
||||
color: UIColor = .white,
|
||||
fontSize: CGFloat
|
||||
) {
|
||||
let paragraph = NSMutableParagraphStyle()
|
||||
paragraph.alignment = alignment
|
||||
text.draw(
|
||||
in: rect,
|
||||
withAttributes: [
|
||||
.font: UIFont.systemFont(ofSize: fontSize, weight: .bold),
|
||||
.foregroundColor: color,
|
||||
.paragraphStyle: paragraph,
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -36,6 +36,7 @@ final class TravelAlbumDetailViewModel {
|
||||
private(set) var sortOption: SortOption = .createdDesc
|
||||
private(set) var isLoading = true
|
||||
private(set) var isRefreshing = false
|
||||
private(set) var isUsingPurchasedDemoData = false
|
||||
private(set) var isSelectionMode = false
|
||||
private(set) var selectedMaterialIds: Set<Int> = []
|
||||
|
||||
@ -126,6 +127,7 @@ final class TravelAlbumDetailViewModel {
|
||||
if reset {
|
||||
currentPage = 1
|
||||
canLoadMore = false
|
||||
isUsingPurchasedDemoData = false
|
||||
} else {
|
||||
guard canLoadMore, !isLoadingMore else { return }
|
||||
isLoadingMore = true
|
||||
@ -140,8 +142,15 @@ final class TravelAlbumDetailViewModel {
|
||||
orderBy: sortOption.rawValue,
|
||||
isPurchased: selectedTab == .purchased ? 1 : nil
|
||||
)
|
||||
materials = reset ? response.list : materials + response.list
|
||||
canLoadMore = materials.count < response.total
|
||||
if reset,
|
||||
selectedTab == .purchased,
|
||||
response.total == 0,
|
||||
response.list.isEmpty {
|
||||
applyPurchasedDemoMaterials()
|
||||
} else {
|
||||
materials = reset ? response.list : materials + response.list
|
||||
canLoadMore = materials.count < response.total
|
||||
}
|
||||
if selectedTab == .all {
|
||||
allPhotoCount = response.total
|
||||
}
|
||||
@ -154,6 +163,12 @@ final class TravelAlbumDetailViewModel {
|
||||
currentPage -= 1
|
||||
}
|
||||
isLoadingMore = false
|
||||
if reset, selectedTab == .purchased {
|
||||
// 已购接口暂不可用时继续提供稳定的本地演示数据,保证产品流程可演示。
|
||||
applyPurchasedDemoMaterials()
|
||||
notifyStateChange()
|
||||
return
|
||||
}
|
||||
onShowMessage?(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
@ -225,6 +240,44 @@ final class TravelAlbumDetailViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// 已购照片接口无数据时生成稳定的本地素材;真实接口一旦返回数据即不再使用。
|
||||
private func applyPurchasedDemoMaterials() {
|
||||
let demoItems: [(fileName: String, fileSize: Int, assetName: String, createdAt: String)] = [
|
||||
("IMG_0724_0001.jpeg", 2_684_928, "purchased_alpine_lake", "2026-07-24 11:25:18"),
|
||||
("IMG_0724_0002.jpeg", 3_215_360, "purchased_forest_waterfall", "2026-07-24 11:27:46"),
|
||||
("IMG_0724_0003.jpeg", 2_936_832, "purchased_grassland_path", "2026-07-24 11:31:09"),
|
||||
("IMG_0724_0004.jpeg", 3_481_600, "purchased_lakeside_flowers", "2026-07-24 11:34:22"),
|
||||
]
|
||||
var materials = demoItems.enumerated().map { index, item in
|
||||
TravelAlbumMaterial(
|
||||
id: -((albumId * 10) + index + 1),
|
||||
userEquityTravelId: albumId,
|
||||
status: 2,
|
||||
fileName: item.fileName,
|
||||
fileType: 1,
|
||||
fileUrl: TravelAlbumMaterial.bundledImageURL(assetName: item.assetName),
|
||||
fileSize: item.fileSize,
|
||||
coverUrl: TravelAlbumMaterial.bundledImageURL(assetName: item.assetName),
|
||||
isPurchased: true,
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.createdAt
|
||||
)
|
||||
}
|
||||
switch sortOption {
|
||||
case .createdAsc:
|
||||
materials.sort { $0.createdAt < $1.createdAt }
|
||||
case .createdDesc:
|
||||
materials.sort { $0.createdAt > $1.createdAt }
|
||||
case .fileNameAsc:
|
||||
materials.sort { $0.fileName.localizedStandardCompare($1.fileName) == .orderedAscending }
|
||||
case .fileNameDesc:
|
||||
materials.sort { $0.fileName.localizedStandardCompare($1.fileName) == .orderedDescending }
|
||||
}
|
||||
self.materials = materials
|
||||
canLoadMore = false
|
||||
isUsingPurchasedDemoData = true
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
|
||||
@ -94,6 +94,7 @@ final class WiredCameraTransferViewModel {
|
||||
albumTitle: String,
|
||||
headerPhone: String,
|
||||
scenicSpotLabel: String? = nil,
|
||||
initialTransferMode: TravelAlbumOTGTransferMode? = nil,
|
||||
connectionManager: (any WiredCameraConnectionManaging)? = nil,
|
||||
storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(),
|
||||
uploader: (any TravelAlbumOTGUploading)? = nil,
|
||||
@ -112,7 +113,11 @@ final class WiredCameraTransferViewModel {
|
||||
self.api = api ?? NetworkServices.shared.travelAlbumAPI
|
||||
self.appStore = appStore
|
||||
self.userDefaults = userDefaults
|
||||
self.transferMode = Self.persistedTransferMode(in: userDefaults)
|
||||
// 从相册管理选择模式进入时,以本次选择为准;其他旧入口继续沿用上次记录。
|
||||
self.transferMode = initialTransferMode ?? Self.persistedTransferMode(in: userDefaults)
|
||||
if let initialTransferMode {
|
||||
userDefaults.set(initialTransferMode.rawValue, forKey: Self.transferModeDefaultsKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// 相机状态文案。
|
||||
|
||||
Reference in New Issue
Block a user